mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
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 | | --- | --- | |  |  | ### 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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user