diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 1bc1e1edb6..93c3f12831 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -26,6 +26,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 1b94330b30..9e4f42cb89 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -22,8 +22,23 @@
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ com.buzz.deeplink
+ CFBundleURLSchemes
+
+ buzz
+
+
+
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ FlutterDeepLinkingEnabled
+
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index ba736289ad..bf85bfdda1 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -8,8 +8,10 @@ import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
+import 'features/channels/deep_link_dispatcher.dart';
import 'features/profile/user_status_cache_provider.dart';
import 'shared/auth/auth.dart';
+import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/relay/relay.dart';
import 'shared/theme/theme.dart';
@@ -39,6 +41,10 @@ class App extends HookConsumerWidget {
ref.watch(userStatusCacheProvider);
}
+ // Start listening for buzz:// links immediately (even pre-auth) so a
+ // cold-start link survives until the authenticated UI can dispatch it.
+ ref.watch(pendingDeepLinkProvider);
+
void applyBadge(UnreadBadgeState state) {
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
@@ -66,7 +72,9 @@ class App extends HookConsumerWidget {
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
- AuthStatus.authenticated => const HomePage(),
+ AuthStatus.authenticated => const DeepLinkDispatcher(
+ child: HomePage(),
+ ),
_ => const PairingPage(),
},
),
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 2f67c84748..a73da29189 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
@@ -47,6 +48,21 @@ part 'channel_detail_page/message_bubble.dart';
part 'channel_detail_page/banners.dart';
part 'channel_detail_page/app_bar.dart';
+/// Fetch deep-link targets that may be outside the loaded channel window.
+Future _loadDeepLinkEvents(
+ WidgetRef ref,
+ String channelId,
+ Set eventIds,
+) async {
+ try {
+ await ref
+ .read(channelMessagesProvider(channelId).notifier)
+ .loadEventsById(eventIds);
+ } catch (error) {
+ debugPrint('deep-link: failed to load target messages: $error');
+ }
+}
+
/// Fetch channel members and preload their profiles into the user cache.
Future _preloadMembers(WidgetRef ref, String channelId) async {
// Capture references before async gap to avoid using disposed ref.
@@ -89,8 +105,15 @@ int? _channelReadTimestamp({
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
+ final String? initialMessageId;
+ final String? initialThreadRootId;
- const ChannelDetailPage({super.key, required this.channel});
+ const ChannelDetailPage({
+ super.key,
+ required this.channel,
+ this.initialMessageId,
+ this.initialThreadRootId,
+ });
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -135,6 +158,15 @@ class ChannelDetailPage extends HookConsumerWidget {
return null;
}, [channel.id]);
+ useEffect(() {
+ final messageId = initialMessageId;
+ if (messageId == null || channel.isForum) return null;
+ final eventIds = {messageId, ?initialThreadRootId};
+ final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
+ unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
+ return () => notifier.releaseDeepLinkEvents(eventIds);
+ }, [channel.id, initialMessageId, initialThreadRootId]);
+
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
return null;
@@ -274,6 +306,8 @@ class ChannelDetailPage extends HookConsumerWidget {
return _MessageList(
entries: entries,
allMessages: messages,
+ initialMessageId: initialMessageId,
+ initialThreadRootId: initialThreadRootId,
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart
index 7d08f9a026..82dced5237 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -3,6 +3,8 @@ part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final List entries;
final List allMessages;
+ final String? initialMessageId;
+ final String? initialThreadRootId;
final String channelId;
final String? currentPubkey;
final bool isMember;
@@ -11,75 +13,119 @@ class _MessageList extends HookConsumerWidget {
const _MessageList({
required this.entries,
required this.allMessages,
+ required this.initialMessageId,
+ required this.initialThreadRootId,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
- static const _fetchOlderThreshold = 200.0;
- static const _latestThreshold = 48.0;
-
@override
Widget build(BuildContext context, WidgetRef ref) {
- // Pagination: fetch older messages when scrolling near the top.
- final scrollController = useScrollController();
+ final itemScrollController = useMemoized(ItemScrollController.new);
+ final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
+ final hasUserScrolled = useState(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef(null);
+ final didOpenInitialThread = useRef(false);
+ final didJumpToInitialMessage = useRef(false);
- bool nearLatest() {
- if (!scrollController.hasClients) return true;
- return scrollController.position.pixels <= _latestThreshold;
- }
-
- void updateLatestState() {
- final next = nearLatest();
- if (isAtLatest.value != next) {
- isAtLatest.value = next;
- }
+ int? reversedIndexOf(String? messageId) {
+ if (messageId == null) return null;
+ final chronologicalIndex = entries.indexWhere(
+ (entry) => entry.message.id == messageId,
+ );
+ return chronologicalIndex < 0
+ ? null
+ : entries.length - 1 - chronologicalIndex;
}
Future scrollToLatest() async {
- if (!scrollController.hasClients) return;
- await scrollController.animateTo(
- 0,
+ if (!itemScrollController.isAttached) return;
+ await itemScrollController.scrollTo(
+ index: 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
- if (context.mounted) {
- isAtLatest.value = true;
- }
+ if (context.mounted) isAtLatest.value = true;
}
useEffect(() {
- void onScroll() {
- updateLatestState();
- if (isLoadingOlder.value) return;
+ void onPositionsChanged() {
+ final positions = itemPositionsListener.itemPositions.value;
+ if (positions.isEmpty) return;
+ final nextIsAtLatest = positions.any(
+ (position) => position.index == 0 && position.itemLeadingEdge < 1,
+ );
+ if (isAtLatest.value != nextIsAtLatest) {
+ isAtLatest.value = nextIsAtLatest;
+ }
+
+ final oldestVisible = positions
+ .map((position) => position.index)
+ .reduce((a, b) => a > b ? a : b);
+ if (!hasUserScrolled.value ||
+ oldestVisible < entries.length - 3 ||
+ isLoadingOlder.value) {
+ return;
+ }
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
- // In a reversed ListView, maxScrollExtent is the oldest messages.
- final pos = scrollController.position;
- if (pos.pixels >= pos.maxScrollExtent - _fetchOlderThreshold) {
- isLoadingOlder.value = true;
- notifier.fetchOlder().whenComplete(
- () => isLoadingOlder.value = false,
- );
- }
+ isLoadingOlder.value = true;
+ notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
}
- scrollController.addListener(onScroll);
- return () => scrollController.removeListener(onScroll);
- }, [channelId, scrollController]);
+ itemPositionsListener.itemPositions.addListener(onPositionsChanged);
+ return () => itemPositionsListener.itemPositions.removeListener(
+ onPositionsChanged,
+ );
+ }, [channelId, entries.length, itemPositionsListener]);
useEffect(() {
+ if (initialThreadRootId == null || didOpenInitialThread.value) {
+ return null;
+ }
+ final threadHead = allMessages
+ .where((message) => message.id == initialThreadRootId)
+ .firstOrNull;
+ if (threadHead == null) return null;
+ didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
- updateLatestState();
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: allMessages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ initialMessageId: initialMessageId,
+ ),
+ ),
+ );
+ });
+ return null;
+ }, [initialThreadRootId, allMessages]);
+
+ useEffect(() {
+ final targetIndex = reversedIndexOf(initialMessageId);
+ if (initialThreadRootId != null ||
+ targetIndex == null ||
+ didJumpToInitialMessage.value) {
+ return null;
+ }
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted || !itemScrollController.isAttached) return;
+ itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
+ didJumpToInitialMessage.value = true;
});
return null;
- }, [entries.length, scrollController]);
+ }, [initialMessageId, initialThreadRootId, entries.length]);
useEffect(() {
final previous = previousLatestEntryId.value;
@@ -90,17 +136,11 @@ class _MessageList extends HookConsumerWidget {
!isAtLatest.value) {
return null;
}
-
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!context.mounted || !scrollController.hasClients) return;
- scrollController.animateTo(
- 0,
- duration: const Duration(milliseconds: 220),
- curve: Curves.easeOutCubic,
- );
+ if (context.mounted) scrollToLatest();
});
return null;
- }, [latestEntryId, scrollController]);
+ }, [latestEntryId]);
if (entries.isEmpty) {
return Center(
@@ -142,92 +182,102 @@ class _MessageList extends HookConsumerWidget {
return Stack(
children: [
- ListView.builder(
- key: const ValueKey('channel-message-list'),
- controller: scrollController,
- reverse: true,
- padding: EdgeInsets.only(
- left: Grid.gutter,
- right: Grid.gutter,
- top: frostedAppBarHeight(context),
- bottom: Grid.xxs,
- ),
- itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
- itemBuilder: (context, index) {
- // Loading indicator at the top (last index in reversed list).
- if (index >= entries.length) {
- return const Padding(
- padding: EdgeInsets.symmetric(vertical: Grid.xs),
- child: Center(
- child: SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- ),
- ),
- );
+ NotificationListener(
+ onNotification: (notification) {
+ if (notification is ScrollStartNotification &&
+ notification.dragDetails != null) {
+ hasUserScrolled.value = true;
}
+ return false;
+ },
+ child: ScrollablePositionedList.builder(
+ key: const ValueKey('channel-message-list'),
+ itemScrollController: itemScrollController,
+ itemPositionsListener: itemPositionsListener,
+ reverse: true,
+ padding: EdgeInsets.only(
+ left: Grid.gutter,
+ right: Grid.gutter,
+ top: frostedAppBarHeight(context),
+ bottom: Grid.xxs,
+ ),
+ itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
+ itemBuilder: (context, index) {
+ // Loading indicator at the top (last index in reversed list).
+ if (index >= entries.length) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: Grid.xs),
+ child: Center(
+ child: SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ ),
+ );
+ }
- // Reversed list: index 0 = newest (bottom of screen).
- final chronIdx = entries.length - 1 - index;
- final entry = entries[chronIdx];
- final message = entry.message;
+ // Reversed list: index 0 = newest (bottom of screen).
+ final chronIdx = entries.length - 1 - index;
+ final entry = entries[chronIdx];
+ final message = entry.message;
- // Day boundary check — applies to all messages including system.
- final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
- final prevMessage = prevEntry?.message;
- final showDayDivider =
- prevMessage == null ||
- !isSameDay(prevMessage.createdAt, message.createdAt);
+ // Day boundary check — applies to all messages including system.
+ final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
+ final prevMessage = prevEntry?.message;
+ final showDayDivider =
+ prevMessage == null ||
+ !isSameDay(prevMessage.createdAt, message.createdAt);
- final showAuthor =
- !message.isSystem &&
- (prevMessage == null ||
- prevMessage.isSystem ||
- showDayDivider ||
- prevMessage.pubkey.toLowerCase() !=
- message.pubkey.toLowerCase() ||
- (message.createdAt - prevMessage.createdAt) > 300);
+ final showAuthor =
+ !message.isSystem &&
+ (prevMessage == null ||
+ prevMessage.isSystem ||
+ showDayDivider ||
+ prevMessage.pubkey.toLowerCase() !=
+ message.pubkey.toLowerCase() ||
+ (message.createdAt - prevMessage.createdAt) > 300);
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (showDayDivider)
- DayDivider(label: formatDayHeading(message.createdAt)),
- if (message.isSystem)
- _SystemMessageRow(
- message: message,
- channelId: channelId,
- currentPubkey: currentPubkey,
- allMessages: null,
- isMember: isMember,
- isArchived: isArchived,
- )
- else ...[
- _MessageBubble(
- message: message,
- showAuthor: showAuthor,
- channelNames: channelNamesMap,
- currentChannelId: channelId,
- currentPubkey: currentPubkey,
- allMessages: allMessages,
- isMember: isMember,
- isArchived: isArchived,
- ),
- if (entry.summary != null)
- _ThreadSummaryRow(
- summary: entry.summary!,
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showDayDivider)
+ DayDivider(label: formatDayHeading(message.createdAt)),
+ if (message.isSystem)
+ _SystemMessageRow(
message: message,
- allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
+ allMessages: null,
+ isMember: isMember,
+ isArchived: isArchived,
+ )
+ else ...[
+ _MessageBubble(
+ message: message,
+ showAuthor: showAuthor,
+ channelNames: channelNamesMap,
+ currentChannelId: channelId,
+ currentPubkey: currentPubkey,
+ allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
+ if (entry.summary != null)
+ _ThreadSummaryRow(
+ summary: entry.summary!,
+ message: message,
+ allMessages: allMessages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ ),
+ ],
],
- ],
- );
- },
+ );
+ },
+ ),
),
if (!isAtLatest.value)
Positioned(
diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart
index 5d2bfe74b5..5d1964f814 100644
--- a/mobile/lib/features/channels/channel_messages_provider.dart
+++ b/mobile/lib/features/channels/channel_messages_provider.dart
@@ -18,6 +18,8 @@ class ChannelMessagesNotifier extends Notifier>> {
bool _usingChannelWindow = false;
int _initVersion = 0;
ChannelWindowStore _windowStore = const ChannelWindowStore.empty();
+ final Map _deepLinkEvents = {};
+ final Set _retainedDeepLinkEventIds = {};
ChannelMessagesNotifier(this.channelId);
@@ -88,10 +90,10 @@ class ChannelMessagesNotifier extends Notifier>> {
final existing = state.value ?? const [];
final existingIds = existing.map((event) => event.id).toSet();
- final merged = [
+ final merged = _withDeepLinkEvents([
...existing,
- ...history.where((event) => !existingIds.contains(event.id)),
- ]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
+ ...history.where((event) => existingIds.add(event.id)),
+ ]);
_lastKnownMessages = merged;
state = AsyncData(merged);
} catch (e, st) {
@@ -175,7 +177,9 @@ class ChannelMessagesNotifier extends Notifier>> {
void _handleWindowLiveEvent(NostrEvent event) {
if (!_mergeWindowEventIntoStore(event)) return;
- final flattened = flattenChannelWindowEvents(_windowStore);
+ final flattened = _withDeepLinkEvents(
+ flattenChannelWindowEvents(_windowStore),
+ );
_lastKnownMessages = flattened;
state = AsyncData(flattened);
}
@@ -244,6 +248,62 @@ class ChannelMessagesNotifier extends Notifier>> {
bool get reachedOldest => _reachedOldest;
+ /// Loads specific deep-link targets that may fall outside the newest window.
+ Future loadEventsById(Iterable eventIds) async {
+ final ids = eventIds.where((id) => id.isNotEmpty).toSet();
+ if (ids.isEmpty) return;
+ _retainedDeepLinkEventIds.addAll(ids);
+
+ final existing = state.value ?? const [];
+ for (final event in existing) {
+ if (ids.contains(event.id)) _deepLinkEvents[event.id] = event;
+ }
+ ids.removeAll(_deepLinkEvents.keys);
+ if (ids.isEmpty) return;
+
+ final events = await ref
+ .read(relaySessionProvider.notifier)
+ .fetchHistory(
+ NostrFilter(
+ kinds: EventKind.channelTimelineContentKinds,
+ ids: ids.toList(),
+ limit: ids.length,
+ ),
+ );
+ for (final event in events) {
+ if (event.channelId == channelId &&
+ _retainedDeepLinkEventIds.contains(event.id)) {
+ _deepLinkEvents[event.id] = event;
+ }
+ }
+
+ // Let the initial history load publish the complete timeline once it
+ // finishes. Publishing a target-only list here would make the UI consume
+ // its one-shot jump against a provisional ordering.
+ if (_initInFlight) return;
+ final merged = _withDeepLinkEvents(
+ state.value ?? _lastKnownMessages ?? const [],
+ );
+ _lastKnownMessages = merged;
+ state = AsyncData(merged);
+ }
+
+ /// Stops pinning deep-link-only events into subsequent window rebuilds.
+ void releaseDeepLinkEvents(Iterable eventIds) {
+ for (final id in eventIds) {
+ _retainedDeepLinkEventIds.remove(id);
+ _deepLinkEvents.remove(id);
+ }
+ }
+
+ List _withDeepLinkEvents(List events) {
+ final ids = events.map((event) => event.id).toSet();
+ return [
+ ...events,
+ ..._deepLinkEvents.values.where((event) => ids.add(event.id)),
+ ]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
+ }
+
Future fetchOlder() async {
if (_reachedOldest || _initInFlight) return false;
@@ -258,7 +318,9 @@ class ChannelMessagesNotifier extends Notifier>> {
final page = await _fetchWindowPage(session, cursor);
_windowStore = appendOlderChannelWindow(_windowStore, page);
_reachedOldest = !channelWindowHasMore(_windowStore);
- final flattened = flattenChannelWindowEvents(_windowStore);
+ final flattened = _withDeepLinkEvents(
+ flattenChannelWindowEvents(_windowStore),
+ );
_lastKnownMessages = flattened;
state = AsyncData(flattened);
return page.rows.isNotEmpty || page.aux.isNotEmpty;
diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart
new file mode 100644
index 0000000000..2a5fc822d0
--- /dev/null
+++ b/mobile/lib/features/channels/deep_link_dispatcher.dart
@@ -0,0 +1,95 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../shared/deeplink/deep_link.dart';
+import '../../shared/deeplink/pending_deep_link_provider.dart';
+import 'channel.dart';
+import 'channel_detail_page.dart';
+import 'channels_provider.dart';
+
+/// Routes pending `buzz://message` deep links into the channel view.
+///
+/// Wraps the authenticated home subtree. Whenever a parsed link is parked in
+/// [pendingDeepLinkProvider] and the channel list is available, this pushes
+/// the target [ChannelDetailPage] on the enclosing [Navigator]. Links are
+/// held (not dropped) while channels are still loading, so cold-start links
+/// dispatch as soon as the first channel fetch completes.
+typedef DeepLinkDestinationBuilder =
+ Widget Function(Channel channel, MessageDeepLink link);
+
+class DeepLinkDispatcher extends ConsumerStatefulWidget {
+ final Widget child;
+ final DeepLinkDestinationBuilder? destinationBuilder;
+
+ const DeepLinkDispatcher({
+ super.key,
+ required this.child,
+ this.destinationBuilder,
+ });
+
+ @override
+ ConsumerState createState() => _DeepLinkDispatcherState();
+}
+
+class _DeepLinkDispatcherState extends ConsumerState {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _maybeDispatch(ref.read(pendingDeepLinkProvider));
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // Re-evaluate dispatch when either a new link arrives or channels load.
+ ref.listen(pendingDeepLinkProvider, (_, link) {
+ _maybeDispatch(link);
+ });
+ ref.listen>>(channelsProvider, (_, _) {
+ _maybeDispatch(ref.read(pendingDeepLinkProvider));
+ });
+
+ return widget.child;
+ }
+
+ void _maybeDispatch(MessageDeepLink? link) {
+ if (link == null) return;
+
+ final channels = ref.read(channelsProvider).asData?.value;
+ // Channels not loaded yet — keep the link parked; the channelsProvider
+ // listener re-attempts once data arrives.
+ if (channels == null) return;
+
+ ref.read(pendingDeepLinkProvider.notifier).consume();
+
+ final channel = channels
+ .where((c) => c.id == link.channelId)
+ .cast()
+ .firstOrNull;
+ if (channel == null) {
+ debugPrint(
+ 'deep-link: channel ${link.channelId} not found in workspace; '
+ 'dropping link',
+ );
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
+ const SnackBar(content: Text('Channel not found in this workspace')),
+ );
+ return;
+ }
+ if (!context.mounted) return;
+
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) =>
+ widget.destinationBuilder?.call(channel, link) ??
+ ChannelDetailPage(
+ channel: channel,
+ initialMessageId: link.messageId,
+ initialThreadRootId: link.threadRootId,
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart
index 8c2e0a80b6..28cedf9a2f 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -36,6 +37,7 @@ class ThreadDetailPage extends HookConsumerWidget {
final String? currentPubkey;
final bool isMember;
final bool isArchived;
+ final String? initialMessageId;
const ThreadDetailPage({
super.key,
@@ -45,6 +47,7 @@ class ThreadDetailPage extends HookConsumerWidget {
required this.currentPubkey,
required this.isMember,
required this.isArchived,
+ this.initialMessageId,
});
@override
@@ -73,6 +76,29 @@ class ThreadDetailPage extends HookConsumerWidget {
}
final replies = childrenByParent[threadHead.id] ?? const [];
+ final itemScrollController = useMemoized(ItemScrollController.new);
+ final didJumpToInitialMessage = useRef(false);
+ useEffect(() {
+ final messageId = initialMessageId;
+ // Wait for the authoritative thread query before consuming the one-shot
+ // jump; the fallback main-timeline list can contain only the linked reply.
+ if (messageId == null || fetchedReplies == null) return null;
+ final chronologicalIndex = replies.indexWhere(
+ (reply) => reply.id == messageId,
+ );
+ final targetIndex = messageId == threadHead.id
+ ? replies.length
+ : chronologicalIndex < 0
+ ? null
+ : replies.length - 1 - chronologicalIndex;
+ if (targetIndex == null || didJumpToInitialMessage.value) return null;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted || !itemScrollController.isAttached) return;
+ itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
+ didJumpToInitialMessage.value = true;
+ });
+ return null;
+ }, [initialMessageId, fetchedReplies, replies.length]);
final readState = ref.watch(readStateProvider);
final visibleReplyReadKey = replies
.map((reply) => '${reply.id}:${reply.createdAt}')
@@ -123,7 +149,8 @@ class ThreadDetailPage extends HookConsumerWidget {
body: Column(
children: [
Expanded(
- child: ListView.builder(
+ child: ScrollablePositionedList.builder(
+ itemScrollController: itemScrollController,
// Reversed so the list opens pinned to the newest reply,
// matching the channel message list.
reverse: true,
diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart
new file mode 100644
index 0000000000..3bdd505fd6
--- /dev/null
+++ b/mobile/lib/shared/deeplink/deep_link.dart
@@ -0,0 +1,63 @@
+/// Parsing for `buzz://` deep links.
+///
+/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
+/// `buzz://message?channel=&id=[&thread=]` references a
+/// message (optionally inside a thread) in a channel. Required params that
+/// are missing or empty make the link invalid — the caller never sees a
+/// half-formed target.
+library;
+
+/// A parsed `buzz://message` deep link.
+class MessageDeepLink {
+ /// Channel UUID from the `channel` query param.
+ final String channelId;
+
+ /// Event ID (hex) from the `id` query param.
+ final String messageId;
+
+ /// Optional thread root event ID from the `thread` query param.
+ final String? threadRootId;
+
+ const MessageDeepLink({
+ required this.channelId,
+ required this.messageId,
+ this.threadRootId,
+ });
+
+ @override
+ bool operator ==(Object other) =>
+ other is MessageDeepLink &&
+ other.channelId == channelId &&
+ other.messageId == messageId &&
+ other.threadRootId == threadRootId;
+
+ @override
+ int get hashCode => Object.hash(channelId, messageId, threadRootId);
+
+ @override
+ String toString() =>
+ 'MessageDeepLink(channel: $channelId, id: $messageId, '
+ 'thread: $threadRootId)';
+}
+
+/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
+///
+/// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g.
+/// `buzz://connect` which is desktop-only), or links missing a non-empty
+/// `channel` or `id` param.
+MessageDeepLink? parseMessageDeepLink(Uri uri) {
+ if (uri.scheme != 'buzz' || uri.host != 'message') return null;
+
+ final channel = uri.queryParameters['channel'];
+ final id = uri.queryParameters['id'];
+ if (channel == null || channel.isEmpty || id == null || id.isEmpty) {
+ return null;
+ }
+
+ final thread = uri.queryParameters['thread'];
+ return MessageDeepLink(
+ channelId: channel,
+ messageId: id,
+ threadRootId: (thread == null || thread.isEmpty) ? null : thread,
+ );
+}
diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
new file mode 100644
index 0000000000..250ffdbd27
--- /dev/null
+++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart
@@ -0,0 +1,52 @@
+import 'dart:async';
+
+import 'package:app_links/app_links.dart';
+import 'package:flutter/foundation.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import 'deep_link.dart';
+
+/// Holds the most recent `buzz://message` deep link that has not been
+/// dispatched yet.
+///
+/// Listens to [AppLinks.uriLinkStream], which delivers both the cold-start
+/// link (the URL that launched the app) and links received while running.
+/// Navigation cannot always happen the moment a link arrives — the user may
+/// not be authenticated yet, or channels may still be loading — so the parsed
+/// link is parked here and consumed by the dispatcher once the app is ready.
+class PendingDeepLinkNotifier extends Notifier {
+ @visibleForTesting
+ static Stream? debugUriStreamOverride;
+
+ StreamSubscription? _subscription;
+
+ @override
+ MessageDeepLink? build() {
+ final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream;
+ _subscription = stream.listen(handleUri);
+ ref.onDispose(() {
+ _subscription?.cancel();
+ _subscription = null;
+ });
+ return null;
+ }
+
+ /// Parse and park an incoming URI. Unsupported links are ignored loudly.
+ @visibleForTesting
+ void handleUri(Uri uri) {
+ final link = parseMessageDeepLink(uri);
+ if (link == null) {
+ debugPrint('deep-link: ignoring unsupported link: $uri');
+ return;
+ }
+ state = link;
+ }
+
+ /// Clear the pending link after it has been dispatched (or dropped).
+ void consume() => state = null;
+}
+
+final pendingDeepLinkProvider =
+ NotifierProvider(
+ PendingDeepLinkNotifier.new,
+ );
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index 260de02c11..dc062b8628 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -49,6 +49,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.10"
+ app_links:
+ dependency: "direct main"
+ description:
+ name: app_links
+ sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.4.1"
+ app_links_linux:
+ dependency: transitive
+ description:
+ name: app_links_linux
+ sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.3"
+ app_links_platform_interface:
+ dependency: transitive
+ description:
+ name: app_links_platform_interface
+ sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.2"
+ app_links_web:
+ dependency: transitive
+ description:
+ name: app_links_web
+ sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.4"
args:
dependency: transitive
description:
@@ -480,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.6"
+ gtk:
+ dependency: transitive
+ description:
+ name: gtk
+ sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
highlight:
dependency: "direct main"
description:
@@ -968,6 +1008,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
+ scrollable_positioned_list:
+ dependency: "direct main"
+ description:
+ name: scrollable_positioned_list
+ sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.8"
shared_preferences:
dependency: "direct main"
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 11d1d7888c..f360bfe7d1 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -30,6 +30,8 @@ dependencies:
video_player: ^2.10.1
package_info_plus: ^10.0.0
app_badge_plus: ^1.2.10
+ app_links: ^6.4.0
+ scrollable_positioned_list: ^0.3.8
dev_dependencies:
flutter_test:
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 57f0bbfeaf..629776ef13 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
@@ -476,14 +477,11 @@ void main() {
);
await tester.pumpAndSettle();
- final listView = tester.widget(
+ final listView = tester.widget(
find.byKey(const ValueKey('channel-message-list')),
);
- final controller = listView.controller!;
- expect(controller.position.maxScrollExtent, greaterThan(0));
-
- controller.jumpTo(controller.position.maxScrollExtent);
- await tester.pump();
+ listView.itemScrollController!.jumpTo(index: 39);
+ await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
@@ -504,7 +502,6 @@ void main() {
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
await tester.pumpAndSettle();
- expect(controller.position.pixels, lessThanOrEqualTo(1));
expect(findRichText('Newest live update'), findsOneWidget);
});
diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart
index 0b102593eb..8885b55e16 100644
--- a/mobile/test/features/channels/channel_messages_provider_test.dart
+++ b/mobile/test/features/channels/channel_messages_provider_test.dart
@@ -95,6 +95,41 @@ void main() {
},
);
+ test(
+ 'waits for initial history before publishing and preserves deep-link target',
+ () async {
+ final relaySession = _RecordingRelaySessionNotifier();
+ final container = _buildContainer(relaySession);
+ addTearDown(container.dispose);
+
+ container.read(channelMessagesProvider(_channelId));
+ await relaySession.subscribed;
+ final notifier = container.read(
+ channelMessagesProvider(_channelId).notifier,
+ );
+
+ final targetLoad = notifier.loadEventsById(const ['target']);
+ relaySession.completeTargetHistory([_event(id: 'target', createdAt: 5)]);
+ await targetLoad;
+
+ expect(
+ container.read(channelMessagesProvider(_channelId)).isLoading,
+ isTrue,
+ );
+
+ relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
+ await _pumpEventQueue();
+
+ expect(
+ container
+ .read(channelMessagesProvider(_channelId))
+ .value
+ ?.map((event) => event.id),
+ ['target', 'history'],
+ );
+ },
+ );
+
test('window pagination failures return false without exhausting', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
@@ -196,6 +231,7 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final List _listeners = [];
final Completer _subscribed = Completer();
final Completer> _history = Completer>();
+ final Queue>> _targetHistories = Queue();
_RecordingRelaySessionNotifier({
this.failSubscribe = false,
@@ -227,6 +263,11 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
}) {
operations.add('fetch');
historyFilters.add(filter);
+ if (filter.ids != null) {
+ final completer = Completer>();
+ _targetHistories.add(completer);
+ return completer.future;
+ }
return _history.future;
}
@@ -256,6 +297,10 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
}
}
+ void completeTargetHistory(List events) {
+ _targetHistories.removeFirst().complete(events);
+ }
+
void completeHistory(List events) {
if (!_history.isCompleted) {
_history.complete(events);
diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart
new file mode 100644
index 0000000000..452c25cb9c
--- /dev/null
+++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart
@@ -0,0 +1,89 @@
+import 'package:buzz/features/channels/channel.dart';
+import 'package:buzz/features/channels/channels_provider.dart';
+import 'package:buzz/features/channels/deep_link_dispatcher.dart';
+import 'package:buzz/shared/deeplink/deep_link.dart';
+import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+void main() {
+ testWidgets('dispatches a link that is already ready on mount', (
+ tester,
+ ) async {
+ const link = MessageDeepLink(
+ channelId: 'channel-1',
+ messageId: 'message-2',
+ threadRootId: 'message-1',
+ );
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pendingDeepLinkProvider.overrideWith(
+ () => _FakePendingDeepLinkNotifier(link),
+ ),
+ channelsProvider.overrideWith(
+ () => _FakeChannelsNotifier(Future.value([_channel])),
+ ),
+ ],
+ child: MaterialApp(
+ home: DeepLinkDispatcher(
+ destinationBuilder: (channel, link) =>
+ _CapturedDestination(channel: channel, link: link),
+ child: const Scaffold(body: SizedBox()),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ final destination = tester.widget<_CapturedDestination>(
+ find.byType(_CapturedDestination),
+ );
+ expect(destination.channel.id, 'channel-1');
+ expect(destination.link.messageId, 'message-2');
+ expect(destination.link.threadRootId, 'message-1');
+ });
+}
+
+final _channel = Channel(
+ id: 'channel-1',
+ name: 'general',
+ channelType: 'stream',
+ visibility: 'open',
+ description: 'General discussion',
+ createdBy: 'creator',
+ createdAt: DateTime(2026),
+ memberCount: 2,
+ isMember: true,
+);
+
+class _FakePendingDeepLinkNotifier extends PendingDeepLinkNotifier {
+ _FakePendingDeepLinkNotifier(this.link);
+
+ final MessageDeepLink link;
+
+ @override
+ MessageDeepLink? build() => link;
+}
+
+class _FakeChannelsNotifier extends ChannelsNotifier {
+ _FakeChannelsNotifier(this.channels);
+
+ final Future> channels;
+
+ @override
+ Future> build() => channels;
+}
+
+class _CapturedDestination extends StatelessWidget {
+ const _CapturedDestination({required this.channel, required this.link});
+
+ final Channel channel;
+ final MessageDeepLink link;
+
+ @override
+ Widget build(BuildContext context) => const SizedBox();
+}
diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart
new file mode 100644
index 0000000000..7624cbe71d
--- /dev/null
+++ b/mobile/test/shared/deeplink/deep_link_test.dart
@@ -0,0 +1,63 @@
+import 'package:buzz/shared/deeplink/deep_link.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ group('parseMessageDeepLink', () {
+ test('parses channel and id', () {
+ final link = parseMessageDeepLink(
+ Uri.parse('buzz://message?channel=d14cd131&id=abc123'),
+ );
+ expect(
+ link,
+ const MessageDeepLink(channelId: 'd14cd131', messageId: 'abc123'),
+ );
+ });
+
+ test('parses optional thread param', () {
+ final link = parseMessageDeepLink(
+ Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread=root99'),
+ );
+ expect(link?.threadRootId, 'root99');
+ });
+
+ test('treats empty thread as absent', () {
+ final link = parseMessageDeepLink(
+ Uri.parse('buzz://message?channel=d14cd131&id=abc123&thread='),
+ );
+ expect(link, isNotNull);
+ expect(link?.threadRootId, isNull);
+ });
+
+ test('rejects missing channel', () {
+ expect(parseMessageDeepLink(Uri.parse('buzz://message?id=abc')), isNull);
+ });
+
+ test('rejects empty channel', () {
+ expect(
+ parseMessageDeepLink(Uri.parse('buzz://message?channel=&id=abc')),
+ isNull,
+ );
+ });
+
+ test('rejects missing id', () {
+ expect(
+ parseMessageDeepLink(Uri.parse('buzz://message?channel=d14cd131')),
+ isNull,
+ );
+ });
+
+ test('rejects non-buzz scheme', () {
+ expect(
+ parseMessageDeepLink(Uri.parse('https://message?channel=a&id=b')),
+ isNull,
+ );
+ });
+
+ test('rejects non-message host (connect is desktop-only)', () {
+ expect(
+ parseMessageDeepLink(Uri.parse('buzz://connect?relay=wss://x')),
+ isNull,
+ );
+ });
+ });
+}