mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Summary One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single `ProviderScope` and doesn't remount on a community switch, so one community's data showed up in another, and some of it was published to the other community's relay. - **Channel groups**: the second community showed the first community's groups. - **Read marks** (contexts, publishable set, NIP-RS client and slot IDs): the first community's context IDs were merged into, and published to, the second community's read-state slot. - **Inbox done/unread rows and thread follows**: one shared 500-entry cap, so activity in one community evicted another's entries. - **Channel stars and mutes**: union-merged across communities and published to every relay. Every one of these is now keyed by pubkey + normalized community relay, the same way channel sort already is. **Switching communities.** The relay session rebuilds for the new community before the previous community's managers are retired. Each manager (groups, read state, stars, mutes) now checks that the session still serves its own community before it publishes, merges a fetch, or handles a live event. Previously, a pending publish flushed on dispose, or an in-flight fetch, could cross into the next community. **Old pubkey-only entries.** Channel IDs can repeat across communities, so nothing tells us which community an old channel-keyed entry belongs to: - **Groups, stars, mutes and read marks** are dropped on upgrade, and each community restores its own from its relay. Any change that never reached a relay is lost, and a read mark the relay doesn't hold shows as unread once. That's an accepted tradeoff: such changes would be very old by now. - **Inbox, thread follows, and read-state slot and client IDs** seed every community, and the old entry is kept. The inbox and follows entries are event IDs, which only ever match their own community. The slot ID keeps each community publishing to the slot it already has on its relay. This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr filters only it used). Nothing in the app navigates to it anymore, and its providers were among the community-unscoped caches. ### Related issue None found. ### Testing - Each affected store has a regression test through the real provider that switches A → B → A. For groups, read marks, stars and mutes, the test sets a value in A, switches to B while A's change is still pending, and checks two cases: B's data arriving live and B answering A's in-flight fetch. It asserts that nothing sent to B contains A's IDs, B shows only its own value, and A comes back unchanged. Each guard was removed in turn to confirm a test fails without it, except two that the current switch order never reaches: the groups merge guard and the read-state live-event guard. - Upgrade tests cover each kind of old entry: dropped for groups, stars, mutes and read marks, seeded for inbox, follows and slot IDs. - `flutter analyze` is clean, and the full mobile suite passes in the pre-push hook at `89ebfcc12`. - Not yet exercised in the app. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
6.2 KiB
Dart
204 lines
6.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:buzz/shared/crypto/nip44.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// A scripted relay behind the real [RelaySessionNotifier], so REQ/EOSE,
|
|
/// CLOSED classification, event batching and OK all run production code.
|
|
/// Retains replaceable heads the way the relay does: `created_at DESC, id ASC`.
|
|
class SidebarRelay {
|
|
SidebarRelay() {
|
|
session.debugAttachSocketForTest(_ScriptedSocket(_onSend));
|
|
}
|
|
|
|
final keys = nostr.Keys.generate();
|
|
final session = ConnectedRelaySession();
|
|
late final signer = SignedEventRelay(session: session, nsec: keys.nsec);
|
|
final _storedByRelay = <String?, List<NostrEvent>>{};
|
|
|
|
/// Heads retained by the relay [session] currently serves, so a community
|
|
/// switch reaches a different relay's store.
|
|
List<NostrEvent> get stored =>
|
|
_storedByRelay.putIfAbsent(session.relayUrl, () => <NostrEvent>[]);
|
|
final published = <NostrEvent>[];
|
|
|
|
/// The relay [session] served when each [published] event was sent.
|
|
final publishedTo = <String?>[];
|
|
final reqs = <List<dynamic>>[];
|
|
final _liveSubIds = <String?, String>{};
|
|
int historyFailures = 0;
|
|
Completer<void>? holdHistory;
|
|
Completer<void>? holdOk;
|
|
String? rejectLive;
|
|
bool withholdEose = false;
|
|
|
|
String get pubkey => keys.public;
|
|
|
|
NostrEvent event(
|
|
String dTag,
|
|
Object payload,
|
|
int createdAt, {
|
|
String? id,
|
|
List<List<String>> tags = const [],
|
|
}) => NostrEvent(
|
|
id: id ?? 'e$createdAt'.padRight(64, 'f'),
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: EventKind.readState,
|
|
tags: [
|
|
['d', dTag],
|
|
...tags,
|
|
],
|
|
content: nip44Encrypt(_key, jsonEncode(payload)),
|
|
sig: 'sig',
|
|
);
|
|
|
|
Object? decrypt(NostrEvent event) =>
|
|
jsonDecode(nip44Decrypt(_key, event.content));
|
|
|
|
late final _key = getConversationKey(
|
|
nostr.Nip19.decode(payload: keys.nsec).data,
|
|
keys.public,
|
|
);
|
|
|
|
List<List<dynamic>> reqsFor(String dTag, String prefix) => [
|
|
for (final req in reqs)
|
|
if ((req[1] as String).startsWith(prefix) && _dTagOf(req) == dTag) req,
|
|
];
|
|
|
|
/// Delivers [event] on the live subscription for its `d` tag, or else on
|
|
/// the one filtered by `#t` alone.
|
|
void emit(NostrEvent event) => session.debugHandleMessage([
|
|
'EVENT',
|
|
_liveSubIds[event.getTagValue('d')] ?? _liveSubIds[null],
|
|
event.toJson(),
|
|
]);
|
|
|
|
void closeLive(String dTag, String message) =>
|
|
session.debugHandleMessage(['CLOSED', _liveSubIds[dTag], message]);
|
|
|
|
void _onSend(List<dynamic> message) {
|
|
if (message.first == 'EVENT') {
|
|
final event = NostrEvent.fromJson(message[1] as Map<String, dynamic>);
|
|
published.add(event);
|
|
publishedTo.add(session.relayUrl);
|
|
stored.add(event);
|
|
final ok = ['OK', event.id, true, ''];
|
|
final hold = holdOk;
|
|
hold == null
|
|
? scheduleMicrotask(() => _reply(ok))
|
|
: hold.future.then((_) => _reply(ok));
|
|
}
|
|
if (message.first != 'REQ') return;
|
|
reqs.add(message);
|
|
final subId = message[1] as String;
|
|
if (subId.startsWith('h-')) {
|
|
unawaited(_answerHistory(subId, _dTagOf(message)));
|
|
return;
|
|
}
|
|
_liveSubIds[_dTagOf(message)] = subId;
|
|
final rejection = rejectLive;
|
|
if (rejection == null && withholdEose) return;
|
|
scheduleMicrotask(
|
|
() => _reply(
|
|
rejection == null ? ['EOSE', subId] : ['CLOSED', subId, rejection],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _answerHistory(String subId, String? dTag) async {
|
|
await holdHistory?.future;
|
|
if (historyFailures > 0) {
|
|
historyFailures--;
|
|
return _reply(['CLOSED', subId, 'error: overloaded']);
|
|
}
|
|
// A `#t` filter reaches every slot it tags; a `#d` filter, one head.
|
|
if (dTag == null) {
|
|
for (final e in stored.where((e) => e.getTagValue('t') != null)) {
|
|
_reply(['EVENT', subId, e.toJson()]);
|
|
}
|
|
return _reply(['EOSE', subId]);
|
|
}
|
|
final heads = stored.where((e) => e.getTagValue('d') == dTag).toList()
|
|
..sort(
|
|
(a, b) => a.createdAt != b.createdAt
|
|
? b.createdAt.compareTo(a.createdAt)
|
|
: a.id.compareTo(b.id),
|
|
);
|
|
if (heads.isNotEmpty) _reply(['EVENT', subId, heads.first.toJson()]);
|
|
_reply(['EOSE', subId]);
|
|
}
|
|
|
|
void _reply(List<dynamic> message) => session.debugHandleMessage(message);
|
|
|
|
/// Null for a filter by `#t` alone, such as read state's slot discovery.
|
|
static String? _dTagOf(List<dynamic> req) =>
|
|
((req[2] as Map<String, dynamic>)['#d'] as List?)?.single as String?;
|
|
}
|
|
|
|
class ConnectedRelaySession extends RelaySessionNotifier {
|
|
@override
|
|
SessionState build() {
|
|
// Follows the active community, as the production session does.
|
|
debugRelayUrl = ref.watch(relayConfigProvider).baseUrl;
|
|
return const SessionState(status: SessionStatus.connected);
|
|
}
|
|
}
|
|
|
|
class _ScriptedSocket extends RelaySocket {
|
|
_ScriptedSocket(this._send)
|
|
: super(
|
|
wsUrl: 'wss://relay.example',
|
|
nsec: null,
|
|
onMessage: (_) {},
|
|
onConnected: () {},
|
|
onDisconnected: (_) {},
|
|
);
|
|
|
|
final void Function(List<dynamic>) _send;
|
|
|
|
@override
|
|
void send(List<dynamic> payload) => _send(payload);
|
|
|
|
@override
|
|
void dispose() {}
|
|
}
|
|
|
|
/// Preferences whose next [failures] writes return false or throw [error].
|
|
class FlakyPrefs extends Fake implements SharedPreferences {
|
|
FlakyPrefs(this._inner);
|
|
|
|
final SharedPreferences _inner;
|
|
int failures = 0;
|
|
Object? error;
|
|
|
|
@override
|
|
String? getString(String key) => _inner.getString(key);
|
|
|
|
@override
|
|
Future<bool> remove(String key) => _inner.remove(key);
|
|
|
|
@override
|
|
Future<bool> setString(String key, String value) async {
|
|
if (failures == 0) return _inner.setString(key, value);
|
|
failures--;
|
|
if (error == null) return false;
|
|
throw error!;
|
|
}
|
|
}
|
|
|
|
Future<SharedPreferences> freshPrefs() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
return SharedPreferences.getInstance();
|
|
}
|
|
|
|
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
void fakeAsyncTest(String name, void Function(FakeAsync clock) body) =>
|
|
test(name, () => fakeAsync(body));
|