Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions mobile/lib/features/activity/activity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -739,11 +739,34 @@ final activityProvider =
/// group by DM channel so one conversation renders as one row.
final inboxItemsProvider = Provider<List<InboxItem>>((ref) {
final feed = ref.watch(activityProvider).value;
if (feed == null) return const [];
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final dmChannelIds = {
for (final channel
in ref.watch(channelsProvider).asData?.value ?? const <Channel>[])
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 = <String, FeedItem>{
for (final event
in ref.read(channelsProvider.notifier).observedThreadReplies(channels))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cap projected replies across all channels

For users in many busy channels, this eagerly projects and groups every retained reply whenever channelsProvider changes—even while Activity is closed, because HomePage watches inboxHasUnreadProvider. The registry limit is 1,000 events per channel, so 300 channels can retain and synchronously process roughly 300,000 full NostrEvent payloads on the UI isolate; this commit also adds the payload retention itself. Add a global bound or a lightweight unread summary, and load/paginate full Activity rows only when needed.

AGENTS.md reference: AGENTS.md:L249-L257

Useful? React with 👍 / 👎.

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 <FeedItem>[]) item.id: item,
};
return buildInboxItems(byId.values, isDmChannel: dmChannelIds.contains);
});
5 changes: 4 additions & 1 deletion mobile/lib/features/activity/inbox_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions mobile/lib/features/activity/inbox_read_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import 'inbox_item.dart';
/// `thread-activity:<root>`. Returns null for an event with no channel.
///
/// `activity:<channel>` 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,
Expand Down
17 changes: 17 additions & 0 deletions mobile/lib/features/channels/channel_directory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ extension _ObservedUnreadRecording on ChannelsNotifier {
highPriority: isHighPriority,
channelType: channel.channelType,
isThreadedReply: isThreadedReply,
threadReply: isThreadedReply && !channel.isDm ? event : null,
),
_unreadCatchUpLimit,
);
Expand All @@ -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<NostrEvent> observedThreadReplies(Iterable<Channel> channels) sync* {
for (final channel in channels) {
if (!channel.isMember || channel.isArchived || channel.isDm) continue;
for (final observed
in _observedUnreadEventsByChannel[channel.id]?.values ??
const <ObservedUnreadEvent>[]) {
if (observed.threadReply case final reply?) yield reply;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '../../../shared/read_state/read_state_format.dart';
import '../../../shared/relay/nostr_models.dart';

class ObservedUnreadEvent {
final String id;
Expand All @@ -11,6 +12,10 @@ class ObservedUnreadEvent {
/// `activity:<channel>` 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,
Expand All @@ -19,6 +24,7 @@ class ObservedUnreadEvent {
required this.countsTowardBadge,
required this.countsTowardAppBadge,
this.channelCatchUp = false,
this.threadReply,
});
}

Expand All @@ -29,6 +35,7 @@ ObservedUnreadEvent makeObservedUnreadEvent({
required bool highPriority,
required String? channelType,
required bool isThreadedReply,
NostrEvent? threadReply,
}) {
final isDm = channelType == 'dm';
return ObservedUnreadEvent(
Expand All @@ -43,6 +50,7 @@ ObservedUnreadEvent makeObservedUnreadEvent({
isReply: isThreadedReply,
highPriority: highPriority,
),
threadReply: threadReply,
);
}

Expand Down
27 changes: 27 additions & 0 deletions mobile/test/features/activity/inbox_item_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading
Loading