Files
buzz/mobile/test/features/channels/channel_detail_page_test.dart
Taylor Ho 9d36778c37 feat(mobile): refactor Activity behavior and ui (#2889)
**Category:** improvement
**User Impact:** Mobile users can scan Activity as a focused
conversation inbox and open the exact unread message or thread
represented by each item.

## Context

Mobile's Activity tab had not kept pace with Desktop: it presented
isolated event headlines, advertised categories that were often empty,
and opened a channel without clearly landing on the selected item.

This PR brings the Mobile surface toward the conversation-oriented
direction explored in Clay Delk's Desktop [Inbox refactor PR
#2045](https://github.com/block/buzz/pull/2045), while adapting it to
Mobile rather than copying the Desktop split-pane implementation. The
related product/UX discussion is captured in the originating [Buzz
thread](buzz://message?channel=a9bbc0e5-d25d-4740-849c-93c34bb578a4&id=a7d9a4d33dcd8c6bf0dc67d81c328892b9e38dedaa8548920224ef388301b6ab).

## UX decisions in this PR

- **Conversation-oriented, not event-oriented:** related updates
collapse into one row per thread/DM conversation, represented by the
latest update and ordered by latest activity. Separate top-level
conversations in the same channel remain separate rows.
- **Resume at the oldest unread:** tapping a grouped row opens the
represented canonical message/thread/DM at its oldest unread item,
rather than merely opening the channel at an arbitrary position.
- **Desktop-aligned row hierarchy:** rows lead with a full avatar and
sender, followed by contextual location/type metadata, unread dot +
time, and a two-line preview. A **New** boundary separates unread and
read content.
- **Mobile-native navigation:** Mobile keeps a single-column `Activity →
canonical conversation → Back` flow. It does not introduce Desktop's
persistent detail pane.
- **Compact filtering:** the old horizontal chip rail becomes a compact
filter menu so the source set fits a phone viewport without horizontal
scanning. Filters are All, Mentions, Threads, Needs Action, Activity,
Agents, Reminders, and Drafts.
- **Focused source semantics:** All covers personally relevant work—DMs,
mentions, thread replies, needs-action events, owned-agent activity, due
reminders, and active drafts—rather than becoming a generic stream of
every channel message. Mobile's standalone Activity source is currently
limited to DM traffic because it does not have Desktop's aggregated
channel-activity feed.
- **Shared read behavior:** rows project canonical
channel/thread/message markers, support unread-only and mark-all-read,
and use local overrides only where canonical markers cannot represent an
item.
- **Reminders and drafts are real data:** reminders use the same
encrypted NIP-ER events as Desktop. Drafts persist device-local composer
state, restore on return, survive failed sends, and clear after
successful sends.
- **Explain navigation failures:** an unavailable destination produces
an explanatory message rather than silently doing nothing or falling
back to an unrelated channel position.

## Implementation summary

- Adds a Mobile inbox model for conversation grouping, category
priority, contextual labels, sorting, filtering, and oldest-unread
targets.
- Expands relay-backed sources for mentions, approvals, owned-agent
lifecycle events, and DM traffic.
- Adds fail-closed NIP-ER reminder decryption and device-local
compose-draft persistence.
- Redesigns Activity rows, boundaries, filters, unread controls, and
empty/loading states.
- Routes rows through Mobile's existing canonical channel/thread screens
with precise target IDs.
- Adds model, provider, widget, reminder, read-state, draft-lifecycle,
and deep-link coverage.

## Reproduction steps

1. Run Mobile and open **Activity**.
2. Confirm full avatars, sender-first rows, context labels, unread
indicators, timestamps, two-line previews, and the compact filter
control.
3. Open the filter menu and verify All, Mentions, Threads, Needs Action,
Activity, Agents, Reminders, and Drafts.
4. Tap a grouped thread row and confirm the canonical conversation opens
at its oldest unread message.
5. Mark rows read/unread, enable unread-only mode, and use
mark-all-read; confirm state agrees with the channel/thread destination.
6. Type without sending in a channel or thread, leave, and confirm the
draft appears in Activity and restores in the composer.

## Screenshots

| Before — merge-base `dd222a509` | After — PR head `52ad40aee` |
|---|---|
| <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/ae961b08-bf8a-4bd5-b487-f6321ae8d85b"
/> | <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/bcbe7ab0-552a-417c-9e85-7a85eb4592ee"
/> |

Recaptured on the same authenticated iPhone 17 simulator, account,
theme, and Activity view, at this PR's current merge-base (`dd222a509`)
and head (`52ad40aee`). Both frames were taken within a few minutes on
the same live feed, so the visible conversation set overlaps closely
(the recent Ned/Bart/Tommy items appear in both). The compared change is
the row *structure*: Before leads with an `@ Mention` headline over a
small inline avatar and a horizontal chip rail; After leads with a full
avatar, a compact `labelMedium` sender label, contextual "Mentioned in"
metadata, and a filter menu. The sender username now renders at the same
compact scale the old `@ Mention` label used.

## Verification

- Current rebased head: `5bd87f4f4` on `origin/main` at `dd222a509`;
GitHub reports the PR mergeable.
- `flutter analyze` — clean at `5bd87f4f4`.
- Full Mobile suite — 698 passed, 1 skipped, 4 failed; all four failures
reproduce identically on clean `origin/main` (`channels_page_test`
create-channel sheet and three `compose_bar_test` agent-mention cases).
- The prior PR-specific `home_page_test` failures were fixed by
providing the Activity local-state dependency in that harness.
- Independent code and simulator UI review — approved.
- Post-rebase GitHub checks are running.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
2026-07-27 16:55:24 +00:00

1741 lines
51 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.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/channel_messages_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/channels/date_formatters.dart';
import 'package:buzz/features/channels/day_divider.dart';
import 'package:buzz/features/channels/reaction_row.dart';
import 'package:buzz/features/channels/thread_detail_page.dart';
import 'package:buzz/features/channels/thread_replies_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _channelId = 'test-channel';
/// Shared mock prefs for providers that read [savedPrefsProvider]
/// (e.g. the compose bar's draft store). Initialized in [main].
late SharedPreferences _testPrefs;
final _testChannel = Channel(
id: _channelId,
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc123',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
);
NostrEvent _textMsg({
required String id,
required String pubkey,
required String content,
int createdAt = 1000,
List<List<String>> extraTags = const [],
}) => NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.streamMessage,
tags: [
['h', _channelId],
...extraTags,
],
content: content,
sig: '',
);
NostrEvent _systemMsg({
required String id,
required Map<String, dynamic> payload,
int createdAt = 1000,
}) => NostrEvent(
id: id,
pubkey: 'relay',
createdAt: createdAt,
kind: EventKind.systemMessage,
tags: [
['h', _channelId],
],
content: jsonEncode(payload),
sig: '',
);
NostrEvent _reaction({
required String id,
required String targetId,
String pubkey = 'bob',
int createdAt = 2000,
String content = '👍',
}) => NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: EventKind.reaction,
tags: [
['h', _channelId],
['e', targetId],
],
content: content,
sig: '',
);
NostrEvent _deletion({
required String id,
required List<String> targetIds,
int createdAt = 2000,
}) => NostrEvent(
id: id,
pubkey: 'abc123',
createdAt: createdAt,
kind: EventKind.deletion,
tags: [
['h', _channelId],
for (final t in targetIds) ['e', t],
],
content: '',
sig: '',
);
NostrEvent _edit({
required String id,
required String targetId,
required String content,
int createdAt = 2000,
}) => NostrEvent(
id: id,
pubkey: 'abc123',
createdAt: createdAt,
kind: EventKind.streamMessageEdit,
tags: [
['h', _channelId],
['e', targetId],
],
content: content,
sig: '',
);
Widget _buildTestable({
required List<NostrEvent> messages,
List<TypingEntry> typing = const [],
Map<String, UserProfile> users = const {},
List<ChannelMember> members = const [],
Channel? channel,
List<Channel>? channels,
_FakeChannelsNotifier? channelsNotifier,
List<NavigatorObserver> navigatorObservers = const [],
Future<List<ChannelMember>> Function()? loadMembers,
ChannelActions Function(Ref ref)? createChannelActions,
ReadStateNotifier? readStateNotifier,
_FakeMessagesNotifier? messagesNotifier,
String? canvasContent,
List<NostrEvent>? threadReplies,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
channelsNotifier ?? _FakeChannelsNotifier(channels ?? [resolvedChannel]);
final fakeMessagesNotifier =
messagesNotifier ?? _FakeMessagesNotifier(messages);
return ProviderScope(
overrides: [
channelMessagesProvider(
_channelId,
).overrideWith(() => fakeMessagesNotifier),
channelTypingProvider(
_channelId,
).overrideWith(() => _FakeTypingNotifier(typing)),
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => fakeChannelsNotifier),
channelDetailsProvider(_channelId).overrideWith(
(ref) async => ChannelDetails.fromChannel(resolvedChannel),
),
channelCanvasProvider(_channelId).overrideWith(
(ref) async => ChannelCanvas(
content: canvasContent,
updatedAt: null,
authorPubkey: null,
),
),
channelMembersProvider(_channelId).overrideWith(
(ref) async => loadMembers != null ? loadMembers() : members,
),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)
readStateProvider.overrideWith(() => readStateNotifier),
if (threadReplies != null)
threadRepliesProvider(
const ThreadRepliesArgs(channelId: _channelId, rootId: 'thread-root'),
).overrideWith((ref) async => threadReplies),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
// Compose bar drafts persist through SharedPreferences.
savedPrefsProvider.overrideWithValue(_testPrefs),
],
child: MaterialApp(
theme: AppTheme.light(),
navigatorObservers: navigatorObservers,
home: ChannelDetailPage(channel: resolvedChannel),
),
);
}
/// Finder that searches for text within RichText spans. [find.text] only
/// matches the top-level text property; this also searches nested TextSpans.
Finder findRichText(String text) {
return find.byWidgetPredicate((widget) {
if (widget is RichText) {
return widget.text.toPlainText().contains(text);
}
return false;
}, description: 'RichText containing "$text"');
}
double? effectiveFontSizeForText(
InlineSpan span,
String text, [
TextStyle? inheritedStyle,
]) {
if (span is! TextSpan) return null;
final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style;
if ((span.text ?? '').contains(text)) return effectiveStyle?.fontSize;
for (final child in span.children ?? const <InlineSpan>[]) {
final size = effectiveFontSizeForText(child, text, effectiveStyle);
if (size != null) return size;
}
return null;
}
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
_testPrefs = await SharedPreferences.getInstance();
});
group('ChannelDetailPage', () {
testWidgets('defers read-state mark until after build', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'First',
createdAt: 1100,
),
_textMsg(
id: 'msg2',
pubkey: 'alice',
content: 'Latest',
createdAt: 1200,
),
],
readStateNotifier: readState,
),
);
expect(tester.takeException(), isNull);
await tester.pump();
expect(readState.markedContexts, {_channelId: 1200});
expect(tester.takeException(), isNull);
});
testWidgets('shows forum posts view for forum channels', (tester) async {
final forumChannel = Channel(
id: _channelId,
name: 'design-forum',
channelType: 'forum',
visibility: 'open',
description: 'Talk through design changes',
createdBy: 'abc123',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
);
await tester.pumpWidget(
_buildTestable(messages: const [], channel: forumChannel),
);
// Allow the forum posts future provider to settle. It will error
// because the stub relay has no real backend, but the ForumPostsView
// should still render (showing an error or loading state).
await tester.pump(const Duration(seconds: 1));
// The old placeholder text should be gone.
expect(find.text('Forum threads are not on mobile yet'), findsNothing);
// The compose bar for stream messages should not appear.
expect(find.text('Message…'), findsNothing);
});
testWidgets('renders video attachments from imeta tags in the timeline', (
tester,
) async {
const videoUrl = 'https://example.com/media/clip.mp4';
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'video-1',
pubkey: 'alice',
content: '![video]($videoUrl)',
extraTags: const [
[
'imeta',
'url https://example.com/media/clip.mp4',
'm video/mp4',
'image https://example.com/media/poster.jpg',
],
],
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mp4',
),
),
findsOneWidget,
);
});
testWidgets('members sheet shows roles and manage controls for owners', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Self',
),
ChannelMember(
pubkey: 'alice',
role: 'member',
joinedAt: DateTime(2025),
displayName: 'Alice',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('View members'));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Member'), findsOneWidget);
expect(find.text('Owner'), findsOneWidget);
});
testWidgets('hides composer for archived channels', (tester) async {
final archivedChannel = _testChannel.copyWith(
archivedAt: DateTime.utc(2025, 1, 2),
);
await tester.pumpWidget(
_buildTestable(messages: const [], channel: archivedChannel),
);
await tester.pumpAndSettle();
expect(find.text('Message…'), findsNothing);
expect(
find.text('This channel is archived and read-only on mobile.'),
findsOneWidget,
);
});
testWidgets('updates detail page state after joining a channel', (
tester,
) async {
final openChannel = _testChannel.copyWith(isMember: false);
final channelsNotifier = _FakeChannelsNotifier([openChannel]);
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: openChannel,
channelsNotifier: channelsNotifier,
createChannelActions: (ref) => _FakeChannelActions(
ref,
onJoinChannel: (_) async {
channelsNotifier.setChannels([
openChannel.copyWith(isMember: true, memberCount: 6),
]);
},
),
),
);
await tester.pumpAndSettle();
expect(
find.text('Join this channel from Manage to participate.'),
findsOneWidget,
);
expect(find.text('Message…'), findsNothing);
await tester.tap(find.byTooltip('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Join channel'));
await tester.pumpAndSettle();
expect(find.text('Join channel'), findsNothing);
expect(
find.text('Join this channel from Manage to participate.'),
findsNothing,
);
expect(find.text('Message #general'), findsOneWidget);
});
testWidgets('keeps manage sheet dismissible with a long canvas', (
tester,
) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_buildTestable(
messages: const [],
canvasContent: List.generate(
80,
(index) => 'Canvas line $index',
).join('\n'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Manage channel'));
await tester.pumpAndSettle();
final sheet = find.byType(BottomSheet);
expect(sheet, findsOneWidget);
expect(tester.getSize(sheet).height, lessThanOrEqualTo(720));
final sheetTop = tester.getTopLeft(sheet).dy;
await tester.dragFrom(
Offset(tester.view.physicalSize.width / 2, sheetTop + 12),
const Offset(0, 800),
);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
});
testWidgets('shows empty state when no messages', (tester) async {
await tester.pumpWidget(_buildTestable(messages: []));
await tester.pumpAndSettle();
expect(find.text('No messages yet'), findsOneWidget);
expect(find.text('Be the first to say something!'), findsOneWidget);
});
testWidgets('renders text messages with author and content', (
tester,
) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Hello world!',
createdAt: 1000,
),
_textMsg(
id: 'msg2',
pubkey: 'bob',
content: 'Hey Alice!',
createdAt: 1100,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('Hello world!'), findsOneWidget);
expect(findRichText('Hey Alice!'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
final messageAvatars = find.byType(CircleAvatar);
expect(messageAvatars, findsNWidgets(2));
for (final avatar in messageAvatars.evaluate()) {
expect(
tester.getSize(find.byWidget(avatar.widget)),
const Size.square(36),
);
}
final aliceName = find.text('Alice');
final aliceText = tester.widget<Text>(aliceName);
final titleStyle = Theme.of(
tester.element(aliceName),
).textTheme.titleSmall;
expect(aliceText.style?.fontSize, titleStyle?.fontSize);
final helloContent = findRichText('Hello world!');
final helloText = tester.widget<RichText>(helloContent);
final bodyStyle = Theme.of(
tester.element(helloContent),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(helloText.text, 'Hello world!'),
bodyStyle?.fontSize,
);
});
testWidgets('uses larger participant avatars in reply summaries', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'root',
pubkey: 'alice',
content: 'Thread head',
createdAt: 1000,
),
_textMsg(
id: 'reply-1',
pubkey: 'bob',
content: 'First reply',
createdAt: 1100,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
_textMsg(
id: 'reply-2',
pubkey: 'carol',
content: 'Second reply',
createdAt: 1200,
extraTags: const [
['e', 'root', '', 'reply'],
],
),
],
),
);
await tester.pumpAndSettle();
expect(
findRichText(
'2 replies · last reply '
'${formatThreadSummaryLastReplyTime(1200)}',
),
findsOneWidget,
);
expect(find.byIcon(LucideIcons.chevronRight), findsNothing);
final replyAvatars = find.byType(SmallAvatar);
expect(replyAvatars, findsNWidgets(2));
for (final avatar in replyAvatars.evaluate()) {
expect(
tester.getSize(find.byWidget(avatar.widget)),
const Size.square(32),
);
}
});
testWidgets('can jump back to latest when newer messages are offscreen', (
tester,
) async {
final initialMessages = [
for (var i = 0; i < 40; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(initialMessages);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final listView = tester.widget<ScrollablePositionedList>(
find.byKey(const ValueKey('channel-message-list')),
);
listView.itemScrollController!.jumpTo(index: 39);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
messagesNotifier.setMessages([
...initialMessages,
_textMsg(
id: 'newest',
pubkey: 'alice',
content: 'Newest live update',
createdAt: 2000,
),
]);
await tester.pump();
expect(findRichText('Newest live update'), findsNothing);
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
await tester.pumpAndSettle();
expect(findRichText('Newest live update'), findsOneWidget);
});
testWidgets('groups consecutive messages from same author', (tester) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'First message',
createdAt: 1000,
),
_textMsg(
id: 'msg2',
pubkey: 'alice',
content: 'Second message',
createdAt: 1060, // within 5 min
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
// Author name should appear only once (grouped).
expect(find.text('Alice'), findsOneWidget);
expect(findRichText('First message'), findsOneWidget);
expect(findRichText('Second message'), findsOneWidget);
});
testWidgets('shows author again after 5min gap', (tester) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'First',
createdAt: 1000,
),
_textMsg(
id: 'msg2',
pubkey: 'alice',
content: 'Second',
createdAt: 1400, // 6+ min later
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
// Author name appears twice since messages are >5min apart.
expect(find.text('Alice'), findsNWidgets(2));
});
testWidgets('shows pubkey fallback when no profile', (tester) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'abcdef1234567890',
content: 'Hi',
createdAt: 1000,
),
];
await tester.pumpWidget(_buildTestable(messages: messages));
await tester.pumpAndSettle();
expect(findRichText('Hi'), findsOneWidget);
// Should show first 8 chars of pubkey + ellipsis
expect(find.text('abcdef12…'), findsOneWidget);
});
});
group('System messages', () {
testWidgets('renders channel_created system event', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'channel_created', 'actor': 'alice'},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
final createdAction = findRichText('created this channel');
expect(createdAction, findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
final nameRect = tester.getRect(find.text('Alice'));
final nameText = tester.widget<Text>(find.text('Alice'));
final nameStyle = Theme.of(
tester.element(find.text('Alice')),
).textTheme.titleSmall;
expect(nameText.style?.fontSize, nameStyle?.fontSize);
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
final createdText = tester.widget<RichText>(createdAction);
final bodyStyle = Theme.of(
tester.element(createdAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(createdText.text, 'created this channel'),
bodyStyle?.fontSize,
);
});
testWidgets('renders member_joined (self-join) system event', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob')},
),
);
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
expect(findRichText('joined the channel'), findsOneWidget);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
});
testWidgets('renders member_joined (added by other) system event', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
final addedAction = findRichText('was added by Alice');
expect(addedAction, findsOneWidget);
expect(find.text('Alice added Bob to the channel'), findsNothing);
expect(tester.getSize(find.byType(CircleAvatar)), const Size.square(36));
final nameRect = tester.getRect(find.text('Bob'));
final timestampRect = tester.getRect(find.text(formatMessageTime(1000)));
expect(timestampRect.left - nameRect.right, Grid.xxs);
final addedText = tester.widget<RichText>(addedAction);
final bodyStyle = Theme.of(
tester.element(addedAction),
).textTheme.bodyLarge;
expect(
effectiveFontSizeForText(addedText.text, 'was added by Alice'),
bodyStyle?.fontSize,
);
});
testWidgets('groups member additions with tappable overflow names', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
createdAt: 1000,
),
_systemMsg(
id: 'sys2',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1060,
),
_systemMsg(
id: 'sys3',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'dave',
},
createdAt: 1120,
),
_systemMsg(
id: 'sys4',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'erin',
},
createdAt: 1180,
),
_systemMsg(
id: 'sys5',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'frank',
},
createdAt: 1240,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'),
'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'),
'frank': const UserProfile(pubkey: 'frank', displayName: 'Frank'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
expect(
findRichText('was added by Alice, along with Carol, Dave, Erin, and '),
findsOneWidget,
);
expect(find.byKey(const Key('membership-overflow')), findsOneWidget);
expect(find.text('1 others'), findsOneWidget);
expect(find.byTooltip('Frank'), findsOneWidget);
});
testWidgets('aligns grouped reactions with the system message content', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'},
createdAt: 1000,
),
_systemMsg(
id: 'sys2',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1060,
),
_reaction(id: 'reaction-1', targetId: 'sys1'),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
},
),
);
await tester.pumpAndSettle();
final avatarRect = tester.getRect(find.byType(CircleAvatar));
final reactionRect = tester.getRect(find.byType(ReactionRow));
expect(reactionRect.left, avatarRect.left + 36 + Grid.xxs);
});
testWidgets('renders member_left system event', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'member_left', 'actor': 'bob'},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob')},
),
);
await tester.pumpAndSettle();
expect(find.text('Bob left the channel'), findsOneWidget);
});
testWidgets('renders member_removed system event', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {
'type': 'member_removed',
'actor': 'alice',
'target': 'bob',
},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice removed Bob from the channel'), findsOneWidget);
});
testWidgets('renders topic_changed system event', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {
'type': 'topic_changed',
'actor': 'alice',
'topic': 'Release planning',
},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(
find.text('Alice changed the topic to "Release planning"'),
findsOneWidget,
);
});
testWidgets('renders purpose_changed system event', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {
'type': 'purpose_changed',
'actor': 'alice',
'purpose': 'Team standup notes',
},
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(
find.text('Alice changed the purpose to "Team standup notes"'),
findsOneWidget,
);
});
testWidgets('system message breaks author grouping', (tester) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Before',
createdAt: 1000,
),
_systemMsg(
id: 'sys1',
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
createdAt: 1010,
),
_textMsg(
id: 'msg2',
pubkey: 'alice',
content: 'After',
createdAt: 1020,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
// Alice should appear twice — system message breaks grouping.
expect(find.text('Alice'), findsNWidgets(2));
});
testWidgets('skips unknown system event types', (tester) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'unknown_future_type', 'actor': 'alice'},
),
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Hello',
createdAt: 1100,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
// Only the text message should render, unknown system event is skipped.
expect(findRichText('Hello'), findsOneWidget);
// No system message row rendered for unknown type.
expect(find.byIcon(LucideIcons.arrowLeftRight), findsNothing);
});
});
group('Deletions', () {
testWidgets('deleted messages are not shown', (tester) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Keep this',
createdAt: 1000,
),
_textMsg(
id: 'msg2',
pubkey: 'bob',
content: 'Delete this',
createdAt: 1100,
),
_deletion(id: 'del1', targetIds: ['msg2'], createdAt: 1200),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('Keep this'), findsOneWidget);
expect(findRichText('Delete this'), findsNothing);
});
testWidgets('deletion of multiple messages', (tester) async {
final messages = [
_textMsg(id: 'msg1', pubkey: 'a', content: 'One', createdAt: 1000),
_textMsg(id: 'msg2', pubkey: 'a', content: 'Two', createdAt: 1100),
_textMsg(id: 'msg3', pubkey: 'a', content: 'Three', createdAt: 1200),
_deletion(id: 'del1', targetIds: ['msg1', 'msg3'], createdAt: 1300),
];
await tester.pumpWidget(_buildTestable(messages: messages));
await tester.pumpAndSettle();
expect(findRichText('One'), findsNothing);
expect(findRichText('Two'), findsOneWidget);
expect(findRichText('Three'), findsNothing);
});
});
group('Edits', () {
testWidgets('edited message shows updated content and (edited) label', (
tester,
) async {
final messages = [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Original text',
createdAt: 1000,
),
_edit(
id: 'edit1',
targetId: 'msg1',
content: 'Edited text',
createdAt: 1100,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('Edited text'), findsOneWidget);
expect(findRichText('Original text'), findsNothing);
expect(find.text('(edited)'), findsOneWidget);
});
testWidgets('latest edit wins when multiple edits exist', (tester) async {
final messages = [
_textMsg(id: 'msg1', pubkey: 'alice', content: 'V1', createdAt: 1000),
_edit(id: 'e1', targetId: 'msg1', content: 'V2', createdAt: 1100),
_edit(id: 'e2', targetId: 'msg1', content: 'V3', createdAt: 1200),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(findRichText('V3'), findsOneWidget);
expect(findRichText('V1'), findsNothing);
expect(findRichText('V2'), findsNothing);
});
});
group('Typing indicator', () {
testWidgets('shows single typer', (tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [],
typing: [
TypingEntry(
pubkey: 'alice',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice is typing…'), findsOneWidget);
});
testWidgets('shows two typers', (tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [],
typing: [
TypingEntry(
pubkey: 'alice',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
TypingEntry(
pubkey: 'bob',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice and Bob are typing…'), findsOneWidget);
});
testWidgets('shows N others for 3+ typers', (tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [],
typing: [
TypingEntry(
pubkey: 'alice',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
TypingEntry(
pubkey: 'bob',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
TypingEntry(
pubkey: 'carol',
expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice and 2 others are typing…'), findsOneWidget);
});
});
group('Compose bar', () {
testWidgets('expands from the channel hint into the composer controls', (
tester,
) async {
await tester.pumpWidget(_buildTestable(messages: []));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing);
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget);
});
testWidgets('shows hint text', (tester) async {
await tester.pumpWidget(_buildTestable(messages: []));
await tester.pumpAndSettle();
expect(find.text('Message #general'), findsOneWidget);
});
});
group('App bar', () {
testWidgets('shows channel name with hash icon', (tester) async {
await tester.pumpWidget(_buildTestable(messages: []));
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
// The hash icon appears in the app bar and in the compose bar toolbar.
expect(find.byIcon(LucideIcons.hash), findsAtLeastNWidgets(1));
});
testWidgets('shows lock icon for private channel', (tester) async {
final privateChannel = Channel(
id: _channelId,
name: 'secret',
channelType: 'stream',
visibility: 'private',
description: 'Private channel',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
);
await tester.pumpWidget(
_buildTestable(messages: [], channel: privateChannel),
);
await tester.pumpAndSettle();
expect(find.text('secret'), findsOneWidget);
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
});
});
group('Error and loading states', () {
testWidgets('shows error message on failure', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
channelMessagesProvider(
_channelId,
).overrideWith(() => _ErrorMessagesNotifier()),
channelTypingProvider(
_channelId,
).overrideWith(() => _FakeTypingNotifier([])),
userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier([_testChannel]),
),
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
savedPrefsProvider.overrideWithValue(_testPrefs),
],
child: MaterialApp(
theme: AppTheme.light(),
home: ChannelDetailPage(channel: _testChannel),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load messages'), findsOneWidget);
});
});
group('Mixed message timeline', () {
testWidgets('interleaves text and system messages correctly', (
tester,
) async {
final messages = [
_systemMsg(
id: 'sys1',
payload: {'type': 'channel_created', 'actor': 'alice'},
createdAt: 900,
),
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Welcome everyone!',
createdAt: 1000,
),
_systemMsg(
id: 'sys2',
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
createdAt: 1100,
),
_textMsg(
id: 'msg2',
pubkey: 'bob',
content: 'Thanks for the invite!',
createdAt: 1200,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsNWidgets(2));
expect(findRichText('created this channel'), findsOneWidget);
expect(findRichText('Welcome everyone!'), findsOneWidget);
expect(find.text('Bob'), findsNWidgets(2));
expect(findRichText('joined the channel'), findsOneWidget);
expect(findRichText('Thanks for the invite!'), findsOneWidget);
});
});
group('Channel links', () {
testWidgets('tapping a channel link opens that channel', (tester) async {
final randomChannel = _channel(id: 'random-channel', name: 'random');
final observer = _TestNavigatorObserver();
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Take this to #random',
createdAt: 1000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
channels: [_testChannel, randomChannel],
navigatorObservers: [observer],
),
);
await tester.pumpAndSettle();
final initialPushCount = observer.pushCount;
await tester.tap(find.text('#random'));
await tester.pumpAndSettle();
expect(observer.pushCount, initialPushCount + 1);
});
testWidgets('missing channel link shows an error', (tester) async {
final randomChannel = _channel(id: 'random-channel', name: 'random');
final channelsNotifier = _FakeChannelsNotifier([
_testChannel,
randomChannel,
]);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Take this to #random',
createdAt: 1000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
channelsNotifier: channelsNotifier,
),
);
await tester.pumpAndSettle();
channelsNotifier.setChannels([_testChannel]);
await tester.tap(find.text('#random'));
await tester.pump();
expect(find.text('Channel could not be opened'), findsOneWidget);
});
testWidgets('tapping a channel link inside a thread opens that channel', (
tester,
) async {
final randomChannel = _channel(id: 'random-channel', name: 'random');
final observer = _TestNavigatorObserver();
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Thread root #random',
createdAt: 1000,
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
channels: [_testChannel, randomChannel],
navigatorObservers: [observer],
),
);
await tester.pumpAndSettle();
final threadMessages = formatTimeline([
_textMsg(
id: 'msg1',
pubkey: 'alice',
content: 'Thread root #random',
createdAt: 1000,
),
]);
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadMessages.single,
allMessages: threadMessages,
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
final initialPushCount = observer.pushCount;
await tester.tap(find.text('#random').last);
await tester.pumpAndSettle();
expect(observer.pushCount, initialPushCount + 1);
});
testWidgets('thread shows day dividers when replies cross days', (
tester,
) async {
final rootCreatedAt =
DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
final nextDayCreatedAt =
DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: rootCreatedAt,
);
final replies = [
_textMsg(
id: 'reply-same-day',
pubkey: 'bob',
content: 'Same day',
createdAt: rootCreatedAt + 60,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
_textMsg(
id: 'reply-next-day',
pubkey: 'bob',
content: 'Next day',
createdAt: nextDayCreatedAt,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: replies,
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(DayDivider), findsNWidgets(2));
expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget);
expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget);
});
});
}
Channel _channel({required String id, required String name}) => Channel(
id: id,
name: name,
channelType: 'stream',
visibility: 'open',
description: '$name discussion',
createdBy: 'abc123',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
);
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
List<NostrEvent> _messages;
_FakeMessagesNotifier(this._messages) : super(_channelId);
@override
AsyncValue<List<NostrEvent>> build() => AsyncData(_messages);
@override
bool get reachedOldest => true;
@override
Future<bool> fetchOlder() async => false;
void setMessages(List<NostrEvent> messages) {
_messages = messages;
state = AsyncData(messages);
}
}
class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
_ErrorMessagesNotifier() : super(_channelId);
@override
AsyncValue<List<NostrEvent>> build() =>
AsyncError('Connection failed', StackTrace.current);
}
class _FakeTypingNotifier extends ChannelTypingNotifier {
final List<TypingEntry> _entries;
_FakeTypingNotifier(this._entries) : super(_channelId);
@override
List<TypingEntry> build() => _entries;
}
class _SynchronousReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> markedContexts = {};
_SynchronousReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
markedContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'self', displayName: 'Self');
}
class _FakeUserCacheNotifier extends UserCacheNotifier {
final Map<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@override
Map<String, UserProfile> build() => _users;
@override
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
}
class _FakeChannelsNotifier extends ChannelsNotifier {
List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
@override
Future<List<Channel>> build() => SynchronousFuture(_channels);
void setChannels(List<Channel> channels) {
_channels = channels;
state = AsyncData(channels);
}
}
class _FakeChannelActions extends ChannelActions {
final Future<void> Function(String channelId)? onJoinChannel;
_FakeChannelActions(Ref ref, {this.onJoinChannel})
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
@override
Future<void> joinChannel(String channelId) async {
await onJoinChannel?.call(channelId);
}
@override
Future<void> leaveChannel(String channelId) async {
return;
}
}
class _TestNavigatorObserver extends NavigatorObserver {
int pushCount = 0;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushCount += 1;
super.didPush(route, previousRoute);
}
}