From ace46ee95cc9ca6a9a47459a1fd41885478b9c93 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 09:53:44 -0700 Subject: [PATCH 1/5] feat(mobile): add unread scroll navigation Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_detail_page.dart | 15 ++++ .../channel_detail_page/message_list.dart | 87 +++++++++++++++++-- .../channels/channel_detail_page_test.dart | 54 ++++++++++++ 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 044342d101..1955d22912 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -130,6 +130,13 @@ class ChannelDetailPage extends HookConsumerWidget { final messagesState = ref.watch(channelMessagesProvider(channel.id)); final sessionStatus = ref.watch(relaySessionProvider).status; final readState = ref.watch(readStateProvider); + final initialChannelReadAtRef = useRef(null); + final didCaptureInitialReadAt = useRef(false); + if (readState.isReady && !didCaptureInitialReadAt.value) { + initialChannelReadAtRef.value = readState.effectiveTimestamp(channel.id); + didCaptureInitialReadAt.value = true; + } + final initialChannelReadAt = initialChannelReadAtRef.value; final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) @@ -360,6 +367,14 @@ class ChannelDetailPage extends HookConsumerWidget { allMessages: messages, initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, + initialChannelReadAt: initialChannelReadAt, + hasInitialUnread: + readState.isReady && + (readState.isForcedUnread(channel.id) || + (readTimestamp != null && + (initialChannelReadAt == null || + readTimestamp > + initialChannelReadAt))), 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 eacba5856e..6826a91ef4 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -5,6 +5,8 @@ class _MessageList extends HookConsumerWidget { final List allMessages; final String? initialMessageId; final String? initialThreadRootId; + final int? initialChannelReadAt; + final bool hasInitialUnread; final String channelId; final String? currentPubkey; final bool isMember; @@ -16,6 +18,8 @@ class _MessageList extends HookConsumerWidget { required this.allMessages, required this.initialMessageId, required this.initialThreadRootId, + required this.initialChannelReadAt, + required this.hasInitialUnread, required this.channelId, required this.currentPubkey, required this.isMember, @@ -40,6 +44,25 @@ class _MessageList extends HookConsumerWidget { final previousLatestEntryId = useRef(null); final didOpenInitialThread = useRef(false); final didJumpToInitialMessage = useRef(false); + final isUnreadNavigationDismissed = useState(false); + final detachedWhileUnreadShown = useRef(false); + final oldestUnreadMessageIdRef = useRef(null); + if (hasInitialUnread && oldestUnreadMessageIdRef.value == null) { + final readAt = initialChannelReadAt; + final unread = entries + .where((entry) => readAt == null || entry.message.createdAt > readAt) + .map((entry) => entry.message) + .firstOrNull; + oldestUnreadMessageIdRef.value = + unread?.id ?? (entries.isEmpty ? null : entries.last.message.id); + } + final oldestUnreadMessageId = + initialMessageId == null && initialThreadRootId == null + ? oldestUnreadMessageIdRef.value + : null; + + final showUnreadNavigation = + !isUnreadNavigationDismissed.value && oldestUnreadMessageId != null; int? reversedIndexOf(String? messageId) { if (messageId == null) return null; @@ -70,6 +93,30 @@ class _MessageList extends HookConsumerWidget { } } + Future scrollToOldestUnread() async { + final targetIndex = reversedIndexOf(oldestUnreadMessageId); + if (targetIndex == null || + !itemScrollController.isAttached || + isAutoScrolling.value) { + return; + } + isUnreadNavigationDismissed.value = true; + followsLatest.value = false; + hasUserScrolled.value = false; + isAtLatest.value = false; + isAutoScrolling.value = true; + try { + await itemScrollController.scrollTo( + index: targetIndex, + alignment: 0.35, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } finally { + isAutoScrolling.value = false; + } + } + void scheduleAutoScrollToLatest() { if (autoScrollScheduled.value || isAutoScrolling.value) return; autoScrollScheduled.value = true; @@ -97,6 +144,11 @@ class _MessageList extends HookConsumerWidget { final positions = itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; final nextIsAtLatest = latestIsAtBoundary(); + if (showUnreadNavigation && + nextIsAtLatest && + detachedWhileUnreadShown.value) { + isUnreadNavigationDismissed.value = true; + } if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; } else if (followsLatest.value && !hasUserScrolled.value) { @@ -235,6 +287,9 @@ class _MessageList extends HookConsumerWidget { notification.direction != ScrollDirection.idle) { hasUserScrolled.value = true; followsLatest.value = false; + if (showUnreadNavigation) { + detachedWhileUnreadShown.value = true; + } } else if (notification is ScrollEndNotification && hasUserScrolled.value) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -352,25 +407,39 @@ class _MessageList extends HookConsumerWidget { ), ), ), - if (!isAtLatest.value) + if (showUnreadNavigation) + Positioned( + left: 0, + right: 0, + bottom: Grid.xs, + child: Center( + child: IconButton.filled( + key: const ValueKey('channel-jump-to-oldest-unread'), + onPressed: scrollToOldestUnread, + tooltip: 'Jump to oldest unread message', + style: IconButton.styleFrom( + backgroundColor: context.colors.primaryContainer, + foregroundColor: context.colors.onPrimaryContainer, + ), + icon: const Icon(LucideIcons.chevronUp, size: 20), + ), + ), + ) + else if (!isAtLatest.value) Positioned( left: 0, right: 0, bottom: Grid.xs, child: Center( - child: FilledButton.icon( + child: IconButton.filled( key: const ValueKey('channel-jump-to-latest'), onPressed: scrollToLatest, - style: FilledButton.styleFrom( + tooltip: 'Jump to latest message', + style: IconButton.styleFrom( backgroundColor: context.colors.primaryContainer, foregroundColor: context.colors.onPrimaryContainer, - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), ), - icon: const Icon(LucideIcons.arrowDown, size: 16), - label: const Text('Latest'), + icon: const Icon(LucideIcons.chevronDown, size: 20), ), ), ), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 899394de23..c445507a2d 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -923,6 +923,60 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('jumps to the oldest unread with compact inverse controls', ( + tester, + ) async { + final messages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 1020}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: messages, + readStateNotifier: readState, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final unreadButton = find.byKey( + const ValueKey('channel-jump-to-oldest-unread'), + ); + expect(unreadButton, findsOneWidget); + expect(find.byTooltip('Jump to oldest unread message'), findsOneWidget); + expect(find.byIcon(LucideIcons.chevronUp), findsOneWidget); + expect(tester.getSize(unreadButton), const Size.square(48)); + expect(find.text('Latest'), findsNothing); + + await tester.tap(unreadButton); + await tester.pumpAndSettle(); + + expect(findRichText('Message 21'), findsOneWidget); + expect(unreadButton, findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + expect(find.byTooltip('Jump to latest message'), findsOneWidget); + expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget); + }); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { From 30192c8d1fc13319441fdb287a54fe312e01c13b Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 17:39:23 -0700 Subject: [PATCH 2/5] fix(mobile): position unread navigation at top Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_detail_page/message_list.dart | 7 ++++++- .../test/features/channels/channel_detail_page_test.dart | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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 6826a91ef4..a42e83127e 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -411,7 +411,12 @@ class _MessageList extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: Grid.xs, + top: + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.xs, child: Center( child: IconButton.filled( key: const ValueKey('channel-jump-to-oldest-unread'), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index c445507a2d..0dceae2297 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -29,6 +29,7 @@ import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -962,6 +963,11 @@ void main() { expect(find.byTooltip('Jump to oldest unread message'), findsOneWidget); expect(find.byIcon(LucideIcons.chevronUp), findsOneWidget); expect(tester.getSize(unreadButton), const Size.square(48)); + final unreadRect = tester.getRect(unreadButton); + expect( + unreadRect.top, + frostedAppBarHeight(tester.element(unreadButton)) + Grid.xs, + ); expect(find.text('Latest'), findsNothing); await tester.tap(unreadButton); From 9a6d7e66be97f8db693cf2893233570657cfb1fd Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 21:08:24 -0700 Subject: [PATCH 3/5] fix(mobile): load through unread boundary Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_detail_page/message_list.dart | 71 +++++++--- .../channels/channel_detail_page_test.dart | 121 +++++++++++++++++- 2 files changed, 170 insertions(+), 22 deletions(-) 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 a42e83127e..9be6a688d4 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -46,23 +46,62 @@ class _MessageList extends HookConsumerWidget { final didJumpToInitialMessage = useRef(false); final isUnreadNavigationDismissed = useState(false); final detachedWhileUnreadShown = useRef(false); - final oldestUnreadMessageIdRef = useRef(null); - if (hasInitialUnread && oldestUnreadMessageIdRef.value == null) { - final readAt = initialChannelReadAt; - final unread = entries - .where((entry) => readAt == null || entry.message.createdAt > readAt) - .map((entry) => entry.message) - .firstOrNull; - oldestUnreadMessageIdRef.value = - unread?.id ?? (entries.isEmpty ? null : entries.last.message.id); - } - final oldestUnreadMessageId = - initialMessageId == null && initialThreadRootId == null - ? oldestUnreadMessageIdRef.value - : null; + final oldestUnreadMessageId = useState(null); + final unreadBoundaryLoadFailed = useState(false); + final hasUnreadDeepLink = + initialMessageId != null || initialThreadRootId != null; + final notifier = ref.read(channelMessagesProvider(channelId).notifier); + + useEffect( + () { + if (!hasInitialUnread || + hasUnreadDeepLink || + oldestUnreadMessageId.value != null || + unreadBoundaryLoadFailed.value || + entries.isEmpty) { + return null; + } + + final readAt = initialChannelReadAt; + final oldestLoadedAt = entries.first.message.createdAt; + final hasCrossedReadBoundary = + readAt != null && oldestLoadedAt <= readAt; + if (!hasCrossedReadBoundary && !notifier.reachedOldest) { + var cancelled = false; + unawaited( + Future(() async { + final loaded = await notifier.fetchOlder(); + if (!cancelled && !loaded && !notifier.reachedOldest) { + unreadBoundaryLoadFailed.value = true; + } + }), + ); + return () => cancelled = true; + } + + final unread = entries + .where( + (entry) => readAt == null || entry.message.createdAt > readAt, + ) + .map((entry) => entry.message) + .firstOrNull; + oldestUnreadMessageId.value = + unread?.id ?? (readAt == null ? entries.first.message.id : null); + return null; + }, + [ + hasInitialUnread, + hasUnreadDeepLink, + initialChannelReadAt, + entries.length, + notifier.reachedOldest, + unreadBoundaryLoadFailed.value, + ], + ); final showUnreadNavigation = - !isUnreadNavigationDismissed.value && oldestUnreadMessageId != null; + !isUnreadNavigationDismissed.value && + oldestUnreadMessageId.value != null; int? reversedIndexOf(String? messageId) { if (messageId == null) return null; @@ -94,7 +133,7 @@ class _MessageList extends HookConsumerWidget { } Future scrollToOldestUnread() async { - final targetIndex = reversedIndexOf(oldestUnreadMessageId); + final targetIndex = reversedIndexOf(oldestUnreadMessageId.value); if (targetIndex == null || !itemScrollController.isAttached || isAutoScrolling.value) { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 0dceae2297..dd30bc4362 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -983,6 +983,102 @@ void main() { expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget); }); + testWidgets('loads history through the oldest unread boundary', ( + tester, + ) async { + final newestPage = [ + for (var i = 50; i < 100; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final olderPage = [ + for (var i = 0; i < 50; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final messagesNotifier = _FakeMessagesNotifier( + newestPage, + olderPages: [olderPage], + ); + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 1020}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + readStateNotifier: readState, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-jump-to-oldest-unread')), + ); + await tester.pumpAndSettle(); + + expect(findRichText('Message 21'), findsOneWidget); + expect(findRichText('Message 50'), findsNothing); + }); + + testWidgets('stops loading the unread boundary after a failed page', ( + tester, + ) async { + final messages = [ + for (var i = 50; i < 100; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final messagesNotifier = _FakeMessagesNotifier( + messages, + failOlderFetch: true, + ); + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 1020}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + readStateNotifier: readState, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-jump-to-oldest-unread')), + findsNothing, + ); + expect(find.bySemanticsLabel('Loading older messages'), findsNothing); + }); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { @@ -2723,10 +2819,17 @@ Channel _channel({required String id, required String name}) => Channel( class _FakeMessagesNotifier extends ChannelMessagesNotifier { List _messages; bool _hasLoadedMessages; - - _FakeMessagesNotifier(this._messages, {bool hasLoadedMessages = true}) - : _hasLoadedMessages = hasLoadedMessages, - super(_channelId); + final List> _olderPages; + final bool failOlderFetch; + + _FakeMessagesNotifier( + this._messages, { + bool hasLoadedMessages = true, + List> olderPages = const [], + this.failOlderFetch = false, + }) : _hasLoadedMessages = hasLoadedMessages, + _olderPages = [...olderPages], + super(_channelId); @override AsyncValue> build() => AsyncData(_messages); @@ -2735,10 +2838,16 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { bool get hasLoadedMessages => _hasLoadedMessages; @override - bool get reachedOldest => true; + bool get reachedOldest => _olderPages.isEmpty && !failOlderFetch; @override - Future fetchOlder() async => false; + Future fetchOlder() async { + if (failOlderFetch || _olderPages.isEmpty) return false; + _messages = [..._olderPages.removeAt(0), ..._messages] + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + state = AsyncData(_messages); + return true; + } void setMessages(List messages) { _messages = messages; From 51409c6b95fcc8cdd9c1fe1507d4b8de427e5794 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 21:09:26 -0700 Subject: [PATCH 4/5] fix(mobile): target forced unread messages Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_detail_page.dart | 42 +++++++++++++---- .../channel_detail_page/message_list.dart | 15 ++++++- .../channels/channel_detail_page_test.dart | 45 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 1955d22912..df1642fdce 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -131,12 +131,20 @@ class ChannelDetailPage extends HookConsumerWidget { final sessionStatus = ref.watch(relaySessionProvider).status; final readState = ref.watch(readStateProvider); final initialChannelReadAtRef = useRef(null); + final initialForcedUnreadMessageIdsRef = useRef>(const {}); final didCaptureInitialReadAt = useRef(false); if (readState.isReady && !didCaptureInitialReadAt.value) { initialChannelReadAtRef.value = readState.effectiveTimestamp(channel.id); + initialForcedUnreadMessageIdsRef.value = { + for (final entry in readState.forcedUnreadContexts.entries) + if (entry.value == channel.id && entry.key.startsWith('msg:')) + entry.key.substring('msg:'.length), + }; didCaptureInitialReadAt.value = true; } final initialChannelReadAt = initialChannelReadAtRef.value; + final initialForcedUnreadMessageIds = + initialForcedUnreadMessageIdsRef.value; final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) @@ -201,14 +209,26 @@ 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 (channel.isForum) return null; + final eventIds = { + ?initialMessageId, + ?initialThreadRootId, + ...initialForcedUnreadMessageIds, + }; + if (eventIds.isEmpty) return null; + final notifier = ref.read(channelMessagesProvider(channel.id).notifier); + unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds)); + return () => notifier.releaseDeepLinkEvents(eventIds); + }, + [ + channel.id, + initialMessageId, + initialThreadRootId, + initialForcedUnreadMessageIds, + ], + ); useEffect(() { if (!readState.isReady || readTimestamp == null) { @@ -368,9 +388,13 @@ class ChannelDetailPage extends HookConsumerWidget { initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, initialChannelReadAt: initialChannelReadAt, + initialForcedUnreadMessageIds: + initialForcedUnreadMessageIds, hasInitialUnread: readState.isReady && - (readState.isForcedUnread(channel.id) || + (readState.locallyForcedChannelIds.contains( + channel.id, + ) || (readTimestamp != null && (initialChannelReadAt == null || readTimestamp > 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 9be6a688d4..7abf229cb9 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -6,6 +6,7 @@ class _MessageList extends HookConsumerWidget { final String? initialMessageId; final String? initialThreadRootId; final int? initialChannelReadAt; + final Set initialForcedUnreadMessageIds; final bool hasInitialUnread; final String channelId; final String? currentPubkey; @@ -19,6 +20,7 @@ class _MessageList extends HookConsumerWidget { required this.initialMessageId, required this.initialThreadRootId, required this.initialChannelReadAt, + required this.initialForcedUnreadMessageIds, required this.hasInitialUnread, required this.channelId, required this.currentPubkey, @@ -79,20 +81,31 @@ class _MessageList extends HookConsumerWidget { return () => cancelled = true; } + final forcedUnread = entries + .where( + (entry) => + initialForcedUnreadMessageIds.contains(entry.message.id), + ) + .map((entry) => entry.message) + .firstOrNull; final unread = entries .where( (entry) => readAt == null || entry.message.createdAt > readAt, ) .map((entry) => entry.message) .firstOrNull; + final candidates = [forcedUnread, unread].nonNulls.toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); oldestUnreadMessageId.value = - unread?.id ?? (readAt == null ? entries.first.message.id : null); + candidates.firstOrNull?.id ?? + (readAt == null ? entries.first.message.id : null); return null; }, [ hasInitialUnread, hasUnreadDeepLink, initialChannelReadAt, + initialForcedUnreadMessageIds, entries.length, notifier.reachedOldest, unreadBoundaryLoadFailed.value, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index dd30bc4362..42a3197b77 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1079,6 +1079,51 @@ void main() { expect(find.bySemanticsLabel('Loading older messages'), findsNothing); }); + testWidgets('targets the oldest message-level forced unread', ( + tester, + ) async { + final messages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 2000}, + version: 0, + forcedUnreadContexts: { + 'msg:msg20': _channelId, + 'msg:msg5': _channelId, + }, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: messages, + readStateNotifier: readState, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-jump-to-oldest-unread')), + ); + await tester.pumpAndSettle(); + + expect(findRichText('Message 5'), findsOneWidget); + expect(findRichText('Message 20'), findsNothing); + }); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { From c152f6fb56feec874e01fec9087a8aa854ea8b06 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 21:12:18 -0700 Subject: [PATCH 5/5] fix(mobile): align unread navigation semantics Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_detail_page.dart | 41 ++++++++-- .../channel_detail_page/message_list.dart | 23 ++++-- .../channels/channel_detail_page_test.dart | 81 ++++++++++++++++++- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index df1642fdce..feb5be24f6 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -31,6 +31,7 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; +import 'unread_badge/observed_unread_event.dart'; import 'compose_bar.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; @@ -41,6 +42,7 @@ import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'read_state/deferred_read_state_update.dart'; +import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; import 'reaction_row.dart'; @@ -130,11 +132,32 @@ class ChannelDetailPage extends HookConsumerWidget { final messagesState = ref.watch(channelMessagesProvider(channel.id)); final sessionStatus = ref.watch(relaySessionProvider).status; final readState = ref.watch(readStateProvider); + final channelsNotifier = ref.read(channelsProvider.notifier); final initialChannelReadAtRef = useRef(null); + final initialOrdinaryUnreadMessageIdsRef = useRef>(const {}); final initialForcedUnreadMessageIdsRef = useRef>(const {}); final didCaptureInitialReadAt = useRef(false); if (readState.isReady && !didCaptureInitialReadAt.value) { - initialChannelReadAtRef.value = readState.effectiveTimestamp(channel.id); + final channelReadAt = readState.effectiveTimestamp(channel.id); + initialChannelReadAtRef.value = channelReadAt; + initialOrdinaryUnreadMessageIdsRef.value = { + for (final event + in channelsNotifier + .observedUnreadEventsByChannel[channel.id] + ?.values ?? + const []) + if (event.createdAt > + (observedUnreadEventReadAt( + event, + channelReadAt, + (rootId) => + readState.effectiveTimestamp(threadContextKey(rootId)), + (messageId) => + readState.effectiveTimestamp(msgContextKey(messageId)), + ) ?? + 0)) + event.id, + }; initialForcedUnreadMessageIdsRef.value = { for (final entry in readState.forcedUnreadContexts.entries) if (entry.value == channel.id && entry.key.startsWith('msg:')) @@ -143,6 +166,8 @@ class ChannelDetailPage extends HookConsumerWidget { didCaptureInitialReadAt.value = true; } final initialChannelReadAt = initialChannelReadAtRef.value; + final initialOrdinaryUnreadMessageIds = + initialOrdinaryUnreadMessageIdsRef.value; final initialForcedUnreadMessageIds = initialForcedUnreadMessageIdsRef.value; final currentPubkey = ref @@ -388,17 +413,17 @@ class ChannelDetailPage extends HookConsumerWidget { initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, initialChannelReadAt: initialChannelReadAt, + initialOrdinaryUnreadMessageIds: + initialOrdinaryUnreadMessageIds, initialForcedUnreadMessageIds: initialForcedUnreadMessageIds, + isInitialChannelForcedUnread: readState + .isForcedUnread(channel.id), hasInitialUnread: readState.isReady && - (readState.locallyForcedChannelIds.contains( - channel.id, - ) || - (readTimestamp != null && - (initialChannelReadAt == null || - readTimestamp > - initialChannelReadAt))), + (readState.isForcedUnread(channel.id) || + initialForcedUnreadMessageIds.isNotEmpty || + initialOrdinaryUnreadMessageIds.isNotEmpty), 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 7abf229cb9..decb2bc41f 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -6,7 +6,9 @@ class _MessageList extends HookConsumerWidget { final String? initialMessageId; final String? initialThreadRootId; final int? initialChannelReadAt; + final Set initialOrdinaryUnreadMessageIds; final Set initialForcedUnreadMessageIds; + final bool isInitialChannelForcedUnread; final bool hasInitialUnread; final String channelId; final String? currentPubkey; @@ -20,7 +22,9 @@ class _MessageList extends HookConsumerWidget { required this.initialMessageId, required this.initialThreadRootId, required this.initialChannelReadAt, + required this.initialOrdinaryUnreadMessageIds, required this.initialForcedUnreadMessageIds, + required this.isInitialChannelForcedUnread, required this.hasInitialUnread, required this.channelId, required this.currentPubkey, @@ -81,31 +85,38 @@ class _MessageList extends HookConsumerWidget { return () => cancelled = true; } - final forcedUnread = entries + final ordinaryUnread = entries .where( (entry) => - initialForcedUnreadMessageIds.contains(entry.message.id), + initialOrdinaryUnreadMessageIds.contains(entry.message.id) || + (isInitialChannelForcedUnread && + (readAt == null || entry.message.createdAt > readAt)), ) .map((entry) => entry.message) .firstOrNull; - final unread = entries + final forcedUnread = entries .where( - (entry) => readAt == null || entry.message.createdAt > readAt, + (entry) => + initialForcedUnreadMessageIds.contains(entry.message.id), ) .map((entry) => entry.message) .firstOrNull; - final candidates = [forcedUnread, unread].nonNulls.toList() + final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); oldestUnreadMessageId.value = candidates.firstOrNull?.id ?? - (readAt == null ? entries.first.message.id : null); + (readAt == null && initialOrdinaryUnreadMessageIds.isEmpty + ? entries.first.message.id + : null); return null; }, [ hasInitialUnread, hasUnreadDeepLink, initialChannelReadAt, + initialOrdinaryUnreadMessageIds, initialForcedUnreadMessageIds, + isInitialChannelForcedUnread, entries.length, notifier.reachedOldest, unreadBoundaryLoadFailed.value, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 42a3197b77..497e94dbe5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -22,6 +22,7 @@ import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; +import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; @@ -936,6 +937,21 @@ void main() { createdAt: 1000 + i, ), ]; + final channelsNotifier = _FakeChannelsNotifier( + [_testChannel], + observedUnread: { + _channelId: [ + makeObservedUnreadEvent( + id: 'msg21', + createdAt: 1021, + rootId: null, + highPriority: false, + channelType: 'stream', + isThreadedReply: false, + ), + ], + }, + ); final readState = _SynchronousReadStateNotifier( const ReadStateState( isReady: true, @@ -948,6 +964,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: messages, + channelsNotifier: channelsNotifier, readStateNotifier: readState, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -1014,6 +1031,7 @@ void main() { pubkey: 'self', contexts: {_channelId: 1020}, version: 0, + forcedUnreadContexts: {_channelId: _channelId}, ), ); @@ -1060,6 +1078,7 @@ void main() { pubkey: 'self', contexts: {_channelId: 1020}, version: 0, + forcedUnreadContexts: {_channelId: _channelId}, ), ); @@ -1124,6 +1143,54 @@ void main() { expect(findRichText('Message 20'), findsNothing); }); + testWidgets('ignores newer events absent from observed unread state', ( + tester, + ) async { + final messages = [ + _textMsg( + id: 'read-message', + pubkey: 'alice', + content: 'Already read', + createdAt: 1000, + ), + _textMsg( + id: 'self-message', + pubkey: 'self', + content: 'My own newer message', + createdAt: 1100, + ), + _systemMsg( + id: 'system-message', + payload: const {'type': 'channel_created'}, + createdAt: 1200, + ), + ]; + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 1000}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: messages, + readStateNotifier: readState, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-jump-to-oldest-unread')), + findsNothing, + ); + }); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { @@ -2972,7 +3039,19 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { class _FakeChannelsNotifier extends ChannelsNotifier { List _channels; - _FakeChannelsNotifier(this._channels); + final Map> _observedUnread; + + _FakeChannelsNotifier( + this._channels, { + Map> observedUnread = const {}, + }) : _observedUnread = { + for (final entry in observedUnread.entries) + entry.key: {for (final event in entry.value) event.id: event}, + }; + + @override + Map> + get observedUnreadEventsByChannel => _observedUnread; @override Future> build() => SynchronousFuture(_channels);