diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index fd81fab9bd1..c0cae4d18c3 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -61,6 +61,7 @@ import '../forum/forum_posts_view.dart'; import '../forum/forum_provider.dart'; import 'android_ime_lift.dart'; import 'channel.dart'; +import 'channel_unread.dart'; import 'channel_actions_sheet.dart'; import 'channel_identity_names_provider.dart'; import 'channel_member_profile_actions.dart'; @@ -652,6 +653,42 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { }); }, [channel.id, readState.isReady, openReadActive, openReadTimestamp]); + // While it is open and in front, the channel's row in the list and the + // app badge stay quiet: the page saves `seen:` row hints for what the row + // shows, reading nothing (`buzz-app`'s `docs/unread.md`). An arrival + // while the page is covered or the app is in the background still + // lights the row. + final observedRevision = ref.watch( + channelsProvider.select( + (_) => Object.hashAllUnordered( + channelsNotifier.observedUnreadEventsFor(channel.id).keys, + ), + ), + ); + useEffect( + () { + if (!readState.isReady || !openReadActive) return null; + return deferReadStateUpdate(context, () { + final marks = channelRowSeenMarks( + channelId: channel.id, + events: ref + .read(channelsProvider.notifier) + .observedUnreadEventsFor(channel.id), + readState: ref.read(readStateProvider), + ); + if (marks.isEmpty) return; + ref.read(readStateProvider.notifier).markContextsRead(marks); + }); + }, + [ + channel.id, + readState.isReady, + readState.version, + openReadActive, + observedRevision, + ], + ); + final dmHeader = resolvedChannel.isDm ? _watchDmHeader(ref, resolvedChannel, currentPubkey) : null; diff --git a/mobile/lib/features/channels/channel_unread.dart b/mobile/lib/features/channels/channel_unread.dart index 7eab5b2573d..343ffda1718 100644 --- a/mobile/lib/features/channels/channel_unread.dart +++ b/mobile/lib/features/channels/channel_unread.dart @@ -1,3 +1,4 @@ +import '../../shared/read_state/read_state_format.dart'; import '../../shared/read_state/read_state_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; @@ -21,7 +22,9 @@ Set computeUnreadChannelIds({ /// Unread thread replies, mentions and broadcasts only add an attention dot /// ([attention]), so a channel the reader caught up on stays regular. An /// unread DM bolds and dots. Marking a channel unread bolds it without -/// fabricating attention, but keeps any real attention's dot. +/// fabricating attention, but keeps any real attention's dot. What the row +/// has seen ([rowSawObservedUnreadEvent]) neither bolds nor dots it, unless +/// the reader marked the channel or one of its messages unread. ({Set unread, Set attention}) computeChannelListHighlights({ required Iterable channels, required ReadStateState readState, @@ -31,6 +34,7 @@ Set computeUnreadChannelIds({ readState: readState, channelsNotifier: channelsNotifier, separateAttention: true, + rowCut: true, ); ({Set unread, Set attention}) _computeHighlights({ @@ -38,6 +42,7 @@ Set computeUnreadChannelIds({ required ReadStateState readState, required ChannelsNotifier channelsNotifier, required bool separateAttention, + bool rowCut = false, }) { final unread = {}; final attention = {}; @@ -60,6 +65,8 @@ Set computeUnreadChannelIds({ final observedEvents = observedEventsByChannel[channel.id]; if (observedEvents == null) continue; + final cut = + rowCut && !readState.locallyForcedChannelIds.contains(channel.id); for (final event in observedEvents.values) { final readAt = observedUnreadEventReadAt( event, @@ -67,6 +74,14 @@ Set computeUnreadChannelIds({ readState.effectiveTimestamp, ); if (readAt != null && event.createdAt <= readAt) continue; + if (cut && + rowSawObservedUnreadEvent( + event, + channel.id, + (key) => readState.contexts[key], + )) { + continue; + } if (!separateAttention || channel.isDm || event.channelCatchUp) { unread.add(channel.id); } @@ -78,3 +93,60 @@ Set computeUnreadChannelIds({ return (unread: unread, attention: attention); } + +/// The observed events [channelId]'s row and the app badge still show: the +/// unread ones its row has not seen, or all of them when the reader marked +/// the channel or one of its messages unread. +Map rowObservedUnreadEvents( + Map events, + String channelId, + ReadStateState readState, +) { + if (readState.locallyForcedChannelIds.contains(channelId)) return events; + return { + for (final MapEntry(key: id, value: event) in events.entries) + if (!rowSawObservedUnreadEvent( + event, + channelId, + (key) => readState.contexts[key], + )) + id: event, + }; +} + +/// The row hints that quiet [channelId]'s row through what it shows now, +/// without reading anything: a receipt for each attention message and the +/// newest shown time for ordinary ones. Empty when the row shows nothing +/// the saved hints do not already hide. +Map channelRowSeenMarks({ + required String channelId, + required Map events, + required ReadStateState readState, +}) { + final marks = {}; + var newest = -1; + var ordinary = false; + for (final event in events.values) { + final readAt = observedUnreadEventReadAt( + event, + channelId, + readState.effectiveTimestamp, + ); + if (readAt != null && event.createdAt <= readAt) continue; + if (rowSawObservedUnreadEvent( + event, + channelId, + (key) => readState.contexts[key], + )) { + continue; + } + if (event.createdAt > newest) newest = event.createdAt; + if (event.channelCatchUp) { + ordinary = true; + } else { + marks[seenMessageContextKey(event.id)] = event.createdAt; + } + } + if (ordinary) marks[seenContextKey(channelId)] = newest; + return marks; +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 88c4e70ed98..010524a963f 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -194,6 +194,11 @@ class ChannelsNotifier extends AsyncNotifier> { previous.complete(); } + /// [channelId]'s entry of [observedUnreadEventsByChannel], without copying + /// every other channel's. + Map observedUnreadEventsFor(String channelId) => + Map.unmodifiable(_observedUnreadEventsByChannel[channelId] ?? const {}); + Map> get observedUnreadEventsByChannel => Map>.unmodifiable({ diff --git a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart index e8cae35031d..b06e43a52c5 100644 --- a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart +++ b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart @@ -145,3 +145,20 @@ int? observedUnreadEventReadAt( if (event.channelCatchUp) markerOf(activityContextKey(channelId)), ]); } + +/// Whether [channelId]'s row has seen [event] (see [seenContextKey]): an +/// attention message by its own receipt, whatever its timestamp, and an +/// ordinary message by the channel's row hint. [storedOf] looks up a stored +/// read-state context, without the retention floor: a missing receipt must +/// stay missing. +bool rowSawObservedUnreadEvent( + ObservedUnreadEvent event, + String channelId, + int? Function(String contextId) storedOf, +) { + if (!event.channelCatchUp) { + return storedOf(seenMessageContextKey(event.id)) != null; + } + final seenAt = storedOf(seenContextKey(channelId)); + return seenAt != null && event.createdAt <= seenAt; +} diff --git a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart index 6f5264347ad..f95d74c855e 100644 --- a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart +++ b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../channel_unread.dart'; import '../channels_provider.dart'; import '../../../shared/read_state/read_state_provider.dart'; import 'observed_unread_event.dart'; @@ -37,7 +38,12 @@ final unreadBadgeProvider = Provider((ref) { if (!latestObservedByChannel.containsKey(channel.id)) continue; - final observedEvents = observedEventsByChannel[channel.id]; + // The badge follows the channel list rows: what a row has seen + // does not count. + final observed = observedEventsByChannel[channel.id]; + final observedEvents = observed == null + ? null + : rowObservedUnreadEvents(observed, channel.id, readState); int? readAtForObservedEvent(ObservedUnreadEvent event) => observedUnreadEventReadAt( event, diff --git a/mobile/lib/shared/read_state/read_state_format.dart b/mobile/lib/shared/read_state/read_state_format.dart index e530954fa12..9d4cdd90b76 100644 --- a/mobile/lib/shared/read_state/read_state_format.dart +++ b/mobile/lib/shared/read_state/read_state_format.dart @@ -22,6 +22,22 @@ String threadContextKey(String rootId) => '$threadContextPrefix$rootId'; String activityContextKey(String channelId) => 'activity:$channelId'; String threadActivityContextKey(String rootId) => 'thread-activity:$rootId'; +/// Row hints, written by this app and `buzz-app` when the reader opens a +/// channel (`buzz-app`'s `docs/unread.md`). They quiet the channel's row in +/// the channel list and the app badge, and read nothing. `seen:` is +/// the newest ordinary message the row has seen, and syncs. +/// `seen:msg:` is the receipt for one attention message (a mention, +/// broadcast, DM or reply), and stays on this device like `msg:`. +String seenContextKey(String channelId) => 'seen:$channelId'; +const _seenMessagePrefix = 'seen:msg:'; +String seenMessageContextKey(String messageId) => + '$_seenMessagePrefix$messageId'; + +/// Whether [contextId] stays on this device and is never published. +bool isLocalOnlyContext(String contextId) => + contextId.startsWith(msgContextPrefix) || + contextId.startsWith(_seenMessagePrefix); + /// Whether `activity:` reads a message: an ordinary top-level /// message, not a reply, mention, broadcast or DM. bool readByChannelCatchUp({ diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index bd3580c263f..f51779a6df4 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -243,17 +243,28 @@ class ReadStateManager { ); } - void markContextRead(String contextId, int unixTimestamp) { - if (_disposed || isOverrideContext(contextId) || unixTimestamp < 0) return; - if (_advanceContext( - contextId, - unixTimestamp, - publishable: !contextId.startsWith(msgContextPrefix), - )) { - _persistLocalState(); - _onChanged(); - if (!contextId.startsWith(msgContextPrefix)) _schedulePublish(); + void markContextRead(String contextId, int unixTimestamp) => + markContextsRead({contextId: unixTimestamp}); + + /// Advance several contexts together, saving and notifying once. Contexts + /// that [isLocalOnlyContext] are never published. + void markContextsRead(Map contexts) { + if (_disposed) return; + var changed = false; + var publish = false; + for (final MapEntry(key: contextId, value: unixTimestamp) + in contexts.entries) { + if (isOverrideContext(contextId) || unixTimestamp < 0) continue; + final publishable = !isLocalOnlyContext(contextId); + if (_advanceContext(contextId, unixTimestamp, publishable: publishable)) { + changed = true; + publish = publish || publishable; + } } + if (!changed) return; + _persistLocalState(); + _onChanged(); + if (publish) _schedulePublish(); } void seedContextRead(String contextId, int unixTimestamp) { @@ -432,7 +443,7 @@ class ReadStateManager { } // Historical own-slot values are recoverable local writes. Peer // maxima never change the locally owned values or placement. - if (own && !entry.key.startsWith(msgContextPrefix)) { + if (own && !isLocalOnlyContext(entry.key)) { _localContexts[entry.key] = max( _localContexts[entry.key] ?? 0, entry.value, @@ -849,7 +860,7 @@ class ReadStateManager { (entry) => stored.publishableContextIds.contains(entry.key) && !isOverrideContext(entry.key) && - !entry.key.startsWith(msgContextPrefix), + !isLocalOnlyContext(entry.key), ), ); _carriedOverrides.addAll( diff --git a/mobile/lib/shared/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart index b02bece7da8..7bbf2d98fef 100644 --- a/mobile/lib/shared/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -186,6 +186,13 @@ class ReadStateNotifier extends Notifier { } } + /// Advance several contexts together, saving once. Clears no forced-unread + /// flag: the channel page writes row hints (`seen:`) this way, which read + /// nothing. + void markContextsRead(Map contexts) { + _manager?.markContextsRead(contexts); + } + /// Force a context unread for the rest of the session. [contextId] is a /// channel id (channel-tile action) or a `msg:` key (message actions /// sheet); [channelId] is the channel the context belongs to, so tiles and diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index df524ed213b..7f905fdc2c5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -2922,6 +2922,79 @@ void main() { expect(readState.state.isForcedUnread('msg:old'), isTrue); }); + testWidgets('an open channel quiets its row without reading it', ( + tester, + ) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + ObservedUnreadEvent observed( + String id, + int createdAt, { + bool mention = false, + bool reply = false, + }) => makeObservedUnreadEvent( + id: id, + createdAt: createdAt, + rootId: reply ? 'root' : null, + highPriority: mention, + channelType: 'stream', + isThreadedReply: reply, + ); + final channelsNotifier = _FakeChannelsNotifier( + [_testChannel], + observedUnread: { + _channelId: [ + observed('m1', 1100), + observed('p1', 1200, mention: true), + observed('r1', 1300, reply: true), + ], + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channelsNotifier: channelsNotifier, + readStateNotifier: readState, + ), + ); + await tester.pump(); + + // Row hints only: the channel, its mention and its reply stay unread. + expect(readState.markedContexts, { + 'seen:$_channelId': 1300, + 'seen:msg:p1': 1200, + 'seen:msg:r1': 1300, + }); + + // An arrival while the app is in the background still lights the row. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + channelsNotifier.observe(_channelId, observed('p2', 1400, mention: true)); + await tester.pump(); + await tester.pump(); + expect(readState.markedContexts, isNot(contains('seen:msg:p2'))); + + // Shown again, the row sees it. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(readState.markedContexts['seen:msg:p2'], 1400); + + // An arrival while it is in front is seen as it lands. + channelsNotifier.observe(_channelId, observed('m2', 1500)); + await tester.pump(); + await tester.pump(); + expect(readState.markedContexts['seen:$_channelId'], 1500); + expect(readState.markedContexts, isNot(contains(_channelId))); + }); + testWidgets('opening a DM reads the whole DM', (tester) async { final readState = _SynchronousReadStateNotifier( const ReadStateState( @@ -19163,6 +19236,13 @@ class _SynchronousReadStateNotifier extends ReadStateNotifier { state = state.copyWithContext(contextId, unixTimestamp); } + @override + void markContextsRead(Map contexts) { + for (final MapEntry(:key, :value) in contexts.entries) { + markContextRead(key, value); + } + } + final List clearedForcedContexts = []; @override @@ -19385,6 +19465,16 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Map> get observedUnreadEventsByChannel => _observedUnread; + @override + Map observedUnreadEventsFor(String channelId) => + Map.unmodifiable(_observedUnread[channelId] ?? const {}); + + /// Records [event] as a live arrival would, republishing the list. + void observe(String channelId, ObservedUnreadEvent event) { + (_observedUnread[channelId] ??= {})[event.id] = event; + state = AsyncData(List.of(_channels)); + } + @override Future> build() => SynchronousFuture(_channels); diff --git a/mobile/test/features/channels/channel_unread_test.dart b/mobile/test/features/channels/channel_unread_test.dart index 943efea8ed7..3e3cb2d78b0 100644 --- a/mobile/test/features/channels/channel_unread_test.dart +++ b/mobile/test/features/channels/channel_unread_test.dart @@ -128,4 +128,119 @@ void main() { expect(highlights.unread, isEmpty); expect(highlights.attention, isEmpty); }); + + group('row hints (seen:)', () { + ReadStateState reads( + Map contexts, { + Map forced = const {}, + int retentionFloor = 0, + }) => ReadStateState( + isReady: true, + pubkey: 'pk', + contexts: contexts, + version: 0, + forcedUnreadContexts: forced, + retentionFloor: retentionFloor, + ); + final rows = channels.where((c) => !c.id.startsWith('forced')).toList(); + + test('opening saves hints that quiet the row and read nothing', () { + final marks = {}; + for (final channel in rows) { + marks.addAll( + channelRowSeenMarks( + channelId: channel.id, + events: notifier.observedUnreadEventsByChannel[channel.id]!, + readState: reads(const {}), + ), + ); + } + expect(marks, { + 'seen:backlog': 20, + 'seen:msg:r1': 20, + 'seen:msg:p1': 20, + 'seen:both': 20, + 'seen:msg:r2': 20, + 'seen:msg:d1': 20, + }); + final seen = reads(marks); + final highlights = computeChannelListHighlights( + channels: rows, + readState: seen, + channelsNotifier: notifier, + ); + expect(highlights.unread, isEmpty); + expect(highlights.attention, isEmpty); + // Nothing was read: Solo and every other reader still see it unread. + expect( + computeUnreadChannelIds( + channels: rows, + readState: seen, + channelsNotifier: notifier, + ), + {for (final channel in rows) channel.id}, + ); + // A revisit with nothing new writes nothing. + for (final channel in rows) { + expect( + channelRowSeenMarks( + channelId: channel.id, + events: notifier.observedUnreadEventsByChannel[channel.id]!, + readState: seen, + ), + isEmpty, + ); + } + }); + + test('read events need no hint', () { + expect( + channelRowSeenMarks( + channelId: 'both', + events: notifier.observedUnreadEventsByChannel['both']!, + readState: reads({'both': 20}), + ), + isEmpty, + ); + }); + + test('attention lights the row again unless its own receipt saw it', () { + final highlights = computeChannelListHighlights( + channels: rows, + readState: reads({ + 'seen:backlog': 19, + 'seen:reply': 99, + 'seen:mention': 99, + 'seen:msg:other': 99, + }), + channelsNotifier: notifier, + ); + // An ordinary message newer than the hint, and attention the channel + // hint cannot cover, whatever its time. + expect(highlights.unread, {'backlog', 'both', 'dm'}); + expect(highlights.attention, {'reply', 'mention', 'both', 'dm'}); + }); + + test('the retention floor does not stand in for a receipt', () { + final highlights = computeChannelListHighlights( + channels: [_channel('reply')], + readState: reads(const {}, retentionFloor: 10), + channelsNotifier: notifier, + ); + expect(highlights.attention, {'reply'}); + }); + + test('a manual unread shows the whole row again', () { + final highlights = computeChannelListHighlights( + channels: channels, + readState: reads( + {'seen:forced-backlog': 20, 'seen:msg:p2': 20}, + forced: {'forced': 'forced', 'forced-backlog': 'forced-backlog'}, + ), + channelsNotifier: notifier, + ); + expect(highlights.unread, containsAll(['forced', 'forced-backlog'])); + expect(highlights.attention, contains('forced')); + }); + }); } diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index b65f0389adf..13693f42ce7 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -827,13 +827,18 @@ void main() { final manager = create(); manager.markContextRead('msg:one', 100); manager.markContextRead('room', 90); + // Row hints: the channel's syncs, a message receipt stays here. + manager.markContextsRead({'seen:msg:two': 100, 'seen:room': 95}); await manager.flush(); manager.dispose(flushPending: false); final restarted = create(); expect(restarted.getEffectiveTimestamp('msg:one'), 100); + expect(restarted.getEffectiveTimestamp('seen:msg:two'), 100); + expect(relay.contents, isNotEmpty); for (final content in relay.contents) { expect(decodeReadStateBlob(crypto.decrypt(content))!.contexts, { 'room': 90, + 'seen:room': 95, }); } restarted.dispose(flushPending: false); diff --git a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart index bffc9e97e1c..945b7062947 100644 --- a/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart +++ b/mobile/test/features/channels/unread_badge/unread_badge_provider_test.dart @@ -189,6 +189,35 @@ void main() { expect(result.highPriorityCount, 1); }); + test('seen: row hints quiet the badge without reading', () async { + final ordinary = await badge(stream, event('m'), {'seen:ch-a': t20}); + expect(ordinary.generalUnreadCount, 0); + final reply = await badge(stream, event('r', rootId: threadIdRoot), { + 'seen:msg:r': t20, + }); + expect(reply.generalUnreadCount, 0); + // The channel hint never quiets attention; only its own receipt does. + final mention = await badge(stream, event('p', highPriority: true), { + 'seen:ch-a': t30, + 'seen:msg:other': t30, + }); + expect(mention.highPriorityCount, 1); + }); + + test('seen: hints yield to a manual unread', () async { + final container = buildContainer( + channels: [stream], + readContexts: {'seen:ch-a': t20}, + forcedUnreadContexts: {'ch-a': 'ch-a'}, + observedEventsByChannel: { + 'ch-a': [event('m')], + }, + ); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + expect(container.read(unreadBadgeProvider).generalUnreadCount, 1); + }); + test('thread-activity: reads replies in its thread only', () async { final read = await badge(stream, event('m', rootId: threadIdRoot), { 'thread-activity:$threadIdRoot': t20,