mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
335
mobile/lib/features/channels/channel_window.dart
Normal file
335
mobile/lib/features/channels/channel_window.dart
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
66
mobile/lib/features/channels/thread_replies_provider.dart
Normal file
66
mobile/lib/features/channels/thread_replies_provider.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))}';
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
231
mobile/test/features/channels/channel_window_test.dart
Normal file
231
mobile/test/features/channels/channel_window_test.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user