mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Foreground reconnects rebuild preference managers and repeatedly derive the same NIP-44 keys on the UI isolate. Retain the six crypto objects in providers scoped to relay configuration and identity while preserving manager refresh, subscription, and publication behavior. Community changes, key rotation, and sign-out invalidate the retained objects. RelayConfig normalizes the signing key once at its shared accessor, so identity, theme encryption, event signing, and NIP-42 authentication receive the same value, including when an active configuration key has surrounding whitespace. The identity-export authorization snapshot uses the same normalized key for the active-identity check and the outgoing recovery payload. The branch incorporates main's relay-scoped preference managers. ### Validation - Regression exercises six production crypto providers across reconnects, configuration/key changes, and sign-out. Reintroducing a session dependency makes it fail. - Added a loopback WebSocket regression that exercises actual RelaySocket NIP-42 authentication with a padded key and verifies its challenge, relay, pubkey and signature. It fails before shared key normalization. - Added a connected-theme regression for whitespace-padded keys, encryption round-trip, and a valid published event signature. It fails before the normalization fix. - Added padded-key identity export coverage through authorization, SAS confirmation, exactly one normalized payload, and successful completion. Separate relay-change and key-rotation tests verify both fresh and expired grants fail closed. The padded-key regression failed before the fix. - Current head: all 48 focused pairing/crypto/auth tests and mobile analysis passed. All [CI checks](https://github.com/block/buzz/actions/runs/39497098787) passed, including mobile test shards, Android, native Swift tests and release builds. Codex feedback is addressed; Jude approved this exact head after independent regression and mutation checks. Fresh [Codex security review](https://github.com/block/buzz/pull/8442#issuecomment-6300273942) found no concrete issues. - Previous head: 122 config/socket/crypto/theme tests, 44 read-state tests, and all [GitHub CI checks](https://github.com/block/buzz/actions/runs/39491550425) passed, including mobile test shards, Android, native Swift tests and release builds. - Original implementation: native iOS Debug build and full local repository checks passed. A paired simulator background/resume capture had no samples in the targeted crypto constructors or key derivation; an 82 ms UI frame remained. Debug/JIT, profiler overhead and shared-host activity prevent an overall speedup claim. The later normalization change is covered by the production-provider regression above. - Two image decode timing failures occurred in the initial local full suite; direct baseline/patch runs, the subsequent full repository run, and the latest full mobile suite passed. Cold-start credential validation is unchanged: padded persisted keys are rejected by AuthNotifier before RelayConfig is constructed. The normalization regressions cover active configurations and identity export, not broader persisted-key acceptance. <!-- buzz-review-completed --> --------- Signed-off-by: Tom Brow <tomb@block.xyz>
260 lines
8.0 KiB
Dart
260 lines
8.0 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);
|
|
}
|
|
|
|
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);
|
|
});
|