Files
buzz/mobile/lib/shared/relay/nostr_models.dart
Wes cd6fc96fc5 feat(mobile): implement message thread support (#338)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:00:02 -07:00

170 lines
4.5 KiB
Dart

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 deletion = 5;
static const reaction = 7;
static const streamMessage = 9;
static const typingIndicator = 20002;
static const auth = 22242;
static const streamMessageV2 = 40002;
static const streamMessageEdit = 40003;
static const streamMessageDiff = 40008;
static const systemMessage = 40099;
static const forumPost = 45001;
static const 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
streamMessage, // 9
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
streamMessageDiff, // 40008
systemMessage, // 40099
];
}
/// 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 int limit;
final int? since;
final int? until;
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
final Map<String, List<String>> tags;
const NostrFilter({
required this.kinds,
this.limit = 100,
this.since,
this.until,
this.tags = const {},
});
/// Return a copy with an updated `since` value.
NostrFilter copyWithSince(int since) => NostrFilter(
kinds: kinds,
limit: limit,
since: since,
until: until,
tags: tags,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
if (since != null) json['since'] = since;
if (until != null) json['until'] = until;
for (final entry in tags.entries) {
json[entry.key] = entry.value;
}
return json;
}
}