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, + ); + }); + }); +}