Files
buzz/mobile/lib/shared/deeplink/deep_link.dart
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

222 lines
6.7 KiB
Dart

/// Parsing for `buzz://` deep links.
///
/// Mirrors the desktop handler in `desktop/src-tauri/src/deep_link.rs`:
/// `buzz://message?channel=<uuid>&id=<hex>[&thread=<hex>]` references a
/// message (optionally inside a thread) in a channel. Required params that
/// are missing or empty make the link invalid — the caller never sees a
/// half-formed target.
library;
import '../relay/relay_validation.dart';
/// A parsed deep link supported by the app.
sealed class BuzzDeepLink {
const BuzzDeepLink();
}
/// A parsed relay invite link.
///
/// Canonical share links are `https://<relay>/invite/<code>`. The custom
/// `buzz://join?relay=<ws(s)://relay>&code=<code>` form is only an installed-app
/// handoff from the web landing page.
class InviteDeepLink extends BuzzDeepLink {
/// Relay URL normalized to the websocket scheme used by the app.
final String relayUrl;
/// Invite code from the link.
final String code;
/// Optional receipt proving acceptance of the relay's current join policy.
final String? policyReceipt;
const InviteDeepLink({
required this.relayUrl,
required this.code,
this.policyReceipt,
});
@override
bool operator ==(Object other) =>
other is InviteDeepLink &&
other.relayUrl == relayUrl &&
other.code == code &&
other.policyReceipt == policyReceipt;
@override
int get hashCode => Object.hash(relayUrl, code, policyReceipt);
@override
String toString() =>
'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)';
}
/// A parsed `buzz://message` deep link.
class MessageDeepLink extends BuzzDeepLink {
/// Channel UUID from the `channel` query param.
final String channelId;
/// Event ID (hex) from the `id` query param.
final String messageId;
/// Optional thread root event ID from the `thread` query param.
final String? threadRootId;
const MessageDeepLink({
required this.channelId,
required this.messageId,
this.threadRootId,
});
@override
bool operator ==(Object other) =>
other is MessageDeepLink &&
other.channelId == channelId &&
other.messageId == messageId &&
other.threadRootId == threadRootId;
@override
int get hashCode => Object.hash(channelId, messageId, threadRootId);
@override
String toString() =>
'MessageDeepLink(channel: $channelId, id: $messageId, '
'thread: $threadRootId)';
}
/// Build a canonical `buzz://message` link for a channel message.
///
/// Mirrors `desktop/src/features/messages/lib/messageLink.ts` so links copied
/// or shared from mobile round-trip through every client's parser:
/// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`.
///
/// An empty [threadRootId] is treated as "no thread" so callers can pass
/// through a nullable thread reference without extra checks.
String buildMessageLink({
required String channelId,
required String messageId,
String? threadRootId,
}) {
if (channelId.isEmpty) {
throw ArgumentError('buildMessageLink: channelId is required');
}
if (messageId.isEmpty) {
throw ArgumentError('buildMessageLink: messageId is required');
}
final params = <String, String>{
'channel': channelId,
'id': messageId,
if (threadRootId != null && threadRootId.isNotEmpty) 'thread': threadRootId,
};
return Uri(
scheme: 'buzz',
host: 'message',
queryParameters: params,
).toString();
}
/// Parse a `buzz://message?…` URI into a [MessageDeepLink].
///
/// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g.
/// `buzz://connect` which is desktop-only), or links missing a non-empty
/// `channel` or `id` param.
MessageDeepLink? parseMessageDeepLink(Uri uri) {
if (uri.scheme != 'buzz' || uri.host != 'message') return null;
final channel = uri.queryParameters['channel'];
final id = uri.queryParameters['id'];
if (channel == null || channel.isEmpty || id == null || id.isEmpty) {
return null;
}
final thread = uri.queryParameters['thread'];
return MessageDeepLink(
channelId: channel,
messageId: id,
threadRootId: (thread == null || thread.isEmpty) ? null : thread,
);
}
/// Parse canonical HTTPS invite links and `buzz://join` app handoffs.
///
/// Accepted forms:
/// - `https://<relay>/invite/<code>` -> `wss://<relay>` + code
/// - `http://localhost/invite/<code>` -> `ws://localhost` + code in debug builds
/// - `buzz://join?relay=<wss://relay>&code=<code>` -> relay + code
/// - `buzz://join?relay=<ws://localhost>&code=<code>` -> local relay in debug
///
/// Rejects credentials, fragments, missing params, nested relay credentials, and
/// non-invite paths so scanners do not accidentally treat arbitrary URLs as
/// community admission links.
InviteDeepLink? parseInviteDeepLink(Uri uri) {
if (uri.hasFragment || uri.userInfo.isNotEmpty) return null;
if (uri.scheme == 'buzz') {
if (uri.host != 'join') return null;
final relay = uri.queryParameters['relay'];
final code = uri.queryParameters['code'];
if (relay == null || relay.isEmpty || code == null || code.isEmpty) {
return null;
}
final relayUri = Uri.tryParse(relay);
if (relayUri == null ||
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss') ||
relayUri.host.isEmpty ||
relayUri.userInfo.isNotEmpty ||
relayUri.hasFragment) {
return null;
}
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final normalizedRelay = Uri(
scheme: relayUri.scheme,
host: relayUri.host,
port: relayUri.hasPort ? relayUri.port : null,
).toString();
final policyReceipt = uri.queryParameters['policy_receipt'];
return InviteDeepLink(
relayUrl: normalizedRelay,
code: code,
policyReceipt: policyReceipt == null || policyReceipt.isEmpty
? null
: policyReceipt,
);
}
if (uri.scheme == 'https' || uri.scheme == 'http') {
if (uri.host.isEmpty) return null;
final segments = uri.pathSegments;
if (segments.length != 2 ||
segments[0] != 'invite' ||
segments[1].isEmpty) {
return null;
}
final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws';
final relayUri = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
);
try {
validateInviteRelayUri(relayUri);
} on FormatException {
return null;
}
final relay = Uri(
scheme: relayScheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
).toString();
return InviteDeepLink(relayUrl: relay, code: segments[1]);
}
return null;
}
/// Parse any supported Buzz deep link.
BuzzDeepLink? parseBuzzDeepLink(Uri uri) =>
parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri);