Port channel windows to mobile (#1518)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-05 10:04:54 -06:00
committed by GitHub
parent 33886e3dec
commit 6716cd31a5
12 changed files with 1188 additions and 124 deletions

View File

@@ -258,7 +258,13 @@ class ChannelDetailPage extends HookConsumerWidget {
events,
currentPubkey: currentPubkey,
);
final entries = buildMainTimelineEntries(messages);
final summaries = ref
.read(channelMessagesProvider(channel.id).notifier)
.threadSummaries;
final entries = buildMainTimelineEntries(
messages,
relaySummaries: summaries,
);
return _MessageList(
entries: entries,
allMessages: messages,

View File

@@ -3,15 +3,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'channel_management_provider.dart';
import 'channel_window.dart';
import 'thread_replies_provider.dart';
/// Provides the message list for a specific channel. Registers a live
/// subscription first, then syncs history via the websocket session.
/// subscription first, then syncs history via the server-assembled channel
/// window fast path, falling back to the legacy websocket history path when the
/// relay does not return a valid NIP-CW bounds overlay.
class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final String channelId;
void Function()? _unsubscribe;
bool _reachedOldest = false;
bool _initInFlight = false;
bool _usingChannelWindow = false;
int _initVersion = 0;
ChannelWindowStore _windowStore = const ChannelWindowStore.empty();
ChannelMessagesNotifier(this.channelId);
@@ -19,6 +25,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
/// UI can show stale data instead of a blank loading spinner.
List<NostrEvent>? _lastKnownMessages;
Map<String, ChannelWindowThreadSummary> get threadSummaries =>
channelWindowThreadSummaries(_windowStore);
@override
AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider);
@@ -30,15 +39,13 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
if (sessionState.status != SessionStatus.connected) {
_initVersion++;
_initInFlight = false;
// Return cached messages if available so the UI remains usable while
// disconnected/reconnecting, instead of showing an empty screen.
return AsyncData(_lastKnownMessages ?? const []);
}
// Reset pagination state on rebuild (e.g. after reconnect).
_reachedOldest = false;
_windowStore = const ChannelWindowStore.empty();
_usingChannelWindow = false;
_init();
// Show previous messages while fetching fresh ones, instead of a spinner.
if (_lastKnownMessages case final cached? when cached.isNotEmpty) {
return AsyncData(cached);
}
@@ -52,9 +59,6 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
try {
final session = ref.read(relaySessionProvider.notifier);
// Register live first, then sync history. This matches desktop and closes
// the race where an event can arrive after history EOSE but before live
// subscription registration.
try {
final unsubscribe = await session.subscribe(
NostrFilter(
@@ -62,6 +66,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
tags: {
'#h': [channelId],
},
since: _currentUnixSeconds(),
limit: 200,
),
_handleLiveEvent,
@@ -72,50 +77,25 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
}
_unsubscribe = unsubscribe;
} catch (error) {
if (!_isCurrentInit(initVersion)) {
return;
}
if (!_isCurrentInit(initVersion)) return;
debugPrint(
'[ChannelMessagesNotifier] live subscription failed for $channelId: $error',
);
}
// Fetch recent history via REQ/EOSE after the subscription is active.
final history = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 200,
),
);
if (!_isCurrentInit(initVersion)) {
return;
}
final history = await _fetchNewestHistory(session);
if (!_isCurrentInit(initVersion)) return;
// Merge fresh history with any events already in state (e.g. from
// fetchOlder() or live events that arrived while _init was in flight)
// to avoid discarding data the user has already scrolled through.
final existing = state.value ?? const [];
final existingIds = existing.map((e) => e.id).toSet();
final newEvents = history
.where((e) => !existingIds.contains(e.id))
.toList();
final merged = [...existing, ...newEvents];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
final existing = state.value ?? const <NostrEvent>[];
final existingIds = existing.map((event) => event.id).toSet();
final merged = [
...existing,
...history.where((event) => !existingIds.contains(event.id)),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
_lastKnownMessages = merged;
state = AsyncData(merged);
// Auto-prefetch: if deletions/reactions crowded out displayable messages,
// loop fetchOlder() until we have enough content to fill the screen.
// Must clear _initInFlight first so fetchOlder() doesn't short-circuit.
_initInFlight = false;
await _ensureMinDisplayable(initVersion);
} catch (e, st) {
if (!_isCurrentInit(initVersion)) {
return;
}
if (!_isCurrentInit(initVersion)) return;
final fallbackMessages = state.value ?? _lastKnownMessages;
if (fallbackMessages != null) {
debugPrint(
@@ -132,65 +112,119 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
}
}
void _handleLiveEvent(NostrEvent event) {
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final merged = _mergeEvent(current, event);
_lastKnownMessages = merged;
state = AsyncData(merged);
Future<List<NostrEvent>> _fetchNewestHistory(
RelaySessionNotifier session,
) async {
try {
final page = await _fetchWindowPage(session, null);
_windowStore = replaceNewestChannelWindow(_windowStore, page);
_usingChannelWindow = true;
_reachedOldest = !channelWindowHasMore(_windowStore);
return flattenChannelWindowEvents(_windowStore);
} catch (error) {
debugPrint(
'[ChannelMessagesNotifier] channel window unavailable for $channelId, falling back to WS history: $error',
);
_usingChannelWindow = false;
final history = await session.fetchHistory(
NostrFilters.messages(channelId),
);
history.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return history;
}
}
Future<ChannelWindowPage> _fetchWindowPage(
RelaySessionNotifier session,
ChannelPageCursor? cursor,
) async {
final events = await session.queryRelay([_channelWindowFilter(cursor)]);
return parseChannelWindowResponse(events, channelId, cursor);
}
NostrFilter _channelWindowFilter(ChannelPageCursor? cursor) => NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#h': [channelId],
},
limit: 50,
until: cursor?.createdAt,
extensions: {
'top_level': true,
'include_summaries': true,
'include_aux': true,
if (cursor != null) 'before_id': cursor.eventId,
},
);
void _handleLiveEvent(NostrEvent event) {
if (_usingChannelWindow) {
_handleWindowLiveEvent(event);
} else {
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final merged = _mergeEvent(current, event);
_lastKnownMessages = merged;
state = AsyncData(merged);
}
// When a membership system event arrives, refresh the channel member list
// so the @mention autocomplete picks up new members without a restart.
if (event.kind == EventKind.systemMessage &&
_isMembershipEvent(event.content)) {
ref.invalidate(channelMembersProvider(channelId));
}
}
void _handleWindowLiveEvent(NostrEvent event) {
if (!_mergeWindowEventIntoStore(event)) return;
final flattened = flattenChannelWindowEvents(_windowStore);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
}
bool _mergeWindowEventIntoStore(NostrEvent event) {
final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
event.kind,
);
final thread = isTimelineRow ? event.threadReference : null;
if (thread?.parentId != null) {
final rootId = thread?.rootId;
if (rootId != null) {
ref.invalidate(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
),
);
}
final parentId = thread?.parentId;
if (parentId != null && parentId != rootId) {
ref.invalidate(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: parentId),
),
);
}
if (!_isBroadcastReply(event)) return false;
}
if (!isTimelineRow &&
!EventKind.channelAuxEventKinds.contains(event.kind)) {
return false;
}
final next = mergeLiveChannelWindowEvent(
_windowStore,
event,
isTimelineRow: isTimelineRow,
);
if (identical(next, _windowStore)) return false;
_windowStore = next;
return true;
}
static bool _isMembershipEvent(String content) {
return content.contains('member_joined') ||
content.contains('member_left') ||
content.contains('member_removed');
}
/// Kinds that are metadata rather than displayable content (deletions,
/// reactions, edits, legacy pre-migration messages).
static const _metadataKinds = {
EventKind.deletion,
EventKind.reaction,
40001,
EventKind.streamMessageEdit,
EventKind.huddleParticipantJoined,
EventKind.huddleParticipantLeft,
};
/// Minimum displayable messages we want after the initial history load.
static const _minDisplayable = 15;
/// Max extra fetchOlder rounds during auto-prefetch to avoid hammering the
/// relay.
static const _maxPrefetchRounds = 3;
/// After the initial history fetch, check whether enough user-visible
/// messages were loaded. If deletion/reaction events consumed most of the
/// fetch limit, loop [fetchOlder] to backfill displayable content.
Future<void> _ensureMinDisplayable(int initVersion) async {
for (var i = 0; i < _maxPrefetchRounds; i++) {
if (!_isCurrentInit(initVersion) || _reachedOldest) return;
final events = state.value;
if (events == null) return;
final displayable = events
.where((e) => !_metadataKinds.contains(e.kind))
.length;
if (displayable >= _minDisplayable) return;
final loaded = await fetchOlder();
if (!loaded) return;
}
}
/// Merge a new event into the sorted list, deduplicating by ID.
static List<NostrEvent> _mergeEvent(
List<NostrEvent> current,
NostrEvent incoming,
@@ -208,47 +242,50 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_unsubscribe = null;
}
/// Whether all history has been loaded (no more older messages).
bool get reachedOldest => _reachedOldest;
/// Fetch older messages (pagination). Call this when the user scrolls up.
/// Returns `true` if new messages were loaded.
Future<bool> fetchOlder() async {
if (_reachedOldest || _initInFlight) return false;
final session = ref.read(relaySessionProvider.notifier);
if (_usingChannelWindow) {
final cursor = channelWindowNextCursor(_windowStore);
if (cursor == null) {
_reachedOldest = true;
return false;
}
try {
final page = await _fetchWindowPage(session, cursor);
_windowStore = appendOlderChannelWindow(_windowStore, page);
_reachedOldest = !channelWindowHasMore(_windowStore);
final flattened = flattenChannelWindowEvents(_windowStore);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
return page.rows.isNotEmpty || page.aux.isNotEmpty;
} catch (error) {
debugPrint(
'[ChannelMessagesNotifier] failed to fetch older channel window page for $channelId: $error',
);
return false;
}
}
final currentEvents = state.value;
if (currentEvents == null || currentEvents.isEmpty) return false;
final oldest = currentEvents.first.createdAt;
final session = ref.read(relaySessionProvider.notifier);
final older = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 100,
until: oldest,
),
NostrFilters.messages(channelId, limit: 100, until: oldest),
);
if (older.isEmpty) {
_reachedOldest = true;
return false;
}
// Dedup against existing events. If nothing new remains after dedup
// (e.g. all returned events share the boundary timestamp), mark as
// exhausted to avoid an infinite fetch loop.
final currentIds = state.value?.map((e) => e.id).toSet() ?? {};
final deduped = older.where((e) => !currentIds.contains(e.id)).toList();
if (deduped.isEmpty) {
_reachedOldest = true;
return false;
}
state = state.whenData((events) {
final merged = [...deduped, ...events];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
@@ -259,6 +296,14 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
}
}
bool _isBroadcastReply(NostrEvent event) {
return event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
}
int _currentUnixSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
final channelMessagesProvider =
NotifierProvider.family<
ChannelMessagesNotifier,

View File

@@ -0,0 +1,335 @@
import 'dart:convert';
import '../../shared/relay/relay.dart';
class ChannelPageCursor {
final int createdAt;
final String eventId;
const ChannelPageCursor({required this.createdAt, required this.eventId});
}
class ChannelWindowThreadSummary {
final int replyCount;
final int descendantCount;
final int? lastReplyAt;
final List<String> participantPubkeys;
const ChannelWindowThreadSummary({
required this.replyCount,
required this.descendantCount,
required this.lastReplyAt,
required this.participantPubkeys,
});
}
class ChannelWindowRow {
final NostrEvent event;
final ChannelWindowThreadSummary? thread;
const ChannelWindowRow({required this.event, this.thread});
ChannelWindowRow copyWith({ChannelWindowThreadSummary? thread}) =>
ChannelWindowRow(event: event, thread: thread ?? this.thread);
}
class ChannelWindowPage {
final ChannelPageCursor? startCursor;
final List<ChannelWindowRow> rows;
final List<NostrEvent> aux;
final ChannelPageCursor? nextCursor;
final bool hasMore;
const ChannelWindowPage({
required this.startCursor,
required this.rows,
required this.aux,
required this.nextCursor,
required this.hasMore,
});
}
class ChannelWindowStore {
final List<ChannelWindowPage> pages;
final List<NostrEvent> liveOverlay;
final List<NostrEvent> liveAux;
const ChannelWindowStore({
required this.pages,
required this.liveOverlay,
required this.liveAux,
});
const ChannelWindowStore.empty()
: pages = const [],
liveOverlay = const [],
liveAux = const [];
}
ChannelWindowPage parseChannelWindowResponse(
List<NostrEvent> events,
String channelId,
ChannelPageCursor? startCursor,
) {
var rows = [
for (final event in events)
if (EventKind.channelTimelineContentKinds.contains(event.kind))
ChannelWindowRow(event: event),
];
final rowIndexesById = <String, int>{
for (var i = 0; i < rows.length; i++) rows[i].event.id: i,
};
for (final event in events) {
if (event.kind != EventKind.channelThreadSummary) continue;
final rootId = event.getTagValue('e');
final rowIndex = rootId == null ? null : rowIndexesById[rootId];
if (rowIndex == null) continue;
final payload = _parseJsonMap(event, 'thread summary');
final participants = payload['participants'];
rows[rowIndex] = rows[rowIndex].copyWith(
thread: ChannelWindowThreadSummary(
replyCount: (payload['reply_count'] as num).toInt(),
descendantCount: (payload['descendant_count'] as num).toInt(),
lastReplyAt: (payload['last_reply_at'] as num?)?.toInt(),
participantPubkeys: participants is List
? participants.whereType<String>().toList()
: const [],
),
);
}
final boundsEvents = events
.where((event) => event.kind == EventKind.channelWindowBounds)
.toList();
if (boundsEvents.length != 1) {
throw Exception(
'Channel window response must contain exactly one bounds event.',
);
}
final boundsEvent = boundsEvents.single;
if (boundsEvent.getTagValue('d') !=
_expectedBoundsKey(channelId, startCursor)) {
throw Exception('Channel window bounds do not match the request cursor.');
}
final bounds = _parseJsonMap(boundsEvent, 'window bounds');
final hasMore = bounds['has_more'] as bool;
final nextCursor = _parseCursor(bounds['next_cursor']);
if (hasMore != (nextCursor != null)) {
throw Exception('Channel window bounds has_more and next_cursor disagree.');
}
return ChannelWindowPage(
startCursor: startCursor,
rows: rows,
aux: [
for (final event in events)
if (EventKind.channelAuxEventKinds.contains(event.kind)) event,
],
nextCursor: nextCursor,
hasMore: hasMore,
);
}
ChannelWindowStore replaceNewestChannelWindow(
ChannelWindowStore current,
ChannelWindowPage page,
) {
if (page.startCursor != null) {
throw Exception('Newest channel page must have a null start cursor.');
}
_assertValidPage(page);
final rowIds = page.rows.map((row) => row.event.id).toSet();
final auxIds = page.aux.map((event) => event.id).toSet();
return ChannelWindowStore(
pages: [page],
liveOverlay: current.liveOverlay
.where((event) => !rowIds.contains(event.id))
.toList(),
liveAux: current.liveAux
.where((event) => !auxIds.contains(event.id))
.toList(),
);
}
ChannelWindowStore appendOlderChannelWindow(
ChannelWindowStore current,
ChannelWindowPage page,
) {
_assertValidPage(page);
if (current.pages.isEmpty) {
throw Exception('Load the newest channel page first.');
}
final tail = current.pages.last;
if (!tail.hasMore || tail.nextCursor == null) {
throw Exception('The channel window is already complete.');
}
if (!_cursorsEqual(page.startCursor, tail.nextCursor)) {
throw Exception(
'Channel page does not continue the retained cursor chain.',
);
}
final retainedIds = current.pages
.expand((page) => page.rows)
.map((row) => row.event.id)
.toSet();
for (final row in page.rows) {
if (retainedIds.contains(row.event.id)) {
throw Exception('Channel row ${row.event.id} overlaps a retained page.');
}
}
final pageIds = page.rows.map((row) => row.event.id).toSet();
return ChannelWindowStore(
pages: [...current.pages, page],
liveOverlay: current.liveOverlay
.where((event) => !pageIds.contains(event.id))
.toList(),
liveAux: current.liveAux,
);
}
ChannelWindowStore mergeLiveChannelWindowEvent(
ChannelWindowStore current,
NostrEvent event, {
required bool isTimelineRow,
}) {
if (!isTimelineRow) {
final alreadyKnown =
current.liveAux.any((candidate) => candidate.id == event.id) ||
current.pages.any(
(page) => page.aux.any((candidate) => candidate.id == event.id),
);
if (alreadyKnown) return current;
return ChannelWindowStore(
pages: current.pages,
liveOverlay: current.liveOverlay,
liveAux: [...current.liveAux, event],
);
}
final inPages = current.pages.any(
(page) => page.rows.any((row) => row.event.id == event.id),
);
if (inPages) return current;
final oldestPage = current.pages.isEmpty ? null : current.pages.last;
final oldest = oldestPage?.rows.isEmpty ?? true
? null
: oldestPage!.rows.last.event;
if (oldest != null && _compareRelayOrder(event, oldest) >= 0) return current;
final overlay =
current.liveOverlay
.where((candidate) => candidate.id != event.id)
.toList()
..add(event)
..sort(_compareRelayOrder);
return ChannelWindowStore(
pages: current.pages,
liveOverlay: overlay,
liveAux: current.liveAux,
);
}
List<NostrEvent> flattenChannelWindowEvents(ChannelWindowStore store) {
final byId = <String, NostrEvent>{};
for (final page in store.pages) {
for (final row in page.rows) {
byId[row.event.id] = row.event;
}
for (final event in page.aux) {
byId[event.id] = event;
}
}
for (final event in store.liveOverlay) {
byId[event.id] = event;
}
for (final event in store.liveAux) {
byId[event.id] = event;
}
return byId.values.toList()
..sort((left, right) => _compareRelayOrder(right, left));
}
bool channelWindowHasMore(ChannelWindowStore store) =>
store.pages.isNotEmpty && store.pages.last.hasMore;
ChannelPageCursor? channelWindowNextCursor(ChannelWindowStore store) =>
store.pages.isEmpty ? null : store.pages.last.nextCursor;
Map<String, ChannelWindowThreadSummary> channelWindowThreadSummaries(
ChannelWindowStore store,
) {
return {
for (final page in store.pages)
for (final row in page.rows)
if (row.thread != null) row.event.id: row.thread!,
};
}
Map<String, dynamic> _parseJsonMap(NostrEvent event, String label) {
try {
final decoded = jsonDecode(event.content);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
throw Exception('Invalid $label event ${event.id}.');
}
ChannelPageCursor? _parseCursor(Object? value) {
if (value == null) return null;
if (value is! Map<String, dynamic>) {
throw Exception('Invalid channel window cursor.');
}
return ChannelPageCursor(
createdAt: (value['created_at'] as num).toInt(),
eventId: value['id'] as String,
);
}
String _expectedBoundsKey(String channelId, ChannelPageCursor? cursor) {
final suffix = cursor == null
? 'head'
: '${cursor.createdAt}:${cursor.eventId.toLowerCase()}';
return '${channelId.toLowerCase()}:$suffix';
}
bool _cursorsEqual(ChannelPageCursor? left, ChannelPageCursor? right) =>
identical(left, right) ||
(left != null &&
right != null &&
left.createdAt == right.createdAt &&
left.eventId == right.eventId);
void _assertValidPage(ChannelWindowPage page) {
if (page.hasMore != (page.nextCursor != null)) {
throw Exception('Channel window hasMore and nextCursor disagree.');
}
final seen = <String>{};
for (var index = 0; index < page.rows.length; index++) {
final event = page.rows[index].event;
if (!seen.add(event.id)) {
throw Exception('Duplicate channel row ${event.id}.');
}
final startCursor = page.startCursor;
if (startCursor != null && !_isStrictlyOlder(event, startCursor)) {
throw Exception(
'Channel row ${event.id} is outside its cursor interval.',
);
}
if (index > 0 &&
_compareRelayOrder(page.rows[index - 1].event, event) > 0) {
throw Exception('Channel window rows are not in relay order.');
}
}
}
bool _isStrictlyOlder(NostrEvent event, ChannelPageCursor cursor) =>
event.createdAt < cursor.createdAt ||
(event.createdAt == cursor.createdAt &&
event.id.compareTo(cursor.eventId) > 0);
int _compareRelayOrder(NostrEvent left, NostrEvent right) {
if (left.createdAt != right.createdAt) {
return right.createdAt - left.createdAt;
}
return left.id.compareTo(right.id);
}

View File

@@ -8,8 +8,8 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'thread_replies_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'date_formatters.dart';
@@ -47,13 +47,19 @@ class ThreadDetailPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Re-derive replies from live message state so new replies appear.
final messagesState = ref.watch(channelMessagesProvider(channelId));
final liveMessages = messagesState.whenData((events) {
final repliesState = ref.watch(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
),
);
final replyMessages = repliesState.whenData((events) {
return formatTimeline(events, currentPubkey: currentPubkey);
});
final allMsgs = liveMessages.value ?? allMessages;
final fetchedReplies = replyMessages.value;
final allMsgs = fetchedReplies == null
? allMessages
: [threadHead, ...fetchedReplies];
// Index all messages by parentId so we can find direct children of any
// message and compute thread summaries for nested threads.

View File

@@ -0,0 +1,66 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
class ThreadRepliesArgs {
final String channelId;
final String rootId;
const ThreadRepliesArgs({required this.channelId, required this.rootId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ThreadRepliesArgs &&
channelId == other.channelId &&
rootId == other.rootId;
@override
int get hashCode => Object.hash(channelId, rootId);
}
class _ThreadCursor {
final int createdAt;
final String eventId;
const _ThreadCursor({required this.createdAt, required this.eventId});
}
final threadRepliesProvider =
FutureProvider.family<List<NostrEvent>, ThreadRepliesArgs>((
ref,
args,
) async {
final session = ref.watch(relaySessionProvider.notifier);
final replies = <NostrEvent>[];
_ThreadCursor? cursor;
for (var page = 0; page < 500; page++) {
final events = await session.queryRelay([
_threadRepliesFilter(args, cursor),
]);
replies.addAll(events);
if (events.length < 200) return replies;
final last = events.last;
cursor = _ThreadCursor(createdAt: last.createdAt, eventId: last.id);
}
throw Exception('Thread ${args.rootId} exceeded the page safety limit.');
});
NostrFilter _threadRepliesFilter(
ThreadRepliesArgs args,
_ThreadCursor? cursor,
) {
return NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#e': [args.rootId],
'#h': [args.channelId],
},
limit: 200,
extensions: {
'depth_limit': 64,
if (cursor != null) 'thread_cursor': cursor.createdAt,
if (cursor != null) 'thread_cursor_id': cursor.eventId,
},
);
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import '../../shared/relay/relay.dart';
import '../custom_emoji/custom_emoji.dart';
import 'channel_window.dart';
enum SystemEventType {
memberJoined,
@@ -374,8 +375,9 @@ List<TimelineMessage> formatTimeline(
///
/// Mirrors the desktop's `buildMainTimelineEntries`.
List<MainTimelineEntry> buildMainTimelineEntries(
List<TimelineMessage> messages,
) {
List<TimelineMessage> messages, {
Map<String, ChannelWindowThreadSummary>? relaySummaries,
}) {
// Index direct children by parentId.
final childrenByParent = <String, List<TimelineMessage>>{};
for (final msg in messages) {
@@ -389,7 +391,11 @@ List<MainTimelineEntry> buildMainTimelineEntries(
if (msg.parentId == null || _isBroadcastReply(msg))
MainTimelineEntry(
message: msg,
summary: _buildSummary(msg.id, childrenByParent),
summary: _buildSummary(
msg.id,
childrenByParent,
relaySummaries?[msg.id],
),
),
];
}
@@ -403,7 +409,16 @@ bool _isBroadcastReply(TimelineMessage message) {
ThreadSummary? _buildSummary(
String messageId,
Map<String, List<TimelineMessage>> childrenByParent,
ChannelWindowThreadSummary? relaySummary,
) {
if (relaySummary != null && relaySummary.replyCount > 0) {
return ThreadSummary(
threadHeadId: messageId,
replyCount: relaySummary.replyCount,
participantPubkeys: relaySummary.participantPubkeys.take(3).toList(),
);
}
final replies = childrenByParent[messageId];
if (replies == null || replies.isEmpty) return null;

View File

@@ -21,9 +21,17 @@ abstract final class EventKind {
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;
@@ -55,6 +63,29 @@ abstract final class EventKind {
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.
@@ -175,6 +206,9 @@ class NostrFilter {
/// 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,
@@ -184,6 +218,7 @@ class NostrFilter {
this.until,
this.search,
this.tags = const {},
this.extensions = const {},
});
/// Return a copy with an updated `since` value.
@@ -196,6 +231,7 @@ class NostrFilter {
until: until,
search: search,
tags: tags,
extensions: extensions,
);
Map<String, dynamic> toJson() {
@@ -208,6 +244,9 @@ class NostrFilter {
for (final entry in tags.entries) {
json[entry.key] = entry.value;
}
for (final entry in extensions.entries) {
json[entry.key] = entry.value;
}
return json;
}
}

View File

@@ -1,11 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../auth/auth.dart';
import 'nostr_models.dart';
import 'relay_client.dart';
import 'relay_provider.dart';
import 'relay_socket.dart';
@@ -59,6 +66,10 @@ class _BufferedEvent {
/// Manages websocket subscriptions, event batching, reconnection with replay,
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({http.Client? httpClient}) : _httpClient = httpClient;
final http.Client? _httpClient;
static const _baseReconnectDelayMs = 1000;
static const _maxReconnectDelayMs = 30000;
static const _eventBatchMs = 16;
@@ -99,6 +110,57 @@ class RelaySessionNotifier extends Notifier<SessionState> {
return const SessionState(status: SessionStatus.disconnected);
}
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
final config = ref.read(relayConfigProvider);
final url = Uri.parse(config.baseUrl).resolve('/query').toString();
final bodyBytes = utf8.encode(
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
);
final client = _httpClient ?? http.Client();
final shouldCloseClient = _httpClient == null;
final response = await client
.post(
Uri.parse(url),
headers: {
'Authorization': _buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(timeout)
.whenComplete(() {
if (shouldCloseClient) client.close();
});
if (response.statusCode < 200 || response.statusCode >= 300) {
throw RelayException(response.statusCode, response.body);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw const FormatException('relay returned malformed query response');
}
try {
return [
for (final eventJson in decoded)
if (eventJson is Map<String, dynamic>)
NostrEvent.fromJson(eventJson)
else
throw const FormatException('relay returned malformed query event'),
];
} catch (error) {
if (error is FormatException) rethrow;
throw FormatException('relay returned malformed query event: $error');
}
}
/// Fetch historical events matching [filter]. Sends REQ, collects events
/// until EOSE, then resolves. One-shot subscription.
Future<List<NostrEvent>> fetchHistory(
@@ -532,6 +594,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_recentDeliveryKeys.clear();
_socket?.dispose();
_socket = null;
_httpClient?.close();
}
}
@@ -539,3 +602,35 @@ final relaySessionProvider =
NotifierProvider<RelaySessionNotifier, SessionState>(
RelaySessionNotifier.new,
);
String _buildNip98AuthHeader({
required String method,
required String url,
required List<int> bodyBytes,
required String? nsec,
}) {
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot query relay: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final payloadHash = SHA256Digest()
.process(Uint8List.fromList(bodyBytes))
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final event = nostr.Event.from(
kind: 27235,
content: '',
tags: [
['u', url],
['method', method.toUpperCase()],
['payload', payloadHash],
['nonce', const Uuid().v4()],
],
secretKey: privkeyHex,
verify: false,
);
return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}';
}

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -34,15 +36,19 @@ void main() {
.read(channelMessagesProvider(_channelId))
.value!;
expect(messages.map((event) => event.id), ['history', 'live']);
// The auto-prefetch fires an extra fetchOlder because fewer than 15
// displayable events were loaded. The deduped result sets _reachedOldest.
expect(relaySession.operations, ['subscribe', 'fetch', 'fetch']);
expect(relaySession.operations, ['subscribe', 'query', 'fetch']);
expect(
relaySession.liveFilters.single.kinds,
EventKind.channelEventKinds,
);
expect(relaySession.liveFilters.single.tags['#h'], [_channelId]);
expect(relaySession.liveFilters.single.limit, 200);
expect(
relaySession.queryFilters.first.kinds,
EventKind.channelTimelineContentKinds,
);
expect(relaySession.queryFilters.first.tags['#h'], [_channelId]);
expect(relaySession.queryFilters.first.extensions['top_level'], isTrue);
expect(
relaySession.historyFilters.first.kinds,
EventKind.channelEventKinds,
@@ -64,7 +70,7 @@ void main() {
final messages = container.read(channelMessagesProvider(_channelId)).value!;
expect(messages.map((event) => event.id), ['history']);
expect(relaySession.operations, ['subscribe', 'fetch', 'fetch']);
expect(relaySession.operations, ['subscribe', 'query', 'fetch']);
});
test(
@@ -88,6 +94,45 @@ void main() {
expect(state.value?.map((event) => event.id), ['live']);
},
);
test('window pagination failures return false without exhausting', () async {
final relaySession = _RecordingRelaySessionNotifier(
queryResults: [
[
_event(id: 'head', createdAt: 20),
_bounds(hasMore: true, cursorCreatedAt: 20, cursorId: 'head'),
],
Exception('page failed'),
[
_event(id: 'older', createdAt: 10),
_bounds(dTag: '${_channelId.toLowerCase()}:20:head'),
],
],
);
final container = _buildContainer(relaySession);
addTearDown(container.dispose);
container.read(channelMessagesProvider(_channelId));
await relaySession.subscribed;
await _pumpEventQueue();
final notifier = container.read(
channelMessagesProvider(_channelId).notifier,
);
expect(notifier.reachedOldest, isFalse);
await expectLater(notifier.fetchOlder(), completion(isFalse));
expect(notifier.reachedOldest, isFalse);
await expectLater(notifier.fetchOlder(), completion(isTrue));
expect(notifier.reachedOldest, isTrue);
expect(
container
.read(channelMessagesProvider(_channelId))
.value
?.map((e) => e.id),
['older', 'head'],
);
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
@@ -112,6 +157,30 @@ NostrEvent _event({required String id, required int createdAt}) {
);
}
NostrEvent _bounds({
bool hasMore = false,
int? cursorCreatedAt,
String? cursorId,
String? dTag,
}) {
return NostrEvent(
id: 'bounds-$hasMore-${cursorId ?? dTag ?? 'none'}',
pubkey: 'relay',
createdAt: 0,
kind: EventKind.channelWindowBounds,
tags: [
['d', dTag ?? '${_channelId.toLowerCase()}:head'],
],
content: jsonEncode({
'has_more': hasMore,
'next_cursor': hasMore
? {'created_at': cursorCreatedAt, 'id': cursorId}
: null,
}),
sig: 'sig',
);
}
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
@@ -119,20 +188,38 @@ Future<void> _pumpEventQueue() async {
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
final bool failSubscribe;
final Queue<Object> _queryResults;
final List<String> operations = [];
final List<NostrFilter> liveFilters = [];
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> queryFilters = [];
final List<void Function(NostrEvent)> _listeners = [];
final Completer<void> _subscribed = Completer<void>();
final Completer<List<NostrEvent>> _history = Completer<List<NostrEvent>>();
_RecordingRelaySessionNotifier({this.failSubscribe = false});
_RecordingRelaySessionNotifier({
this.failSubscribe = false,
List<Object> queryResults = const [],
}) : _queryResults = Queue<Object>.of(queryResults);
Future<void> get subscribed => _subscribed.future;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
operations.add('query');
queryFilters.addAll(filters);
if (_queryResults.isEmpty) throw Exception('unsupported');
final result = _queryResults.removeFirst();
if (result is Exception) throw result;
return (result as List<NostrEvent>).toList();
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {

View File

@@ -0,0 +1,231 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_window.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
group('parseChannelWindowResponse', () {
test('requires exactly one matching bounds event', () {
expect(
() => parseChannelWindowResponse([_row('a')], _channelId, null),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(), _bounds(id: 'b2')],
_channelId,
null,
),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(dTag: 'wrong:head')],
_channelId,
null,
),
throwsException,
);
});
test('rejects hasMore and nextCursor disagreement', () {
expect(
() => parseChannelWindowResponse(
[
_row('a'),
_bounds(content: {'has_more': true, 'next_cursor': null}),
],
_channelId,
null,
),
throwsException,
);
});
test('attaches summaries and keeps overlays out of rows', () {
final page = parseChannelWindowResponse(
[
_row('root'),
_summary('root', replyCount: 3, participants: ['p1', 'p2']),
_reaction('reaction'),
_bounds(),
],
_channelId,
null,
);
expect(page.rows.map((row) => row.event.id), ['root']);
expect(page.rows.single.thread?.replyCount, 3);
expect(page.rows.single.thread?.participantPubkeys, ['p1', 'p2']);
expect(page.aux.map((event) => event.id), ['reaction']);
});
});
group('ChannelWindowStore', () {
test('rejects cursor interval and row order violations', () {
expect(
() => appendOlderChannelWindow(
replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('head', createdAt: 20)], hasMore: true),
),
_page(
startCursor: const ChannelPageCursor(
createdAt: 20,
eventId: 'head',
),
rows: [_row('newer', createdAt: 21)],
),
),
throwsException,
);
expect(
() => replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('b', createdAt: 9), _row('a', createdAt: 10)]),
),
throwsException,
);
});
test('rejects overlapping older pages and drops tail on head refresh', () {
final head = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10)], hasMore: true),
);
final withOlder = appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('z', createdAt: 9)],
),
);
expect(withOlder.pages, hasLength(2));
expect(
() => appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('a', createdAt: 9)],
),
),
throwsException,
);
final refreshed = replaceNewestChannelWindow(
withOlder,
_page(rows: [_row('b', createdAt: 11)]),
);
expect(refreshed.pages, hasLength(1));
expect(refreshed.pages.single.rows.single.event.id, 'b');
});
test('drops live rows at or older than oldest loaded boundary', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)]),
);
final ignored = mergeLiveChannelWindowEvent(
store,
_row('z', createdAt: 8),
isTimelineRow: true,
);
expect(identical(ignored, store), isTrue);
final merged = mergeLiveChannelWindowEvent(
store,
_row('live', createdAt: 11),
isTimelineRow: true,
);
expect(merged.liveOverlay.map((event) => event.id), ['live']);
});
});
}
const _channelId = 'ABCDEF';
ChannelWindowPage _page({
ChannelPageCursor? startCursor,
required List<NostrEvent> rows,
bool hasMore = false,
}) {
return ChannelWindowPage(
startCursor: startCursor,
rows: [for (final row in rows) ChannelWindowRow(event: row)],
aux: const [],
nextCursor: hasMore
? ChannelPageCursor(
createdAt: rows.last.createdAt,
eventId: rows.last.id,
)
: null,
hasMore: hasMore,
);
}
NostrEvent _row(String id, {int createdAt = 10}) => _event(
id: id,
createdAt: createdAt,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelId],
],
);
NostrEvent _reaction(String id) => _event(
id: id,
kind: EventKind.reaction,
tags: const [
['e', 'root'],
],
);
NostrEvent _summary(
String rootId, {
required int replyCount,
required List<String> participants,
}) => _event(
id: 'summary-$rootId',
kind: EventKind.channelThreadSummary,
tags: [
['e', rootId],
],
content: jsonEncode({
'reply_count': replyCount,
'descendant_count': replyCount,
'last_reply_at': 12,
'participants': participants,
}),
);
NostrEvent _bounds({
String id = 'bounds',
String? dTag,
Map<String, dynamic>? content,
}) => _event(
id: id,
kind: EventKind.channelWindowBounds,
tags: [
['d', dTag ?? '${_channelId.toLowerCase()}:head'],
],
content: jsonEncode(content ?? {'has_more': false, 'next_cursor': null}),
);
NostrEvent _event({
required String id,
int createdAt = 10,
required int kind,
List<List<String>> tags = const [],
String content = '',
}) {
return NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: 'sig',
);
}

