mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +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>
532 lines
17 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|