mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
**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>
389 lines
12 KiB
Dart
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',
|
|
);
|
|
});
|
|
});
|
|
}
|