mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
### Context Every app resume and relay reconnect tore down `ReadStateManager` and built a new one. `ReadStateNotifier.build()` watched the whole `relaySessionProvider` state, so each status change (disconnected, connecting, connected) rebuilt the notifier. On an iPhone 12 mini each resume re-ran the full read-state fetch and decrypt (about 19 to 24 decrypts, 60 to 95 ms). The rebuild also cleared `_forcedUnreadContexts`, so a message or channel the user marked unread lost that flag on any connection change. The watch dates from #422, when the manager was built with `remoteEnabled` tied to the connection status. #802 made `remoteEnabled` constant and added the `ref.listen` that calls `reinitializeRemote()` on connect, but left the watch behind. ### Change - Watch `relaySessionProvider.notifier` instead of its state. Config, nsec, community, pubkey, crypto and a replaced session still rebuild. Status changes don't. - Reconnects re-sync through the existing listener (`reinitializeRemote()`: re-fetch, then re-subscribe, preserving the history and live overlap). - Re-syncs are serialized. One runs at a time after any pending `initialize()`, and reconnects that arrive while one waits share it. Overlapping passes could otherwise each install a live subscription and leak one. - A connect that lands while the first `initialize()` is running (the cold-start connect) skips the re-sync when that initialize fetched and subscribed successfully. Without this, cold start did the read-state work twice. ### Validation - Provider journey: disconnected, connecting and reconnecting cause no refetch, no re-subscribe and no readiness drop, and the forced-unread flag and read mark survive. Connected runs exactly one refetch and one live-subscription swap. A second reconnect behaves the same. Account and community switches still rebuild and clear forced flags. - Manager tests: a connect during a successful `initialize()` does not sync again; one during a failed `initialize()` does, and leaves one live subscription; three reconnects during a running re-sync produce one more shared pass and one live subscription. - Falsified: restoring the old watch fails the reconnect journey (`isReady` false, flag lost). The previous version without serialized re-syncs fails the overlap test (5 passes instead of 3). - `just mobile-check` and `just mobile-test` pass. iPhone 12 mini (Profile builds of base and this branch, real community over the production relay, instrumented manager lifecycle and live-subscription counts): | | base | this branch | | --- | --- | --- | | Resume after 3 s, 10 s (x3), 60 s background | 3 new managers and 3 initializes per resume | no new manager, 1 re-sync | | Live read-state subscriptions after resume | 1 | 1 | | Channel marked unread, then 10 s / 60 s background | flag lost after the first resume | still unread after both | | Read-state work per resume (median) | ~24 decrypts, ~176 ms main isolate | ~18 decrypts, ~125 ms | | Unread badges | same bold set | same bold set | The first device run also found the doubled cold-start sync, fixed above. A re-check on this head: | Cold launch | base | first version | this head | | --- | --- | --- | --- | | Managers built | 3 | 1 | 1 | | Read-state syncs | 2 initializes | 1 initialize + 1 re-sync | 1 initialize | | Decrypts | 19 | 30 | 15 | | Main-isolate ms in read-state code | 124 | 185 to 197 | 94 to 98 | Four resumes on this head: no new manager, 1 re-sync and 1 live subscription each, 17 to 20 decrypts. A foreground socket drop was not exercised on the device; every resume there was a full disconnect and reconnect. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
8.4 KiB
Dart
268 lines
8.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../relay/relay.dart';
|
|
import '../theme/theme_provider.dart';
|
|
import '../community/community_provider.dart';
|
|
import 'read_state_manager.dart';
|
|
|
|
class ReadStateState {
|
|
final bool isReady;
|
|
final String? pubkey;
|
|
final Map<String, int> contexts;
|
|
final int version;
|
|
|
|
/// Session-local forced-unread flags, keyed by the forced context id
|
|
/// (a channel id from the channel tile, or a `msg:` key from the message
|
|
/// actions sheet), each mapped to the channel it belongs to so channel
|
|
/// tiles and badges can surface message-level forces.
|
|
final Map<String, String> forcedUnreadContexts;
|
|
|
|
const ReadStateState({
|
|
required this.isReady,
|
|
required this.pubkey,
|
|
required this.contexts,
|
|
required this.version,
|
|
this.forcedUnreadContexts = const {},
|
|
});
|
|
|
|
const ReadStateState.inert()
|
|
: isReady = false,
|
|
pubkey = null,
|
|
contexts = const {},
|
|
version = 0,
|
|
forcedUnreadContexts = const {};
|
|
|
|
/// Channels that should surface as unread because of a forced-unread flag —
|
|
/// either forced directly, or containing a forced message.
|
|
Set<String> get locallyForcedChannelIds =>
|
|
forcedUnreadContexts.values.toSet();
|
|
|
|
/// Whether this exact context (channel id or `msg:` key) is forced unread.
|
|
bool isForcedUnread(String contextId) =>
|
|
forcedUnreadContexts.containsKey(contextId);
|
|
|
|
int? effectiveTimestamp(String contextId) => contexts[contextId];
|
|
|
|
ReadStateState copyWithContext(String contextId, int timestamp) {
|
|
final current = contexts[contextId] ?? 0;
|
|
if (timestamp <= current) {
|
|
return this;
|
|
}
|
|
|
|
return ReadStateState(
|
|
isReady: isReady,
|
|
pubkey: pubkey,
|
|
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
|
|
version: version + 1,
|
|
forcedUnreadContexts: forcedUnreadContexts,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ReadStateNotifier extends Notifier<ReadStateState> {
|
|
ReadStateManager? _manager;
|
|
bool _isInitialized = false;
|
|
final Map<String, String> _forcedUnreadContexts = {};
|
|
|
|
@override
|
|
ReadStateState build() {
|
|
_manager?.dispose(flushPending: false);
|
|
_manager = null;
|
|
_isInitialized = false;
|
|
_forcedUnreadContexts.clear();
|
|
|
|
final relayConfig = ref.watch(relayConfigProvider);
|
|
// Watch the session notifier, not its state: a status change (app resume,
|
|
// reconnect) must not rebuild the manager and drop session-local forced
|
|
// unread flags. The listener below re-syncs on reconnect instead.
|
|
ref.watch(relaySessionProvider.notifier);
|
|
ref.watch(activeCommunityProvider);
|
|
|
|
final nsec = relayConfig.nsec?.trim();
|
|
if (nsec == null || nsec.isEmpty) {
|
|
return const ReadStateState.inert();
|
|
}
|
|
|
|
final signedRelay = SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: nsec,
|
|
);
|
|
final pubkey = ref.watch(_readStatePubkeyProvider);
|
|
if (pubkey == null) return const ReadStateState.inert();
|
|
final crypto = ref.watch(readStateCryptoProvider);
|
|
if (crypto == null) {
|
|
return const ReadStateState.inert();
|
|
}
|
|
|
|
final prefs = ref.read(savedPrefsProvider);
|
|
late final ReadStateManager manager;
|
|
manager = ReadStateManager(
|
|
pubkey: pubkey,
|
|
relayUrl: relayConfig.baseUrl,
|
|
prefs: prefs,
|
|
crypto: crypto,
|
|
relaySession: ref.read(relaySessionProvider.notifier),
|
|
signedEventRelay: signedRelay,
|
|
remoteEnabled: true,
|
|
onChanged: () => _emitManagerState(manager),
|
|
);
|
|
_manager = manager;
|
|
|
|
ref.onDispose(() {
|
|
manager.dispose();
|
|
if (_manager == manager) {
|
|
_manager = null;
|
|
}
|
|
});
|
|
|
|
ref.listen(appLifecycleProvider, (_, next) {
|
|
if (next == AppLifecycleState.paused ||
|
|
next == AppLifecycleState.detached ||
|
|
next == AppLifecycleState.hidden) {
|
|
unawaited(manager.flush());
|
|
}
|
|
});
|
|
|
|
ref.listen(relaySessionProvider, (prev, next) {
|
|
if (prev?.status != SessionStatus.connected &&
|
|
next.status == SessionStatus.connected) {
|
|
unawaited(manager.reinitializeRemote());
|
|
}
|
|
});
|
|
|
|
Future.microtask(() async {
|
|
await manager.initialize();
|
|
if (_manager != manager) return;
|
|
_isInitialized = true;
|
|
_emitManagerState(manager);
|
|
});
|
|
|
|
return _stateFromManager(manager, isReady: false);
|
|
}
|
|
|
|
/// Advance a context's read marker. Clears the forced-unread flag for
|
|
/// exactly this context, if any. An explicit channel-level "Mark read"
|
|
/// (channel tile/menu) should pass [clearForcedMessages] so message-level
|
|
/// forces inside the channel are released too; the automatic read on
|
|
/// channel open must not, so a message deliberately marked unread stays
|
|
/// unread until acted on.
|
|
void markContextRead(
|
|
String contextId,
|
|
int unixTimestamp, {
|
|
bool clearForcedMessages = false,
|
|
}) {
|
|
var removed = _forcedUnreadContexts.remove(contextId) != null;
|
|
if (clearForcedMessages) {
|
|
final before = _forcedUnreadContexts.length;
|
|
_forcedUnreadContexts.removeWhere(
|
|
(_, channelId) => channelId == contextId,
|
|
);
|
|
removed = removed || _forcedUnreadContexts.length != before;
|
|
}
|
|
_manager?.markContextRead(contextId, unixTimestamp);
|
|
if (removed) {
|
|
_refreshForcedState();
|
|
}
|
|
}
|
|
|
|
/// Force a context unread for the rest of the session. [contextId] is a
|
|
/// channel id (channel-tile action) or a `msg:` key (message actions
|
|
/// sheet); [channelId] is the channel the context belongs to, so tiles and
|
|
/// badges can surface message-level forces. Read markers are monotonic,
|
|
/// so this is the only way to move a context back to unread.
|
|
void markContextUnread(String contextId, {required String channelId}) {
|
|
if (_manager == null) return;
|
|
_forcedUnreadContexts[contextId] = channelId;
|
|
_refreshForcedState();
|
|
}
|
|
|
|
/// Clear a forced-unread flag without moving any read marker. Reading to
|
|
/// the bottom of a channel the reader marked unread uses this: it ends the
|
|
/// manual unread but must not read mentions or replies the reader has not
|
|
/// seen.
|
|
void clearForcedUnread(String contextId) {
|
|
if (_forcedUnreadContexts.remove(contextId) != null) {
|
|
_refreshForcedState();
|
|
}
|
|
}
|
|
|
|
void _refreshForcedState() {
|
|
final manager = _manager;
|
|
if (manager == null) return;
|
|
state = _stateFromManager(
|
|
manager,
|
|
isReady: _isInitialized,
|
|
previousVersion: state.version,
|
|
);
|
|
}
|
|
|
|
void seedContextRead(String contextId, int unixTimestamp) {
|
|
_manager?.seedContextRead(contextId, unixTimestamp);
|
|
}
|
|
|
|
/// Seed a channel-list snapshot with one local save and state emission.
|
|
void seedContextsRead(Map<String, int> contexts) {
|
|
_manager?.seedContextsRead(contexts);
|
|
}
|
|
|
|
void _emitManagerState(ReadStateManager manager) {
|
|
if (_manager != manager) return;
|
|
final advances = manager.drainSyncedAdvances();
|
|
for (final contextId in advances) {
|
|
_forcedUnreadContexts.remove(contextId);
|
|
}
|
|
state = _stateFromManager(
|
|
manager,
|
|
isReady: _isInitialized,
|
|
previousVersion: state.version,
|
|
);
|
|
}
|
|
|
|
ReadStateState _stateFromManager(
|
|
ReadStateManager manager, {
|
|
required bool isReady,
|
|
int? previousVersion,
|
|
}) {
|
|
return ReadStateState(
|
|
isReady: isReady,
|
|
pubkey: manager.pubkey,
|
|
contexts: manager.effectiveContexts,
|
|
version: (previousVersion ?? 0) + 1,
|
|
forcedUnreadContexts: Map.unmodifiable(
|
|
Map<String, String>.from(_forcedUnreadContexts),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final readStateProvider = NotifierProvider<ReadStateNotifier, ReadStateState>(
|
|
ReadStateNotifier.new,
|
|
);
|
|
|
|
String? _normalizePubkey(String? value) {
|
|
final normalized = value?.trim().toLowerCase();
|
|
if (normalized == null || normalized.isEmpty) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
final _readStatePubkeyProvider = Provider<String?>((ref) {
|
|
final active = ref.watch(activeCommunityProvider).value;
|
|
final config = ref.watch(relayConfigProvider);
|
|
return _normalizePubkey(active?.pubkey) ??
|
|
_normalizePubkey(pubkeyFromNsec(config.nsec?.trim()));
|
|
});
|
|
|
|
/// Retains read-state encryption material across connection status changes.
|
|
final readStateCryptoProvider = Provider<ReadStateCrypto?>((ref) {
|
|
final config = ref.watch(relayConfigProvider);
|
|
final pubkey = ref.watch(_readStatePubkeyProvider);
|
|
final nsec = config.nsec?.trim();
|
|
if (nsec == null || nsec.isEmpty || pubkey == null) return null;
|
|
return ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
|
|
});
|