feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
VoidCallback deferReadStateUpdate(BuildContext context, VoidCallback update) {
|
||||
var cancelled = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!cancelled && context.mounted) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
return () {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'read_state_format.dart';
|
||||
import 'read_state_provider.dart';
|
||||
|
||||
/// Effective read timestamp for a single message: the newest of the channel
|
||||
/// marker, the message's own `msg:` marker, and (for thread replies) the
|
||||
/// thread's `thread:` marker — the same precedence the unread badge uses via
|
||||
/// `observedUnreadEventReadAt`.
|
||||
int? effectiveMessageReadAt(
|
||||
ReadStateState readState, {
|
||||
required String channelId,
|
||||
required String messageId,
|
||||
String? threadRootId,
|
||||
}) {
|
||||
return maxReadAt([
|
||||
readState.effectiveTimestamp(channelId),
|
||||
readState.effectiveTimestamp(msgContextKey(messageId)),
|
||||
if (threadRootId != null)
|
||||
readState.effectiveTimestamp(threadContextKey(threadRootId)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Whether a message should currently show as unread in the actions menu.
|
||||
///
|
||||
/// A message-level forced unread (from this menu) wins; otherwise the
|
||||
/// timestamp precedence decides. A channel-level forced unread (from the
|
||||
/// channel tile) is deliberately not consulted: it is a channel-scoped
|
||||
/// choice, and letting it leak in here would make the message-level toggle
|
||||
/// unable to round-trip without clearing the channel-level choice.
|
||||
bool isMessageUnread(
|
||||
ReadStateState readState, {
|
||||
required String channelId,
|
||||
required String messageId,
|
||||
required int createdAt,
|
||||
String? threadRootId,
|
||||
}) {
|
||||
if (readState.isForcedUnread(msgContextKey(messageId))) return true;
|
||||
final readAt = effectiveMessageReadAt(
|
||||
readState,
|
||||
channelId: channelId,
|
||||
messageId: messageId,
|
||||
threadRootId: threadRootId,
|
||||
);
|
||||
return readAt == null || createdAt > readAt;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../relay/nostr_models.dart';
|
||||
|
||||
const readStateDTagPrefix = 'read-state:';
|
||||
const readStateFetchLimit = 500;
|
||||
const readStateHorizonSeconds = 7 * 24 * 60 * 60;
|
||||
const _maxContexts = 10000;
|
||||
const msgContextPrefix = 'msg:';
|
||||
const threadContextPrefix = 'thread:';
|
||||
|
||||
String msgContextKey(String messageId) => '$msgContextPrefix$messageId';
|
||||
String threadContextKey(String rootId) => '$threadContextPrefix$rootId';
|
||||
|
||||
int? maxReadAt(Iterable<int?> markers) {
|
||||
int? latest;
|
||||
for (final marker in markers) {
|
||||
if (marker == null) continue;
|
||||
if (latest == null || marker > latest) {
|
||||
latest = marker;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
typedef ReadStateDecrypt = String Function(String ciphertext);
|
||||
|
||||
class ReadStateBlob {
|
||||
final String clientId;
|
||||
final Map<String, int> contexts;
|
||||
|
||||
ReadStateBlob({required this.clientId, required Map<String, int> contexts})
|
||||
: contexts = Map.unmodifiable(contexts);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'v': 1,
|
||||
'client_id': clientId,
|
||||
'contexts': contexts,
|
||||
};
|
||||
}
|
||||
|
||||
class DecodedReadStateEvent {
|
||||
final NostrEvent event;
|
||||
final String dTag;
|
||||
final ReadStateBlob blob;
|
||||
|
||||
const DecodedReadStateEvent({
|
||||
required this.event,
|
||||
required this.dTag,
|
||||
required this.blob,
|
||||
});
|
||||
}
|
||||
|
||||
bool isPlainJsonObject(Object? value) {
|
||||
if (value is! Map) return false;
|
||||
return value.keys.every((key) => key is String);
|
||||
}
|
||||
|
||||
Map<String, Object?>? asStringObjectMap(Object? value) {
|
||||
if (!isPlainJsonObject(value)) return null;
|
||||
return (value as Map).cast<String, Object?>();
|
||||
}
|
||||
|
||||
bool isValidReadStateDTag(String? value) {
|
||||
if (value == null || !value.startsWith(readStateDTagPrefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final slotId = value.substring(readStateDTagPrefix.length);
|
||||
if (slotId.isEmpty || slotId.length > 64) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < slotId.length; index++) {
|
||||
if (slotId.codeUnitAt(index) > 0x7f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool hasValidReadStateTags(NostrEvent event) {
|
||||
final dTags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd');
|
||||
if (dTags.length != 1) {
|
||||
return false;
|
||||
}
|
||||
final dTag = dTags.single;
|
||||
if (dTag.length < 2 || !isValidReadStateDTag(dTag[1])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final tTags = event.tags.where(
|
||||
(tag) => tag.length >= 2 && tag[0] == 't' && tag[1] == 'read-state',
|
||||
);
|
||||
return tTags.length == 1;
|
||||
}
|
||||
|
||||
ReadStateBlob? decodeReadStateBlob(String plaintext) {
|
||||
final Object? parsed;
|
||||
try {
|
||||
parsed = jsonDecode(plaintext);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final record = asStringObjectMap(parsed);
|
||||
if (record == null) return null;
|
||||
|
||||
if (record['v'] != 1) return null;
|
||||
|
||||
final clientId = record['client_id'];
|
||||
if (clientId is! String || clientId.isEmpty || clientId.runes.length > 64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final contexts = asStringObjectMap(record['contexts']);
|
||||
if (contexts == null || contexts.length > _maxContexts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadStateBlob(
|
||||
clientId: clientId,
|
||||
contexts: sanitizeReadStateContexts(contexts),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, int> sanitizeReadStateContexts(Map<String, Object?> contexts) {
|
||||
final sanitized = <String, int>{};
|
||||
for (final entry in contexts.entries) {
|
||||
if (utf8.encode(entry.key).length > 256) continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value is! int) continue;
|
||||
if (value < 0 || value > 4294967295) continue;
|
||||
|
||||
sanitized[entry.key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
DecodedReadStateEvent? decodeReadStateEvent(
|
||||
NostrEvent event, {
|
||||
required String pubkey,
|
||||
required ReadStateDecrypt decrypt,
|
||||
}) {
|
||||
if (event.pubkey.toLowerCase() != pubkey.toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
if (!hasValidReadStateTags(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final dTag = event.tags.firstWhere(
|
||||
(tag) => tag.isNotEmpty && tag[0] == 'd',
|
||||
)[1];
|
||||
|
||||
final String plaintext;
|
||||
try {
|
||||
plaintext = decrypt(event.content);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ReadStateManager] decrypt failed for event ${event.id.substring(0, 8)}…: $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final blob = decodeReadStateBlob(plaintext);
|
||||
if (blob == null) {
|
||||
debugPrint(
|
||||
'[ReadStateManager] blob decode failed for event ${event.id.substring(0, 8)}…',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return DecodedReadStateEvent(event: event, dTag: dTag, blob: blob);
|
||||
}
|
||||
|
||||
Map<String, int> mergeReadStateContexts(
|
||||
Iterable<Map<String, int>> contextSets,
|
||||
) {
|
||||
final merged = <String, int>{};
|
||||
for (final contexts in contextSets) {
|
||||
for (final entry in contexts.entries) {
|
||||
final current = merged[entry.key] ?? 0;
|
||||
if (entry.value > current) {
|
||||
merged[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
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 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 = {};
|
||||
Map<String, int> _lastPublishedContexts = {};
|
||||
|
||||
Timer? _debounceTimer;
|
||||
void Function()? _unsubscribeLive;
|
||||
bool _initialized = false;
|
||||
bool _disposed = false;
|
||||
bool _isPublishing = false;
|
||||
Completer<void>? _publishCompleter;
|
||||
bool _remoteUnsupported = false;
|
||||
int _maxFetchedCreatedAt = 0;
|
||||
final Map<String, int> _contextSourceCreatedAt = {};
|
||||
final Set<String> _pendingSyncedAdvances = {};
|
||||
|
||||
ReadStateManager({
|
||||
required this.pubkey,
|
||||
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);
|
||||
_slotId = _storage.getOrCreateSlotId(pubkey);
|
||||
_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) {
|
||||
_advanceContext(contextId, unixTimestamp, publishable: true);
|
||||
_contextSourceCreatedAt[contextId] = max(
|
||||
currentUnixSeconds(),
|
||||
_maxFetchedCreatedAt + 1,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
final result = _applyRemoteContextTimestamp(
|
||||
contextId: entry.key,
|
||||
timestamp: entry.value,
|
||||
eventCreatedAt: event.createdAt,
|
||||
);
|
||||
if (result == _ApplyRemoteContextResult.advanced) {
|
||||
_pendingSyncedAdvances.add(entry.key);
|
||||
_publishableContextIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (decoded.blob.clientId == _clientId &&
|
||||
event.createdAt > ownBlobCreatedAt) {
|
||||
ownBlob = decoded.blob;
|
||||
ownBlobCreatedAt = event.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (ownBlob != null) {
|
||||
_lastPublishedContexts = Map<String, int>.from(ownBlob.contexts);
|
||||
_publishableContextIds.addAll(ownBlob.contexts.keys);
|
||||
}
|
||||
}
|
||||
|
||||
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) 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 = false;
|
||||
for (final entry in decoded.blob.contexts.entries) {
|
||||
final result = _applyRemoteContextTimestamp(
|
||||
contextId: entry.key,
|
||||
timestamp: entry.value,
|
||||
eventCreatedAt: event.createdAt,
|
||||
);
|
||||
if (result == _ApplyRemoteContextResult.advanced) {
|
||||
_pendingSyncedAdvances.add(entry.key);
|
||||
changed = true;
|
||||
}
|
||||
if (_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) return;
|
||||
|
||||
final completer = Completer<void>();
|
||||
_publishCompleter = completer;
|
||||
_isPublishing = true;
|
||||
debugPrint('[ReadStateManager] publish starting slotId=$_slotId');
|
||||
try {
|
||||
await _fetchOwnBlobBeforePublish();
|
||||
|
||||
final contexts = _currentContexts();
|
||||
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');
|
||||
} finally {
|
||||
_isPublishing = false;
|
||||
completer.complete();
|
||||
if (_publishCompleter == completer) {
|
||||
_publishCompleter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
bool _isIdenticalToLastPublished(Map<String, int> contexts) {
|
||||
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;
|
||||
}
|
||||
|
||||
Map<String, int> _currentContexts() {
|
||||
final contexts = <String, int>{};
|
||||
for (final entry in _effectiveState.entries) {
|
||||
if (_publishableContextIds.contains(entry.key)) {
|
||||
contexts[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}
|
||||
|
||||
void _hydrateFromLocalStorage() {
|
||||
final stored = _storage.read(pubkey);
|
||||
_effectiveState
|
||||
..clear()
|
||||
..addAll(stored.contexts);
|
||||
_publishableContextIds
|
||||
..clear()
|
||||
..addAll(stored.publishableContextIds);
|
||||
_contextSourceCreatedAt
|
||||
..clear()
|
||||
..addAll(stored.sourceCreatedAt);
|
||||
_persistLocalState();
|
||||
}
|
||||
|
||||
void _persistLocalState() {
|
||||
_storage.write(
|
||||
pubkey,
|
||||
_effectiveState,
|
||||
_publishableContextIds,
|
||||
_contextSourceCreatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
void _rotateSlotId() {
|
||||
_slotId = generateReadStateSlotId();
|
||||
_storage.writeSlotId(pubkey, _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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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);
|
||||
final activeCommunity = ref.watch(activeCommunityProvider).value;
|
||||
|
||||
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 =
|
||||
_normalizePubkey(activeCommunity?.pubkey) ??
|
||||
_safeDerivedPubkey(signedRelay);
|
||||
if (pubkey == null) {
|
||||
return const ReadStateState.inert();
|
||||
}
|
||||
|
||||
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
|
||||
if (crypto == null) {
|
||||
return const ReadStateState.inert();
|
||||
}
|
||||
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
late final ReadStateManager manager;
|
||||
manager = ReadStateManager(
|
||||
pubkey: pubkey,
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String? _safeDerivedPubkey(SignedEventRelay relay) {
|
||||
try {
|
||||
return _normalizePubkey(relay.pubkey);
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'read_state_format.dart';
|
||||
import 'read_state_time.dart';
|
||||
|
||||
const _clientIdKeyPrefix = 'buzz.nip-rs.client-id';
|
||||
const _slotIdKeyPrefix = 'buzz.nip-rs.slot-id';
|
||||
const _uuid = Uuid();
|
||||
|
||||
String localReadStateKey(String pubkey) => 'buzz.channel-read-state.v2:$pubkey';
|
||||
|
||||
String localPublishableContextKey(String pubkey) =>
|
||||
'buzz.channel-read-state.publishable.v1:$pubkey';
|
||||
|
||||
String localSourceCreatedAtKey(String pubkey) =>
|
||||
'buzz.channel-read-state.source-created-at.v1:$pubkey';
|
||||
|
||||
String clientIdKey(String pubkey) => '$_clientIdKeyPrefix:$pubkey';
|
||||
|
||||
String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey';
|
||||
|
||||
class StoredReadState {
|
||||
final Map<String, int> contexts;
|
||||
final Set<String> publishableContextIds;
|
||||
final Map<String, int> sourceCreatedAt;
|
||||
|
||||
StoredReadState({
|
||||
required Map<String, int> contexts,
|
||||
required Set<String> publishableContextIds,
|
||||
required Map<String, int> sourceCreatedAt,
|
||||
}) : contexts = Map.unmodifiable(contexts),
|
||||
publishableContextIds = Set.unmodifiable(publishableContextIds),
|
||||
sourceCreatedAt = Map.unmodifiable(sourceCreatedAt);
|
||||
}
|
||||
|
||||
class ReadStateStorage {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ReadStateStorage(this._prefs);
|
||||
|
||||
String getOrCreateClientId(String pubkey) {
|
||||
final key = clientIdKey(pubkey);
|
||||
final stored = _prefs.getString(key);
|
||||
if (_isValidClientId(stored)) {
|
||||
return stored!;
|
||||
}
|
||||
|
||||
final generated = _uuid.v4();
|
||||
_prefs.setString(key, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
String getOrCreateSlotId(String pubkey) {
|
||||
final key = slotIdKey(pubkey);
|
||||
final stored = _prefs.getString(key);
|
||||
if (_isValidSlotId(stored)) {
|
||||
return stored!;
|
||||
}
|
||||
|
||||
final generated = generateReadStateSlotId();
|
||||
_prefs.setString(key, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
void writeSlotId(String pubkey, String slotId) {
|
||||
_prefs.setString(slotIdKey(pubkey), slotId);
|
||||
}
|
||||
|
||||
StoredReadState read(String pubkey) {
|
||||
return StoredReadState(
|
||||
contexts: _readContexts(pubkey),
|
||||
publishableContextIds: _readPublishableContextIds(pubkey),
|
||||
sourceCreatedAt: _readSourceCreatedAt(pubkey),
|
||||
);
|
||||
}
|
||||
|
||||
void write(
|
||||
String pubkey,
|
||||
Map<String, int> contexts,
|
||||
Set<String> publishableContextIds,
|
||||
Map<String, int> sourceCreatedAt,
|
||||
) {
|
||||
final state = <String, String>{};
|
||||
for (final entry in contexts.entries) {
|
||||
state[entry.key] = unixSecondsToDateTime(entry.value).toIso8601String();
|
||||
}
|
||||
|
||||
_prefs.setString(localReadStateKey(pubkey), jsonEncode(state));
|
||||
_prefs.setString(
|
||||
localPublishableContextKey(pubkey),
|
||||
jsonEncode(publishableContextIds.toList()),
|
||||
);
|
||||
_prefs.setString(
|
||||
localSourceCreatedAtKey(pubkey),
|
||||
jsonEncode(sourceCreatedAt.map((k, v) => MapEntry(k, v.toString()))),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, int> _readContexts(String pubkey) {
|
||||
final raw = _prefs.getString(localReadStateKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final Object? parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw);
|
||||
} catch (e) {
|
||||
debugPrint('[ReadStateManager] storage: contexts JSON corrupt: $e');
|
||||
return {};
|
||||
}
|
||||
|
||||
final record = asStringObjectMap(parsed);
|
||||
if (record == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final contexts = <String, int>{};
|
||||
for (final entry in record.entries) {
|
||||
final timestamp = isoToUnixSeconds(entry.value);
|
||||
if (timestamp == null) continue;
|
||||
|
||||
final current = contexts[entry.key] ?? 0;
|
||||
if (timestamp > current) {
|
||||
contexts[entry.key] = timestamp;
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}
|
||||
|
||||
Set<String> _readPublishableContextIds(String pubkey) {
|
||||
final raw = _prefs.getString(localPublishableContextKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final Object? parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ReadStateManager] storage: publishableContextIds JSON corrupt: $e',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (parsed is! List) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
for (final value in parsed)
|
||||
if (value is String) value,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, int> _readSourceCreatedAt(String pubkey) {
|
||||
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
|
||||
final Object? parsed;
|
||||
try {
|
||||
parsed = jsonDecode(raw);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ReadStateManager] storage: sourceCreatedAt JSON corrupt: $e',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
final record = asStringObjectMap(parsed);
|
||||
if (record == null) return {};
|
||||
|
||||
final result = <String, int>{};
|
||||
for (final entry in record.entries) {
|
||||
final value = entry.value;
|
||||
if (value is int) {
|
||||
result[entry.key] = value;
|
||||
} else if (value is String) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed != null) result[entry.key] = parsed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
String generateReadStateSlotId() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
bool _isValidClientId(String? value) {
|
||||
return value != null && value.isNotEmpty && value.runes.length <= 64;
|
||||
}
|
||||
|
||||
bool _isValidSlotId(String? value) {
|
||||
if (value == null || value.isEmpty || value.length > 64) {
|
||||
return false;
|
||||
}
|
||||
return isValidReadStateDTag('$readStateDTagPrefix$value');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const _millisecondsPerSecond = 1000;
|
||||
|
||||
int? dateTimeToUnixSeconds(DateTime? value) {
|
||||
if (value == null) return null;
|
||||
return value.millisecondsSinceEpoch ~/ _millisecondsPerSecond;
|
||||
}
|
||||
|
||||
int currentUnixSeconds() => dateTimeToUnixSeconds(DateTime.now())!;
|
||||
|
||||
DateTime unixSecondsToDateTime(int value) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
value * _millisecondsPerSecond,
|
||||
isUtc: true,
|
||||
);
|
||||
}
|
||||
|
||||
int? isoToUnixSeconds(Object? value) {
|
||||
if (value is! String || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateTimeToUnixSeconds(DateTime.tryParse(value));
|
||||
}
|
||||
|
||||
const readStateMaxClockDriftSeconds = 300;
|
||||
Reference in New Issue
Block a user