Files
buzz/mobile/lib/shared/relay/nostr_models.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

408 lines
12 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
/// Nostr event kind constants.
///
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
abstract final class EventKind {
static const note = 1;
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;
static const typingIndicator = 20002;
static const auth = 22242;
static const agentObserverFrame = 24200;
static const huddleReaction = 24810;
static const readState = 30078;
static const eventReminder = 30300;
static const userStatus = 30315;
static const dmVisibility = 30622;
static const streamMessageV2 = 40002;
static const channelThreadSummary = 39005;
static const channelWindowBounds = 39006;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
static const systemMessage = 40099;
static const jobRequest = 43001;
static const jobAccepted = 43002;
static const jobProgress = 43003;
static const jobResult = 43004;
static const jobCancel = 43005;
static const jobError = 43006;
static const forumPost = 45001;
static const forumComment = 45003;
static const huddleStarted = 48100;
static const huddleParticipantJoined = 48101;
static const huddleParticipantLeft = 48102;
static const huddleEnded = 48103;
/// Event kinds that represent user-visible channel messages.
static const channelMessageEventKinds = [
streamMessage, // 9
streamMessageV2, // 40002
forumPost, // 45001
forumComment, // 45003
];
/// Event kinds that represent channel activity (messages, edits, reactions,
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
static const channelEventKinds = [
deletion, // 5
reaction, // 7
nip29DeleteEvent, // 9005 — Buzz-native deletion
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
streamMessageDiff, // 40008
systemMessage, // 40099
huddleStarted, // 48100 — visible huddle session row
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
huddleEnded, // 48103 — visible huddle ended row
];
/// Auxiliary timeline kinds that overlay or hide existing rows.
static const channelAuxEventKinds = [
deletion,
reaction,
nip29DeleteEvent,
streamMessageEdit,
];
/// Visible content kinds requested by the NIP-CW channel-window path.
static const channelTimelineContentKinds = [
streamMessage,
streamMessageV2,
streamMessageDiff,
systemMessage,
jobRequest,
jobAccepted,
jobProgress,
jobResult,
jobCancel,
jobError,
huddleStarted,
];
}
/// A Nostr event as defined by NIP-01.
@immutable
class NostrEvent {
final String id;
final String pubkey;
final int createdAt;
final int kind;
final List<List<String>> tags;
final String content;
final String sig;
const NostrEvent({
required this.id,
required this.pubkey,
required this.createdAt,
required this.kind,
required this.tags,
required this.content,
required this.sig,
});
factory NostrEvent.fromJson(Map<String, dynamic> json) {
return NostrEvent(
id: json['id'] as String,
pubkey: json['pubkey'] as String,
createdAt: json['created_at'] as int,
kind: json['kind'] as int,
tags: (json['tags'] as List<dynamic>)
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
.toList(),
content: json['content'] as String,
sig: json['sig'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'pubkey': pubkey,
'created_at': createdAt,
'kind': kind,
'tags': tags,
'content': content,
'sig': sig,
};
/// Get the first value for a given tag key.
String? getTagValue(String key) {
for (final tag in tags) {
if (tag.isNotEmpty && tag[0] == key && tag.length > 1) {
return tag[1];
}
}
return null;
}
/// The channel/group ID from the `h` tag (NIP-29).
String? get channelId => getTagValue('h');
/// Extract thread parent and root IDs from `e` tags.
///
/// Matches the desktop's `getThreadReference` logic:
/// - Tags with marker `"reply"` identify the direct parent.
/// - Tags with marker `"root"` identify the thread root.
/// - If no markers are present, falls back to null (top-level message).
({String? parentId, String? rootId}) get threadReference {
final eTags = [
for (final tag in tags)
if (tag.length >= 2 && tag[0] == 'e') tag,
];
if (eTags.isEmpty) return (parentId: null, rootId: null);
// Find tagged root and reply markers (desktop convention).
List<String>? rootTag;
List<String>? replyTag;
for (final tag in eTags) {
if (tag.length >= 4) {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
final rootId = rootTag?[1] ?? parentId;
return (parentId: parentId, rootId: rootId);
}
/// The parent event ID from the `e` tag.
String? get parentEventId => threadReference.parentId;
@override
bool operator ==(Object other) =>
identical(this, other) || other is NostrEvent && id == other.id;
@override
int get hashCode => id.hashCode;
}
/// A NIP-01 subscription filter.
@immutable
class NostrFilter {
final List<int> kinds;
final List<String>? authors;
/// Specific event IDs (NIP-01 single-event lookup).
final List<String>? ids;
final int limit;
final int? since;
final int? until;
/// NIP-50 full-text search query.
final String? search;
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
final Map<String, List<String>> tags;
/// Buzz relay bridge filter extensions (for example NIP-CW `top_level`).
final Map<String, Object?> extensions;
const NostrFilter({
required this.kinds,
this.authors,
this.ids,
this.limit = 100,
this.since,
this.until,
this.search,
this.tags = const {},
this.extensions = const {},
});
/// Return a copy with an updated `since` value.
NostrFilter copyWithSince(int since) => NostrFilter(
kinds: kinds,
authors: authors,
ids: ids,
limit: limit,
since: since,
until: until,
search: search,
tags: tags,
extensions: extensions,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
if (authors != null) json['authors'] = authors;
if (ids != null) json['ids'] = ids;
if (since != null) json['since'] = since;
if (until != null) json['until'] = until;
if (search != null) json['search'] = search;
for (final entry in tags.entries) {
json[entry.key] = entry.value;
}
for (final entry in extensions.entries) {
json[entry.key] = entry.value;
}
return json;
}
}
/// Parsed kind:0 user profile metadata.
@immutable
class ProfileData {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? about;
final String? nip05;
const ProfileData({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.about,
this.nip05,
});
factory ProfileData.fromEvent(NostrEvent event) {
Map<String, dynamic> meta = {};
try {
final decoded = jsonDecode(event.content);
if (decoded is Map<String, dynamic>) meta = decoded;
} catch (_) {}
return ProfileData(
pubkey: event.pubkey,
displayName:
(meta['display_name'] as String?) ?? (meta['name'] as String?),
avatarUrl: meta['picture'] as String?,
about: meta['about'] as String?,
nip05: meta['nip05'] as String?,
);
}
}
/// Parsed kind:39000 channel metadata.
@immutable
class ChannelData {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final List<String> participantPubkeys;
final int? ttlSeconds;
final DateTime? ttlDeadline;
final bool isArchived;
const ChannelData({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
this.topic,
this.participantPubkeys = const [],
this.ttlSeconds,
this.ttlDeadline,
this.isArchived = false,
});
factory ChannelData.fromEvent(NostrEvent event) {
final id = event.getTagValue('d') ?? '';
final name = event.getTagValue('name') ?? '';
// Prefer explicit ["t", type]; fall back to ["hidden"] => dm, else "stream".
// The fallback exists for relays that haven't been upgraded to emit the
// explicit type tag yet.
final explicitType = event.getTagValue('t');
final hasHidden = event.tags.any((t) => t.isNotEmpty && t[0] == 'hidden');
final channelType = explicitType ?? (hasHidden ? 'dm' : 'stream');
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
final hasPublic = event.tags.any((t) => t.isNotEmpty && t[0] == 'public');
final hasPrivate = event.tags.any((t) => t.isNotEmpty && t[0] == 'private');
final visibility = hasPublic
? 'open'
: hasPrivate
? 'private'
: 'open';
final description = event.getTagValue('about') ?? '';
final topic = event.getTagValue('topic');
final participants = [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p') t[1],
];
final ttlRaw = event.getTagValue('ttl');
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
final ttlDeadline = ttlDeadlineRaw != null
? DateTime.tryParse(ttlDeadlineRaw)
: null;
// Relay republishes kind:39000 with `["archived", "true"]` when a channel
// is archived (including the auto-archive emitted by the TTL reaper). The
// tag value "false" is also accepted server-side, so only treat "true" as
// archived — anything else (missing tag, "false", unexpected value) means
// active.
final isArchived = event.getTagValue('archived') == 'true';
return ChannelData(
id: id,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
topic: topic,
participantPubkeys: participants,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isArchived: isArchived,
);
}
}
/// A single member entry parsed from a kind:39002 members event.
@immutable
class MemberEntry {
final String pubkey;
final String role;
const MemberEntry({required this.pubkey, required this.role});
}
/// Parse a kind:39002 members event into the list of `(pubkey, role)` entries.
///
/// NIP-29 members tags follow the shape `["p", <pubkey>, <relay>, <role>]`.
List<MemberEntry> membersFromEvent(NostrEvent event) {
return [
for (final t in event.tags)
if (t.length >= 2 && t[0] == 'p')
MemberEntry(pubkey: t[1], role: t.length >= 4 ? t[3] : 'member'),
];
}
/// Parse a Buzz command response from the relay's OK message content.
///
/// Command kinds (e.g. 41010, 30620, 46020) return `"response:{...}"` in the
/// OK message. Returns `null` if the message is not a command response or the
/// JSON is invalid.
Map<String, dynamic>? parseCommandResponse(String message) {
// Try the spec format first: "response:{...}".
const prefix = 'response:';
if (message.startsWith(prefix)) {
try {
final decoded = jsonDecode(message.substring(prefix.length));
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}
// Fallback: raw JSON object (older relays, backward compat).
try {
final decoded = jsonDecode(message);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
return null;
}