Files
buzz/mobile/lib/shared/relay/nostr_models.dart
Tom Brow 8222e99915 fix(mobile): keep thread references consistent across push and Inbox (#8332)
Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed
trailing thread tags could make a notification open the right thread
while Flutter indexed its reply elsewhere; uppercase references could
similarly miss lowercase event IDs.

Share one validated thread-reference parser between the mobile timeline,
Inbox, and feed helper. It ignores malformed markers, keeps the last
valid root/reply, and normalizes lookup IDs to lowercase. Native push
parsing now uses the same casing. Existing thread fixtures use valid
event IDs so they continue exercising real thread relationships.

Add RunnerTests coverage for the production native-to-Flutter argument
conversion, including buffered cold-start navigation and omitted
optional thread IDs, and run it in the native CI lane.

### Validation

- New parser and Inbox regressions cover malformed markers and uppercase
references. Widget regressions open and highlight the exact reply for
valid, uppercase, and trailing-malformed tags.
- After integrating [#8106](https://github.com/block/buzz/pull/8106),
Flutter analysis and the complete mobile suite pass: 3,337 tests, four
existing skips. Integration preserves per-event read predicates and
migrates incoming thread fixtures to valid IDs. An unchanged
keyboard-history test also failed with main's original parser; recording
reads before the scroll transition fixes its timing assumption while
retaining visibility assertions.
- Native package tests passed (127 tests), and both Runner bridge tests
passed on an iOS simulator with Xcode 26.5. Removing the production
`threadRootId` assignment made the bridge regression fail; restoring it
passed. Native code is unchanged by the main integration.
- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/38659307247),
including Mobile and Mobile Swift. [Codex
review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528)
and [security
review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235)
found no issues on the integrated commit. [Owner
approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957)
covers the exact integrated commit.
- Standards and spec reviews found no blockers, including the merge
resolutions and test adjustments. Validation is scoped to mobile/native
per VISION_MOBILE; unrelated repository-wide checks were stopped, not
counted as passing.

The bridge test covers serialization and buffering, not end-to-end APNs
delivery or app launch. No new physical-device acceptance is claimed;
routine post-merge dogfood remains human-owned.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-14 18:42:39 -07:00

392 lines
11 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;
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;
}