mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +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>
671 lines
20 KiB
Dart
671 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../crypto/nip44.dart';
|
|
import '../relay/relay.dart';
|
|
import 'read_state_format.dart';
|
|
import 'read_state_storage.dart';
|
|
import 'read_state_time.dart';
|
|
|
|
class ReadStateCrypto {
|
|
final Uint8List conversationKey;
|
|
|
|
const ReadStateCrypto._(this.conversationKey);
|
|
|
|
static ReadStateCrypto? tryCreate({
|
|
required String nsec,
|
|
required String pubkey,
|
|
}) {
|
|
try {
|
|
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
|
if (privkeyHex.isEmpty || pubkey.isEmpty) {
|
|
return null;
|
|
}
|
|
return ReadStateCrypto._(getConversationKey(privkeyHex, pubkey));
|
|
} catch (e) {
|
|
debugPrint('[ReadStateManager] crypto init failed: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext);
|
|
|
|
String decrypt(String ciphertext) =>
|
|
nip44Decrypt(conversationKey, ciphertext);
|
|
}
|
|
|
|
enum _ApplyRemoteContextResult { unchanged, advanced }
|
|
|
|
class ReadStateManager {
|
|
final String pubkey;
|
|
final String relayUrl;
|
|
final ReadStateCrypto _crypto;
|
|
final ReadStateStorage _storage;
|
|
final RelaySessionNotifier? _relaySession;
|
|
final SignedEventRelay? _signedEventRelay;
|
|
final bool _remoteEnabled;
|
|
final VoidCallback _onChanged;
|
|
|
|
late final String _clientId;
|
|
late String _slotId;
|
|
|
|
final Map<String, int> _effectiveState = {};
|
|
final Set<String> _publishableContextIds = {};
|
|
// Override keys already in this device's own slot, carried unchanged.
|
|
// See [isOverrideContext].
|
|
final Map<String, int> _carriedOverrides = {};
|
|
Map<String, int> _lastPublishedContexts = {};
|
|
|
|
Timer? _debounceTimer;
|
|
void Function()? _unsubscribeLive;
|
|
bool _initialized = false;
|
|
bool _disposed = false;
|
|
bool _isPublishing = false;
|
|
// Set when a publish is asked for while one is running. The running
|
|
// publish took its snapshot first, so it publishes again when it ends.
|
|
bool _publishAgain = false;
|
|
Completer<void>? _publishCompleter;
|
|
bool _remoteUnsupported = false;
|
|
int _maxFetchedCreatedAt = 0;
|
|
final Map<String, int> _contextSourceCreatedAt = {};
|
|
final Set<String> _pendingSyncedAdvances = {};
|
|
|
|
ReadStateManager({
|
|
required this.pubkey,
|
|
required this.relayUrl,
|
|
required SharedPreferences prefs,
|
|
required ReadStateCrypto crypto,
|
|
required RelaySessionNotifier? relaySession,
|
|
required SignedEventRelay? signedEventRelay,
|
|
required bool remoteEnabled,
|
|
required VoidCallback onChanged,
|
|
}) : _crypto = crypto,
|
|
_storage = ReadStateStorage(prefs),
|
|
_relaySession = relaySession,
|
|
_signedEventRelay = signedEventRelay,
|
|
_remoteEnabled = remoteEnabled,
|
|
_onChanged = onChanged {
|
|
_clientId = _storage.getOrCreateClientId(pubkey, relayUrl);
|
|
_slotId = _storage.getOrCreateSlotId(pubkey, relayUrl);
|
|
_hydrateFromLocalStorage();
|
|
}
|
|
|
|
Map<String, int> get effectiveContexts => Map.unmodifiable(_effectiveState);
|
|
|
|
int? getEffectiveTimestamp(String contextId) => _effectiveState[contextId];
|
|
|
|
Future<void> initialize() async {
|
|
if (_initialized || _disposed) return;
|
|
_initialized = true;
|
|
debugPrint(
|
|
'[ReadStateManager] initialize pubkey=${pubkey.substring(0, 8)}… clientId=${_clientId.substring(0, 8)}… slotId=$_slotId',
|
|
);
|
|
|
|
if (!_remoteEnabled || _relaySession == null) {
|
|
_onChanged();
|
|
return;
|
|
}
|
|
|
|
await _fetchAndMerge();
|
|
await _startLiveSubscription();
|
|
if (!_isIdenticalToLastPublished(_currentContexts())) {
|
|
_schedulePublish();
|
|
}
|
|
|
|
_onChanged();
|
|
debugPrint(
|
|
'[ReadStateManager] initialize complete maxFetchedCreatedAt=$_maxFetchedCreatedAt contexts=${_effectiveState.length}',
|
|
);
|
|
}
|
|
|
|
void markContextRead(String contextId, int unixTimestamp) {
|
|
if (_disposed || isOverrideContext(contextId)) return;
|
|
// Set first: retention keeps the most recently written marks, so the
|
|
// save that follows must already see this read as the newest.
|
|
_contextSourceCreatedAt[contextId] = max(
|
|
currentUnixSeconds(),
|
|
_maxFetchedCreatedAt + 1,
|
|
);
|
|
_advanceContext(contextId, unixTimestamp, publishable: true);
|
|
}
|
|
|
|
void seedContextRead(String contextId, int unixTimestamp) {
|
|
_advanceContext(contextId, unixTimestamp, publishable: false);
|
|
}
|
|
|
|
Future<void> flush() async {
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = null;
|
|
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
|
|
await _publish();
|
|
}
|
|
|
|
Future<void> reinitializeRemote() async {
|
|
if (_disposed || !_remoteEnabled || !_initialized) return;
|
|
debugPrint('[ReadStateManager] reinitializeRemote');
|
|
if (_isPublishing) {
|
|
await _publishCompleter?.future;
|
|
}
|
|
_unsubscribeLive?.call();
|
|
_unsubscribeLive = null;
|
|
await _fetchAndMerge();
|
|
await _startLiveSubscription();
|
|
if (!_isIdenticalToLastPublished(_currentContexts())) {
|
|
_schedulePublish();
|
|
}
|
|
_onChanged();
|
|
}
|
|
|
|
void dispose({bool flushPending = true}) {
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
final hadPendingPublish = _debounceTimer != null;
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = null;
|
|
|
|
if (flushPending &&
|
|
hadPendingPublish &&
|
|
_remoteEnabled &&
|
|
!_remoteUnsupported) {
|
|
unawaited(_publish(allowDisposed: true));
|
|
}
|
|
|
|
_unsubscribeLive?.call();
|
|
_unsubscribeLive = null;
|
|
}
|
|
|
|
void _advanceContext(
|
|
String contextId,
|
|
int unixTimestamp, {
|
|
required bool publishable,
|
|
}) {
|
|
if (_disposed || unixTimestamp < 0) return;
|
|
|
|
final current = _effectiveState[contextId] ?? 0;
|
|
if (unixTimestamp <= current) {
|
|
if (!publishable || _publishableContextIds.contains(contextId)) {
|
|
return;
|
|
}
|
|
|
|
_publishableContextIds.add(contextId);
|
|
_persistLocalState();
|
|
_onChanged();
|
|
_schedulePublish();
|
|
return;
|
|
}
|
|
|
|
_effectiveState[contextId] = unixTimestamp;
|
|
if (publishable) {
|
|
_publishableContextIds.add(contextId);
|
|
}
|
|
_persistLocalState();
|
|
_onChanged();
|
|
if (publishable) {
|
|
_schedulePublish();
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchAndMerge() async {
|
|
try {
|
|
final events = await _relaySession!.fetchHistory(
|
|
NostrFilter(
|
|
kinds: const [EventKind.readState],
|
|
authors: [pubkey],
|
|
tags: const {
|
|
'#t': ['read-state'],
|
|
},
|
|
since: currentUnixSeconds() - readStateHorizonSeconds,
|
|
limit: readStateFetchLimit,
|
|
),
|
|
);
|
|
_mergeEvents(events);
|
|
_persistLocalState();
|
|
_onChanged();
|
|
} catch (e) {
|
|
debugPrint('[ReadStateManager] fetchAndMerge failed: $e');
|
|
}
|
|
}
|
|
|
|
void _mergeEvents(List<NostrEvent> events) {
|
|
// Another community's marks must never reach this community's state.
|
|
if (!_servesThisCommunity) return;
|
|
ReadStateBlob? ownBlob;
|
|
var ownBlobCreatedAt = 0;
|
|
|
|
for (final event in events) {
|
|
final decoded = decodeReadStateEvent(
|
|
event,
|
|
pubkey: pubkey,
|
|
decrypt: _crypto.decrypt,
|
|
);
|
|
if (decoded == null) continue;
|
|
|
|
if (_isPlausibleCreatedAt(event.createdAt)) {
|
|
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
|
|
}
|
|
|
|
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
|
|
decoded.blob.clientId != _clientId) {
|
|
_rotateSlotId();
|
|
}
|
|
|
|
for (final entry in decoded.blob.contexts.entries) {
|
|
if (isOverrideContext(entry.key)) continue;
|
|
final result = _applyRemoteContextTimestamp(
|
|
contextId: entry.key,
|
|
timestamp: entry.value,
|
|
eventCreatedAt: event.createdAt,
|
|
);
|
|
if (result == _ApplyRemoteContextResult.advanced) {
|
|
_pendingSyncedAdvances.add(entry.key);
|
|
if (republishesMergedContext(entry.key)) {
|
|
_publishableContextIds.add(entry.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (decoded.blob.clientId == _clientId &&
|
|
event.createdAt > ownBlobCreatedAt) {
|
|
ownBlob = decoded.blob;
|
|
ownBlobCreatedAt = event.createdAt;
|
|
}
|
|
}
|
|
|
|
if (ownBlob != null) {
|
|
_carryOwnOverrides(ownBlob.contexts);
|
|
_lastPublishedContexts = Map<String, int>.from(ownBlob.contexts);
|
|
_publishableContextIds.addAll(
|
|
ownBlob.contexts.keys.where((key) => !isOverrideContext(key)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Merges the complete override groups of this device's own slot into
|
|
/// [_carriedOverrides] by `max()`, the NIP-RS merge rule. An incomplete
|
|
/// group is rejected whole (see [completeOverrideGroups]).
|
|
bool _carryOwnOverrides(Map<String, int> contexts) {
|
|
var changed = false;
|
|
for (final entry in completeOverrideGroups(contexts).entries) {
|
|
if (entry.value > (_carriedOverrides[entry.key] ?? -1)) {
|
|
_carriedOverrides[entry.key] = entry.value;
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
Future<void> _startLiveSubscription() async {
|
|
try {
|
|
final unsub = await _relaySession!.subscribe(
|
|
NostrFilter(
|
|
kinds: const [EventKind.readState],
|
|
authors: [pubkey],
|
|
tags: const {
|
|
'#t': ['read-state'],
|
|
},
|
|
limit: readStateFetchLimit,
|
|
),
|
|
_handleIncomingEvent,
|
|
);
|
|
if (_disposed) {
|
|
unsub.call();
|
|
return;
|
|
}
|
|
_unsubscribeLive = unsub;
|
|
debugPrint('[ReadStateManager] live subscription established');
|
|
} catch (e) {
|
|
debugPrint('[ReadStateManager] live subscription FAILED: $e');
|
|
}
|
|
}
|
|
|
|
void _handleIncomingEvent(NostrEvent event) {
|
|
if (_disposed || !_servesThisCommunity) return;
|
|
debugPrint(
|
|
'[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.createdAt}',
|
|
);
|
|
|
|
final decoded = decodeReadStateEvent(
|
|
event,
|
|
pubkey: pubkey,
|
|
decrypt: _crypto.decrypt,
|
|
);
|
|
if (decoded == null) return;
|
|
|
|
if (_isPlausibleCreatedAt(event.createdAt)) {
|
|
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
|
|
}
|
|
|
|
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
|
|
decoded.blob.clientId != _clientId) {
|
|
_rotateSlotId();
|
|
}
|
|
|
|
var changed =
|
|
decoded.blob.clientId == _clientId &&
|
|
_carryOwnOverrides(decoded.blob.contexts);
|
|
for (final entry in decoded.blob.contexts.entries) {
|
|
if (isOverrideContext(entry.key)) continue;
|
|
final result = _applyRemoteContextTimestamp(
|
|
contextId: entry.key,
|
|
timestamp: entry.value,
|
|
eventCreatedAt: event.createdAt,
|
|
);
|
|
if (result == _ApplyRemoteContextResult.advanced) {
|
|
_pendingSyncedAdvances.add(entry.key);
|
|
changed = true;
|
|
}
|
|
if ((decoded.blob.clientId == _clientId ||
|
|
republishesMergedContext(entry.key)) &&
|
|
_publishableContextIds.add(entry.key)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
debugPrint(
|
|
'[ReadStateManager] incoming result changed=$changed clientId=${decoded.blob.clientId.substring(0, min(8, decoded.blob.clientId.length))}…',
|
|
);
|
|
|
|
if (decoded.blob.clientId == _clientId) {
|
|
_lastPublishedContexts = Map<String, int>.from(decoded.blob.contexts);
|
|
}
|
|
|
|
if (changed) {
|
|
_persistLocalState();
|
|
_onChanged();
|
|
}
|
|
|
|
if (decoded.blob.clientId != _clientId &&
|
|
!_isIdenticalToLastPublished(_currentContexts())) {
|
|
_schedulePublish();
|
|
}
|
|
}
|
|
|
|
_ApplyRemoteContextResult _applyRemoteContextTimestamp({
|
|
required String contextId,
|
|
required int timestamp,
|
|
required int eventCreatedAt,
|
|
}) {
|
|
final sourceCreatedAt = _contextSourceCreatedAt[contextId] ?? 0;
|
|
final current = _effectiveState[contextId] ?? 0;
|
|
final next = max(current, timestamp);
|
|
final result = next == current
|
|
? _ApplyRemoteContextResult.unchanged
|
|
: _ApplyRemoteContextResult.advanced;
|
|
|
|
if (result == _ApplyRemoteContextResult.advanced) {
|
|
_effectiveState[contextId] = next;
|
|
}
|
|
if (eventCreatedAt > sourceCreatedAt) {
|
|
_contextSourceCreatedAt[contextId] = eventCreatedAt;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void _schedulePublish() {
|
|
if (!_remoteEnabled || _remoteUnsupported || _disposed) return;
|
|
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = Timer(const Duration(seconds: 5), () {
|
|
_debounceTimer = null;
|
|
unawaited(_publish());
|
|
});
|
|
}
|
|
|
|
Future<void> _publish({bool allowDisposed = false}) async {
|
|
if ((!allowDisposed && _disposed) ||
|
|
!_remoteEnabled ||
|
|
_remoteUnsupported ||
|
|
_signedEventRelay == null) {
|
|
return;
|
|
}
|
|
if (_isPublishing) {
|
|
// The running publish may have taken its snapshot before this change.
|
|
_publishAgain = true;
|
|
return _publishCompleter?.future;
|
|
}
|
|
|
|
final completer = Completer<void>();
|
|
_publishCompleter = completer;
|
|
_isPublishing = true;
|
|
try {
|
|
do {
|
|
_publishAgain = false;
|
|
await _publishOnce(_signedEventRelay);
|
|
} while (_publishAgain &&
|
|
(allowDisposed || !_disposed) &&
|
|
!_remoteUnsupported);
|
|
} finally {
|
|
_isPublishing = false;
|
|
_publishAgain = false;
|
|
completer.complete();
|
|
if (_publishCompleter == completer) {
|
|
_publishCompleter = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _publishOnce(SignedEventRelay signedEventRelay) async {
|
|
if (!_servesThisCommunity) return;
|
|
debugPrint('[ReadStateManager] publish starting slotId=$_slotId');
|
|
try {
|
|
await _fetchOwnBlobBeforePublish();
|
|
if (!_servesThisCommunity) return;
|
|
|
|
final contexts = _currentContexts();
|
|
if (contexts == null) {
|
|
debugPrint(
|
|
'[ReadStateManager] publish skipped: carried override keys do not '
|
|
'fit the slot, so it stays as it is.',
|
|
);
|
|
return;
|
|
}
|
|
if (_isIdenticalToLastPublished(contexts)) {
|
|
return;
|
|
}
|
|
|
|
final blob = ReadStateBlob(clientId: _clientId, contexts: contexts);
|
|
final ciphertext = _crypto.encrypt(jsonEncode(blob.toJson()));
|
|
final createdAt = max(currentUnixSeconds(), _maxFetchedCreatedAt + 1);
|
|
|
|
await signedEventRelay.submit(
|
|
kind: EventKind.readState,
|
|
content: ciphertext,
|
|
tags: [
|
|
['d', '$readStateDTagPrefix$_slotId'],
|
|
['t', 'read-state'],
|
|
],
|
|
createdAt: createdAt,
|
|
);
|
|
debugPrint('[ReadStateManager] publish accepted createdAt=$createdAt');
|
|
|
|
for (final key in contexts.keys) {
|
|
if (_lastPublishedContexts[key] != contexts[key]) {
|
|
_contextSourceCreatedAt[key] = createdAt;
|
|
}
|
|
}
|
|
_lastPublishedContexts = contexts;
|
|
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
|
|
_persistLocalState();
|
|
} catch (error) {
|
|
if (_isOversizedReadStateError(error)) {
|
|
_remoteUnsupported = true;
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = null;
|
|
debugPrint(
|
|
'[ReadStateManager] remote read-state sync disabled because the '
|
|
'local state exceeds the NIP-44 plaintext limit.',
|
|
);
|
|
return;
|
|
}
|
|
if (_isPermanentReadStateRemoteError(error)) {
|
|
_remoteUnsupported = true;
|
|
_debounceTimer?.cancel();
|
|
_debounceTimer = null;
|
|
debugPrint(
|
|
'[ReadStateManager] remote read-state sync is unavailable; '
|
|
'using local read state.',
|
|
);
|
|
return;
|
|
}
|
|
debugPrint('[ReadStateManager] publish failed: $error');
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchOwnBlobBeforePublish() async {
|
|
if (_relaySession == null) return;
|
|
|
|
try {
|
|
final events = await _relaySession.fetchHistory(
|
|
NostrFilter(
|
|
kinds: const [EventKind.readState],
|
|
authors: [pubkey],
|
|
tags: {
|
|
'#d': ['$readStateDTagPrefix$_slotId'],
|
|
},
|
|
limit: readStateFetchLimit,
|
|
),
|
|
);
|
|
_mergeEvents(events);
|
|
_persistLocalState();
|
|
if (!_disposed) {
|
|
_onChanged();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[ReadStateManager] fetchOwnBlobBeforePublish failed: $e');
|
|
}
|
|
}
|
|
|
|
/// Switching community rebuilds the session in place before this manager
|
|
/// is retired, so in between it must neither take in nor publish data.
|
|
bool get _servesThisCommunity {
|
|
final served = _relaySession?.relayUrl;
|
|
return served == null || served == relayUrl;
|
|
}
|
|
|
|
bool _isIdenticalToLastPublished(Map<String, int>? contexts) {
|
|
// Null: the slot cannot be published, so there is nothing to send.
|
|
if (contexts == null) return true;
|
|
if (_lastPublishedContexts.length != contexts.length) {
|
|
return false;
|
|
}
|
|
for (final entry in contexts.entries) {
|
|
if (_lastPublishedContexts[entry.key] != entry.value) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Set<String> drainSyncedAdvances() {
|
|
final drained = Set<String>.from(_pendingSyncedAdvances);
|
|
_pendingSyncedAdvances.clear();
|
|
return drained;
|
|
}
|
|
|
|
/// The slot to publish, or null when the carried override keys alone do
|
|
/// not fit, so the current slot must stay as it is.
|
|
Map<String, int>? _currentContexts() {
|
|
final contexts = <String, int>{};
|
|
for (final entry in _effectiveState.entries) {
|
|
if (_publishableContextIds.contains(entry.key)) {
|
|
contexts[entry.key] = entry.value;
|
|
}
|
|
}
|
|
// A carried group's frontier travels with it, even a merged `msg:` mark
|
|
// that would not be republished on its own.
|
|
for (final key in overrideGroupFrontierKeys(_carriedOverrides)) {
|
|
if (_effectiveState[key] case final frontier?) contexts[key] = frontier;
|
|
}
|
|
return retainReadStateContexts(
|
|
contexts,
|
|
clientId: _clientId,
|
|
recent: _contextSourceCreatedAt,
|
|
carried: _carriedOverrides,
|
|
);
|
|
}
|
|
|
|
void _hydrateFromLocalStorage() {
|
|
final stored = _storage.read(pubkey, relayUrl);
|
|
// Earlier versions merged the web app's override keys and published
|
|
// them in this device's slot, so publishable ones are carried.
|
|
_carriedOverrides
|
|
..clear()
|
|
..addAll(
|
|
completeOverrideGroups({
|
|
for (final entry in stored.contexts.entries)
|
|
if (isOverrideContext(entry.key) &&
|
|
stored.publishableContextIds.contains(entry.key))
|
|
entry.key: entry.value,
|
|
}, frontiers: stored.contexts),
|
|
);
|
|
_effectiveState
|
|
..clear()
|
|
..addEntries(
|
|
stored.contexts.entries.where((entry) => !isOverrideContext(entry.key)),
|
|
);
|
|
_publishableContextIds
|
|
..clear()
|
|
..addAll(stored.publishableContextIds.where(_effectiveState.containsKey));
|
|
_contextSourceCreatedAt
|
|
..clear()
|
|
..addAll(stored.sourceCreatedAt);
|
|
_persistLocalState();
|
|
}
|
|
|
|
void _persistLocalState() {
|
|
// Save a bounded copy. Memory keeps every mark for this session, so a
|
|
// message read from old history stays read until the app restarts.
|
|
final saved = pruneStaleContexts(
|
|
_effectiveState,
|
|
nowUnixSeconds: currentUnixSeconds(),
|
|
)..addAll(_carriedOverrides);
|
|
// A carried group's frontier is never pruned away from it.
|
|
for (final key in overrideGroupFrontierKeys(_carriedOverrides)) {
|
|
if (_effectiveState[key] case final frontier?) saved[key] = frontier;
|
|
}
|
|
_storage.write(
|
|
pubkey,
|
|
relayUrl,
|
|
saved,
|
|
{
|
|
..._publishableContextIds.where(saved.containsKey),
|
|
..._carriedOverrides.keys,
|
|
},
|
|
{
|
|
for (final entry in _contextSourceCreatedAt.entries)
|
|
if (saved.containsKey(entry.key)) entry.key: entry.value,
|
|
},
|
|
);
|
|
}
|
|
|
|
void _rotateSlotId() {
|
|
_slotId = generateReadStateSlotId();
|
|
_storage.writeSlotId(pubkey, relayUrl, _slotId);
|
|
}
|
|
|
|
bool _isPlausibleCreatedAt(int createdAt) =>
|
|
createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds;
|
|
|
|
bool _isOversizedReadStateError(Object error) {
|
|
final msg = error.toString().toLowerCase();
|
|
return error is ArgumentError &&
|
|
msg.contains('plaintext must be 1-65535 bytes');
|
|
}
|
|
|
|
bool _isPermanentReadStateRemoteError(Object error) {
|
|
// Relay rejections come back as `Exception("<message>")` from the
|
|
// websocket OK handler. Pattern-match on the message text since we no
|
|
// longer have HTTP status codes.
|
|
final msg = error.toString().toLowerCase();
|
|
return msg.contains('unknown event kind') ||
|
|
msg.contains('missing users:write') ||
|
|
msg.contains('insufficient scope') ||
|
|
msg.contains('restricted: unknown');
|
|
}
|
|
}
|