fix(mobile): keep the unread jump after a deep boundary give-up

The unread-boundary effect bailed out entirely once it gave up paging
toward the target, so a channel whose oldest unread sat further back
than the four-fetch cap never offered the jump control again for that
visit, even after the user scrolled the target into view themselves.

Scope the give-up flag to fetching, which is what it is for, and let
candidate resolution keep running. Direction of the bug was fail-closed
(a missing control, never a wrong jump), so this only restores a
control that PR #4239's predecessor head offered.

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1w85l93z2dyetvaev42kvmgv3r5qsgc7rutrvgpqshqefj4sydqqskwstfm
2026-08-03 17:58:38 -07:00
co-authored by Tom Brow
parent d5da74e4e0
commit bde10f65cf
2 changed files with 93 additions and 1 deletions
@@ -64,7 +64,6 @@ class _MessageList extends HookConsumerWidget {
if (!hasInitialUnread ||
hasUnreadDeepLink ||
oldestUnreadMessageId.value != null ||
unreadBoundaryLoadFailed.value ||
entries.isEmpty) {
return null;
}
@@ -104,6 +103,8 @@ class _MessageList extends HookConsumerWidget {
return () => cancelled = true;
}
// Giving up only stops further fetching. Resolution below still runs,
// so a target the user pages in later can still be offered.
if (hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest) {
@@ -1758,6 +1758,97 @@ void main() {
);
});
testWidgets(
'still offers the unread jump once the user pages the target in',
(tester) async {
// The unread boundary sits further back than the automatic fetch cap
// can reach, so the initial load gives up before the target arrives.
// A user who keeps scrolling brings that page in themselves, and the
// jump control has to come back when it does.
List<NostrEvent> page(int from, int to) => [
for (var i = from; i < to; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(
page(250, 300),
olderPages: [
page(200, 250),
page(150, 200),
page(100, 150),
page(50, 100),
page(0, 50),
],
);
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,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
// The cap is spent and the target is still missing, so no control yet.
expect(messagesNotifier.fetchOlderCalls, 4);
expect(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
findsNothing,
);
// The user scrolls back until the notifier runs out of history.
final list = find.byKey(const ValueKey('channel-message-list'));
for (var i = 0; i < 40 && !messagesNotifier.reachedOldest; i++) {
await tester.drag(list, const Offset(0, 600));
await tester.pumpAndSettle();
}
expect(messagesNotifier.reachedOldest, isTrue);
// Tapping has to land on the unread message, not merely render a
// control: presence alone would pass even if the jump did nothing.
final unreadButton = find.byKey(
const ValueKey('channel-jump-to-oldest-unread'),
);
expect(unreadButton, findsOneWidget);
await tester.tap(unreadButton);
await tester.pumpAndSettle();
expect(findRichText('Message 5'), findsOneWidget);
},
);
testWidgets('can jump back to latest after a non-drag user scroll', (
tester,
) async {