Files
buzz/mobile/test/features/channels/message_actions_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

389 lines
12 KiB
Dart

import 'package:buzz/features/channels/message_actions.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart';
import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/reminders/reminder_service.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
const _channelId = 'chan-1';
TimelineMessage _message({
String id = 'msg-1',
String pubkey = 'alice',
int createdAt = 1000,
bool isSystem = false,
String? rootId,
}) => TimelineMessage(
id: id,
pubkey: pubkey,
createdAt: createdAt,
content: 'hello world',
isSystem: isSystem,
rootId: rootId,
);
class _FakeReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> markedRead = {};
final List<String> markedUnread = [];
_FakeReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
markedRead[contextId] = unixTimestamp;
var forced = {
for (final entry in state.forcedUnreadContexts.entries)
if (entry.key != contextId) entry.key: entry.value,
};
if (clearForcedMessages) {
forced = {
for (final entry in forced.entries)
if (entry.value != contextId) entry.key: entry.value,
};
}
state = _withForced(forced).copyWithContext(contextId, unixTimestamp);
}
@override
void markContextUnread(String contextId, {required String channelId}) {
markedUnread.add(contextId);
state = _withForced({...state.forcedUnreadContexts, contextId: channelId});
}
ReadStateState _withForced(Map<String, String> forced) => ReadStateState(
isReady: state.isReady,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
forcedUnreadContexts: Map.unmodifiable(forced),
);
}
ReadStateState _readState(Map<String, int> contexts, {bool isReady = true}) =>
ReadStateState(
isReady: isReady,
pubkey: 'self',
contexts: contexts,
version: 1,
);
Future<SharedPreferences> _mockPrefs() async {
SharedPreferences.setMockInitialValues({});
return SharedPreferences.getInstance();
}
/// A [ReminderService] whose constructor dependencies are inert until used —
/// enough for visibility checks on the "Remind me" fast action.
ReminderService _stubReminderService() {
final keys = nostr.Keys.generate();
return ReminderService(
signedEventRelay: SignedEventRelay(
session: RelaySessionNotifier(),
nsec: keys.nsec,
),
crypto: ReminderCrypto(keys.nsec, keys.public),
);
}
Future<void> _pumpSheet(
WidgetTester tester, {
required TimelineMessage message,
required SharedPreferences prefs,
ReadStateNotifier Function()? readStateOverride,
bool canManageMessage = false,
List<TimelineMessage>? allMessages,
ReminderService? reminderService,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('self'),
readStateProvider.overrideWith(
readStateOverride ??
() => _FakeReadStateNotifier(
_readState(const {_channelId: 100000}),
),
),
// No signing identity → "Remind me" hidden by default; individual
// tests opt in by passing a stub service.
reminderServiceProvider.overrideWithValue(reminderService),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Consumer(
builder: (context, ref, _) => TextButton(
onPressed: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: _channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: 'self',
isMember: true,
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
void main() {
group('showMessageActions', () {
testWidgets('shows parity actions for a regular message', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
expect(find.text('Copy text'), findsOneWidget);
expect(find.text('Copy link'), findsOneWidget);
expect(find.text('Mark unread'), findsOneWidget);
expect(find.text('Follow thread'), findsOneWidget);
// No thread context → no Reply fast action.
expect(find.text('Reply'), findsNothing);
// No signing identity → no reminders; no manage rights → no edit/delete.
expect(find.text('Remind me'), findsNothing);
expect(find.text('Edit message'), findsNothing);
expect(find.text('Delete message'), findsNothing);
});
testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '
'row', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
allMessages: [_message()],
reminderService: _stubReminderService(),
);
expect(find.text('Reply'), findsOneWidget);
expect(find.text('Copy link'), findsOneWidget);
expect(find.text('Remind me'), findsOneWidget);
// Promoted actions no longer appear under their old list-row labels.
expect(find.text('Reply in thread'), findsNothing);
expect(find.text('Remind me later'), findsNothing);
});
testWidgets('hides utility actions for system messages', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(isSystem: true), prefs: prefs);
expect(find.text('Copy text'), findsNothing);
expect(find.text('Copy link'), findsNothing);
expect(find.text('Mark unread'), findsNothing);
expect(find.text('Follow thread'), findsNothing);
expect(find.text('Reply'), findsNothing);
expect(find.text('Remind me'), findsNothing);
});
testWidgets('shows Edit/Delete only with manage rights', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
canManageMessage: true,
);
expect(find.text('Edit message'), findsOneWidget);
expect(find.text('Delete message'), findsOneWidget);
});
testWidgets('Mark read appears for unread messages and advances the '
'message marker', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 500}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark read'), findsOneWidget);
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
expect(notifier.markedRead, {'msg:msg-1': 900});
});
testWidgets('Mark unread forces the message unread', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 2000}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark unread'), findsOneWidget);
await tester.tap(find.text('Mark unread'));
await tester.pumpAndSettle();
// The force flag is message-scoped, mapped to its channel so tiles and
// badges surface it.
expect(notifier.markedUnread, ['msg:msg-1']);
expect(notifier.state.forcedUnreadContexts, {'msg:msg-1': _channelId});
expect(notifier.state.locallyForcedChannelIds, {_channelId});
});
testWidgets('Mark read after a forced unread clears the force flag so '
'the toggle round-trips', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
_readState(const {_channelId: 2000}),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
// Force the message unread; the row flips to Mark read.
await tester.tap(find.text('Mark unread'));
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Mark read'), findsOneWidget);
// Mark read must clear the message's own force flag — otherwise the
// row sticks on "Mark read".
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
expect(container.read(readStateProvider).forcedUnreadContexts, isEmpty);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Mark unread'), findsOneWidget);
expect(find.text('Mark read'), findsNothing);
});
testWidgets('message-level Mark read leaves a channel-level forced '
'unread untouched', (tester) async {
final prefs = await _mockPrefs();
final notifier = _FakeReadStateNotifier(
ReadStateState(
isReady: true,
pubkey: 'self',
contexts: const {_channelId: 2000},
version: 1,
// Channel forced unread from the channel tile, message forced
// unread from this sheet.
forcedUnreadContexts: const {
_channelId: _channelId,
'msg:msg-1': _channelId,
},
),
);
await _pumpSheet(
tester,
message: _message(createdAt: 900),
prefs: prefs,
readStateOverride: () => notifier,
);
expect(find.text('Mark read'), findsOneWidget);
await tester.tap(find.text('Mark read'));
await tester.pumpAndSettle();
// The message's flag is gone; the user's channel-level choice stays.
final readState = notifier.state;
expect(readState.forcedUnreadContexts, {_channelId: _channelId});
expect(readState.locallyForcedChannelIds, {_channelId});
});
testWidgets('hides read-state row while read state is not ready', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
readStateOverride: () =>
_FakeReadStateNotifier(_readState(const {}, isReady: false)),
);
expect(find.text('Mark unread'), findsNothing);
expect(find.text('Mark read'), findsNothing);
});
testWidgets('Follow thread toggles the effective root id', (tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(rootId: 'root-9'),
prefs: prefs,
);
await tester.tap(find.text('Follow thread'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
expect(container.read(threadFollowsProvider).followedRootIds, {'root-9'});
// Re-open: the row now offers Unfollow.
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Unfollow thread'), findsOneWidget);
await tester.tap(find.text('Unfollow thread'));
await tester.pumpAndSettle();
expect(container.read(threadFollowsProvider).followedRootIds, isEmpty);
});
});
group('messageLinkFor', () {
test('builds a canonical link with thread context', () {
expect(
messageLinkFor(
message: _message(rootId: 'root-1'),
channelId: _channelId,
),
'buzz://message?channel=chan-1&id=msg-1&thread=root-1',
);
});
test('omits thread for top-level messages', () {
expect(
messageLinkFor(message: _message(), channelId: _channelId),
'buzz://message?channel=chan-1&id=msg-1',
);
});
});
}