Files
buzz/mobile/lib/shared/read_state/read_state_provider.dart
Wes 8ceca8ac46 perf(mobile): batch initial channel read-state seeds (#8607)
## Summary

Opening an uncached community seeded each previously unseen channel's
read marker separately. Every seed copied and serialized the full
read-state snapshot, started three preference writes, and notified
listeners. Home now collects those seeds and saves/emits once, using the
existing monotonic marker admission rules.

For a 400-channel fixture with one existing marker, initial seeding
falls from 399 full snapshot saves to one. Existing unread frontiers,
local-only seeds and explicit read publishing keep their behavior. No
transport, cache, relay or unread eligibility changes. This is
independent of #8580 and #8490.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,779
tests passed, 19 skipped, plus four checks for builds without push
configuration.
- Real Home/channel-provider/read-state journey covers 400 channels,
preservation of an existing unread highlight, persisted marks, no seed
publication, and no repeated seed save on refresh. It replaces the
earlier fake-notifier seed test.
- Restoring the old per-channel loop makes the new journey fail at the
single-snapshot assertion.
- Diff and file-size checks pass.
- Merged current main (`209fcd217`) at `d34d29627`; retained both the
cold read-seeding and cached-community landing test registrations. The
production diff is byte-for-byte unchanged from the version reviewed by
the Court at `6a232ed37`. The complete mobile gates above passed again
on the merged tree.

Device latency is unmeasured. The Court source review found no blockers
at `6a232ed37`; app/device testing and human acceptance remain
unconfirmed. To try: open a community without a saved channel list,
confirm its channels load and existing unread highlights remain correct,
then refresh and reopen it. The performance claim here is reduced save
work, not a measured end-to-end speedup.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
2026-10-26 13:36:23 -06:00

265 lines
8.2 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);
ref.watch(relaySessionProvider);
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);
});