View File

@@ -40,6 +40,32 @@ void main() {
});
});
test('NostrFilter serializes relay query extensions', () {
const filter = NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#h': ['channel-id'],
},
limit: 50,
extensions: {
'top_level': true,
'include_summaries': true,
'include_aux': true,
'before_id': 'cursor-id',
},
);
expect(filter.toJson(), {
'kinds': EventKind.channelTimelineContentKinds,
'limit': 50,
'#h': ['channel-id'],
'top_level': true,
'include_summaries': true,
'include_aux': true,
'before_id': 'cursor-id',
});
});
test('channel unread activity kinds exclude non-message updates', () {
expect(
EventKind.channelMessageEventKinds,

View File

@@ -1,7 +1,108 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('queryRelay sends NIP-98 auth over POST /query', () async {
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
http.Request? capturedRequest;
final client = http_testing.MockClient((request) async {
capturedRequest = request;
return http.Response('[]', 200);
});
final session = RelaySessionNotifier(httpClient: client);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(
baseUrl: 'https://relay.example/base',
nsec: nsec,
),
),
],
);
addTearDown(container.dispose);
const filter = NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#h': [_channelId],
},
limit: 50,
extensions: {
'top_level': true,
'include_summaries': true,
'include_aux': true,
},
);
await container.read(relaySessionProvider.notifier).queryRelay([filter]);
expect(capturedRequest, isNotNull);
expect(capturedRequest!.method, 'POST');
expect(capturedRequest!.url.toString(), 'https://relay.example/query');
expect(capturedRequest!.headers['Content-Type'], 'application/json');
expect(jsonDecode(capturedRequest!.body), [filter.toJson()]);
final authHeader = capturedRequest!.headers['Authorization'];
expect(authHeader, isNotNull);
expect(authHeader, startsWith('Nostr '));
final encoded = authHeader!.substring('Nostr '.length);
final decoded = utf8.decode(base64Url.decode(base64Url.normalize(encoded)));
final authEvent = jsonDecode(decoded) as Map<String, dynamic>;
final tags = (authEvent['tags'] as List<dynamic>)
.map((tag) => (tag as List<dynamic>).cast<String>())
.toList();
final payloadHash = SHA256Digest()
.process(utf8.encode(capturedRequest!.body))
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
expect(authEvent['kind'], 27235);
expect(authEvent['pubkey'], keychain.public);
expect(
tags,
anyElement(equals(<String>['u', 'https://relay.example/query'])),
);
expect(tags, anyElement(equals(<String>['method', 'POST'])));
expect(tags, anyElement(equals(<String>['payload', payloadHash])));
expect(tags.any((tag) => tag.length == 2 && tag[0] == 'nonce'), isTrue);
});
test('queryRelay rejects malformed event arrays', () async {
final keychain = nostr.Keys.generate();
final session = RelaySessionNotifier(
httpClient: http_testing.MockClient(
(_) async => http.Response('[{}]', 200),
),
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(
() => _FakeRelayConfigNotifier(
baseUrl: 'https://relay.example',
nsec: keychain.nsec,
),
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(relaySessionProvider.notifier).queryRelay(const []),
throwsA(isA<FormatException>()),
);
});
test('delivers the same live event to each matching subscription', () async {
final session = RelaySessionNotifier();
final firstEvents = <NostrEvent>[];
@@ -94,6 +195,18 @@ void main() {
const _channelId = '11111111-1111-4111-8111-111111111111';
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
final String _baseUrl;
final String? _nsec;
_FakeRelayConfigNotifier({required String baseUrl, required String? nsec})
: _baseUrl = baseUrl,
_nsec = nsec;
@override
RelayConfig build() => RelayConfig(baseUrl: _baseUrl, nsec: _nsec);
}
NostrEvent _event() {
return const NostrEvent(
id: 'event-1',