From 916aed578804eb876c98b3fcd2a11cbefa264fa5 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Sat, 7 Nov 2026 12:34:31 -0600 Subject: [PATCH 1/2] fix(mobile): read mentions taller than the screen Opening an Inbox mention in a Solo chat could leave the row unread: a long agent answer never fits on a phone screen, and automatic reading only marked fully visible rows. Bottom catch-up also skipped mentions. - Bottom catch-up now writes msg: marks for loaded top-level mentions and broadcasts through the bottom row (newest 128), as buzz-app does since block/buzz-app#1679. - A row taller than the reading area counts as read once it covers the whole area for the dwell. Co-authored-by: BlakeBot <85bbd70443139fd5c5762e34c2e214240939eb243c37f98380f69fed3b3bb4a6@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Blake McAnally --- .../channel_detail_page/message_list.dart | 8 +- .../lib/features/channels/reading_marks.dart | 117 +++++++++----- .../features/channels/thread_detail_page.dart | 8 +- .../channels/channel_detail_page_test.dart | 75 ++++++++- .../features/channels/reading_marks_test.dart | 143 ++++++++++++++++++ 5 files changed, 310 insertions(+), 41 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 0948b3106b1..5aa53b2e9d5 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -632,8 +632,12 @@ class _MessageList extends HookConsumerWidget { final visible = []; for (final position in itemPositionsListener.itemPositions.value) { if (position.index >= displayEntries.length || - position.itemLeadingEdge < bottomEdge || - position.itemTrailingEdge > topEdge) { + !isReadableRow( + leadingEdge: position.itemLeadingEdge, + trailingEdge: position.itemTrailingEdge, + start: bottomEdge, + end: topEdge, + )) { continue; } final group = diff --git a/mobile/lib/features/channels/reading_marks.dart b/mobile/lib/features/channels/reading_marks.dart index 9e1dcc7c675..3e3df30d202 100644 --- a/mobile/lib/features/channels/reading_marks.dart +++ b/mobile/lib/features/channels/reading_marks.dart @@ -21,6 +21,10 @@ const readingDwell = Duration(milliseconds: 300); /// `activity:` and a thread writes `thread-activity:`. These /// catch-up marks read ordinary messages and that thread's replies. They do /// not read mentions, broadcasts or DMs. +/// - The same channel catch-up also gives each loaded top-level mention and +/// broadcast through the bottom row its own `msg:` mark, at most the newest +/// [catchUpMessageMarkLimit], so a mention taller than the screen is still +/// read. Messages the reader marked unread keep that intent. /// - Each fully visible message gets its own `msg:` mark unless its own mark, /// the channel mark, or (for an ordinary top-level message) `activity:` /// already reads it. A thread mark never counts here: a reply finds its @@ -29,7 +33,8 @@ const readingDwell = Duration(milliseconds: 300); /// Mark read does that. /// /// [loaded] is every message loaded for the channel or thread, including -/// replies. [visible] is the messages fully visible for the whole dwell. +/// replies. [visible] is the messages fully visible for the whole dwell; see +/// [isReadableRow]. /// [bottom] is the newest message when its bottom edge is visible at the /// bottom of the list; otherwise null. Set [threadRootId] when reading a /// thread, and [isRootThread] when its head is the thread root rather than a @@ -51,26 +56,12 @@ Map readingMarks({ int? markerOf(String contextId) => maxReadAt([marks[contextId], readState.effectiveTimestamp(contextId)]); - // A nested thread view shows only one branch, so reaching its bottom does - // not read the whole thread. - if (bottom != null && (threadRootId == null || isRootThread)) { - final catchUp = _catchUpMark( - channelId: channelId, - loaded: loaded, - bottom: bottom, - threadRootId: threadRootId, - markerOf: markerOf, - now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); - if (catchUp != null) marks[catchUp.key] = catchUp.value; - } - final self = currentPubkey?.toLowerCase(); - for (final message in visible) { - if (message.isSystem || message.pubkey.toLowerCase() == self) continue; + bool needsMark(TimelineMessage message) { + if (message.isSystem || message.pubkey.toLowerCase() == self) return false; final contextId = msgContextKey(message.id); // Automatic reading keeps a message the reader marked unread. - if (readState.isForcedUnread(contextId)) continue; + if (readState.isForcedUnread(contextId)) return false; final ordinary = readByChannelCatchUp( isDm: isDm, isReply: message.parentId != null && !_isBroadcast(message), @@ -81,35 +72,91 @@ Map readingMarks({ markerOf(channelId), if (ordinary) markerOf(activityContextKey(channelId)), ]); - if (readAt != null && readAt >= message.createdAt) continue; - marks[contextId] = message.createdAt; + return readAt == null || readAt < message.createdAt; + } + + void markMessage(TimelineMessage message) { + if (needsMark(message)) { + marks[msgContextKey(message.id)] = message.createdAt; + } + } + + // A nested thread view shows only one branch, so reaching its bottom does + // not read the whole thread. + if (bottom != null && + (threadRootId == null || isRootThread) && + _showsNewest( + loaded: loaded, + bottom: bottom, + threadRootId: threadRootId, + )) { + final catchUp = _catchUpMark( + channelId: channelId, + bottom: bottom, + threadRootId: threadRootId, + markerOf: markerOf, + now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + if (catchUp != null) marks[catchUp.key] = catchUp.value; + if (threadRootId == null && !isDm && self != null) { + final attention = [ + for (final message in loaded) + if (message.createdAt <= bottom.createdAt && + (message.parentId == null || _isBroadcast(message)) && + isHighPriorityEvent(message.tags, self) && + needsMark(message)) + message, + ]..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + attention.take(catchUpMessageMarkLimit).forEach(markMessage); + } } + + visible.forEach(markMessage); return marks; } +/// The most mentions and broadcasts one channel catch-up marks, as in +/// `buzz-app`, so a backlog cannot crowd older marks out of the synced budget. +const catchUpMessageMarkLimit = 128; + +/// Whether a row with these item-position edges counts as read: it lies +/// inside the reading area from [start] to [end], or it is taller than that +/// area and covers all of it. +bool isReadableRow({ + required double leadingEdge, + required double trailingEdge, + required double start, + required double end, +}) => + (leadingEdge >= start && trailingEdge <= end) || + (leadingEdge <= start && trailingEdge >= end); + +/// Whether [bottom] is the live bottom. A loaded message newer than the +/// bottom row means the list does not show it, for example after a jump +/// into history. +bool _showsNewest({ + required Iterable loaded, + required TimelineMessage bottom, + required String? threadRootId, +}) => !loaded.any( + (message) => + (threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) && + // The main timeline never renders these, so they can't be its bottom. + !isHiddenMembershipNotice(message) && + message.createdAt > bottom.createdAt && + (threadRootId != null + ? _threadRootOf(message) == threadRootId + : message.parentId == null || _isBroadcast(message)), +); + /// The catch-up mark for [bottom], or null when it would read nothing new. MapEntry? _catchUpMark({ required String channelId, - required Iterable loaded, required TimelineMessage bottom, required String? threadRootId, required int? Function(String contextId) markerOf, required int now, }) { - // A loaded message newer than the bottom row means the list does not show - // the live bottom, for example after a jump into history. - final showsNewest = !loaded.any( - (message) => - (threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) && - // The main timeline never renders these, so they can't be its bottom. - !isHiddenMembershipNotice(message) && - message.createdAt > bottom.createdAt && - (threadRootId != null - ? _threadRootOf(message) == threadRootId - : message.parentId == null || _isBroadcast(message)), - ); - if (!showsNewest) return null; - // Never read beyond the last rendered message: a message between that // row and a hidden lifecycle event may still arrive out of order. final cut = bottom.createdAt < now ? bottom.createdAt : now; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 87945397cc0..36b6b2c6423 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -733,8 +733,12 @@ class ThreadDetailPage extends HookConsumerWidget { 1 - ((Grid.xs + navigationBottomInset) / viewportHeight) + 0.01; final visible = []; for (final position in itemPositionsListener.itemPositions.value) { - if (position.itemLeadingEdge < topEdge || - position.itemTrailingEdge > bottomEdge) { + if (!isReadableRow( + leadingEdge: position.itemLeadingEdge, + trailingEdge: position.itemTrailingEdge, + start: topEdge, + end: bottomEdge, + )) { continue; } if (position.index == headIndex) { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9949730bc7b..23d18bd8849 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2663,6 +2663,71 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('reading the bottom of an archived Solo chat reads a tall ' + 'mention', (tester) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final solo = Channel( + id: _channelId, + name: 'Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + archivedAt: DateTime.utc(2025, 1, 2), + ); + final messages = [ + _textMsg( + id: 'tall', + pubkey: 'alice', + content: List.filled(120, 'A long answer line.').join('\n\n'), + createdAt: 1100, + extraTags: const [ + ['p', 'self'], + ], + ), + _textMsg( + id: 'latest', + pubkey: 'alice', + content: 'Latest', + createdAt: 1200, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + channel: solo, + personalChat: true, + signingPubkey: 'self', + channelMessagesNotifier: _FakeMessagesNotifier(messages), + readStateNotifier: readState, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.text('This channel is archived and read-only on mobile.'), + findsOneWidget, + ); + expect(readState.markedContexts, { + 'activity:$_channelId': 1200, + 'msg:tall': 1100, + }); + expect(tester.takeException(), isNull); + }); + testWidgets('hidden huddle end does not block bottom catch-up', ( tester, ) async { @@ -3178,8 +3243,9 @@ void main() { context: messageListElement, direction: ScrollDirection.reverse, ).dispatch(messageListElement); - // Settling can already complete the read dwell; record the whole jump. - readState.markedContexts.clear(); + // Settling at the bottom catches up every mention; forget those + // marks so only the jump's reads count. + readState.forgetMarks(); tester .widget(messageList) .itemScrollController! @@ -18990,6 +19056,11 @@ class _SynchronousReadStateNotifier extends ReadStateNotifier { state = state.copyWithContext(contextId, unixTimestamp); } + void forgetMarks() { + markedContexts.clear(); + state = _initialState; + } + final List clearedForcedContexts = []; @override diff --git a/mobile/test/features/channels/reading_marks_test.dart b/mobile/test/features/channels/reading_marks_test.dart index 0fd1418c0a3..aee99990a9d 100644 --- a/mobile/test/features/channels/reading_marks_test.dart +++ b/mobile/test/features/channels/reading_marks_test.dart @@ -209,6 +209,123 @@ void main() { }); }); + test('catch-up marks unseen top-level mentions and broadcasts', () { + // A mention taller than the screen is never fully visible, so the + // bottom catch-up reads it, as `buzz-app` does. + final mention = _msg( + 'm', + 100, + tags: const [ + ['p', _self], + ], + ); + final broadcast = _msg( + 'bc', + 110, + parentId: 'root', + rootId: 'root', + tags: const [ + ['broadcast', '1'], + ['p', _self], + ], + ); + final reply = _msg( + 'r', + 120, + parentId: 'root', + rootId: 'root', + tags: const [ + ['p', _self], + ], + ); + final marked = _msg( + 'marked', + 130, + tags: const [ + ['p', _self], + ], + ); + final read = _msg( + 'read', + 140, + tags: const [ + ['p', _self], + ], + ); + final b = _msg('b', 200); + expect( + _marks( + readState: _state({'msg:read': 140}, {'msg:marked': _channel}), + loaded: [mention, broadcast, reply, marked, read, b], + visible: [b], + bottom: b, + ), + {'activity:$_channel': 200, 'msg:m': 100, 'msg:bc': 110}, + ); + }); + + test('catch-up marks mentions even when activity already covers it', () { + final mention = _msg( + 'm', + 100, + tags: const [ + ['p', _self], + ], + ); + final b = _msg('b', 200); + expect( + _marks( + readState: _state({'activity:$_channel': 200}), + loaded: [mention, b], + visible: [b], + bottom: b, + ), + {'msg:m': 100}, + ); + }); + + test('catch-up marks at most the newest $catchUpMessageMarkLimit ' + 'unread mentions', () { + final mentions = [ + for (var i = 0; i < catchUpMessageMarkLimit + 2; i++) + _msg( + 'm$i', + 100 + i, + tags: const [ + ['p', _self], + ], + ), + ]; + final b = _msg('b', 1000); + final first = _marks(loaded: [...mentions, b], visible: [b], bottom: b); + expect(first.keys.where((key) => key.startsWith('msg:')), hasLength(128)); + expect(first.containsKey('msg:m0'), isFalse); + expect(first.containsKey('msg:m1'), isFalse); + + final second = _marks( + readState: _state(first), + loaded: [...mentions, b], + visible: [b], + bottom: b, + ); + expect(second, {'msg:m0': 100, 'msg:m1': 101}); + }); + + test('catch-up does not mark a mention away from the live bottom', () { + final mention = _msg( + 'm', + 100, + tags: const [ + ['p', _self], + ], + ); + final a = _msg('a', 150); + final b = _msg('b', 200); + expect(_marks(loaded: [mention, a, b], visible: [a], bottom: a), { + 'msg:a': 150, + }); + }); + test('away from the bottom, visible unread rows get message marks', () { final a = _msg('a', 100); final b = _msg('b', 200); @@ -282,6 +399,32 @@ void main() { }); }); + group('isReadableRow', () { + bool readable(double leading, double trailing) => isReadableRow( + leadingEdge: leading, + trailingEdge: trailing, + start: 0.1, + end: 0.9, + ); + + test('reads a row inside the reading area', () { + expect(readable(0.2, 0.8), isTrue); + expect(readable(0.1, 0.9), isTrue); + }); + + test('reads a row that covers the whole reading area', () { + expect(readable(-0.5, 1.4), isTrue); + expect(readable(0.1, 1.4), isTrue); + }); + + test('does not read a row cut off at one edge', () { + expect(readable(0.05, 0.5), isFalse); + expect(readable(0.5, 0.95), isFalse); + expect(readable(0.5, 2), isFalse); + expect(readable(-1, 0.5), isFalse); + }); + }); + group('thread', () { final root = _msg('root', 50); TimelineMessage reply(String id, int at, {List>? tags}) => From 5e1e35b90323bce9ed6577f388248b53a3eeaa37 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Sat, 7 Nov 2026 12:49:05 -0600 Subject: [PATCH 2/2] fix(mobile): catch up inline reply mentions in Solo chats A Solo chat shows replies inline, so bottom catch-up there also marks reply mentions through the bottom row. Adds widget tests for that and for a tall row read away from the bottom. Co-authored-by: BlakeBot <85bbd70443139fd5c5762e34c2e214240939eb243c37f98380f69fed3b3bb4a6@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Blake McAnally --- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_list.dart | 10 +- .../lib/features/channels/reading_marks.dart | 29 +++-- .../features/channels/thread_detail_page.dart | 7 +- .../channels/channel_detail_page_test.dart | 121 ++++++++++++++++++ .../features/channels/reading_marks_test.dart | 35 ++++- 6 files changed, 177 insertions(+), 26 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 17ab0c28e4d..f0525de5d68 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -918,6 +918,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { null), channelId: channel.id, isDm: resolvedChannel.isDm, + flat: personalChat, currentPubkey: currentPubkey, isMember: resolvedChannel.isMember, isArchived: resolvedChannel.isArchived, 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 5aa53b2e9d5..66d2a4de550 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -20,6 +20,7 @@ class _MessageList extends HookConsumerWidget { final String? welcomeAgent; final ChannelMessagesNotifier notifier; final bool isDm; + final bool flat; final String? currentPubkey; final bool isMember; final bool isArchived; @@ -46,6 +47,7 @@ class _MessageList extends HookConsumerWidget { this.welcomeAgent, required this.notifier, required this.isDm, + required this.flat, required this.currentPubkey, required this.isMember, required this.isArchived, @@ -632,12 +634,7 @@ class _MessageList extends HookConsumerWidget { final visible = []; for (final position in itemPositionsListener.itemPositions.value) { if (position.index >= displayEntries.length || - !isReadableRow( - leadingEdge: position.itemLeadingEdge, - trailingEdge: position.itemTrailingEdge, - start: bottomEdge, - end: topEdge, - )) { + !isReadableRow(position, start: bottomEdge, end: topEdge)) { continue; } final group = @@ -659,6 +656,7 @@ class _MessageList extends HookConsumerWidget { loaded: allMessages, visible: visible, bottom: atBottom ? latest : null, + flat: flat, ); final notifier = ref.read(readStateProvider.notifier); // Reading to the bottom ends a manual "mark unread" on the channel. diff --git a/mobile/lib/features/channels/reading_marks.dart b/mobile/lib/features/channels/reading_marks.dart index 3e3df30d202..7b235b5fbb2 100644 --- a/mobile/lib/features/channels/reading_marks.dart +++ b/mobile/lib/features/channels/reading_marks.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/read_state/read_state_format.dart'; import '../../shared/read_state/read_state_provider.dart'; @@ -22,7 +23,8 @@ const readingDwell = Duration(milliseconds: 300); /// catch-up marks read ordinary messages and that thread's replies. They do /// not read mentions, broadcasts or DMs. /// - The same channel catch-up also gives each loaded top-level mention and -/// broadcast through the bottom row its own `msg:` mark, at most the newest +/// broadcast through the bottom row its own `msg:` mark (in a [flat] +/// timeline, each mention through it, replies included), at most the newest /// [catchUpMessageMarkLimit], so a mention taller than the screen is still /// read. Messages the reader marked unread keep that intent. /// - Each fully visible message gets its own `msg:` mark unless its own mark, @@ -38,7 +40,8 @@ const readingDwell = Duration(milliseconds: 300); /// [bottom] is the newest message when its bottom edge is visible at the /// bottom of the list; otherwise null. Set [threadRootId] when reading a /// thread, and [isRootThread] when its head is the thread root rather than a -/// nested reply. [now] is the current Unix time in seconds; it defaults to +/// nested reply. Set [flat] for a channel timeline that shows replies inline, +/// as a Solo chat does. [now] is the current Unix time in seconds; it defaults to /// the clock. The result maps each read-state context to its new time. Map readingMarks({ required ReadStateState readState, @@ -50,6 +53,7 @@ Map readingMarks({ TimelineMessage? bottom, String? threadRootId, bool isRootThread = true, + bool flat = false, int? now, }) { final marks = {}; @@ -89,6 +93,7 @@ Map readingMarks({ loaded: loaded, bottom: bottom, threadRootId: threadRootId, + flat: flat, )) { final catchUp = _catchUpMark( channelId: channelId, @@ -102,7 +107,7 @@ Map readingMarks({ final attention = [ for (final message in loaded) if (message.createdAt <= bottom.createdAt && - (message.parentId == null || _isBroadcast(message)) && + (flat || message.parentId == null || _isBroadcast(message)) && isHighPriorityEvent(message.tags, self) && needsMark(message)) message, @@ -119,17 +124,16 @@ Map readingMarks({ /// `buzz-app`, so a backlog cannot crowd older marks out of the synced budget. const catchUpMessageMarkLimit = 128; -/// Whether a row with these item-position edges counts as read: it lies -/// inside the reading area from [start] to [end], or it is taller than that -/// area and covers all of it. -bool isReadableRow({ - required double leadingEdge, - required double trailingEdge, +/// Whether a row at [position] counts as read: it lies inside the reading +/// area from [start] to [end], or it is taller than that area and covers all +/// of it. +bool isReadableRow( + ItemPosition position, { required double start, required double end, }) => - (leadingEdge >= start && trailingEdge <= end) || - (leadingEdge <= start && trailingEdge >= end); + (position.itemLeadingEdge >= start && position.itemTrailingEdge <= end) || + (position.itemLeadingEdge <= start && position.itemTrailingEdge >= end); /// Whether [bottom] is the live bottom. A loaded message newer than the /// bottom row means the list does not show it, for example after a jump @@ -138,6 +142,7 @@ bool _showsNewest({ required Iterable loaded, required TimelineMessage bottom, required String? threadRootId, + required bool flat, }) => !loaded.any( (message) => (threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) && @@ -146,7 +151,7 @@ bool _showsNewest({ message.createdAt > bottom.createdAt && (threadRootId != null ? _threadRootOf(message) == threadRootId - : message.parentId == null || _isBroadcast(message)), + : flat || message.parentId == null || _isBroadcast(message)), ); /// The catch-up mark for [bottom], or null when it would read nothing new. diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 36b6b2c6423..96456860a46 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -733,12 +733,7 @@ class ThreadDetailPage extends HookConsumerWidget { 1 - ((Grid.xs + navigationBottomInset) / viewportHeight) + 0.01; final visible = []; for (final position in itemPositionsListener.itemPositions.value) { - if (!isReadableRow( - leadingEdge: position.itemLeadingEdge, - trailingEdge: position.itemTrailingEdge, - start: topEdge, - end: bottomEdge, - )) { + if (!isReadableRow(position, start: topEdge, end: bottomEdge)) { continue; } if (position.index == headIndex) { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 23d18bd8849..69a62e804d5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2728,6 +2728,127 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('a tall row away from the bottom is read once it fills the ' + 'screen', (tester) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'tall', + pubkey: 'alice', + content: List.filled(120, 'A long answer line.').join('\n\n'), + createdAt: 1100, + extraTags: const [ + ['p', 'self'], + ], + ), + for (var i = 0; i < 3; i++) + _textMsg( + id: 'later$i', + pubkey: 'alice', + content: List.filled(40, 'Later line.').join('\n\n'), + createdAt: 1200 + i, + ), + ], + readStateNotifier: readState, + ), + ); + await tester.pumpAndSettle(); + readState.forgetMarks(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + tester + .widget(messageList) + .itemScrollController! + .jumpTo(index: 3, alignment: -0.5); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + + final row = tester.getRect( + find.byKey(const ValueKey('channel-message-group-tall')), + ); + final screen = tester.getRect(messageList); + expect(row.top, lessThan(screen.top)); + expect(row.bottom, greaterThan(screen.bottom)); + expect(readState.markedContexts, {'msg:tall': 1100}); + }); + + testWidgets('reading the bottom of a Solo chat reads an inline reply ' + 'mention', (tester) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final solo = Channel( + id: _channelId, + name: 'Solo', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + description: '', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + ); + final rootId = 'a' * 64; + final messages = [ + _textMsg( + id: 'root', + pubkey: 'self', + content: 'Question', + createdAt: 1000, + ), + _textMsg( + id: 'tall-reply', + pubkey: 'alice', + content: List.filled(120, 'A long answer line.').join('\n\n'), + createdAt: 1100, + extraTags: [ + ['e', rootId, '', 'reply'], + ['p', 'self'], + ], + ), + _textMsg( + id: 'latest', + pubkey: 'bob', + content: 'Latest', + createdAt: 1200, + extraTags: [ + ['e', rootId, '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + channel: solo, + personalChat: true, + signingPubkey: 'self', + channelMessagesNotifier: _FakeMessagesNotifier(messages), + readStateNotifier: readState, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(readState.markedContexts['msg:tall-reply'], 1100); + expect(tester.takeException(), isNull); + }); + testWidgets('hidden huddle end does not block bottom catch-up', ( tester, ) async { diff --git a/mobile/test/features/channels/reading_marks_test.dart b/mobile/test/features/channels/reading_marks_test.dart index aee99990a9d..01eaf8fdcfe 100644 --- a/mobile/test/features/channels/reading_marks_test.dart +++ b/mobile/test/features/channels/reading_marks_test.dart @@ -4,6 +4,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; const _channel = 'channel-1'; const _self = 'self'; @@ -326,6 +327,33 @@ void main() { }); }); + test('a flat timeline catches up inline reply mentions', () { + final reply = _msg( + 'r', + 120, + parentId: 'root', + rootId: 'root', + tags: const [ + ['p', _self], + ], + ); + final b = _msg('b', 200, parentId: 'root', rootId: 'root'); + expect( + readingMarks( + readState: _state(), + channelId: _channel, + isDm: false, + currentPubkey: _self, + loaded: [reply, b], + visible: [b], + bottom: b, + flat: true, + now: 10000, + ), + {'activity:$_channel': 200, 'msg:r': 120, 'msg:b': 200}, + ); + }); + test('away from the bottom, visible unread rows get message marks', () { final a = _msg('a', 100); final b = _msg('b', 200); @@ -401,8 +429,11 @@ void main() { group('isReadableRow', () { bool readable(double leading, double trailing) => isReadableRow( - leadingEdge: leading, - trailingEdge: trailing, + ItemPosition( + index: 0, + itemLeadingEdge: leading, + itemTrailingEdge: trailing, + ), start: 0.1, end: 0.9, );