Files
buzz/mobile/lib/shared/read_state/read_state_provider.dart

287 lines
9.1 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';
import 'read_state_format.dart';
import 'read_state_time.dart';
class ReadStateState {
final bool isReady;
final String? pubkey;
final Map<String, int> contexts;
final int version;
final int retentionFloor;
/// 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 {},
this.retentionFloor = 0,
});
const ReadStateState.inert()
: isReady = false,
pubkey = null,
contexts = const {},
version = 0,
retentionFloor = 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) {
if (pubkey == null || retentionFloor == 0 || isOverrideContext(contextId)) {
return contexts[contextId];
}
final floor = retentionFloor;
final stored = contexts[contextId];
return stored == null || stored < floor ? floor : stored;
}
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,
retentionFloor: retentionFloor,
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;
// Expiry is semantic even on an idle screen with no incoming events.
final floorTimer = Timer.periodic(const Duration(minutes: 1), (_) {
if (_manager == manager) _emitManagerState(manager);
});
ref.onDispose(() {
floorTimer.cancel();
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 the first channel-list landing once, durably across restarts.
void seedContextsRead(Map<String, int> contexts) {
_manager?.seedInitialContextsRead(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,
retentionFloor: readStateFloor(currentUnixSeconds()),
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);
});