feat(mobile): add channel scroll navigation (#4239)

**Category:** improvement
**User Impact:** Mobile readers can jump directly to their oldest unread
message and return to the latest message with compact directional
controls.
**Problem:** Opening an active channel at its newest message makes it
easy to miss where unread conversation began, while moving back through
history lacks a lightweight route to the live edge.
**Solution:** Capture the channel's unread boundary when it opens, offer
an accessible up-chevron beneath the app bar to reach that stable
target, then reveal the inverse down-chevron at the bottom whenever the
reader is away from latest. Deep links retain precedence, and
live-follow, pagination, composer resizing, and explicit scroll
ownership continue to use the existing timeline behavior.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/channel_detail_page.dart**
Captures the channel's read state at open time and passes a stable
unread snapshot into the timeline before the normal deferred read update
advances it.

**mobile/lib/features/channels/channel_detail_page/message_list.dart**
Adds mutually exclusive oldest-unread and latest navigation, with
accessible icon controls positioned at opposite edges of the message
surface while preserving existing follow and deep-link behavior.

**mobile/test/features/channels/channel_detail_page_test.dart**
Covers the unread target, compact inverse controls, accessible tooltips,
and placement beneath the frosted app bar.

</details>

## Reproduction steps

1. Open a Flutter mobile channel that has unread messages without
entering through a message or thread deep link.
2. Confirm an up-chevron appears directly below the channel app bar
while the timeline remains at latest.
3. Tap the up-chevron and confirm the timeline scrolls to the oldest
message that was unread when the channel opened.
4. Confirm the unread control is replaced by a down-chevron at the
bottom of the timeline.
5. Tap the down-chevron and confirm the timeline returns to latest and
resumes following new messages.

## Screenshots

| At latest — up-chevron to oldest unread | Away from latest —
down-chevron to latest |
|---|---|
| ![Up-chevron beneath the mobile channel app
bar](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4239/buzz-mobile-scroll-to-oldest-unread.png)
| ![Down-chevron above the mobile channel
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4239/buzz-mobile-scroll-to-latest.png)
|

_Real iPhone 17 Pro Simulator captures from the neutral
`buzz-mobile-scroll-to` channel._

Originating Buzz thread:
`buzz://message?channel=5b16c478-22d8-4ddd-951a-6036e19b81ff&id=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741&thread=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-03 17:48:41 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent b29c8cdaa4
commit d5da74e4e0
3 changed files with 906 additions and 12 deletions
@@ -34,6 +34,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 'composer_dock_size_reporter.dart';
import 'date_formatters.dart';
@@ -44,6 +45,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';
@@ -134,6 +136,52 @@ 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 initialOrdinaryUnreadMessageIdsRef = useRef<Set<String>>(const {});
final initialOldestOrdinaryUnreadMessageIdRef = useRef<String?>(null);
final initialForcedUnreadMessageIdsRef = useRef<Set<String>>(const {});
final didCaptureInitialReadAt = useRef(false);
if (readState.isReady && !didCaptureInitialReadAt.value) {
final channelReadAt = readState.effectiveTimestamp(channel.id);
final ordinaryUnreadEvents = [
for (final event
in channelsNotifier
.observedUnreadEventsByChannel[channel.id]
?.values ??
const <ObservedUnreadEvent>[])
if (event.rootId == null &&
event.createdAt >
(observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => readState.effectiveTimestamp(
threadContextKey(rootId),
),
(messageId) => readState.effectiveTimestamp(
msgContextKey(messageId),
),
) ??
0))
event,
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
initialOrdinaryUnreadMessageIdsRef.value = {
for (final event in ordinaryUnreadEvents) event.id,
};
initialOldestOrdinaryUnreadMessageIdRef.value =
ordinaryUnreadEvents.firstOrNull?.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 initialOrdinaryUnreadMessageIds =
initialOrdinaryUnreadMessageIdsRef.value;
final initialOldestOrdinaryUnreadMessageId =
initialOldestOrdinaryUnreadMessageIdRef.value;
final initialForcedUnreadMessageIds =
initialForcedUnreadMessageIdsRef.value;
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
@@ -207,14 +255,28 @@ 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,
?initialOldestOrdinaryUnreadMessageId,
...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,
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds,
],
);
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
@@ -378,6 +440,19 @@ class ChannelDetailPage extends HookConsumerWidget {
allMessages: messages,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
initialOrdinaryUnreadMessageIds:
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId:
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds:
initialForcedUnreadMessageIds,
hasInitialUnread:
readState.isReady &&
(readState.isForcedUnread(channel.id) ||
initialForcedUnreadMessageIds
.isNotEmpty ||
initialOldestOrdinaryUnreadMessageId !=
null),
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
@@ -5,6 +5,10 @@ class _MessageList extends HookConsumerWidget {
final List<TimelineMessage> allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
final Set<String> initialOrdinaryUnreadMessageIds;
final String? initialOldestOrdinaryUnreadMessageId;
final Set<String> initialForcedUnreadMessageIds;
final bool hasInitialUnread;
final String channelId;
final String? currentPubkey;
final bool isMember;
@@ -17,6 +21,10 @@ class _MessageList extends HookConsumerWidget {
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
required this.initialOrdinaryUnreadMessageIds,
required this.initialOldestOrdinaryUnreadMessageId,
required this.initialForcedUnreadMessageIds,
required this.hasInitialUnread,
required this.channelId,
required this.currentPubkey,
required this.isMember,
@@ -42,6 +50,100 @@ class _MessageList extends HookConsumerWidget {
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
final isUnreadNavigationDismissed = useState(false);
final detachedWhileUnreadShown = useRef(false);
final oldestUnreadMessageId = useState<String?>(null);
final unreadBoundaryLoadFailed = useState(false);
final unreadBoundaryFetchCount = useRef(0);
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 hasLoadedOrdinaryTarget =
initialOldestOrdinaryUnreadMessageId != null &&
entries.any(
(entry) =>
entry.message.id == initialOldestOrdinaryUnreadMessageId,
);
final hasLoadedForcedTarget = entries.any(
(entry) => initialForcedUnreadMessageIds.contains(entry.message.id),
);
final hasKnownTarget =
initialOldestOrdinaryUnreadMessageId != null ||
initialForcedUnreadMessageIds.isNotEmpty;
final hasLoadedFetchTarget =
initialOldestOrdinaryUnreadMessageId != null
? hasLoadedOrdinaryTarget
: hasLoadedForcedTarget;
final canFetchTarget =
hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest &&
unreadBoundaryFetchCount.value < 4;
if (canFetchTarget) {
unreadBoundaryFetchCount.value += 1;
var cancelled = false;
unawaited(
Future<void>(() async {
final loaded = await notifier.fetchOlder();
if (!cancelled && !loaded && !notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
}),
);
return () => cancelled = true;
}
if (hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
final ordinaryUnread = entries
.where(
(entry) =>
initialOrdinaryUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final forcedUnread = entries
.where(
(entry) =>
initialForcedUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
oldestUnreadMessageId.value = candidates.firstOrNull?.id;
return null;
},
[
hasInitialUnread,
hasUnreadDeepLink,
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds,
entries.length,
notifier.reachedOldest,
unreadBoundaryLoadFailed.value,
],
);
final showUnreadNavigation =
!isUnreadNavigationDismissed.value &&
oldestUnreadMessageId.value != null;
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
@@ -72,6 +174,30 @@ class _MessageList extends HookConsumerWidget {
}
}
Future<void> scrollToOldestUnread() async {
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
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;
@@ -99,6 +225,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) {
@@ -237,6 +368,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((_) {
@@ -354,7 +488,30 @@ class _MessageList extends HookConsumerWidget {
),
),
),
if (!isAtLatest.value)
if (showUnreadNavigation)
Positioned(
left: 0,
right: 0,
top:
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
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,
@@ -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';
@@ -29,6 +30,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';
@@ -1182,6 +1184,580 @@ 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 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,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: messages,
channelsNotifier: channelsNotifier,
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));
final unreadRect = tester.getRect(unreadButton);
expect(
unreadRect.top,
frostedAppBarHeight(tester.element(unreadButton)) + Grid.xs,
);
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.text('Latest'), findsOneWidget);
expect(find.byIcon(LucideIcons.arrowDown), 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 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,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
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);
expect(messagesNotifier.fetchOlderCalls, 1);
});
testWidgets('does not load history for threaded-only unread events', (
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 channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'thread-reply',
createdAt: 1021,
rootId: 'thread-root',
highPriority: true,
channelType: 'stream',
isThreadedReply: true,
),
],
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
),
);
await tester.pumpAndSettle();
expect(messagesNotifier.fetchOlderCalls, 0);
expect(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
findsNothing,
);
});
testWidgets('caps unread target history loading', (tester) async {
final newestPage = [
for (var i = 250; i < 300; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final olderPages = [
for (var page = 4; page >= 0; page--)
[
for (var i = page * 50; i < (page + 1) * 50; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
],
];
final messagesNotifier = _FakeMessagesNotifier(
newestPage,
olderPages: olderPages,
);
final channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'missing-target',
createdAt: 1001,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
),
makeObservedUnreadEvent(
id: 'msg275',
createdAt: 1275,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
),
],
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
),
);
await tester.pumpAndSettle();
expect(messagesNotifier.fetchOlderCalls, 4);
final unreadButton = find.byKey(
const ValueKey('channel-jump-to-oldest-unread'),
);
expect(unreadButton, findsOneWidget);
await tester.tap(unreadButton);
await tester.pumpAndSettle();
expect(findRichText('Message 275'), findsOneWidget);
});
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,
forcedUnreadContexts: {_channelId: _channelId},
),
);
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('falls back when the oldest unread row is deleted', (
tester,
) async {
final messagesNotifier = _FakeMessagesNotifier([
_textMsg(
id: 'deleted-oldest',
pubkey: 'alice',
content: 'Deleted oldest',
createdAt: 1021,
),
_textMsg(
id: 'reachable-unread',
pubkey: 'alice',
content: 'Reachable unread',
createdAt: 1022,
),
_deletion(
id: 'delete-oldest',
targetIds: ['deleted-oldest'],
createdAt: 1023,
),
]);
final channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'deleted-oldest',
createdAt: 1021,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
),
makeObservedUnreadEvent(
id: 'reachable-unread',
createdAt: 1022,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
),
],
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
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);
await tester.tap(unreadButton);
await tester.pumpAndSettle();
expect(findRichText('Reachable unread'), findsOneWidget);
});
testWidgets('pages past a loaded forced unread for an older target', (
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 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,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
forcedUnreadContexts: {'msg:msg75': _channelId},
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(messagesNotifier.fetchOlderCalls, 1);
await tester.tap(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
);
await tester.pumpAndSettle();
expect(findRichText('Message 21'), findsOneWidget);
expect(findRichText('Message 75'), 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('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 {
@@ -1398,6 +1974,67 @@ void main() {
expect(findRichText('Newest live update'), findsNothing);
});
testWidgets(
'gives an initial deep link precedence over unread navigation',
(tester) async {
final initialMessages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(initialMessages);
final channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'msg5',
createdAt: 1005,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
),
],
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {_channelId: 1004},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
initialMessageId: 'msg20',
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('Message 20'), findsOneWidget);
expect(findRichText('Message 5'), findsNothing);
expect(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
findsNothing,
);
expect(messagesNotifier.fetchOlderCalls, 0);
},
);
testWidgets(
'keeps a deep-linked message in view when its page arrives after a '
'small scroll near the latest message',
@@ -3187,12 +3824,18 @@ Channel _channel({required String id, required String name}) => Channel(
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
List<NostrEvent> _messages;
bool _hasLoadedMessages;
final List<List<NostrEvent>> _olderPages;
final bool failOlderFetch;
int fetchOlderCalls = 0;
_FakeMessagesNotifier(
this._messages, {
String channelId = _channelId,
bool hasLoadedMessages = true,
List<List<NostrEvent>> olderPages = const [],
this.failOlderFetch = false,
}) : _hasLoadedMessages = hasLoadedMessages,
_olderPages = [...olderPages],
super(channelId);
@override
@@ -3202,10 +3845,17 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier {
bool get hasLoadedMessages => _hasLoadedMessages;
@override
bool get reachedOldest => true;
bool get reachedOldest => _olderPages.isEmpty && !failOlderFetch;
@override
Future<bool> fetchOlder() async => false;
Future<bool> fetchOlder() async {
fetchOlderCalls += 1;
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<NostrEvent> messages) {
_messages = messages;
@@ -3286,7 +3936,19 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
class _FakeChannelsNotifier extends ChannelsNotifier {
List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
final Map<String, Map<String, ObservedUnreadEvent>> _observedUnread;
_FakeChannelsNotifier(
this._channels, {
Map<String, List<ObservedUnreadEvent>> observedUnread = const {},
}) : _observedUnread = {
for (final entry in observedUnread.entries)
entry.key: {for (final event in entry.value) event.id: event},
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedUnread;
@override
Future<List<Channel>> build() => SynchronousFuture(_channels);