Files
buzz/mobile/lib/shared/relay/nostr_models.dart
klopez4212 c9e35f8ac7 fix(mobile): restore Solo chats from channel labels (#8466)
Restore Solo chats and project placement on mobile using `buzz.solo`
labels and creator metadata. Invalidate stale channel details when
identity changes so opening and sending preserve refreshed Solo
identity; shared chats retain ordinary sending. New mobile Solo chats
publish the discovery label, and the personal send guard enforces
ownership even when route hints or legacy placement suggest Solo.

The current desktop producer lives in `block/buzz-app`. [Contract
documentation](0201ca0572/docs/solo-mobile-contract.md)
links its production creation call to relay identity generation and
mobile regression coverage. Public metadata fields are documented.

Validation: clean analysis; 3,542 mobile tests (5 skipped), 4 push
checks, and 280 core unit/doc tests passed. Removing the cache fix
reproduces the send failure. Local push hooks were skipped with owner
approval after an unrelated terminal timeout and disk exhaustion; full
local CI is not green. GitHub CI must validate the pushed commit. The
initial build was installed/launched on iPhone; follow-up native and
live cross-client confirmation remain pending.

The ownership regression supplies a valid owned member recipient and
checks the exact rejection. A New chat UI test captures the real signed
kind-9007 command. Both regressions were mutation-verified: removing the
ownership guard or the production creation label causes the
corresponding test to fail.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 11:10:24 -04:00

406 lines
12 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'thread_reference.dart';
/// Nostr event kind constants.
///
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
abstract final class EventKind {
/// Kind:0 user profile metadata.
static const profile = 0;
static const note = 1;
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
/// Kind:9030 event requesting that the relay add a community member.
static const relayAdminAddMember = 9030;
/// Kind:13534 event containing the current relay-community membership.
static const relayMembership = 13534;
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,
huddleEnded,
];
}
/// 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');
/// Validated, lowercase thread IDs shared by timeline and Inbox navigation.
({String? parentId, String? rootId}) get threadReference =>
parseThreadReference(tags);
/// 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;
/// NIP-CL labels in `t` tags after the leading channel-type tag.
final List<String> labels;
/// Original channel creator from the relay-authored uppercase `P` tag.
final String? creator;
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.labels = const [],
this.creator,
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,
labels: event.tags
.where((t) => t.length >= 2 && t[0] == 't')
.skip(1)
.map((t) => t[1])
.toList(),
creator: event.getTagValue('P'),
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;
}