fix(mobile): show observed thread replies in Activity (#9286)

## Summary

A reply in a thread you started, participated in, or followed can light
a mobile channel's attention dot without appearing in Activity. Activity
now includes eligible replies already observed by the channel unread
pipeline in **All** and **Threads**. Tapping a row uses the existing
oldest-unread reply and thread navigation.

Retain the reply payload alongside its bounded unread evidence, then
merge by event ID with the addressed feed. Mentions retain their
category and do not duplicate. Deletion, eviction, and account/community
resets share the same registry. This adds no relay queries or
subscriptions.

Coverage follows the existing channel unread catch-up and live
subscriptions. This does not introduce complete historical thread
discovery: replies outside that retained window, and replies not
eligible for the channel attention dot, are not newly fetched.

### Related issue

Related to #9271 (clearing channel rows) and closed #9186 (broader
Activity feed expansion). #9213 supplied the existing Activity filters.
This PR connects existing observed replies to those filters.

### Testing

- Full mobile suite, push-unconfigured tests, analysis, formatting and
file-size checks.
- Production-provider regression: cold catch-up, live reply, mention
deduplication, excluded unrelated/quiet replies, thread read markers,
deletion/replay and identity reset.
- Real Activity widget journey with fake relay/storage boundaries: show
the unmentioned reply, select Threads, tap the row, and verify the reply
ID and thread ID passed to the conversation page.
- Mutation check: removing reply-payload retention makes the widget
journey fail because the reply is absent.
- Independent agent source review: no blockers.

### Preview

Actual Flutter widgets at phone width with synthetic data and the new
reply wiring disabled/enabled. These are widget-test captures, not
installed-device or live-relay evidence.

| Before | After |
| --- | --- |
| ![Activity omits the unmentioned
reply](https://github.com/user-attachments/assets/a6fc8193-b1ef-4022-a5f1-a1cbbcdf5fb2)
| ![Activity shows the unmentioned thread
reply](https://github.com/user-attachments/assets/b2a6ba13-384a-4d0f-bda3-88d83307d31e)
|

### Human validation pending

Draft per the repository readiness checklist. On a device, receive an
unmentioned reply in a thread you started, replied in, or followed.
Check Activity → All and Threads, tap the row to reach the reply, then
read it and confirm Unread clears without reading unrelated channel
messages. Native/live-relay testing and human confirmation remain
pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-11-09 19:48:08 +00:00
committed by GitHub
parent 2bbaa061d0
commit 49511f02b6
8 changed files with 360 additions and 8 deletions

View File

@@ -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))
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);
});

View File

@@ -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',

View File

@@ -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,

View File

@@ -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<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;
}
}
}
}

View File

@@ -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:<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,
@@ -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,
);
}

View File

@@ -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);
});
});
}

View File

@@ -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<ChannelDetailPage>(
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<List<String>> 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);
},
);
}

View File

@@ -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(