Files
buzz/mobile/test/features/channels/channels_page_test.dart
Taylor Ho 68f39f3697 feat(mobile): bring message actions to desktop parity (#3070)
**Category:** new-feature
**User Impact:** Mobile users can copy permalinks, revisit, follow, and
manage messages through a clearer long-press menu that matches desktop
capabilities.

**Problem:** The mobile message menu exposed only a small subset of
desktop actions, and important workflows such as copying a permalink or
scheduling a reminder were unavailable or hard to discover.

**Solution:** Bring applicable desktop actions to mobile using native
patterns, promote Reply, Copy link, and Remind me above the fold, and
group the remaining actions by intent in a scrollable sheet.

**Intentional behavior changes (per review):**
- The quick-reaction row goes from 6 emojis to 4 (👀 and 🙏 dropped) to
make room for larger 52px tap targets alongside the `+` picker, which
still offers the full set.
- **Copy link — not a native share sheet — is the permalink path.** An
earlier revision shipped a `share_plus` Share message row; it was
removed in review since Copy link covers the job and custom-scheme
`buzz://` URIs are handled inconsistently by share targets. Native share
can return as a follow-up with an https fallback.
- Mark unread is message-scoped and session-local: it forces just that
message unread (surfacing its channel as unread), and message-level Mark
read can never clear a channel-level unread set from the channel tile.

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/channels_provider.dart**
Feeds followed thread roots into unread and notification evaluation so
following a thread has meaningful behavior.

**mobile/lib/features/channels/message_actions.dart**
Reworks the long-press sheet with promoted fast actions, message links,
reminders, read state, thread following, and clearer action grouping
while preserving existing guards. Quick-reaction circles share one
extracted widget.

**mobile/lib/features/channels/read_state/message_read_state.dart**
Centralizes message-level unread evaluation across channel, message, and
thread markers; channel-level forced unread deliberately does not leak
into message state.

**mobile/lib/features/channels/read_state/read_state_provider.dart**
Forced-unread flags are per-context (channel id or `msg:` key) mapped to
their channel, so message- and channel-level unread choices round-trip
independently.


**mobile/lib/features/channels/thread_follows/thread_follows_provider.dart**
Exposes per-identity thread follow state to the message menu and
notification pipeline.


**mobile/lib/features/channels/thread_follows/thread_follows_storage.dart**
Persists a bounded, validated set of followed thread roots on the
device.

**mobile/lib/shared/reminders/remind_me_later_sheet.dart**
Adds reminder presets and a native custom date/time flow for deferring a
message. Lives under `shared/` so the channels feature never imports
another feature module. Cancelling the custom picker keeps the preset
sheet open; submission failures show stable copy and log the underlying
error.

**mobile/lib/shared/reminders/reminder_service.dart**
Creates desktop-compatible, self-encrypted kind-30300 reminder events.

**mobile/lib/shared/reminders/reminder_time_presets.dart**
Defines reminder choices that match the desktop experience.

**mobile/lib/shared/deeplink/deep_link.dart**
Builds canonical Buzz message links, including thread context when
present.

**mobile/lib/shared/relay/nostr_models.dart**
Adds the reminder event kind to the shared Nostr model constants.

**mobile/lib/shared/widgets/sheet_divider.dart**
Shared bottom-sheet section divider used by the message actions and
reminder sheets.

**mobile/test/features/channels/message_actions_test.dart**
Covers action visibility and guards, promoted actions, read/unread
round-tripping (including channel- vs message-level force isolation),
thread follows, and canonical links.


**mobile/test/features/channels/read_state/message_read_state_test.dart**
Covers unread precedence for channel, message, and thread contexts.


**mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart**
Covers follow persistence, identity separation, validation, and storage
bounds.

**mobile/test/shared/reminders/reminder_service_test.dart**
Covers reminder payloads, tags, crypto round-tripping, and preset
behavior.


**mobile/test/features/channels/read_state/read_state_provider_test.dart**
Drives the production ReadStateNotifier/ReadStateManager (no fake
bookkeeping) through message unread → read → unread round-trips,
explicit channel-level Mark read clearing forced messages, and automatic
channel-open reads preserving them.

**mobile/test/shared/reminders/remind_me_later_sheet_test.dart**
Covers custom-picker cancel keeping the sheet open, stable failure copy
without the raw error, and the happy preset path.

**mobile/test/shared/deeplink/deep_link_test.dart**
Covers canonical top-level and threaded message-link generation.

</details>

## Reproduction steps

1. Run the mobile app with a signed-in identity and open a channel
containing regular messages and threads.
2. Long-press a message and confirm reactions plus Reply, Copy link, and
Remind me appear as fast actions above the fold.
3. Use Copy link; confirm the resulting `buzz://message` link opens the
correct channel and thread context.
4. Toggle Mark unread/Mark read and Follow thread/Unfollow thread,
reopening the sheet to confirm each state changes correctly. Force a
channel unread from the channel tile, then mark a message read — the
channel stays unread.
5. Choose a reminder preset and a custom date/time; confirm the reminder
is created and appears in the desktop reminder experience. Cancel the
custom date picker and confirm the reminder sheet stays open.
6. Long-press a system message and a message you cannot manage; confirm
utility and destructive actions remain appropriately hidden.

## Screenshots / demos

<img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/81096cd6-329b-408f-bcff-712e23b268a4"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-27 09:45:09 -07:00

1326 lines
41 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_page.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/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
void main() {
Widget buildTestable({
required List<Override> overrides,
bool previewDirectory = false,
double keyboardInset = 0,
bool disableAnimations = false,
Map<String, String?> communityIcons = const {},
ValueChanged<String>? onCommunityIconLoad,
TextScaler textScaler = TextScaler.noScaling,
}) {
return ProviderScope(
overrides: [
// Provide a fake profile and presence so the avatar doesn't hit the network.
profileProvider.overrideWith(() => _FakeProfileNotifier()),
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
communityIconProvider.overrideWith((ref, relayUrl) async {
onCommunityIconLoad?.call(relayUrl);
return communityIcons[relayUrl];
}),
dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory,
),
...overrides,
],
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations,
textScaler: textScaler,
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: child!,
),
home: const Stack(
children: [
ChannelsPage(settingsPageBuilder: _buildSettingsPage),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
final testChannels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
isMember: true,
),
Channel(
id: '2',
name: 'design-forum',
channelType: 'forum',
visibility: 'open',
description: 'Discuss designs',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
),
Channel(
id: '3',
name: 'DM',
channelType: 'dm',
visibility: 'open',
description: 'Direct message',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Test', 'Alice'],
participantPubkeys: const ['aabb', 'alice'],
isMember: true,
),
];
testWidgets('shows grouped channel list when data loads', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('design-forum'), findsNothing);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Channels'), findsOneWidget);
expect(find.text('FORUMS'), findsNothing);
expect(find.text('DMs'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
});
testWidgets('opens the settings page supplied by the app layer', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(find.text('Injected settings'), findsOneWidget);
});
testWidgets('community switcher separates selection from edit removal', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
Community(
id: 'bravo',
name: 'Bravo',
relayUrl: 'wss://bravo.example.com',
addedAt: DateTime(2025),
),
];
final communityNotifier = _FakeCommunityListNotifier(communities);
await tester.pumpWidget(
buildTestable(
communityIcons: const {
'wss://alpha.example.com':
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
},
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(() => communityNotifier),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(find.text('Switch Community'), findsOneWidget);
final options = find.byKey(const Key('community-switcher-options'));
expect(options, findsOneWidget);
final editButton = find.byKey(const Key('community-switcher-edit'));
expect(
tester.getRect(options).top - tester.getRect(editButton).bottom,
closeTo(8, 0.01),
);
expect(tester.getSize(editButton).height, 32);
expect(find.text('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget);
expect(find.text('Rename'), findsNothing);
expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing);
expect(find.text('Edit'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNothing);
expect(
tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))),
const Size.square(36),
);
final alphaAvatar = tester.widget<AvatarImage>(
find.descendant(
of: find.byKey(const Key('community-switcher-avatar-alpha')),
matching: find.byType(AvatarImage),
),
);
expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,'));
final activeSelection = find.byKey(
const Key('community-switcher-selection-alpha'),
);
final inactiveSelection = find.byKey(
const Key('community-switcher-selection-bravo'),
);
expect(tester.getSize(activeSelection), const Size.square(40));
expect(
tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))),
const Size.square(24),
);
expect(
find.descendant(
of: activeSelection,
matching: find.byIcon(LucideIcons.check),
),
findsOneWidget,
);
expect(
find.descendant(
of: inactiveSelection,
matching: find.byIcon(LucideIcons.check),
),
findsNothing,
);
expect(
find.descendant(of: options, matching: find.byType(Divider)),
findsNWidgets(2),
);
await tester.tap(find.text('Edit'));
await tester.pump();
final activeAction = find.byKey(
const Key('community-switcher-action-alpha'),
);
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: activeAction,
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, const Duration(milliseconds: 250));
expect(activeSelection, findsOneWidget);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 125));
final transitioningOpacities = tester
.widgetList<Opacity>(
find.descendant(of: activeAction, matching: find.byType(Opacity)),
)
.map((opacity) => opacity.opacity);
expect(
transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1),
isTrue,
);
expect(
find.descendant(of: activeAction, matching: find.byType(ImageFiltered)),
findsWidgets,
);
await tester.pumpAndSettle();
expect(find.text('Done'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2));
expect(activeSelection, findsNothing);
expect(inactiveSelection, findsNothing);
await tester.tap(find.byTooltip('Remove Bravo'));
await tester.pumpAndSettle();
expect(find.text('Remove community?'), findsOneWidget);
expect(
find.text(
'Are you sure you want to remove “Bravo”? '
'You can pair with it again later.',
),
findsOneWidget,
);
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(communityNotifier.removedIds, ['bravo']);
expect(find.text('Switch Community'), findsOneWidget);
expect(find.text('Bravo'), findsNothing);
expect(find.text('Done'), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsOneWidget,
);
});
testWidgets('opening the community switcher refreshes visible icons', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
var iconLoads = 0;
await tester.pumpWidget(
buildTestable(
onCommunityIconLoad: (_) => iconLoads++,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
final initialIconLoads = iconLoads;
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(iconLoads, greaterThan(initialIconLoads));
});
testWidgets('community switcher header grows with accessible text', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
await tester.pumpWidget(
buildTestable(
textScaler: TextScaler.linear(2),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byKey(const Key('community-switcher-title'))).height,
greaterThan(32),
);
});
testWidgets('community switcher disables icon motion when requested', (
tester,
) async {
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
];
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier(communities),
),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit'));
await tester.pump();
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: find.byKey(const Key('community-switcher-action-alpha')),
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, Duration.zero);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsNothing,
);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
});
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
Widget buildLauncher({required bool visible}) {
return ProviderScope(
overrides: [profileProvider.overrideWith(() => _FakeProfileNotifier())],
child: MaterialApp(
theme: AppTheme.light(),
home: Stack(
children: [
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: visible,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
await tester.pumpWidget(buildLauncher(visible: true));
await tester.pumpAndSettle();
Transform motionTransform() => tester.widget<Transform>(
find.byKey(const Key('channel-quick-actions-transform')),
);
expect(motionTransform().transform.getTranslation().x, 0);
await tester.pumpWidget(buildLauncher(visible: false));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
final midpoint = motionTransform().transform.getTranslation().x;
expect(midpoint, lessThan(0));
expect(midpoint, greaterThan(-279));
await tester.pumpAndSettle();
expect(motionTransform().transform.getTranslation().x, closeTo(-279, 0.01));
final hiddenOpacity = tester.widget<Opacity>(
find.byKey(const Key('channel-quick-actions-opacity')),
);
expect(hiddenOpacity.opacity, 0);
final hiddenPointerGate = tester.widget<IgnorePointer>(
find
.ancestor(
of: find.byKey(const Key('channel-quick-actions-transform')),
matching: find.byType(IgnorePointer),
)
.first,
);
expect(hiddenPointerGate.ignoring, isTrue);
});
testWidgets('quick actions spring into spaced muted cards', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final surface = find.byKey(const Key('quick-actions-surface'));
expect(tester.getSize(surface), const Size.square(56));
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
var largestHeight = tester.getSize(surface).height;
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
largestHeight = max(largestHeight, tester.getSize(surface).height);
}
await tester.pumpAndSettle();
expect(largestHeight, greaterThan(160));
expect(tester.getSize(surface).height, closeTo(160, 0.01));
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
final surfaceRect = tester.getRect(surface);
expect(surfaceRect.left, closeTo(20, 0.01));
expect(surfaceRect.right, closeTo(screenWidth - 20, 0.01));
final menuRect = tester.getRect(
find.byKey(const Key('quick-actions-menu')),
);
final createCard = find.byKey(
const Key('quick-action-create-channel-card'),
);
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
final createRect = tester.getRect(createCard);
final dmRect = tester.getRect(dmCard);
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
expect(dmRect.width, createRect.width);
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
final cardScheme = Theme.of(tester.element(createCard)).colorScheme;
final expectedCardColor = Color.alphaBlend(
cardScheme.onPrimary.withValues(alpha: 0.1),
cardScheme.primary,
);
final createMaterial = tester.widget<Material>(
find.descendant(of: createCard, matching: find.byType(Material)).first,
);
final dmMaterial = tester.widget<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
expect(createMaterial.color, expectedCardColor);
expect(dmMaterial.color, expectedCardColor);
expect(
(createMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(
(dmMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(find.text('Start a new stream channel'), findsNothing);
expect(
tester.widget<Text>(find.text('Create channel')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(find.text('Message one or more people'), findsNothing);
});
testWidgets('create channel sheet lists type and visibility radio options', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create channel'));
await tester.pumpAndSettle();
final createSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(createSheetRect.top, greaterThanOrEqualTo(24));
tester.testTextInput.hide();
await tester.pumpAndSettle();
expect(find.text('Create a new channel'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Description Optional'), findsOneWidget);
expect(find.text('Channel type'), findsOneWidget);
expect(find.text('Ongoing'), findsOneWidget);
expect(find.text('Temporary'), findsOneWidget);
expect(find.text('Visibility'), findsOneWidget);
expect(find.text('Public'), findsOneWidget);
expect(find.text('Private'), findsOneWidget);
expect(find.byKey(const Key('create-channel-submit')), findsNothing);
final nameField = tester.widget<TextField>(
find.byKey(const Key('create-channel-name')),
);
final descriptionField = tester.widget<TextField>(
find.byKey(const Key('create-channel-description')),
);
expect(nameField.textInputAction, TextInputAction.done);
expect(nameField.onSubmitted, isNotNull);
expect(descriptionField.textInputAction, TextInputAction.done);
expect(descriptionField.onSubmitted, isNotNull);
expect(
tester.getSize(find.byKey(const Key('create-channel-name'))).height,
greaterThanOrEqualTo(52),
);
expect(
tester
.getSize(find.byKey(const Key('create-channel-type-ongoing')))
.height,
greaterThanOrEqualTo(56),
);
await tester.tap(find.byKey(const Key('create-channel-type-temporary')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsOneWidget);
expect(find.text('7 days'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('create-channel-ttl'))).height,
greaterThanOrEqualTo(51),
);
final privateVisibility = find.byKey(
const Key('create-channel-visibility-private'),
);
await tester.ensureVisible(privateVisibility);
await tester.pumpAndSettle();
await tester.tap(privateVisibility);
await tester.pumpAndSettle();
expect(
tester
.widget<RadioGroup<String>>(find.byType(RadioGroup<String>))
.groupValue,
'private',
);
});
testWidgets('new message sheet lists and selects relay members', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const directoryUsers = [
DirectoryUser(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
DirectoryUser(pubkey: 'bob', displayName: 'Bob'),
DirectoryUser(pubkey: 'charlie', displayName: 'Charlie'),
DirectoryUser(pubkey: 'danielle', displayName: 'Danielle'),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
relayDirectorySearchProvider.overrideWith((ref, query) async {
return directoryUsers
.where(
(user) =>
user.label.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
final dmSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(dmSheetRect.top, greaterThanOrEqualTo(24));
expect(find.text('New message'), findsOneWidget);
expect(find.text('To:'), findsOneWidget);
expect(find.text('Search for a person'), findsOneWidget);
final recipientField = find.byKey(const Key('new-dm-recipient-field'));
final initialRecipientWidth = tester.getSize(recipientField).width;
expect(tester.getSize(recipientField).height, greaterThanOrEqualTo(48));
expect(find.byKey(const Key('new-dm-person-alice')), findsOneWidget);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('new-dm-person-alice')),
matching: find.byType(AvatarImage),
),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('new-dm-search')), 'bob');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-dm-person-alice')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
await tester.enterText(find.byKey(const Key('new-dm-search')), '');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-dm-person-alice')));
await tester.pump();
final aliceChip = find.byKey(const Key('new-dm-selected-alice'));
final inlineSearch = find.byKey(const Key('new-dm-search'));
final aliceRect = tester.getRect(aliceChip);
final inlineSearchRect = tester.getRect(inlineSearch);
expect(inlineSearchRect.left, greaterThan(aliceRect.right));
expect(
(inlineSearchRect.center.dy - aliceRect.center.dy).abs(),
lessThan(1),
);
expect(find.text('Search for a person'), findsNothing);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(tester.getSize(aliceChip).height, 40);
expect(tester.getSize(aliceChip).width, lessThanOrEqualTo(224));
final aliceChipMaterial = tester.widget<Material>(
find.descendant(of: aliceChip, matching: find.byType(Material)).first,
);
expect(aliceChipMaterial.shape, isA<StadiumBorder>());
expect(
aliceChipMaterial.color,
Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest,
);
expect(
tester
.widget<AvatarImage>(
find.descendant(of: aliceChip, matching: find.byType(AvatarImage)),
)
.radius,
16,
);
expect(
tester
.widget<Text>(
find.descendant(of: aliceChip, matching: find.text('Alice')),
)
.style
?.fontSize,
16,
);
expect(
find.descendant(of: aliceChip, matching: find.byIcon(LucideIcons.x)),
findsNothing,
);
expect(find.bySemanticsLabel('Remove Alice'), findsOneWidget);
await tester.tap(find.byKey(const Key('new-dm-person-bob')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-charlie')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-danielle')));
await tester.pump();
expect(aliceChip, findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-bob')), findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-charlie')), findsOneWidget);
final danielleChip = find.byKey(const Key('new-dm-selected-danielle'));
expect(danielleChip, findsOneWidget);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(
tester.getRect(danielleChip).top,
greaterThan(tester.getRect(aliceChip).top),
);
final recipientScrollViews = tester.widgetList<SingleChildScrollView>(
find.ancestor(
of: aliceChip,
matching: find.byType(SingleChildScrollView),
),
);
expect(
recipientScrollViews.every(
(scrollView) => scrollView.scrollDirection == Axis.vertical,
),
isTrue,
);
expect(
find.ancestor(
of: aliceChip,
matching: find.byKey(const Key('new-dm-recipient-wrap')),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('new-dm-selected-bob')));
await tester.pump();
expect(find.byKey(const Key('new-dm-selected-bob')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(find.text('Cancel'), findsNothing);
expect(find.text('Open DM'), findsNothing);
final searchField = tester.widget<TextField>(
find.byKey(const Key('new-dm-search')),
);
expect(searchField.textInputAction, TextInputAction.done);
expect(searchField.onSubmitted, isNotNull);
});
testWidgets('wraps many recipients without overflowing above the keyboard', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final directoryUsers = [
for (var index = 0; index < 8; index++)
DirectoryUser(
pubkey: 'person-$index',
displayName: 'Long recipient name number $index',
),
];
await tester.pumpWidget(
buildTestable(
keyboardInset: 300,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
for (final user in directoryUsers) {
final result = find.byKey(Key('new-dm-person-${user.pubkey}'));
await tester.ensureVisible(result);
await tester.pumpAndSettle();
await tester.tap(result);
await tester.pumpAndSettle();
}
expect(find.byKey(const Key('new-dm-recipient-wrap')), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp(r'^Remove Long recipient')),
findsNWidgets(8),
);
expect(
find.text('DMs support up to nine people, including you.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('shows local preview people when the relay is offline', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildTestable(
previewDirectory: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
expect(find.text('Could not load people from this relay.'), findsNothing);
expect(find.text('Maya Chen'), findsOneWidget);
expect(find.text('Jordan Brooks'), findsOneWidget);
expect(find.text('Priya Shah'), findsOneWidget);
await tester.tap(
find.byKey(
const Key(
'new-dm-person-'
'1111111111111111111111111111111111111111111111111111111111111111',
),
),
);
await tester.pump();
expect(find.text('Maya Chen'), findsWidgets);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(
find.text('Preview only — mock people cannot be messaged.'),
findsOneWidget,
);
});
testWidgets('hides unjoined and archived channels from the main list', (
tester,
) async {
final channels = [
...testChannels,
Channel(
id: '4',
name: 'open-stream',
channelType: 'stream',
visibility: 'open',
description: 'Available to join',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 8,
isMember: false,
),
Channel(
id: '5',
name: 'archived-stream',
channelType: 'stream',
visibility: 'open',
description: 'Archived channel',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 4,
isMember: true,
archivedAt: DateTime(2025, 1, 2),
),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
],
),
);
await tester.pumpAndSettle();
// Unjoined and archived channels should not appear in the main list.
expect(find.text('general'), findsOneWidget);
expect(find.text('open-stream'), findsNothing);
expect(find.text('archived-stream'), findsNothing);
});
testWidgets('shows empty state when no channels', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))],
),
);
await tester.pumpAndSettle();
expect(find.text('No conversations yet'), findsOneWidget);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [channelsProvider.overrideWith(() => _ErrorNotifier())],
),
);
// The error view is gated on a grace timer in ChannelsPage to absorb
// transient AsyncError frames during relay reconnect. Pump once to mount
// and schedule the timer, advance the fake clock past the grace window,
// then pump again to flush the setState the timer triggered.
await tester.pump();
await tester.pump(const Duration(seconds: 3));
await tester.pump();
expect(find.text('Could not load channels'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('renders and clears unread dot indicator', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [_observed(id: 'msg-1', createdAt: 20)],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('channel-unread-dot-1')), findsOneWidget);
readState.markContextRead('1', 20);
await tester.pump();
expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing);
});
testWidgets('renders numeric unread count for counted events', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
30 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [
_observed(
id: 'reply-1',
createdAt: 20,
rootId: 'root',
isThreadedReply: true,
),
_observed(
id: 'reply-2',
createdAt: 30,
rootId: 'root',
isThreadedReply: true,
),
],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('channel-unread-1')), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.byKey(const Key('channel-unread-dot-1')), findsNothing);
});
testWidgets('seeds first loaded channels as read', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
expect(find.byKey(const Key('channel-unread-1')), findsNothing);
});
testWidgets('waits for read-state readiness before initial seeding', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: false,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, isEmpty);
expect(readState.markedContexts, isEmpty);
readState.setReady();
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
});
}
Widget _buildSettingsPage(BuildContext context) =>
const Scaffold(body: Text('Injected settings'));
class _FakeNotifier extends ChannelsNotifier {
final List<Channel> _channels;
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
_FakeNotifier(
this._channels, {
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
}) : _observedEventsByChannel = {
for (final entry in observedEventsByChannel.entries)
entry.key: {for (final event in entry.value) event.id: event},
};
@override
Future<List<Channel>> build() async => _channels;
@override
Map<String, int> get latestObservedByChannel => {
for (final entry in _observedEventsByChannel.entries)
if (entry.value.isNotEmpty)
entry.key: entry.value.values
.map((event) => event.createdAt)
.reduce((left, right) => left > right ? left : right),
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _FakeCommunityListNotifier extends CommunityListNotifier {
_FakeCommunityListNotifier(this._communities);
List<Community> _communities;
final List<String> removedIds = [];
@override
Future<List<Community>> build() async => _communities;
@override
Future<void> removeCommunity(String id) async {
removedIds.add(id);
_communities = _communities
.where((community) => community.id != id)
.toList();
state = AsyncData(_communities);
}
}
class _ErrorNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Future.error('Connection refused');
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakePresenceNotifier extends PresenceNotifier {
@override
Future<String> build() async => 'online';
}
class _FakeReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> seededContexts = {};
final Map<String, int> markedContexts = {};
_FakeReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
void setReady() {
state = ReadStateState(
isReady: true,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
forcedUnreadContexts: state.forcedUnreadContexts,
);
}
@override
void seedContextRead(String contextId, int unixTimestamp) {
seededContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
markedContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
}
ObservedUnreadEvent _observed({
required String id,
required int createdAt,
String? rootId,
bool highPriority = false,
bool isThreadedReply = false,
}) => makeObservedUnreadEvent(
id: id,
createdAt: createdAt,
rootId: rootId,
highPriority: highPriority,
channelType: 'stream',
isThreadedReply: isThreadedReply,
);