diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 6e3e19fdac..60df2efdfc 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -739,11 +739,34 @@ final activityProvider = /// group by DM channel so one conversation renders as one row. final inboxItemsProvider = Provider>((ref) { final feed = ref.watch(activityProvider).value; - if (feed == null) return const []; + final channels = + ref.watch(channelsProvider).asData?.value ?? const []; final dmChannelIds = { - for (final channel - in ref.watch(channelsProvider).asData?.value ?? const []) + for (final channel in channels) if (channel.isDm) channel.id, }; - return buildInboxItems(feed.all, isDmChannel: dmChannelIds.contains); + final myPk = ref.watch(myPubkeyProvider); + // Channel discovery already catches up and subscribes to eligible replies. + // Keep that one source of truth for the dots and their Activity destination, + // rather than opening another set of channel subscriptions here. + final byId = { + for (final event + in ref.read(channelsProvider.notifier).observedThreadReplies(channels)) + event.id: FeedItem( + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + content: event.content, + createdAt: event.createdAt, + channelId: event.channelId, + channelName: '', + tags: event.tags, + category: myPk != null && tagsAddress(event.tags, myPk) + ? 'mention' + : 'activity', + ), + // The addressed feed owns category precedence for overlapping events. + for (final item in feed?.all ?? const []) item.id: item, + }; + return buildInboxItems(byId.values, isDmChannel: dmChannelIds.contains); }); diff --git a/mobile/lib/features/activity/inbox_item.dart b/mobile/lib/features/activity/inbox_item.dart index 701d3c1509..a21a0e5188 100644 --- a/mobile/lib/features/activity/inbox_item.dart +++ b/mobile/lib/features/activity/inbox_item.dart @@ -134,7 +134,10 @@ class InboxItem { ); } - final category = item.categories.firstOrNull ?? item.item.category; + // Label the event the row displays. A group can hold an older mention and + // a newer plain reply; the row shows the reply, so it must not say + // "Mentioned in". [InboxItem.categories] still drives filtering. + final category = item.item.category; if (category == 'mention') { return ( text: channelName != null ? 'Mentioned in' : 'Mentioned', diff --git a/mobile/lib/features/activity/inbox_read_state.dart b/mobile/lib/features/activity/inbox_read_state.dart index b92e55fdb5..5a59810393 100644 --- a/mobile/lib/features/activity/inbox_read_state.dart +++ b/mobile/lib/features/activity/inbox_read_state.dart @@ -8,8 +8,8 @@ import 'inbox_item.dart'; /// `thread-activity:`. Returns null for an event with no channel. /// /// `activity:` never counts here. Activity rows hold only mentions, -/// needs-action and agent events addressed to the reader, and DM messages, -/// and that catch-up mark reads none of those. +/// needs-action and agent events addressed to the reader, eligible thread +/// replies, and DM messages. That catch-up mark reads none of those. int? inboxEventReadAt( FeedItem event, { required int? Function(String contextId) markerOf, diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 3f3dfbec08..df399e9d71 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -585,6 +585,7 @@ extension _ObservedUnreadRecording on ChannelsNotifier { highPriority: isHighPriority, channelType: channel.channelType, isThreadedReply: isThreadedReply, + threadReply: isThreadedReply && !channel.isDm ? event : null, ), _unreadCatchUpLimit, ); @@ -596,3 +597,19 @@ extension _ObservedUnreadRecording on ChannelsNotifier { return true; } } + +/// Activity projection of the channel unread registry. +extension ObservedThreadActivity on ChannelsNotifier { + /// The same eligible replies that supply channel attention dots. Consumers + /// watch [channelsProvider] for changes; this does not fetch extra history. + Iterable observedThreadReplies(Iterable channels) sync* { + for (final channel in channels) { + if (!channel.isMember || channel.isArchived || channel.isDm) continue; + for (final observed + in _observedUnreadEventsByChannel[channel.id]?.values ?? + const []) { + if (observed.threadReply case final reply?) yield reply; + } + } + } +} 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 e8cae35031..ecc7fec0d5 100644 --- a/mobile/lib/features/channels/unread_badge/observed_unread_event.dart +++ b/mobile/lib/features/channels/unread_badge/observed_unread_event.dart @@ -1,4 +1,5 @@ import '../../../shared/read_state/read_state_format.dart'; +import '../../../shared/relay/nostr_models.dart'; class ObservedUnreadEvent { final String id; @@ -11,6 +12,10 @@ class ObservedUnreadEvent { /// `activity:` reads this event (see `readByChannelCatchUp`). final bool channelCatchUp; + /// Reply payload for Activity's existing thread rows. Retained in the same + /// bounded registry as its unread evidence, so eviction and deletion agree. + final NostrEvent? threadReply; + const ObservedUnreadEvent({ required this.id, required this.createdAt, @@ -19,6 +24,7 @@ class ObservedUnreadEvent { required this.countsTowardBadge, required this.countsTowardAppBadge, this.channelCatchUp = false, + this.threadReply, }); } @@ -29,6 +35,7 @@ ObservedUnreadEvent makeObservedUnreadEvent({ required bool highPriority, required String? channelType, required bool isThreadedReply, + NostrEvent? threadReply, }) { final isDm = channelType == 'dm'; return ObservedUnreadEvent( @@ -43,6 +50,7 @@ ObservedUnreadEvent makeObservedUnreadEvent({ isReply: isThreadedReply, highPriority: highPriority, ), + threadReply: threadReply, ); } diff --git a/mobile/test/features/activity/inbox_item_test.dart b/mobile/test/features/activity/inbox_item_test.dart index d163d403b1..19ca22fe9b 100644 --- a/mobile/test/features/activity/inbox_item_test.dart +++ b/mobile/test/features/activity/inbox_item_test.dart @@ -382,5 +382,32 @@ void main() { ); expect(label.text, 'Thread in'); }); + + test('labels the displayed event, not an older mention in its thread', () { + final row = buildInboxItems([ + item( + id: threadIdInboxA, + createdAt: 100, + category: 'mention', + tags: replyTags(threadIdInboxR, threadIdInboxP), + ), + item( + id: threadIdInboxB, + createdAt: 200, + category: 'activity', + pubkey: 'pk2', + tags: replyTags(threadIdInboxR, threadIdInboxA), + ), + ]).single; + final label = inboxTypeLabel( + row, + channelName: 'general', + isDm: false, + senderLabel: 'Bob', + ); + expect(row.item.id, threadIdInboxB); + expect(label.text, 'Thread in'); + expect(matchesInboxFilter(row, InboxFilter.mention), isTrue); + }); }); } diff --git a/mobile/test/features/channels/channels_provider_activity_cases.dart b/mobile/test/features/channels/channels_provider_activity_cases.dart new file mode 100644 index 0000000000..c75993020c --- /dev/null +++ b/mobile/test/features/channels/channels_provider_activity_cases.dart @@ -0,0 +1,261 @@ +part of 'channels_provider_test.dart'; + +void _threadActivityCases() { + testWidgets('open an unmentioned reply from Activity Threads', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + const capturePath = String.fromEnvironment('THREAD_ACTIVITY_CAPTURE'); + final captureKey = GlobalKey(); + if (capturePath.isNotEmpty) { + await tester.runAsync(() async { + for (final font in { + 'Inter': 'assets/fonts/InterVariable.ttf', + 'BuzzTabler': 'assets/fonts/TablerIcons.ttf', + }.entries) { + await (FontLoader( + font.key, + )..addFont(rootBundle.load(font.value))).load(); + } + }); + } + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final self = '1' * 64; + final root = 'c' * 64; + final reply = 'd' * 64; + final session = _FakeRelaySession( + memberships: [_membership(_channelA, self)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [ + NostrEvent( + id: root, + pubkey: self, + createdAt: now - 120, + kind: 9, + tags: const [ + ['h', _channelA], + ], + content: 'Project update', + sig: '', + ), + NostrEvent( + id: reply, + pubkey: 'e' * 64, + createdAt: now - 60, + kind: 9, + tags: [ + ['h', _channelA], + ['e', root, '', 'root'], + ['e', root, '', 'reply'], + ], + content: 'The thread reply is here', + sig: '', + ), + ], + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue(self), + relaySessionProvider.overrideWith(() => session), + appLifecycleProvider.overrideWith(_FakeAppLifecycleNotifier.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: RepaintBoundary( + key: captureKey, + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: const ActivityPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + if (capturePath.isNotEmpty) { + await tester.runAsync(() async { + final boundary = + captureKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await File(capturePath).writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); + } + expect(find.text('The thread reply is here'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('activity-type-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Threads').last); + await tester.pumpAndSettle(); + expect(find.text('The thread reply is here'), findsOneWidget); + await tester.tap(find.byKey(ValueKey('inbox-row-$reply'))); + await tester.pumpAndSettle(); + // The row carries the reply and its thread into the conversation route. + final destination = tester.widget( + find.byType(ChannelDetailPage), + ); + expect(destination.initialMessageId, reply); + expect(destination.initialThreadRootId, root); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await tester.pumpAndSettle(); + }); + + test( + 'channel thread attention has an Activity destination without new reads', + () async { + final root = 'a' * 64; + final otherRoot = 'b' * 64; + NostrEvent reply( + String id, + int at, { + String? inRoot, + String author = 'alice', + List> extraTags = const [], + }) => NostrEvent( + id: id, + pubkey: author, + createdAt: at, + kind: 9, + tags: [ + ['h', _channelA], + ['e', inRoot ?? root, '', 'root'], + ['e', inRoot ?? root, '', 'reply'], + ...extraTags, + ], + content: 'Reply $id', + sig: '', + ); + final cold = reply('cold', 20); + final mentioned = reply( + 'mention', + 30, + extraTags: [ + ['p', 'me'], + ], + ); + final session = _FakeRelaySession( + memberships: [_membership(_channelA, 'me')], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [ + cold, + mentioned, + reply('unrelated', 40, inRoot: otherRoot), + reply( + 'quiet', + 45, + extraTags: [ + ['audience', 'agents'], + ], + ), + NostrEvent( + id: root, + pubkey: 'me', + createdAt: 10, + kind: 9, + tags: const [ + ['h', _channelA], + ], + content: 'My thread', + sig: '', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + final channels = container.read(channelsProvider.notifier); + await channels.waitForUnreadCatchUp(); + await container.read(activityProvider.future); + final subscription = container.listen(inboxItemsProvider, (_, _) {}); + addTearDown(subscription.close); + + // The addressed feed still has only the mention. All/Threads also expose + // the non-mentioned reply already used by the channel's attention dot. + expect( + container.read(activityProvider).requireValue.all.map((e) => e.id), + ['mention'], + ); + var row = container.read(inboxItemsProvider).single; + expect( + row.groupItems.map((e) => e.id), + unorderedEquals(['cold', 'mention']), + ); + expect(matchesInboxFilter(row, InboxFilter.thread), isTrue); + expect( + row.groupItems.where((e) => e.category == 'mention').single.id, + 'mention', + ); + expect(row.deepLinkTarget((_) => false).id, 'cold'); + expect(row.deepLinkTarget((_) => false).threadHeadId, root); + + final reads = session.queryBatches.length; + final subscriptions = session.totalSubscribeCount; + session.emit(reply('live', 50)); + row = container.read(inboxItemsProvider).single; + expect(row.id, 'live'); + expect(row.groupItems, hasLength(3)); + expect(session.queryBatches, hasLength(reads)); + expect(session.totalSubscribeCount, subscriptions); + // The existing thread read marker clears these rows without reading the + // whole channel. Ordinary channel catch-up does not read a thread. + expect( + isInboxItemDone( + row, + markerOf: (key) => key == 'activity:$_channelA' ? 50 : null, + localUnreadOverrides: {}, + localDoneSet: {}, + ), + isFalse, + ); + expect( + isInboxItemDone( + row, + markerOf: (key) => key == 'thread:$root' ? 50 : null, + localUnreadOverrides: {}, + localDoneSet: {}, + ), + isTrue, + ); + + session.emit( + NostrEvent( + id: 'delete', + pubkey: 'alice', + createdAt: 60, + kind: 5, + tags: const [ + ['h', _channelA], + ['e', 'live'], + ], + content: '', + sig: '', + ), + ); + expect( + container.read(inboxItemsProvider).single.groupItems.map((e) => e.id), + unorderedEquals(['cold', 'mention']), + ); + // A late replay cannot resurrect the deleted Activity item. + session.emit(reply('live', 50)); + expect( + container.read(inboxItemsProvider).single.groupItems, + hasLength(2), + ); + + // Identity changes reset the same registry, not an independent feed cache. + container.read(_testPubkeyProvider.notifier).set('other-user'); + await container.read(channelsProvider.future); + await container.read(activityProvider.future); + expect(container.read(inboxItemsProvider), isEmpty); + }, + ); +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 2883413276..709f6d549b 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,9 +1,17 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:io'; +import 'dart:ui' as ui; import 'package:fake_async/fake_async.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/features/activity/activity_page.dart'; +import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; @@ -14,12 +22,16 @@ import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/features/activity/activity_provider.dart'; +import 'package:buzz/features/activity/inbox_item.dart'; +import 'package:buzz/features/activity/inbox_read_state.dart'; part 'channels_provider_live_cases.dart'; part 'channels_provider_deletion_cases.dart'; part 'channels_provider_terminal_cases.dart'; part 'channels_provider_readiness_cases.dart'; part 'channels_provider_request_path_cases.dart'; +part 'channels_provider_activity_cases.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// @@ -37,6 +49,7 @@ void main() { _unreadReadinessCases(); _unreadDeletionCases(); _requestPathCases(); + _threadActivityCases(); const myPk = 'me'; test(