Files
buzz/mobile/test/features/activity/activity_page_test.dart
Tom Brow f38285562c fix(mobile): read only shown events from filtered Activity rows (#9215)
## Summary

Follow-up to [#9213 Activity filters
UI](https://github.com/block/buzz/pull/9213), from its [last Codex
finding](https://github.com/block/buzz/pull/9213#discussion_r4497322772).

With Threads, Mentions or a People/Agents filter active, an Activity row
holds only part of its conversation. Marking it read advanced the
`thread:<root>` (or channel) marker through the row's latest event,
which also read replies the filter hid. For example, reading a person's
reply in People also read an older agent reply in the same thread.

Filtered rows now advance the synced thread or channel marker through
every event that is read, stopping just before the first unread event
the filter hides, and write local `msg:` marks only for shown events
past that point. Once the hidden event is read too, the marker catches
up and syncs both. This also fixes filtered DM rows from #9213, whose
`msg:`-only reads never synced to other devices. Unfiltered rows are
unchanged.

## Testing

- New widget test: "reading a thread row under People leaves hidden
agent replies unread". It fails without the fix, and checks the synced
marker stops short of a hidden unread reply, then advances fully once
nothing hidden is unread.
- `flutter test test/features/activity/` (136 pass) and `flutter
analyze` on the activity feature.

## Before merge

- [x] Automated simulator run (iPhone 17 Pro sim, iOS 27, re-run on
d17f79a; the catch-up change in 28b927e is covered by the widget test):
with Agents selected, marked a channel mention row unread then read; it
stays read under Anyone and after relaunch. Screenshots are in Buzz
(internal data). The test account has no thread mixing agent and human
replies, so the hidden-reply case is covered by the widget test.
- [x] Main vs PR simulator comparison of a filtered thread read with a
hidden unread reply (iPhone 17 Pro sim, iOS 27; main at
[65e8643](https://github.com/block/buzz/commit/65e864301d), PR at
[b2414cf](b2414cf6d8)).
Neither test account has a real thread with both a person and an agent
mention, so this runs the Activity page on the simulator with seeded
events through the widget-test harness (`flutter run` of the test). The
thread has an older agent mention and a newer person mention, both
unread. The steps: choose People, swipe the person row to read it,
switch to Agents, then Anyone. Left is main, right is this PR.
  - After the read, the person row is read under People on both.
- Under Agents, main shows the older agent reply as read (the thread
marker moved to the person reply's timestamp). The PR keeps it unread
(marker stops one second before the agent reply).


![cmp-4-agents-after](https://raw.githubusercontent.com/block/buzz/aa95e2b22a6ac7815ba1ba36453c0c6c3a994bec/pr-9215--cmp-4-agents-after.png)
- Under Anyone, the thread row is read on main and still unread on the
PR, because its agent reply is unread.


![cmp-5-anyone-after](https://raw.githubusercontent.com/block/buzz/aa95e2b22a6ac7815ba1ba36453c0c6c3a994bec/pr-9215--cmp-5-anyone-after.png)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:59:18 -08:00

1603 lines
51 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:buzz/shared/widgets/ios_message_create_button.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:flutter/foundation.dart';
import 'package:buzz/features/activity/activity_page.dart';
import 'package:buzz/features/activity/activity_provider.dart';
import 'package:buzz/features/activity/compose_drafts_provider.dart';
import 'package:buzz/features/activity/feed_item.dart';
import 'package:buzz/features/activity/reminders_provider.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/read_state/read_state_format.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/reminders/reminder_service.dart' as reminder;
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/thread_ids.dart';
part 'activity_page_test/tabs_journey.dart';
void main() {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final testMention = FeedItem(
id: 'm1',
kind: 9,
pubkey: 'a11ce00000000000000000000000000000000000000000000000000000000000',
content: 'Hey check this out',
createdAt: now - 120,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'mention',
);
final testThreadReply = FeedItem(
id: 'a1',
kind: 9,
pubkey: 'bob_pk',
content: 'Deployed the fix',
createdAt: now - 3600,
channelId: 'ch2',
channelName: 'engineering',
tags: const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
],
category: 'activity',
);
final testAgent = FeedItem(
id: 'ag1',
kind: 43004,
pubkey: 'agent_pk',
content: 'Job completed successfully',
createdAt: now - 60,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'agent_activity',
);
final testFeed = HomeFeedResponse(
mentions: [testMention],
needsAction: const [],
activity: [testThreadReply],
agentActivity: [testAgent],
);
final testChannels = [
Channel(
id: 'ch1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
),
Channel(
id: 'ch2',
name: 'engineering',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
),
];
final testUsers = <String, UserProfile>{
'a11ce00000000000000000000000000000000000000000000000000000000000':
const UserProfile(
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'),
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
};
Future<Widget> buildTestable({
HomeFeedResponse? feed,
ActivityNotifier Function()? activityNotifier,
Map<String, UserProfile>? users,
Map<String, int> readContexts = const {},
List<Channel>? channels,
TextScaler? textScaler,
EdgeInsets mediaPadding = EdgeInsets.zero,
ValueListenable<int>? tabReselection,
List<ComposeDraft> drafts = const [],
List<Reminder> reminders = const [],
RemindersNotifier Function()? remindersNotifier,
Set<String> knownAgentPubkeys = const {},
List<Override> overrides = const [],
UserCacheNotifier Function()? userCache,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
return ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
activityProvider.overrideWith(
activityNotifier ?? () => _FakeActivityNotifier(feed ?? testFeed),
),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(channels ?? testChannels),
),
userCacheProvider.overrideWith(
userCache ?? () => _FakeUserCacheNotifier(users ?? testUsers),
),
knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys),
readStateProvider.overrideWith(
() => _FakeReadStateNotifier(readContexts),
),
composeDraftsProvider.overrideWith(
() => _FakeComposeDraftsNotifier(drafts),
),
remindersProvider.overrideWith(
remindersNotifier ?? () => _FakeRemindersNotifier(reminders),
),
...overrides,
],
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: textScaler, padding: mediaPadding),
child: child!,
),
home: ActivityPage(tabReselection: tabReselection),
),
);
}
_activityTabJourneys(
({Set<String> knownAgentPubkeys = const {}, TextScaler? textScaler}) =>
buildTestable(
knownAgentPubkeys: knownAgentPubkeys,
textScaler: textScaler,
),
);
testWidgets('shows loading skeleton while feed loads', (tester) async {
await tester.pumpWidget(
await buildTestable(activityNotifier: _PendingActivityNotifier.new),
);
// Single pump - don't settle, the future never completes.
await tester.pump();
expect(find.byType(Container), findsWidgets);
expect(find.text('Hey check this out'), findsNothing);
});
testWidgets('shows empty state when feed is empty', (tester) async {
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
),
);
await tester.pumpAndSettle();
expect(find.text('No activity yet'), findsOneWidget);
});
testWidgets(
'Activity keeps its header above the clipped feed at large text',
(tester) async {
await tester.pumpWidget(
await buildTestable(textScaler: const TextScaler.linear(2)),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Close activity'), findsOneWidget);
expect(find.byTooltip('Back'), findsNothing);
expect(find.byType(FrostedAppBar), findsNothing);
final filters = find.byKey(const ValueKey('activity-filters'));
final viewport = find.byKey(const ValueKey('activity-content-viewport'));
final cutoff = tester.getBottomLeft(filters).dy;
expect(tester.getTopLeft(viewport).dy, cutoff);
expect(
tester.getBottomLeft(find.text('Activity')).dy,
lessThanOrEqualTo(tester.getTopLeft(filters).dy),
);
await tester.drag(find.byType(CustomScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(tester.getTopLeft(viewport).dy, cutoff);
expect(tester.getBottomLeft(filters).dy, cutoff);
expect(tester.takeException(), isNull);
},
);
testWidgets('keeps footer clearance inside the scrollable content', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(mediaPadding: const EdgeInsets.only(bottom: 88)),
);
await tester.pumpAndSettle();
final padding = tester.widget<SliverPadding>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(SliverPadding),
),
);
expect(padding.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
});
testWidgets('scrolls Activity to the top when its tab is selected again', (
tester,
) async {
tester.view.physicalSize = const Size(320, 180);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
await buildTestable(tabReselection: tabReselection),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
expect(scrollable.position.maxScrollExtent, greaterThan(0));
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
tabReselection.value++;
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
expect(
scrollable.position.pixels,
lessThan(scrollable.position.maxScrollExtent),
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
await buildTestable(activityNotifier: _ErrorActivityNotifier.new),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load activity'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('activity popovers use fixed-layout scale and opacity motion', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final filterTrigger = find.byKey(const ValueKey('activity-sender-menu'));
expect(
tester.getSize(filterTrigger).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'The Activity filter trigger must keep a 48dp touch target.',
);
await tester.ensureVisible(filterTrigger);
await tester.pumpAndSettle();
await tester.tap(filterTrigger);
await tester.pump();
final surface = find.byKey(const ValueKey('activity-sender-popover'));
final fade = find.byKey(const ValueKey('activity-popover-fade'));
final scale = find.byKey(const ValueKey('activity-popover-scale'));
expect(surface, findsOneWidget);
expect(fade, findsOneWidget);
expect(scale, findsOneWidget);
final initialSize = tester.getSize(surface);
final initialFade = tester.widget<FadeTransition>(fade);
final initialScale = tester.widget<ScaleTransition>(scale);
expect(initialSize.width, 240);
expect(initialFade.opacity.value, lessThan(1));
expect(initialScale.scale.value, greaterThanOrEqualTo(0.96));
expect(initialScale.scale.value, lessThan(1));
expect(initialScale.alignment, Alignment.topLeft);
await tester.pump(const Duration(milliseconds: 75));
final movingFade = tester.widget<FadeTransition>(fade);
final movingScale = tester.widget<ScaleTransition>(scale);
expect(movingFade.opacity.value, greaterThan(0));
expect(movingFade.opacity.value, lessThan(1));
expect(movingScale.scale.value, greaterThan(0.96));
expect(movingScale.scale.value, lessThan(1));
expect(tester.getSize(surface), initialSize);
await tester.pump(const Duration(milliseconds: 75));
expect(tester.widget<FadeTransition>(fade).opacity.value, 1);
expect(tester.widget<ScaleTransition>(scale).scale.value, 1);
expect(tester.getSize(surface), initialSize);
final material = tester.widget<Material>(surface);
final shape = material.shape! as RoundedRectangleBorder;
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
expect(shape.side.color, Colors.black.withValues(alpha: 0.04));
expect(material.elevation, appPopoverElevation);
expect(
material.shadowColor,
appPopoverShadowColor(tester.element(surface)),
);
expect(material.surfaceTintColor, Colors.transparent);
expect(material.clipBehavior, Clip.antiAlias);
final items = tester.widgetList<PopupMenuItem<String>>(
find.byType(PopupMenuItem<String>),
);
expect(items, hasLength(3));
expect(
items.every((item) => item.height >= Grid.xl),
isTrue,
reason: 'Activity filter choices must keep 48dp touch targets.',
);
await tester.tap(
find.descendant(of: surface, matching: find.text('Anyone')),
);
await tester.pumpAndSettle();
});
testWidgets('rows lead with sender, contextual label, and preview', (
tester,
) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
// Sender names resolved from the user cache.
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Scout'), findsOneWidget);
// Contextual labels + channel chips.
expect(find.text('Mentioned in'), findsOneWidget);
expect(find.text('Thread in'), findsOneWidget);
expect(
find.text('#general'),
findsNWidgets(2),
); // mention and agent activity
expect(find.text('#engineering'), findsOneWidget);
// Context labels and channel pills share the compact Activity style.
final contextLabel = tester.widget<Text>(find.text('Mentioned in'));
final channelLabel = tester.widgetList<Text>(find.text('#general')).first;
expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(contextLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(contextLabel.style?.height, activityContextTextStyle.height);
expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize);
expect(channelLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
expect(channelLabel.style?.height, activityContextTextStyle.height);
// Message previews.
expect(find.textContaining('Hey check this out'), findsOneWidget);
expect(find.textContaining('Deployed the fix'), findsOneWidget);
// Activity rows use their conversation-oriented scale.
final senderText = tester.widget<Text>(find.text('Alice'));
final theme = Theme.of(tester.element(find.text('Alice')));
expect(senderText.style?.fontSize, activityUsernameTextStyle.fontSize);
expect(senderText.style?.fontWeight, activityUsernameTextStyle.fontWeight);
expect(senderText.style?.height, activityUsernameTextStyle.height);
expect(senderText.style?.color, theme.colorScheme.onSurface);
final usernameText = tester.widget<Text>(
find.byKey(const ValueKey('activity-username-m1')),
);
final timestampText = tester.widget<Text>(
find.byKey(const ValueKey('activity-timestamp-m1')),
);
expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize);
expect(usernameText.style?.fontWeight, FontWeight.w400);
expect(usernameText.style?.height, messageMetadataTextStyle.height);
expect(timestampText.style?.fontSize, activityTimestampTextStyle.fontSize);
expect(timestampText.style?.fontWeight, FontWeight.w400);
expect(
timestampText.style?.fontSize,
lessThan(usernameText.style!.fontSize!),
);
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
expect(avatars, isNotEmpty);
expect(
avatars.every((avatar) => avatar.radius == activityAvatarSize / 2),
isTrue,
);
final previews = tester.widgetList<MessagePreview>(
find.byType(MessagePreview),
);
expect(previews, isNotEmpty);
expect(
previews.every(
(preview) =>
preview.baseStyle?.fontSize == activityPreviewTextStyle.fontSize &&
preview.baseStyle?.fontWeight ==
activityPreviewTextStyle.fontWeight &&
preview.baseStyle?.height == activityPreviewTextStyle.height &&
preview.baseStyle?.letterSpacing ==
activityPreviewTextStyle.letterSpacing &&
preview.baseStyle?.color == theme.colorScheme.onSurface,
),
isTrue,
);
});
testWidgets('blank cached sender names fall back to the compact npub', (
tester,
) async {
// Relay profiles can cache blank display names (empty and
// whitespace-only) unchanged, so the sender must resolve through the
// shared nonblank-name label contract: the row shows the compact npub
// of the a11ce key instead of a blank author label. Binds the production
// seam — the sender resolves through the user cache exactly as the live
// page does. Keyed remounts keep each ProviderScope (and its user-cache
// override) fresh between scenarios, so each iteration actually
// consumes its own blank-name fixture.
const sender =
'a11ce00000000000000000000000000000000000000000000000000000000000';
for (final blankName in const ['', ' ']) {
await tester.pumpWidget(
KeyedSubtree(
key: ValueKey('blank-sender-${blankName.length}'),
child: await buildTestable(
users: {
...testUsers,
sender: UserProfile(pubkey: sender, displayName: blankName),
},
),
),
);
await tester.pumpAndSettle();
expect(find.text(blankName), findsNothing);
expect(
find.descendant(
of: find.byKey(const ValueKey('inbox-row-m1')),
matching: find.text('npub15yw…ccpw'),
),
findsOneWidget,
);
}
});
testWidgets('same-name senders get their channel\'s contextual label', (
tester,
) async {
// Another ch1 member shares the sender's name, so the row must use the
// channel's disambiguated label rather than the bare profile name.
const sender =
'a11ce00000000000000000000000000000000000000000000000000000000000';
const twin =
'b0b0000000000000000000000000000000000000000000000000000000000000';
final members = [
for (final pubkey in const [sender, twin])
ChannelMember(pubkey: pubkey, role: 'member', joinedAt: DateTime(2025)),
];
final users = {
...testUsers,
sender: const UserProfile(pubkey: sender, displayName: 'Scout'),
twin: const UserProfile(pubkey: twin, displayName: 'Scout'),
};
await tester.pumpWidget(
await buildTestable(
users: users,
overrides: [
channelMembersProvider('ch1').overrideWith((ref) async => members),
],
),
);
await tester.pumpAndSettle();
final expected = IdentityNameSources(
profiles: users,
).scope(const [sender, twin]).labelFor(sender);
expect(expected, isNot('Scout'));
expect(
find.descendant(
of: find.byKey(const ValueKey('inbox-row-m1')),
matching: find.textContaining(expected),
),
findsWidgets,
);
});
testWidgets('directory-known Activity authors use agent avatars', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(knownAgentPubkeys: const {'bob_pk'}),
);
await tester.pumpAndSettle();
final agentRow = find.byKey(const ValueKey('inbox-row-a1'));
final agentAvatar = tester.widget<AvatarImage>(
find.descendant(of: agentRow, matching: find.byType(AvatarImage)),
);
final humanRow = find.byKey(const ValueKey('inbox-row-m1'));
final humanAvatar = tester.widget<AvatarImage>(
find.descendant(of: humanRow, matching: find.byType(AvatarImage)),
);
expect(agentAvatar.isAgent, isTrue);
expect(humanAvatar.isAgent, isFalse);
});
testWidgets('multiple top-level messages in one DM render one row', (
tester,
) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
FeedItem dmMessage(String id, int age) => FeedItem(
id: id,
kind: 9,
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
content: 'dm body $id',
createdAt: now - age,
channelId: 'dm1',
channelName: '',
tags: const [],
category: 'activity',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: [dmMessage('dm-a', 300), dmMessage('dm-b', 200)],
agentActivity: const [],
),
channels: [...testChannels, dmChannel],
),
);
await tester.pumpAndSettle();
// One conversation row for the DM, represented by the latest message.
expect(find.byKey(const ValueKey('inbox-row-dm-b')), findsOneWidget);
expect(find.byKey(const ValueKey('inbox-row-dm-a')), findsNothing);
expect(find.textContaining('dm body dm-b'), findsOneWidget);
expect(find.textContaining('dm body dm-a'), findsNothing);
});
testWidgets('marking a DM row read covers replies under every thread root', (
tester,
) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
FeedItem reply(String id, String root, int age) => FeedItem(
id: id,
kind: 9,
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
content: 'dm reply $id',
createdAt: now - age,
channelId: 'dm1',
channelName: '',
tags: [
['e', root, '', 'root'],
['e', root, '', 'reply'],
],
category: 'activity',
);
const rootA =
'aaaa000000000000000000000000000000000000000000000000000000000001';
const rootB =
'bbbb000000000000000000000000000000000000000000000000000000000002';
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: [reply('dm-r1', rootA, 300), reply('dm-r2', rootB, 200)],
agentActivity: const [],
),
channels: [...testChannels, dmChannel],
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('inbox-unread-dot-dm-r2')),
findsOneWidget,
);
await tester.drag(
find.byKey(const ValueKey('inbox-row-dm-r2')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-dm-r2')), findsNothing);
});
testWidgets(
'sender filter keeps a person\'s message under a later agent reply',
(tester) async {
FeedItem threadEvent(String id, String pubkey, String body, int age) =>
FeedItem(
id: id,
kind: 9,
pubkey: pubkey,
content: body,
createdAt: now - age,
channelId: 'ch1',
channelName: 'general',
tags: const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
],
category: 'mention',
);
await tester.pumpWidget(
await buildTestable(
knownAgentPubkeys: const {'agent_pk'},
feed: HomeFeedResponse(
mentions: [
threadEvent('p1', 'bob_pk', 'person asks', 300),
threadEvent('g1', 'agent_pk', 'agent answers', 200),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('agent answers'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('activity-sender-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('People').last);
await tester.pumpAndSettle();
expect(find.textContaining('person asks'), findsOneWidget);
expect(find.textContaining('agent answers'), findsNothing);
},
);
testWidgets('Agents filter counts channel bot members as agents', (
tester,
) async {
FeedItem event(String id, String pubkey, String body, int age) => FeedItem(
id: id,
kind: 9,
pubkey: pubkey,
content: body,
createdAt: now - age,
channelId: 'ch1',
channelName: 'general',
tags: const [],
category: 'mention',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: [
event('b1', 'bot_pk', 'bot reports', 300),
event('p1', 'bob_pk', 'person asks', 200),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
overrides: [
channelBotPubkeysProvider(
'ch1',
).overrideWith((ref) async => {'bot_pk'}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-sender-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Agents').last);
await tester.pumpAndSettle();
expect(find.textContaining('bot reports'), findsOneWidget);
expect(find.textContaining('person asks'), findsNothing);
});
testWidgets('Threads filter shows a DM reply, not a later DM message', (
tester,
) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
FeedItem dmEvent(
String id,
String body,
int age,
List<List<String>> tags,
) => FeedItem(
id: id,
kind: 9,
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
content: body,
createdAt: now - age,
channelId: 'dm1',
channelName: '',
tags: tags,
category: 'activity',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: [
dmEvent('dm-reply', 'reply in thread', 300, const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
]),
dmEvent('dm-top', 'later top level', 200, const []),
],
agentActivity: const [],
),
channels: [...testChannels, dmChannel],
),
);
await tester.pumpAndSettle();
await _chooseActivityType(tester, 'Threads');
expect(find.textContaining('reply in thread'), findsOneWidget);
expect(find.textContaining('later top level'), findsNothing);
});
testWidgets(
'reading a DM reply under Threads leaves other DM messages unread',
(tester) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
FeedItem dmEvent(String id, int age, List<List<String>> tags) => FeedItem(
id: id,
kind: 9,
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
content: 'dm body $id',
createdAt: now - age,
channelId: 'dm1',
channelName: '',
tags: tags,
category: 'activity',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: const [],
needsAction: const [],
activity: [
dmEvent('dm-top', 300, const []),
dmEvent('dm-reply', 200, const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
]),
],
agentActivity: const [],
),
channels: [...testChannels, dmChannel],
),
);
await tester.pumpAndSettle();
await _chooseActivityType(tester, 'Threads');
await tester.drag(
find.byKey(const ValueKey('inbox-row-dm-reply')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('inbox-unread-dot-dm-reply')),
findsNothing,
);
await _chooseActivityType(tester, 'All');
expect(
find.byKey(const ValueKey('inbox-unread-dot-dm-reply')),
findsOneWidget,
);
},
);
testWidgets(
'reading a thread row under People leaves hidden agent replies unread',
(tester) async {
FeedItem threadEvent(String id, String pubkey, int age) => FeedItem(
id: id,
kind: 9,
pubkey: pubkey,
content: 'body $id',
createdAt: now - age,
channelId: 'ch1',
channelName: 'general',
tags: const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
],
category: 'mention',
);
await tester.pumpWidget(
await buildTestable(
knownAgentPubkeys: const {'agent_pk'},
feed: HomeFeedResponse(
mentions: [
threadEvent('g1', 'agent_pk', 300),
threadEvent('p1', 'bob_pk', 200),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
),
);
await tester.pumpAndSettle();
Future<void> chooseSender(String label) async {
await tester.tap(find.byKey(const ValueKey('activity-sender-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text(label).last);
await tester.pumpAndSettle();
}
await chooseSender('People');
await tester.drag(
find.byKey(const ValueKey('inbox-row-p1')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsNothing);
await chooseSender('Anyone');
expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsOneWidget);
int? threadMarker() => ProviderScope.containerOf(
tester.element(find.byType(ActivityPage)),
).read(readStateProvider).contexts[threadContextKey(threadIdRoot1)];
// The synced marker stops short of the hidden agent reply.
expect(threadMarker(), now - 301);
await chooseSender('Agents');
await tester.drag(
find.byKey(const ValueKey('inbox-row-g1')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
// With the agent reply read too, the synced marker covers both.
expect(threadMarker(), now - 200);
await chooseSender('Anyone');
expect(find.byKey(const ValueKey('inbox-unread-dot-p1')), findsNothing);
},
);
testWidgets('sender filter preloads authors hidden under newer rows', (
tester,
) async {
final cache = _RecordingUserCacheNotifier(testUsers);
FeedItem threadEvent(String id, String pubkey, int age) => FeedItem(
id: id,
kind: 9,
pubkey: pubkey,
content: 'body $id',
createdAt: now - age,
channelId: 'ch1',
channelName: 'general',
tags: const [
['e', threadIdRoot1, '', 'root'],
['e', threadIdRoot1, '', 'reply'],
],
category: 'mention',
);
await tester.pumpWidget(
await buildTestable(
feed: HomeFeedResponse(
mentions: [
threadEvent('g1', 'owned_agent_pk', 300),
threadEvent('p1', 'bob_pk', 200),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
),
userCache: () => cache,
),
);
await tester.pumpAndSettle();
expect(cache.preloaded, isNot(contains('owned_agent_pk')));
await tester.tap(find.byKey(const ValueKey('activity-sender-menu')));
await tester.pumpAndSettle();
await tester.tap(find.text('Agents').last);
await tester.pumpAndSettle();
expect(cache.preloaded, contains('owned_agent_pk'));
});
testWidgets('unread rows show a dot; read rows do not', (tester) async {
await tester.pumpWidget(
await buildTestable(
// ch1 fully read; ch2 (thread) unread.
readContexts: {'ch1': now},
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
expect(find.byKey(const ValueKey('inbox-unread-dot-ag1')), findsNothing);
expect(find.byKey(const ValueKey('inbox-unread-dot-a1')), findsOneWidget);
});
testWidgets(
'chronological Activity does not imply a contiguous unread boundary',
(tester) async {
await tester.pumpWidget(
await buildTestable(
// Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read.
readContexts: {'thread:$threadIdRoot1': now, 'ch2': now},
),
);
await tester.pumpAndSettle();
expect(find.text('New'), findsNothing);
},
);
testWidgets('tabs switch sources and show empty states', (tester) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await _chooseActivityType(tester, 'Mentions');
await tester.pumpAndSettle();
expect(find.textContaining('Hey check this out'), findsOneWidget);
expect(find.textContaining('Deployed the fix'), findsNothing);
expect(find.textContaining('Job completed successfully'), findsNothing);
await _chooseActivityType(tester, 'DMs');
await tester.pumpAndSettle();
expect(find.text('No direct messages yet'), findsOneWidget);
});
testWidgets('sender menu supports accessibility text scaling', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(textScaler: const TextScaler.linear(3)),
);
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const ValueKey('activity-sender-menu')),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-sender-menu')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-sender-popover')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('opens a thread reply at the referenced message', (tester) async {
final dmChannel = Channel(
id: 'dm1',
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
participants: const ['Alice'],
);
// A DM reply must still open its thread: the DM timeline hides replies.
for (final channelId in ['ch1', 'dm1']) {
final threadMention = FeedItem(
id: 'reply-event',
kind: 9,
pubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
content: 'Reply in a thread',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
channelId: channelId,
channelName: 'general',
tags: const [
['e', threadIdThreadDashRoot, '', 'root'],
['e', threadIdParentDashReply, '', 'reply'],
],
category: 'mention',
);
final feed = HomeFeedResponse(
mentions: [threadMention],
needsAction: const [],
activity: const [],
agentActivity: const [],
);
await tester.pumpWidget(
await buildTestable(feed: feed, channels: [...testChannels, dmChannel]),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event')));
await tester.pumpAndSettle();
final page = tester.widget<ChannelDetailPage>(
find.byType(ChannelDetailPage),
);
expect(page.channel.id, channelId);
expect(page.initialThreadRootId, threadIdParentDashReply);
expect(page.initialMessageId, 'reply-event');
expect(
page.initialThreadRouteBehavior,
InitialThreadRouteBehavior.replaceCurrentRoute,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
});
testWidgets('thread filter matches grouped thread replies', (tester) async {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
await _chooseActivityType(tester, 'Threads');
await tester.pumpAndSettle();
expect(find.textContaining('Deployed the fix'), findsOneWidget);
expect(find.textContaining('Hey check this out'), findsNothing);
});
testWidgets('unread-only toggle hides read rows', (tester) async {
await tester.pumpWidget(await buildTestable(readContexts: {'ch1': now}));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('activity-options-menu')), findsNothing);
expect(find.text('Mark all as read'), findsNothing);
await tester.tap(find.byKey(const ValueKey('activity-unread-filter')));
await tester.pumpAndSettle();
// Only the unread thread row remains.
expect(find.textContaining('Deployed the fix'), findsOneWidget);
expect(find.textContaining('Hey check this out'), findsNothing);
expect(find.textContaining('Job completed successfully'), findsNothing);
});
testWidgets('long-press mark unread reopens a read row', (tester) async {
await tester.pumpWidget(
await buildTestable(
readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now},
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Mark unread'),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
});
testWidgets('long-press follows a thread and sets a reminder', (
tester,
) async {
final reminders = _CapturingReminderService();
final remindersNotifier = _FakeRemindersNotifier(const []);
await tester.pumpWidget(
await buildTestable(
remindersNotifier: () => remindersNotifier,
overrides: [
reminder.reminderServiceProvider.overrideWithValue(reminders),
],
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ActivityPage)),
);
Finder sheetText(String text) => find.descendant(
of: find.byType(BottomSheet),
matching: find.text(text),
);
// A thread row follows its root, and the menu then offers unfollow.
await tester.longPress(find.byKey(const ValueKey('inbox-row-a1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Follow thread'));
await tester.pumpAndSettle();
expect(
container.read(threadFollowsProvider).isFollowing(threadIdRoot1),
isTrue,
);
await tester.longPress(find.byKey(const ValueKey('inbox-row-a1')));
await tester.pumpAndSettle();
expect(sheetText('Follow thread'), findsNothing);
await tester.tap(sheetText('Unfollow thread'));
await tester.pumpAndSettle();
expect(
container.read(threadFollowsProvider).isFollowing(threadIdRoot1),
isFalse,
);
// A top-level row follows the message itself as a potential thread head.
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Follow thread'));
await tester.pumpAndSettle();
expect(container.read(threadFollowsProvider).isFollowing('m1'), isTrue);
// Remind me opens the shared reminder sheet for the row's message.
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(sheetText('Remind me'));
await tester.pumpAndSettle();
expect(find.text('Remind me about this message'), findsOneWidget);
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
final first = reminders.saved.single;
expect(first.target.eventId, 'm1');
expect(first.target.channelId, 'ch1');
expect(first.target.preview, 'Hey check this out');
expect(first.dTag, isNull);
expect(find.text('Reminder set'), findsOneWidget);
expect(remindersNotifier.refreshes, 1);
});
testWidgets('long-press reschedules an existing reminder instead of adding '
'one', (tester) async {
final reminders = _CapturingReminderService();
final existing = Reminder(
id: 'reminder-d1',
notBefore: now + 600,
status: 'pending',
target: const ReminderTarget(
eventId: 'm1',
channelId: 'ch1',
preview: 'Hey check this out',
authorPubkey:
'a11ce00000000000000000000000000000000000000000000000000000000000',
),
note: 'Reply after standup',
createdAt: now - 60,
eventId: 'reminder-event-1',
);
await tester.pumpWidget(
await buildTestable(
reminders: [existing],
overrides: [
reminder.reminderServiceProvider.overrideWithValue(reminders),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
await tester.tap(find.text('Remind me'));
await tester.pumpAndSettle();
await tester.tap(find.text('In 1 hour'));
await tester.pumpAndSettle();
final saved = reminders.saved.single;
expect(saved.dTag, 'reminder-d1');
expect(saved.note, 'Reply after standup');
expect(find.text('Reminder updated'), findsOneWidget);
});
testWidgets('long-press omits Follow in a Solo chat', (tester) async {
await tester.pumpWidget(
await buildTestable(
channels: [
Channel(
id: 'ch1',
name: 'Notes',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'x',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
labels: const ['buzz.solo'],
),
testChannels[1],
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
expect(find.text('Open conversation'), findsOneWidget);
expect(find.text('Follow thread'), findsNothing);
});
testWidgets('long-press hides reminders when signed out', (tester) async {
await tester.pumpWidget(
await buildTestable(
overrides: [reminder.reminderServiceProvider.overrideWithValue(null)],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
expect(find.text('Follow thread'), findsOneWidget);
expect(find.text('Remind me'), findsNothing);
});
testWidgets('swiping an inbox row reveals and runs its row actions', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(
readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now},
),
);
await tester.pumpAndSettle();
final row = find.byKey(const ValueKey('inbox-row-m1'));
final originalLeft = tester.getTopLeft(row).dx;
await tester.drag(row, const Offset(-200, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(row).dx, lessThan(originalLeft));
expect(find.byKey(const ValueKey('inbox-swipe-read-m1')), findsOneWidget);
expect(find.byKey(const ValueKey('inbox-swipe-open-m1')), findsNothing);
expect(
find.byKey(const ValueKey('inbox-swipe-background-m1')),
findsOneWidget,
);
expect(
tester
.getSize(find.byKey(const ValueKey('inbox-swipe-background-m1')))
.height,
tester.getSize(row).height,
);
final markUnreadAction = find.byKey(const ValueKey('inbox-swipe-read-m1'));
final markUnreadMaterial = tester.widget<Material>(
find.descendant(of: markUnreadAction, matching: find.byType(Material)),
);
expect(markUnreadMaterial.color, tester.element(row).colors.primary);
expect(markUnreadMaterial.borderRadius, BorderRadius.circular(Radii.full));
await tester.tap(find.byKey(const ValueKey('inbox-swipe-read-m1')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
await tester.drag(row, const Offset(-200, 0));
await tester.pumpAndSettle();
final markReadAction = find.byKey(const ValueKey('inbox-swipe-read-m1'));
final markReadMaterial = tester.widget<Material>(
find.descendant(of: markReadAction, matching: find.byType(Material)),
);
expect(markReadMaterial.color, tester.element(row).appColors.success);
expect(markReadMaterial.color, isNot(markUnreadMaterial.color));
});
testWidgets('swipe action reveals its label with one threshold haptic', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
await buildTestable(
readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now},
),
);
await tester.pumpAndSettle();
final row = find.byKey(const ValueKey('inbox-row-m1'));
final gesture = await tester.startGesture(tester.getCenter(row));
for (var step = 1; step <= 7; step++) {
await gesture.moveBy(
const Offset(-10, 0),
timeStamp: Duration(milliseconds: step * 50),
);
await tester.pump();
}
final action = find.byKey(const ValueKey('inbox-swipe-read-m1'));
expect(action, findsOneWidget);
expect(
find.descendant(of: action, matching: find.byIcon(BuzzIcons.mail)),
findsOneWidget,
);
expect(find.text('Mark unread'), findsNothing);
expect(hapticCalls, isEmpty);
for (var step = 8; step <= 10; step++) {
await gesture.moveBy(
const Offset(-10, 0),
timeStamp: Duration(milliseconds: step * 50),
);
await tester.pump();
}
expect(find.text('Mark unread'), findsOneWidget);
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
await gesture.moveBy(
const Offset(20, 0),
timeStamp: const Duration(milliseconds: 550),
);
await tester.pump();
await gesture.moveBy(
const Offset(-20, 0),
timeStamp: const Duration(milliseconds: 600),
);
await tester.pump();
expect(hapticCalls, hasLength(1));
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('commits the inbox read-state action past the swipe threshold', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(
readContexts: {'ch1': now, 'ch2': now, 'thread:$threadIdRoot1': now},
),
);
await tester.pumpAndSettle();
final row = find.byKey(const ValueKey('inbox-row-m1'));
await tester.drag(row, const Offset(-500, 0));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
expect(
find.byKey(const ValueKey('inbox-swipe-background-m1')),
findsNothing,
);
});
testWidgets('falls back to a compact npub when user is not cached', (
tester,
) async {
await tester.pumpWidget(await buildTestable(users: const {}));
await tester.pumpAndSettle();
// Sender label falls back to the compact npub of the author's key.
expect(
find.text(
'a11ce00000000000000000000000000000000000000000000000000000000000',
),
findsNothing,
);
expect(find.text('npub15yw\u2026ccpw'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
});
}
class _FakeActivityNotifier extends ActivityNotifier {
final HomeFeedResponse _feed;
_FakeActivityNotifier(this._feed);
@override
Future<HomeFeedResponse> build() async => _feed;
}
class _PendingActivityNotifier extends ActivityNotifier {
@override
Future<HomeFeedResponse> build() => Completer<HomeFeedResponse>().future;
}
class _ErrorActivityNotifier extends ActivityNotifier {
@override
Future<HomeFeedResponse> build() => Future.error('Connection refused');
}
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
@override
Future<List<Channel>> build() async => _channels;
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@override
Map<String, UserProfile> build() => _users;
}
class _RecordingUserCacheNotifier extends _FakeUserCacheNotifier {
_RecordingUserCacheNotifier(super._users);
final preloaded = <String>{};
@override
Future<bool> preload(List<String> pubkeys) async {
preloaded.addAll(pubkeys);
return true;
}
}
class _FakeReadStateNotifier extends ReadStateNotifier {
final Map<String, int> _contexts;
_FakeReadStateNotifier(this._contexts);
@override
ReadStateState build() => ReadStateState(
isReady: true,
pubkey: 'me_pk',
contexts: Map.unmodifiable(_contexts),
version: 1,
);
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
state = state.copyWithContext(contextId, unixTimestamp);
}
}
class _FakeRemindersNotifier extends RemindersNotifier {
final List<Reminder> _reminders;
_FakeRemindersNotifier(this._reminders);
int refreshes = 0;
@override
Future<List<Reminder>> build() async => _reminders;
@override
Future<void> refresh() async => refreshes++;
}
class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier {
final List<ComposeDraft> _drafts;
_FakeComposeDraftsNotifier(this._drafts);
@override
List<ComposeDraft> build() => _drafts;
}
class _CapturingReminderService extends reminder.ReminderService {
_CapturingReminderService._(nostr.Keys keys)
: super(
signedEventRelay: SignedEventRelay(
session: RelaySessionNotifier(),
nsec: keys.nsec,
),
crypto: reminder.ReminderCrypto(keys.nsec, keys.public),
);
factory _CapturingReminderService() =>
_CapturingReminderService._(nostr.Keys.generate());
final saved =
<({reminder.ReminderTarget target, String? note, String? dTag})>[];
@override
Future<void> createReminder({
required reminder.ReminderTarget target,
required int notBefore,
String? note,
String? replacingDTag,
}) async {
saved.add((target: target, note: note, dTag: replacingDTag));
}
}