diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index fd81fab9bd..3cc6985941 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -974,6 +974,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 0948b3106b..3ab803c302 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, @@ -631,14 +633,16 @@ class _MessageList extends HookConsumerWidget { 0.01; final visible = []; for (final position in itemPositionsListener.itemPositions.value) { - if (position.index >= displayEntries.length || - position.itemLeadingEdge < bottomEdge || - position.itemTrailingEdge > topEdge) { - continue; + if (position.index >= displayEntries.length) continue; + final group = displayEntries.reversed.elementAt(position.index); + if (isReadableRow( + position, + start: bottomEdge, + end: topEdge, + linked: group.any((e) => e.message.id == initialMessageId), + )) { + visible.addAll(group.map((entry) => entry.message)); } - final group = - displayEntries[displayEntries.length - 1 - position.index]; - visible.addAll(group.map((entry) => entry.message)); } final latest = entries.last.message; final atBottom = @@ -655,6 +659,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 9e1dcc7c67..6ca8069ba3 100644 --- a/mobile/lib/features/channels/reading_marks.dart +++ b/mobile/lib/features/channels/reading_marks.dart @@ -4,14 +4,15 @@ 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'; import 'timeline_message.dart'; import 'unread_badge/is_high_priority_event.dart'; -/// How long a row must stay fully visible before it counts as read. This is -/// the same dwell the web and desktop app (`buzz-app`) uses. +/// How long a row must stay readable ([isReadableRow]) before it counts as +/// read. This is the same dwell the web and desktop app (`buzz-app`) uses. const readingDwell = Duration(milliseconds: 300); /// The read marks to write after a reading dwell, following `buzz-app`'s @@ -21,19 +22,27 @@ 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. -/// - 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 +/// - Unlike `buzz-app`, the newest message itself is read at the live bottom, +/// in a thread too, so one taller than the screen is read at its end. +/// Mentions above it are read only on their own, never by reaching the +/// bottom. +/// - Each readable message (see [isReadableRow]) 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 /// thread only while the root is loaded, so `buzz-app` keeps reply marks. +/// Unlike `buzz-app`, the message a link opened is readable once any part +/// of it shows, since a phone often cannot fit a long one. /// - Automatic reading never writes the channel mark. Only an explicit /// 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 readable 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 -/// 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, so its newest reply is the bottom. [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, @@ -45,32 +54,19 @@ Map readingMarks({ TimelineMessage? bottom, String? threadRootId, bool isRootThread = true, + bool flat = false, int? now, }) { final marks = {}; 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 +77,87 @@ 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; + } + } + + final liveBottom = + bottom != null && + _showsNewest( + loaded: loaded, + bottom: bottom, + threadRootId: threadRootId, + flat: flat, + ) + ? bottom + : null; + // A nested thread view shows only one branch, so reaching its bottom does + // not read the whole thread. + if (liveBottom != null && (threadRootId == null || isRootThread)) { + final catchUp = _catchUpMark( + channelId: channelId, + bottom: liveBottom, + threadRootId: threadRootId, + markerOf: markerOf, + now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + if (catchUp != null) marks[catchUp.key] = catchUp.value; + } + // The newest row is read once its end shows at the live bottom, even when + // it is taller than the screen and never fills it, as at the end of a thread. + if (liveBottom != null) markMessage(liveBottom); + + visible.forEach(markMessage); return marks; } +/// 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. A [linked] row, the message a link opened, counts once any part of +/// it is inside: the jump can leave a long one cut off. +bool isReadableRow( + ItemPosition position, { + required double start, + required double end, + bool linked = false, +}) => linked + ? position.itemLeadingEdge < end && position.itemTrailingEdge > start + : (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 +/// into history. +bool _showsNewest({ + required Iterable loaded, + required TimelineMessage bottom, + required String? threadRootId, + required bool flat, +}) => !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 + : flat || 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 888302571e..c581fde767 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -743,19 +743,23 @@ 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) { - continue; - } - if (position.index == headIndex) { - // The head is read like a reply. A thread opened directly, from - // a link or a notification, may be the only place it is seen. - if (!liveDeletionHidesHead) visible.add(liveHead); - continue; - } + // The head is read like a reply. A thread opened directly, from a + // link or a notification, may be the only place it is seen. final replyIndex = position.index - indexForReply(0); - if (replyIndex < 0 || replyIndex >= replies.length) continue; - visible.add(replies[replyIndex]); + final message = position.index == headIndex + ? (liveDeletionHidesHead ? null : liveHead) + : replyIndex < 0 || replyIndex >= replies.length + ? null + : replies[replyIndex]; + if (message != null && + isReadableRow( + position, + start: topEdge, + end: bottomEdge, + linked: message.id == initialMessageId, + )) { + visible.add(message); + } } final isDm = ref.read(channelsProvider.notifier).channelById(channelId)?.isDm ?? diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 03ec0e5008..52e61b0be4 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2830,6 +2830,229 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('reading the bottom of an archived Solo chat reads a tall ' + 'newest 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'], + ], + ), + ]; + + 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, + ); + // Only its end shows at the bottom, so it neither fits nor fills the + // screen; being the newest message reads it. + expect(readState.markedContexts, { + 'activity:$_channelId': 1100, + 'msg:tall': 1100, + }); + 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.markedContexts.clear(); + + 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('a tall linked message is read when the jump leaves it cut ' + 'off', (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, + ), + ], + initialMessageId: 'tall', + readStateNotifier: readState, + ), + ); + 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( + find.byKey(const ValueKey('channel-message-list')), + ); + // The jump leaves the row's top above the screen, with later messages + // below it, so it neither fits nor fills the screen. + expect(row.top, lessThan(screen.top)); + expect(row.bottom, lessThan(screen.bottom)); + expect(readState.markedContexts, {'msg:tall': 1100}); + }); + + testWidgets('reading the bottom of a Solo chat reads a tall newest ' + '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'], + ], + ), + ]; + + 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 { @@ -3280,6 +3503,179 @@ void main() { expect(readState.markedContexts, {'msg:$threadIdRoot': 1000}); }); + testWidgets('a tall linked thread reply is read when the jump leaves it ' + 'cut off', (tester) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final root = _textMsg( + id: threadIdRoot, + pubkey: 'self', + content: 'Question', + createdAt: 1000, + ); + final tall = _textMsg( + id: threadIdReplyZero, + pubkey: 'alice', + content: List.filled(120, 'A long answer line.').join('\n\n'), + createdAt: 1100, + extraTags: const [ + ['e', threadIdRoot, '', 'reply'], + ['p', 'self'], + ], + ); + + await tester.pumpWidget( + _buildTestable( + messages: [root, tall], + threadReplies: { + threadIdRoot: [tall], + }, + readStateNotifier: readState, + home: ThreadDetailPage( + threadHead: formatTimeline([root]).single, + allMessages: formatTimeline([root, tall]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: threadIdReplyZero, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + + final row = tester.getRect( + find.byKey(const ValueKey('thread-message-group-$threadIdReplyZero')), + ); + final screen = tester.getRect( + find.byKey(const ValueKey('thread-message-list')), + ); + // The jump leaves the reply's bottom below the screen, and its top + // well inside it, so it neither fits nor fills the screen. + expect(row.top, greaterThan(screen.top)); + expect(row.bottom, greaterThan(screen.bottom)); + expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100); + }); + + group('tall thread replies', () { + Future<_SynchronousReadStateNotifier> openThread( + WidgetTester tester, + List replies, + ) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final root = _textMsg( + id: threadIdRoot, + pubkey: 'self', + content: 'Question', + createdAt: 1000, + ); + await tester.pumpWidget( + _buildTestable( + messages: [root, ...replies], + threadReplies: {threadIdRoot: replies}, + readStateNotifier: readState, + home: ThreadDetailPage( + threadHead: formatTimeline([root]).single, + allMessages: formatTimeline([root, ...replies]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + return readState; + } + + NostrEvent reply(String id, int createdAt, String content) => _textMsg( + id: id, + pubkey: 'alice', + content: content, + createdAt: createdAt, + extraTags: const [ + ['e', threadIdRoot, '', 'reply'], + ['p', 'self'], + ], + ); + + final tallContent = List.filled(120, 'A long answer line.').join('\n\n'); + + Rect rowOf(WidgetTester tester, String id) => + tester.getRect(find.byKey(ValueKey('thread-message-group-$id'))); + Rect screenOf(WidgetTester tester) => + tester.getRect(find.byKey(const ValueKey('thread-message-list'))); + + testWidgets('the last reply is read at the end of the thread', ( + tester, + ) async { + final readState = await openThread(tester, [ + reply(threadIdReplyZero, 1100, tallContent), + ]); + await tester.fling( + find.byKey(const ValueKey('thread-message-list')), + const Offset(0, -20000), + 4000, + ); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + + // Resting at the end, its top is off screen, so it never fits or + // fills the screen. + final row = rowOf(tester, threadIdReplyZero); + expect(row.top, lessThan(screenOf(tester).top)); + expect(row.bottom, lessThan(screenOf(tester).bottom)); + expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100); + }); + + testWidgets('an earlier reply is read once it fills the screen', ( + tester, + ) async { + final later = indexedThreadId('reply-1'); + final readState = await openThread(tester, [ + reply(threadIdReplyZero, 1100, tallContent), + reply(later, 1200, 'Short follow-up'), + ]); + final screen = screenOf(tester); + // The thread opens at its end: the tall reply's top is cut off, so + // it is not read yet. + var row = rowOf(tester, threadIdReplyZero); + expect(row.top, lessThan(screen.top)); + expect(row.bottom, lessThan(screen.bottom)); + expect( + readState.markedContexts, + isNot(contains('msg:$threadIdReplyZero')), + ); + + await tester.drag( + find.byKey(const ValueKey('thread-message-list')), + Offset(0, screen.bottom - row.bottom + screen.height / 2), + ); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + + row = rowOf(tester, threadIdReplyZero); + expect(row.top, lessThan(screen.top)); + expect(row.bottom, greaterThan(screen.bottom)); + expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100); + }); + }); + testWidgets('rows under the Android keyboard are not read in history', ( tester, ) async { diff --git a/mobile/test/features/channels/reading_marks_test.dart b/mobile/test/features/channels/reading_marks_test.dart index 0fd1418c0a..986709cbce 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'; @@ -209,6 +210,108 @@ void main() { }); }); + test('the bottom does not read mentions above it', () { + // Only a mention that showed is read. Reaching the bottom past one that + // never filled the screen leaves it unread. + 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 b = _msg('b', 200); + expect(_marks(loaded: [mention, broadcast, b], visible: [b], bottom: b), { + 'activity:$_channel': 200, + }); + }); + + test('the newest message is read at the bottom even when not visible', () { + // A mention taller than the screen shows only its end at the bottom. + final mention = _msg( + 'm', + 200, + tags: const [ + ['p', _self], + ], + ); + expect( + _marks( + readState: _state(const {}, {'msg:forced': _channel}), + loaded: [_msg('a', 100), mention], + bottom: mention, + ), + {'activity:$_channel': 200, 'msg:m': 200}, + ); + final forced = _msg( + 'forced', + 200, + tags: const [ + ['p', _self], + ], + ); + expect( + _marks( + readState: _state(const {}, {'msg:forced': _channel}), + loaded: [forced], + bottom: forced, + ), + {'activity:$_channel': 200}, + ); + }); + + 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('a flat timeline reads only its newest inline reply', () { + final reply = _msg( + 'r', + 120, + parentId: 'root', + rootId: 'root', + tags: const [ + ['p', _self], + ], + ); + final b = _msg('b', 200, parentId: 'root', rootId: 'root'); + Map marks(TimelineMessage bottom) => readingMarks( + readState: _state(), + channelId: _channel, + isDm: false, + currentPubkey: _self, + loaded: [reply, b], + visible: const [], + bottom: bottom, + flat: true, + now: 10000, + ); + expect(marks(b), {'activity:$_channel': 200, 'msg:b': 200}); + // A newer inline reply means the bottom is not live. + expect(marks(reply), isEmpty); + }); + test('away from the bottom, visible unread rows get message marks', () { final a = _msg('a', 100); final b = _msg('b', 200); @@ -271,6 +374,21 @@ void main() { ); }); + test('in a DM, catch-up does not mark mentions above the bottom', () { + final mention = _msg( + 'mention', + 100, + tags: const [ + ['p', _self], + ], + ); + final b = _msg('b', 200); + expect(_marks(isDm: true, loaded: [mention, b], bottom: b), { + 'activity:$_channel': 200, + 'msg:b': 200, + }); + }); + test('in a DM, catch-up does not read messages', () { final a = _msg('a', 100); final b = _msg('b', 200); @@ -282,6 +400,53 @@ void main() { }); }); + group('isReadableRow', () { + bool readable(double leading, double trailing) => isReadableRow( + ItemPosition( + index: 0, + itemLeadingEdge: leading, + itemTrailingEdge: 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); + }); + + test('reads a linked row once any part is inside the reading area', () { + bool linked(double leading, double trailing) => isReadableRow( + ItemPosition( + index: 0, + itemLeadingEdge: leading, + itemTrailingEdge: trailing, + ), + start: 0.1, + end: 0.9, + linked: true, + ); + expect(linked(0.35, 2), isTrue); + expect(linked(-1, 0.5), isTrue); + expect(linked(0.2, 0.8), isTrue); + expect(linked(-1, 0.1), isFalse); + expect(linked(0.9, 2), isFalse); + }); + }); + group('thread', () { final root = _msg('root', 50); TimelineMessage reply(String id, int at, {List>? tags}) => @@ -346,6 +511,31 @@ void main() { ); }); + test('the tail reply is read at its end even when it never fits', () { + // A reply taller than the screen shows only its end at the tail, so it + // is never in [visible]. + final mention = reply( + 'r1', + 100, + tags: const [ + ['p', _self], + ], + ); + expect( + _marks(loaded: [root, mention], bottom: mention, threadRootId: 'root'), + {'thread-activity:root': 100, 'msg:r1': 100}, + ); + // Away from the live tail it waits until it fills the screen. + expect( + _marks( + loaded: [root, mention, reply('r2', 200)], + bottom: mention, + threadRootId: 'root', + ), + isEmpty, + ); + }); + test('a thread mark replaces catch-up but not reply marks', () { final r1 = reply('r1', 100); expect(