mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): mitigate message-post delay with optimistic rendering (#3037)
### What changed? Adds optimistic rendering for newly locally posted messages in channels and threads. - Messages appear immediately - Relay echoes and history are deduplicated by event ID - Rejected or timed-out publishes erase the optimistic rendering. The implementation covers reconnect and hydration races, channel-window and legacy WebSocket paths, thread-local overlays, and rapid concurrent sends. ### Why? This is a valuable partial mitigation for [BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the): senders no longer depend on receiving a relay echo before seeing their own post. It does not address the likely primary cause of stale channels. Mobile currently does not recover live subscriptions after a rate-limited relay `CLOSED`; that recovery is being handled separately. ### How is it tested? Full mobile suite: 676 passed, 1 skipped. Added regression coverage for optimistic insertion, authoritative deduplication, rollback, reconnect and hydration, thread replies, rapid and equal-time sends, never-echoed successful sends, and legacy WebSocket retirement. --------- Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
import 'channel_management_provider.dart';
|
import 'channel_management_provider.dart';
|
||||||
|
import 'pending_local_messages_provider.dart';
|
||||||
import 'channel_window.dart';
|
import 'channel_window.dart';
|
||||||
import 'thread_replies_provider.dart';
|
import 'thread_replies_provider.dart';
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
|||||||
|
|
||||||
final history = await _fetchNewestHistory(session);
|
final history = await _fetchNewestHistory(session);
|
||||||
if (!_isCurrentInit(initVersion)) return;
|
if (!_isCurrentInit(initVersion)) return;
|
||||||
|
_confirmLocalMessages(history.map((event) => event.id));
|
||||||
|
|
||||||
final existing = state.value ?? const <NostrEvent>[];
|
final existing = state.value ?? const <NostrEvent>[];
|
||||||
final existingIds = existing.map((event) => event.id).toSet();
|
final existingIds = existing.map((event) => event.id).toSet();
|
||||||
@@ -159,7 +161,13 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
void _handleLiveEvent(NostrEvent event) {
|
void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) {
|
||||||
|
// Reply ownership and its thread-local overlay must transition together.
|
||||||
|
// The authoritative thread query performs both confirmations after it
|
||||||
|
// contains the reply; a live echo only triggers that query below.
|
||||||
|
if (authoritative && event.threadReference.parentId == null) {
|
||||||
|
_confirmLocalMessages([event.id]);
|
||||||
|
}
|
||||||
if (_usingChannelWindow) {
|
if (_usingChannelWindow) {
|
||||||
_handleWindowLiveEvent(event);
|
_handleWindowLiveEvent(event);
|
||||||
} else {
|
} else {
|
||||||
@@ -223,19 +231,108 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _confirmLocalMessages(Iterable<String> eventIds) {
|
||||||
|
ref
|
||||||
|
.read(pendingLocalMessagesProvider(channelId).notifier)
|
||||||
|
.confirm(eventIds);
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isMembershipEvent(String content) {
|
static bool _isMembershipEvent(String content) {
|
||||||
return content.contains('member_joined') ||
|
return content.contains('member_joined') ||
|
||||||
content.contains('member_left') ||
|
content.contains('member_left') ||
|
||||||
content.contains('member_removed');
|
content.contains('member_removed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a just-signed outgoing message before the relay acknowledges it.
|
||||||
|
/// The live relay echo is deduplicated by event id.
|
||||||
|
void addLocalMessage(NostrEvent event) {
|
||||||
|
ref.read(pendingLocalMessagesProvider(channelId).notifier).add(event);
|
||||||
|
final thread = event.threadReference;
|
||||||
|
if (thread.parentId != null) {
|
||||||
|
final rootId = thread.rootId;
|
||||||
|
if (rootId == null) {
|
||||||
|
throw StateError('Reply ${event.id} has a parent but no thread root.');
|
||||||
|
}
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
threadLocalRepliesProvider(
|
||||||
|
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
|
||||||
|
).notifier,
|
||||||
|
)
|
||||||
|
.add(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
|
||||||
|
event.kind,
|
||||||
|
);
|
||||||
|
if (!_usingChannelWindow && isTimelineRow) {
|
||||||
|
_windowStore = mergeLiveChannelWindowEvent(
|
||||||
|
_windowStore,
|
||||||
|
event,
|
||||||
|
isTimelineRow: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_handleLiveEvent(event, authoritative: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases rollback ownership after the publish future succeeds. The
|
||||||
|
/// optimistic row (and any thread overlay) remains visible until relay data
|
||||||
|
/// replaces it, because OK and EVENT delivery are unordered.
|
||||||
|
void completeLocalMessage(String eventId) {
|
||||||
|
_confirmLocalMessages([eventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolls back a local message when its publish is rejected or times out.
|
||||||
|
void removeLocalMessage(String eventId) {
|
||||||
|
final pending = ref
|
||||||
|
.read(pendingLocalMessagesProvider(channelId).notifier)
|
||||||
|
.take(eventId);
|
||||||
|
if (pending == null) return;
|
||||||
|
|
||||||
|
final thread = pending.threadReference;
|
||||||
|
if (thread.parentId != null) {
|
||||||
|
final rootId = thread.rootId;
|
||||||
|
if (rootId == null) {
|
||||||
|
throw StateError('Reply $eventId has a parent but no thread root.');
|
||||||
|
}
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
threadLocalRepliesProvider(
|
||||||
|
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
|
||||||
|
).notifier,
|
||||||
|
)
|
||||||
|
.remove(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextOverlay = _windowStore.liveOverlay
|
||||||
|
.where((event) => event.id != eventId)
|
||||||
|
.toList();
|
||||||
|
if (nextOverlay.length != _windowStore.liveOverlay.length) {
|
||||||
|
_windowStore = ChannelWindowStore(
|
||||||
|
pages: _windowStore.pages,
|
||||||
|
liveOverlay: nextOverlay,
|
||||||
|
liveAux: _windowStore.liveAux,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
|
||||||
|
final next = current.where((event) => event.id != eventId).toList();
|
||||||
|
_lastKnownMessages = next;
|
||||||
|
state = AsyncData(next);
|
||||||
|
}
|
||||||
|
|
||||||
static List<NostrEvent> _mergeEvent(
|
static List<NostrEvent> _mergeEvent(
|
||||||
List<NostrEvent> current,
|
List<NostrEvent> current,
|
||||||
NostrEvent incoming,
|
NostrEvent incoming,
|
||||||
) {
|
) {
|
||||||
if (current.any((e) => e.id == incoming.id)) return current;
|
if (current.any((e) => e.id == incoming.id)) return current;
|
||||||
final updated = [...current, incoming];
|
final updated = [...current, incoming];
|
||||||
updated.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
updated.sort((a, b) {
|
||||||
|
final createdAt = a.createdAt.compareTo(b.createdAt);
|
||||||
|
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../shared/relay/relay.dart';
|
||||||
|
|
||||||
|
/// Signed local messages whose publish has not yet been corroborated by an
|
||||||
|
/// authoritative relay EVENT or query result.
|
||||||
|
class PendingLocalMessagesNotifier extends Notifier<Map<String, NostrEvent>> {
|
||||||
|
final String channelId;
|
||||||
|
|
||||||
|
PendingLocalMessagesNotifier(this.channelId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, NostrEvent> build() => const {};
|
||||||
|
|
||||||
|
void add(NostrEvent event) {
|
||||||
|
state = {...state, event.id: event};
|
||||||
|
}
|
||||||
|
|
||||||
|
NostrEvent? take(String eventId) {
|
||||||
|
final event = state[eventId];
|
||||||
|
if (event == null) return null;
|
||||||
|
final next = {...state}..remove(eventId);
|
||||||
|
state = next;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirm(Iterable<String> eventIds) {
|
||||||
|
final confirmed = eventIds.toSet();
|
||||||
|
if (!state.keys.any(confirmed.contains)) return;
|
||||||
|
state = {
|
||||||
|
for (final entry in state.entries)
|
||||||
|
if (!confirmed.contains(entry.key)) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingLocalMessagesProvider =
|
||||||
|
NotifierProvider.family<
|
||||||
|
PendingLocalMessagesNotifier,
|
||||||
|
Map<String, NostrEvent>,
|
||||||
|
String
|
||||||
|
>(PendingLocalMessagesNotifier.new);
|
||||||
@@ -4,6 +4,7 @@ import '../../shared/relay/relay.dart';
|
|||||||
import '../channels/channel_management_provider.dart';
|
import '../channels/channel_management_provider.dart';
|
||||||
import '../profile/user_cache_provider.dart';
|
import '../profile/user_cache_provider.dart';
|
||||||
import '../profile/user_profile.dart';
|
import '../profile/user_profile.dart';
|
||||||
|
import 'channel_messages_provider.dart';
|
||||||
|
|
||||||
/// Sends messages by signing an event with the user's nsec and publishing it
|
/// Sends messages by signing an event with the user's nsec and publishing it
|
||||||
/// over the relay's NIP-42-authenticated WebSocket session.
|
/// over the relay's NIP-42-authenticated WebSocket session.
|
||||||
@@ -11,15 +12,25 @@ class SendMessage {
|
|||||||
final SignedEventRelay _signedEventRelay;
|
final SignedEventRelay _signedEventRelay;
|
||||||
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
|
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
|
||||||
final Map<String, UserProfile> Function() _readUserCache;
|
final Map<String, UserProfile> Function() _readUserCache;
|
||||||
|
final void Function(String channelId, NostrEvent event) _addLocalMessage;
|
||||||
|
final void Function(String channelId, String eventId) _completeLocalMessage;
|
||||||
|
final void Function(String channelId, String eventId) _removeLocalMessage;
|
||||||
|
|
||||||
SendMessage({
|
SendMessage({
|
||||||
required SignedEventRelay signedEventRelay,
|
required SignedEventRelay signedEventRelay,
|
||||||
required Future<List<ChannelMember>> Function(String channelId)
|
required Future<List<ChannelMember>> Function(String channelId)
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
required Map<String, UserProfile> Function() readUserCache,
|
required Map<String, UserProfile> Function() readUserCache,
|
||||||
|
required void Function(String channelId, NostrEvent event) addLocalMessage,
|
||||||
|
required void Function(String channelId, String eventId)
|
||||||
|
completeLocalMessage,
|
||||||
|
required void Function(String channelId, String eventId) removeLocalMessage,
|
||||||
}) : _signedEventRelay = signedEventRelay,
|
}) : _signedEventRelay = signedEventRelay,
|
||||||
_fetchMembers = fetchMembers,
|
_fetchMembers = fetchMembers,
|
||||||
_readUserCache = readUserCache;
|
_readUserCache = readUserCache,
|
||||||
|
_addLocalMessage = addLocalMessage,
|
||||||
|
_completeLocalMessage = completeLocalMessage,
|
||||||
|
_removeLocalMessage = removeLocalMessage;
|
||||||
|
|
||||||
/// Send a text message to a channel.
|
/// Send a text message to a channel.
|
||||||
///
|
///
|
||||||
@@ -58,11 +69,24 @@ class SendMessage {
|
|||||||
...mediaTags,
|
...mediaTags,
|
||||||
];
|
];
|
||||||
|
|
||||||
await _signedEventRelay.submit(
|
NostrEvent? localMessage;
|
||||||
kind: EventKind.streamMessage,
|
try {
|
||||||
content: content,
|
await _signedEventRelay.submit(
|
||||||
tags: tags,
|
kind: EventKind.streamMessage,
|
||||||
);
|
content: content,
|
||||||
|
tags: tags,
|
||||||
|
onSigned: (event) {
|
||||||
|
localMessage = event;
|
||||||
|
_addLocalMessage(channelId, event);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final event = localMessage;
|
||||||
|
if (event != null) _completeLocalMessage(channelId, event.id);
|
||||||
|
} catch (_) {
|
||||||
|
final event = localMessage;
|
||||||
|
if (event != null) _removeLocalMessage(channelId, event.id);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve @mentions to pubkeys, scoped to channel members.
|
/// Resolve @mentions to pubkeys, scoped to channel members.
|
||||||
@@ -146,5 +170,14 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
|
|||||||
fetchMembers: (channelId) =>
|
fetchMembers: (channelId) =>
|
||||||
ref.read(channelMembersProvider(channelId).future),
|
ref.read(channelMembersProvider(channelId).future),
|
||||||
readUserCache: () => ref.read(userCacheProvider),
|
readUserCache: () => ref.read(userCacheProvider),
|
||||||
|
addLocalMessage: (channelId, event) => ref
|
||||||
|
.read(channelMessagesProvider(channelId).notifier)
|
||||||
|
.addLocalMessage(event),
|
||||||
|
completeLocalMessage: (channelId, eventId) => ref
|
||||||
|
.read(channelMessagesProvider(channelId).notifier)
|
||||||
|
.completeLocalMessage(eventId),
|
||||||
|
removeLocalMessage: (channelId, eventId) => ref
|
||||||
|
.read(channelMessagesProvider(channelId).notifier)
|
||||||
|
.removeLocalMessage(eventId),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ThreadDetailPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final repliesState = ref.watch(
|
final repliesState = ref.watch(
|
||||||
threadRepliesProvider(
|
threadRepliesWithLocalProvider(
|
||||||
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
|
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../shared/relay/relay.dart';
|
import '../../shared/relay/relay.dart';
|
||||||
|
import 'pending_local_messages_provider.dart';
|
||||||
|
|
||||||
class ThreadRepliesArgs {
|
class ThreadRepliesArgs {
|
||||||
final String channelId;
|
final String channelId;
|
||||||
@@ -64,3 +67,77 @@ NostrFilter _threadRepliesFilter(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ThreadLocalRepliesNotifier extends Notifier<List<NostrEvent>> {
|
||||||
|
final ThreadRepliesArgs args;
|
||||||
|
|
||||||
|
ThreadLocalRepliesNotifier(this.args);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<NostrEvent> build() => const [];
|
||||||
|
|
||||||
|
void add(NostrEvent event) {
|
||||||
|
state = _mergeReplies(state, [event]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String eventId) {
|
||||||
|
state = state.where((event) => event.id != eventId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirm(Set<String> eventIds) {
|
||||||
|
if (!state.any((event) => eventIds.contains(event.id))) return;
|
||||||
|
state = state.where((event) => !eventIds.contains(event.id)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final threadLocalRepliesProvider =
|
||||||
|
NotifierProvider.family<
|
||||||
|
ThreadLocalRepliesNotifier,
|
||||||
|
List<NostrEvent>,
|
||||||
|
ThreadRepliesArgs
|
||||||
|
>(ThreadLocalRepliesNotifier.new);
|
||||||
|
|
||||||
|
/// Relay-backed replies merged with signed local replies that are still
|
||||||
|
/// waiting for acknowledgement.
|
||||||
|
final threadRepliesWithLocalProvider =
|
||||||
|
Provider.family<AsyncValue<List<NostrEvent>>, ThreadRepliesArgs>((
|
||||||
|
ref,
|
||||||
|
args,
|
||||||
|
) {
|
||||||
|
final relayReplies = ref.watch(threadRepliesProvider(args));
|
||||||
|
final localReplies = ref.watch(threadLocalRepliesProvider(args));
|
||||||
|
final authoritative = relayReplies.value;
|
||||||
|
if (authoritative != null && localReplies.isNotEmpty) {
|
||||||
|
final authoritativeIds = authoritative.map((event) => event.id).toSet();
|
||||||
|
if (localReplies.any((event) => authoritativeIds.contains(event.id))) {
|
||||||
|
Future.microtask(() {
|
||||||
|
ref
|
||||||
|
.read(threadLocalRepliesProvider(args).notifier)
|
||||||
|
.confirm(authoritativeIds);
|
||||||
|
ref
|
||||||
|
.read(pendingLocalMessagesProvider(args.channelId).notifier)
|
||||||
|
.confirm(authoritativeIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localReplies.isEmpty) return relayReplies;
|
||||||
|
return relayReplies.when(
|
||||||
|
data: (events) => AsyncData(_mergeReplies(events, localReplies)),
|
||||||
|
loading: () => AsyncData(localReplies),
|
||||||
|
error: (error, stackTrace) => AsyncData(localReplies),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<NostrEvent> _mergeReplies(
|
||||||
|
Iterable<NostrEvent> first,
|
||||||
|
Iterable<NostrEvent> second,
|
||||||
|
) {
|
||||||
|
final byId = <String, NostrEvent>{};
|
||||||
|
for (final event in [...first, ...second]) {
|
||||||
|
byId[event.id] = event;
|
||||||
|
}
|
||||||
|
return byId.values.toList()..sort((a, b) {
|
||||||
|
final createdAt = a.createdAt.compareTo(b.createdAt);
|
||||||
|
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class SignedEventRelay {
|
|||||||
required String content,
|
required String content,
|
||||||
required List<List<String>> tags,
|
required List<List<String>> tags,
|
||||||
int? createdAt,
|
int? createdAt,
|
||||||
|
void Function(NostrEvent event)? onSigned,
|
||||||
}) async {
|
}) async {
|
||||||
final nsec = _nsec;
|
final nsec = _nsec;
|
||||||
if (nsec == null || nsec.isEmpty) {
|
if (nsec == null || nsec.isEmpty) {
|
||||||
@@ -52,6 +53,7 @@ class SignedEventRelay {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final nostrEvent = NostrEvent.fromJson(event.toMap());
|
final nostrEvent = NostrEvent.fromJson(event.toMap());
|
||||||
|
onSigned?.call(nostrEvent);
|
||||||
return _session.publish(nostrEvent);
|
return _session.publish(nostrEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
|
||||||
|
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||||
import 'package:buzz/shared/relay/relay.dart';
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -130,6 +132,369 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'adds and rolls back a local message in the websocket timeline',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier();
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['local'],
|
||||||
|
);
|
||||||
|
|
||||||
|
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
|
||||||
|
// The initial history merge must retain a local row even if the relay's
|
||||||
|
// history snapshot was taken before that outgoing event was durable.
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history', 'local'],
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.removeLocalMessage('local');
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'legacy websocket echo retires ownership without duplicating the row',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier();
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
final local = _event(id: 'local', createdAt: 20);
|
||||||
|
notifier.addLocalMessage(local);
|
||||||
|
|
||||||
|
relaySession.emit(local);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['local'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('adds and rolls back a local message in the channel window', () async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history', 'local'],
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.removeLocalMessage('local');
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconnect hydration cannot retain a rolled-back local row', () async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
|
||||||
|
|
||||||
|
relaySession.setConnected(false);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
relaySession.setConnected(true);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history', 'local'],
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.removeLocalMessage('local');
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'thread replies are inserted, deduped, and rolled back locally',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
<NostrEvent>[],
|
||||||
|
[
|
||||||
|
_event(
|
||||||
|
id: 'reply',
|
||||||
|
createdAt: 20,
|
||||||
|
extraTags: const [
|
||||||
|
['e', 'root', '', 'reply'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
|
||||||
|
container.read(threadRepliesWithLocalProvider(args));
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
final reply = _event(
|
||||||
|
id: 'reply',
|
||||||
|
createdAt: 20,
|
||||||
|
extraTags: const [
|
||||||
|
['e', 'root', '', 'reply'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
notifier.addLocalMessage(reply);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(threadRepliesWithLocalProvider(args))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['reply'],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history'],
|
||||||
|
);
|
||||||
|
|
||||||
|
relaySession.emit(reply);
|
||||||
|
await container.read(threadRepliesProvider(args).future);
|
||||||
|
container.read(threadRepliesWithLocalProvider(args));
|
||||||
|
await _pumpEventQueue();
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(threadRepliesWithLocalProvider(args))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['reply'],
|
||||||
|
);
|
||||||
|
expect(container.read(threadLocalRepliesProvider(args)), isEmpty);
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
|
||||||
|
|
||||||
|
final rejected = _event(
|
||||||
|
id: 'rejected',
|
||||||
|
createdAt: 21,
|
||||||
|
extraTags: const [
|
||||||
|
['e', 'root', '', 'reply'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
notifier.addLocalMessage(rejected);
|
||||||
|
notifier.removeLocalMessage('rejected');
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(threadRepliesWithLocalProvider(args))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['reply'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'thread live echo keeps ownership until the authoritative refetch succeeds',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
<NostrEvent>[],
|
||||||
|
Exception('thread refetch failed'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
|
||||||
|
container.read(threadRepliesWithLocalProvider(args));
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
final reply = _event(
|
||||||
|
id: 'reply',
|
||||||
|
createdAt: 20,
|
||||||
|
extraTags: const [
|
||||||
|
['e', 'root', '', 'reply'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
notifier.addLocalMessage(reply);
|
||||||
|
|
||||||
|
relaySession.emit(reply);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [
|
||||||
|
'reply',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(threadLocalRepliesProvider(args))
|
||||||
|
.map((event) => event.id),
|
||||||
|
['reply'],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(threadRepliesWithLocalProvider(args))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['reply'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'successful never-echoed send releases ownership but keeps its row across reconnect',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
notifier.addLocalMessage(_event(id: 'local', createdAt: 20));
|
||||||
|
notifier.completeLocalMessage('local');
|
||||||
|
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
|
||||||
|
relaySession.setConnected(false);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
relaySession.setConnected(true);
|
||||||
|
await _pumpEventQueue();
|
||||||
|
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history', 'local'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'window dedupes echoes and orders rapid equal-time local sends',
|
||||||
|
() async {
|
||||||
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
|
queryResults: [
|
||||||
|
[_event(id: 'history', createdAt: 10), _bounds()],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final container = _buildContainer(relaySession);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
container.read(channelMessagesProvider(_channelId));
|
||||||
|
await relaySession.subscribed;
|
||||||
|
await _pumpEventQueue();
|
||||||
|
final notifier = container.read(
|
||||||
|
channelMessagesProvider(_channelId).notifier,
|
||||||
|
);
|
||||||
|
notifier.addLocalMessage(_event(id: 'z-local', createdAt: 20));
|
||||||
|
notifier.addLocalMessage(_event(id: 'a-local', createdAt: 20));
|
||||||
|
relaySession.emit(_event(id: 'z-local', createdAt: 20));
|
||||||
|
await _pumpEventQueue();
|
||||||
|
|
||||||
|
expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [
|
||||||
|
'a-local',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(channelMessagesProvider(_channelId))
|
||||||
|
.value
|
||||||
|
?.map((event) => event.id),
|
||||||
|
['history', 'z-local', 'a-local'],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('window pagination failures return false without exhausting', () async {
|
test('window pagination failures return false without exhausting', () async {
|
||||||
final relaySession = _RecordingRelaySessionNotifier(
|
final relaySession = _RecordingRelaySessionNotifier(
|
||||||
queryResults: [
|
queryResults: [
|
||||||
@@ -178,14 +543,19 @@ ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
NostrEvent _event({required String id, required int createdAt}) {
|
NostrEvent _event({
|
||||||
|
required String id,
|
||||||
|
required int createdAt,
|
||||||
|
List<List<String>> extraTags = const [],
|
||||||
|
}) {
|
||||||
return NostrEvent(
|
return NostrEvent(
|
||||||
id: id,
|
id: id,
|
||||||
pubkey: 'alice',
|
pubkey: 'alice',
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
kind: EventKind.streamMessageV2,
|
kind: EventKind.streamMessageV2,
|
||||||
tags: const [
|
tags: [
|
||||||
['h', _channelId],
|
['h', _channelId],
|
||||||
|
...extraTags,
|
||||||
],
|
],
|
||||||
content: id,
|
content: id,
|
||||||
sig: 'sig',
|
sig: 'sig',
|
||||||
@@ -243,6 +613,12 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
|||||||
@override
|
@override
|
||||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||||
|
|
||||||
|
void setConnected(bool connected) {
|
||||||
|
state = SessionState(
|
||||||
|
status: connected ? SessionStatus.connected : SessionStatus.disconnected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<NostrEvent>> queryRelay(
|
Future<List<NostrEvent>> queryRelay(
|
||||||
List<NostrFilter> filters, {
|
List<NostrFilter> filters, {
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ class _FakeSignedEventRelay implements SignedEventRelay {
|
|||||||
required String content,
|
required String content,
|
||||||
required List<List<String>> tags,
|
required List<List<String>> tags,
|
||||||
int? createdAt,
|
int? createdAt,
|
||||||
|
void Function(NostrEvent event)? onSigned,
|
||||||
}) async {
|
}) async {
|
||||||
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
|
submitted.complete(_SubmittedEvent(kind: kind, tags: tags));
|
||||||
return _stubAckEvent();
|
return _stubAckEvent();
|
||||||
@@ -199,6 +200,7 @@ class _UnsupportedKindSignedEventRelay implements SignedEventRelay {
|
|||||||
required String content,
|
required String content,
|
||||||
required List<List<String>> tags,
|
required List<List<String>> tags,
|
||||||
int? createdAt,
|
int? createdAt,
|
||||||
|
void Function(NostrEvent event)? onSigned,
|
||||||
}) async {
|
}) async {
|
||||||
submitCount++;
|
submitCount++;
|
||||||
throw Exception('restricted: unknown event kind');
|
throw Exception('restricted: unknown event kind');
|
||||||
@@ -217,6 +219,7 @@ class _MissingScopeSignedEventRelay implements SignedEventRelay {
|
|||||||
required String content,
|
required String content,
|
||||||
required List<List<String>> tags,
|
required List<List<String>> tags,
|
||||||
int? createdAt,
|
int? createdAt,
|
||||||
|
void Function(NostrEvent event)? onSigned,
|
||||||
}) async {
|
}) async {
|
||||||
submitCount++;
|
submitCount++;
|
||||||
throw Exception('missing users:write');
|
throw Exception('missing users:write');
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:nostr/nostr.dart' as nostr;
|
||||||
|
import 'package:buzz/features/channels/send_message_provider.dart';
|
||||||
|
import 'package:buzz/shared/relay/relay.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'adds the signed message locally before relay acknowledgement',
|
||||||
|
() async {
|
||||||
|
final session = _PendingPublishRelaySession();
|
||||||
|
final localMessages = <NostrEvent>[];
|
||||||
|
final removedIds = <String>[];
|
||||||
|
final completedIds = <String>[];
|
||||||
|
final send = SendMessage(
|
||||||
|
signedEventRelay: SignedEventRelay(
|
||||||
|
session: session,
|
||||||
|
nsec: nostr.Keys.generate().nsec,
|
||||||
|
),
|
||||||
|
fetchMembers: (_) async => const [],
|
||||||
|
readUserCache: () => const {},
|
||||||
|
addLocalMessage: (_, event) => localMessages.add(event),
|
||||||
|
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
|
||||||
|
removeLocalMessage: (_, eventId) => removedIds.add(eventId),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = send(channelId: _channelId, content: 'hello');
|
||||||
|
await session.published;
|
||||||
|
|
||||||
|
expect(localMessages, hasLength(1));
|
||||||
|
expect(localMessages.single.id, session.event.id);
|
||||||
|
expect(localMessages.single.content, 'hello');
|
||||||
|
expect(localMessages.single.channelId, _channelId);
|
||||||
|
expect(removedIds, isEmpty);
|
||||||
|
|
||||||
|
session.accept();
|
||||||
|
await result;
|
||||||
|
expect(completedIds, [localMessages.single.id]);
|
||||||
|
expect(removedIds, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('rolls back the signed local message when publish fails', () async {
|
||||||
|
final session = _PendingPublishRelaySession();
|
||||||
|
final localMessages = <NostrEvent>[];
|
||||||
|
final completedIds = <String>[];
|
||||||
|
final removedIds = <String>[];
|
||||||
|
final send = SendMessage(
|
||||||
|
signedEventRelay: SignedEventRelay(
|
||||||
|
session: session,
|
||||||
|
nsec: nostr.Keys.generate().nsec,
|
||||||
|
),
|
||||||
|
fetchMembers: (_) async => const [],
|
||||||
|
readUserCache: () => const {},
|
||||||
|
addLocalMessage: (_, event) => localMessages.add(event),
|
||||||
|
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
|
||||||
|
removeLocalMessage: (_, eventId) => removedIds.add(eventId),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = send(channelId: _channelId, content: 'hello');
|
||||||
|
await session.published;
|
||||||
|
session.reject();
|
||||||
|
|
||||||
|
await expectLater(result, throwsException);
|
||||||
|
expect(completedIds, isEmpty);
|
||||||
|
expect(removedIds, [localMessages.single.id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const _channelId = '11111111-1111-4111-8111-111111111111';
|
||||||
|
|
||||||
|
class _PendingPublishRelaySession extends RelaySessionNotifier {
|
||||||
|
final Completer<NostrEvent> _result = Completer<NostrEvent>();
|
||||||
|
final Completer<void> _published = Completer<void>();
|
||||||
|
late NostrEvent event;
|
||||||
|
|
||||||
|
Future<void> get published => _published.future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NostrEvent> publish(
|
||||||
|
NostrEvent event, {
|
||||||
|
Duration timeout = const Duration(seconds: 8),
|
||||||
|
}) {
|
||||||
|
this.event = event;
|
||||||
|
_published.complete();
|
||||||
|
return _result.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void accept() => _result.complete(event);
|
||||||
|
|
||||||
|
void reject() => _result.completeError(Exception('relay rejected event'));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user