mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02: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>
408 lines
12 KiB
Dart
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;
|
|
}
|