Files
buzz/mobile/lib/features/channels/message_actions.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

532 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/theme/theme.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/widgets/sheet_divider.dart';
import '../../shared/reminders/remind_me_later_sheet.dart';
import '../../shared/reminders/reminder_service.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'read_state/message_read_state.dart';
import 'read_state/read_state_format.dart';
import 'read_state/read_state_provider.dart';
import 'thread_detail_page.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
const quickEmojis = ['\u{1F44D}', '\u{2764}\u{FE0F}', '\u{1F602}', '\u{1F389}'];
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Quick emoji row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final emoji in quickEmojis)
_QuickReactionCircle(
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelActionsProvider)
.addReaction(message.id, emoji);
},
child: Text(
emoji,
style: const TextStyle(fontSize: 24),
),
),
_QuickReactionCircle(
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(
context: context,
onSelect: (emoji) {
ref
.read(channelActionsProvider)
.addReaction(message.id, emoji);
},
);
},
child: Icon(
LucideIcons.plus,
size: 24,
color: sheetContext.colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
),
),
),
),
),
);
}
/// Canonical `buzz://message` link for a timeline message, including thread
/// context when the message is a reply.
String messageLinkFor({
required TimelineMessage message,
required String channelId,
}) {
return buildMessageLink(
channelId: channelId,
messageId: message.id,
threadRootId: message.rootId,
);
}
class _MarkReadUnreadTile extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
const _MarkReadUnreadTile({required this.message, required this.channelId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return const SizedBox.shrink();
final unread = isMessageUnread(
readState,
channelId: channelId,
messageId: message.id,
createdAt: message.createdAt,
threadRootId: message.rootId,
);
return ListTile(
leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen),
title: Text(unread ? 'Mark read' : 'Mark unread'),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(readStateProvider.notifier);
if (unread) {
// Advances the `msg:` marker and clears this message's own forced
// flag — a channel-level forced unread (channel tile) is a separate
// choice and stays untouched.
notifier.markContextRead(
msgContextKey(message.id),
message.createdAt,
);
} else {
// Read markers are monotonic and cannot move backward, so mark
// unread forces this message unread locally (session-local, does
// not survive relaunch). The channel surfaces it as unread too.
notifier.markContextUnread(
msgContextKey(message.id),
channelId: channelId,
);
}
},
);
}
}
class _FollowThreadTile extends ConsumerWidget {
final TimelineMessage message;
const _FollowThreadTile({required this.message});
@override
Widget build(BuildContext context, WidgetRef ref) {
final follows = ref.watch(threadFollowsProvider);
// Follow acts on the effective thread root: the root for replies, the
// message itself for potential thread heads (matches desktop).
final rootId = message.rootId ?? message.id;
final following = follows.isFollowing(rootId);
return ListTile(
leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing),
title: Text(following ? 'Unfollow thread' : 'Follow thread'),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(threadFollowsProvider.notifier);
if (following) {
notifier.unfollowThread(rootId);
} else {
notifier.followThread(rootId);
}
},
);
}
}
/// Promoted actions for the three dominant mobile jobs: respond now (Reply),
/// hand off context (Copy link — the `buzz://message` link is the workspace's
/// context-transfer primitive), and defer (Remind me).
class _FastActionsRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
final List<TimelineMessage>? allMessages;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
/// The long-pressed message's page context — survives the sheet pop, used
/// for the thread push and the copy-link snackbar.
final BuildContext pageContext;
const _FastActionsRow({
required this.message,
required this.channelId,
required this.allMessages,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.pageContext,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = allMessages;
final canRemind = ref.watch(reminderServiceProvider) != null;
final tiles = <Widget>[
if (messages != null)
_FastActionTile(
icon: LucideIcons.messageSquareReply,
label: 'Reply',
onTap: () {
Navigator.of(context).pop();
Navigator.of(pageContext).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
_FastActionTile(
icon: LucideIcons.link2,
label: 'Copy link',
onTap: () {
Navigator.of(context).pop();
copyToClipboard(
pageContext,
messageLinkFor(message: message, channelId: channelId),
message: 'Message link copied',
);
},
),
if (canRemind)
_FastActionTile(
icon: LucideIcons.clock,
label: 'Remind me',
onTap: () {
final rootContext = Navigator.of(
context,
rootNavigator: true,
).context;
Navigator.of(context).pop();
showRemindMeLaterSheet(
context: rootContext,
ref: ref,
target: ReminderTarget(
eventId: message.id,
channelId: channelId,
preview: message.content.characters
.take(_reminderPreviewLength)
.toString(),
authorPubkey: message.pubkey,
),
);
},
),
];
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: tiles,
);
}
}
class _FastActionTile extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _FastActionTile({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Pill-shaped icon container with the caption below the fill,
// matching the emoji circles' fill and tap treatment.
Container(
width: 76,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Icon(icon, size: 24, color: context.colors.onSurface),
),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
);
}
}
/// Circular filled tap target shared by the quick-reaction emojis and the
/// "+" emoji-picker tile — one treatment, one place to change it.
class _QuickReactionCircle extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
const _QuickReactionCircle({required this.onTap, required this.child});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: child,
),
);
}
}
void _showEditSheet({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
}) {
final controller = TextEditingController(text: message.content);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 5,
decoration: const InputDecoration(hintText: 'Edit message'),
),
const SizedBox(height: Grid.xxs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty || text == message.content) {
Navigator.of(sheetContext).pop();
return;
}
ref
.read(channelActionsProvider)
.editMessage(
channelId: channelId,
eventId: message.id,
content: text,
mediaTags: buildCustomEmojiTags(
text,
ref.read(customEmojiListProvider),
),
);
Navigator.of(sheetContext).pop();
},
child: const Text('Save'),
),
],
),
],
),
),
);
}
void _confirmDelete({
required BuildContext context,
required WidgetRef ref,
required String channelId,
required String messageId,
}) {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete message'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
final messenger = ScaffoldMessenger.of(context);
ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId)
.catchError((Object error) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete message: $error')),
);
});
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: const Text('Delete'),
),
],
),
);
}