mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
294 lines
9.1 KiB
Dart
294 lines
9.1 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../theme/community_theme_preference.dart';
|
|
import 'read_state_format.dart';
|
|
import 'read_state_time.dart';
|
|
|
|
const _contextsKeyPrefix = 'buzz.channel-read-state.v2';
|
|
const _publishableKeyPrefix = 'buzz.channel-read-state.publishable.v1';
|
|
const _sourceCreatedAtKeyPrefix =
|
|
'buzz.channel-read-state.source-created-at.v1';
|
|
const _clientIdKeyPrefix = 'buzz.nip-rs.client-id';
|
|
const _slotIdKeyPrefix = 'buzz.nip-rs.slot-id';
|
|
const _uuid = Uuid();
|
|
|
|
/// Read marks belong to one community, so every key is scoped by relay as
|
|
/// well as identity. One identity can be paired with several communities.
|
|
String _scope(String pubkey, String relayUrl) =>
|
|
'$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
|
|
|
|
String localReadStateKey(String pubkey, String relayUrl) =>
|
|
'$_contextsKeyPrefix:${_scope(pubkey, relayUrl)}';
|
|
|
|
String localPublishableContextKey(String pubkey, String relayUrl) =>
|
|
'$_publishableKeyPrefix:${_scope(pubkey, relayUrl)}';
|
|
|
|
String localSourceCreatedAtKey(String pubkey, String relayUrl) =>
|
|
'$_sourceCreatedAtKeyPrefix:${_scope(pubkey, relayUrl)}';
|
|
|
|
String clientIdKey(String pubkey, String relayUrl) =>
|
|
'$_clientIdKeyPrefix:${_scope(pubkey, relayUrl)}';
|
|
|
|
String slotIdKey(String pubkey, String relayUrl) =>
|
|
'$_slotIdKeyPrefix:${_scope(pubkey, relayUrl)}';
|
|
|
|
/// The earlier keys, shared by every community of the identity.
|
|
String _legacyKey(String prefix, String pubkey) => '$prefix:$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;
|
|
static final _lanes = Expando<Map<String, _JournalLane>>();
|
|
|
|
/// Claims the sole writer for this identity/community journal. A successor
|
|
/// fences already queued writes as well as late network acknowledgements.
|
|
Object claimJournal(String key) {
|
|
final lanes = _lanes[_prefs] ??= {};
|
|
final lane = lanes.putIfAbsent(key, _JournalLane.new);
|
|
return lane.owner = Object();
|
|
}
|
|
|
|
bool ownsJournal(String key, Object owner) =>
|
|
identical(_lanes[_prefs]?[key]?.owner, owner);
|
|
|
|
ReadStateStorage(this._prefs);
|
|
|
|
String getOrCreateClientId(String pubkey, String relayUrl) => _getOrCreateId(
|
|
clientIdKey(pubkey, relayUrl),
|
|
_legacyKey(_clientIdKeyPrefix, pubkey),
|
|
isValid: _isValidClientId,
|
|
generate: _uuid.v4,
|
|
);
|
|
|
|
String getOrCreateSlotId(String pubkey, String relayUrl) => _getOrCreateId(
|
|
slotIdKey(pubkey, relayUrl),
|
|
_legacyKey(_slotIdKeyPrefix, pubkey),
|
|
isValid: _isValidSlotId,
|
|
generate: generateReadStateSlotId,
|
|
);
|
|
|
|
/// Each community seeds its id from the identity-wide one when it has none,
|
|
/// so it keeps publishing to the slot that already exists on its relay. The
|
|
/// shared id stays for the other communities to seed from.
|
|
String _getOrCreateId(
|
|
String key,
|
|
String legacyKey, {
|
|
required bool Function(String?) isValid,
|
|
required String Function() generate,
|
|
}) {
|
|
final stored = _prefs.getString(key);
|
|
if (isValid(stored)) return stored!;
|
|
|
|
final legacy = _prefs.getString(legacyKey);
|
|
final id = isValid(legacy) ? legacy! : generate();
|
|
_prefs.setString(key, id);
|
|
return id;
|
|
}
|
|
|
|
void writeSlotId(String pubkey, String relayUrl, String slotId) {
|
|
_prefs.setString(slotIdKey(pubkey, relayUrl), slotId);
|
|
}
|
|
|
|
/// The complete multi-slot journal, saved atomically as one preference.
|
|
Map<String, Object?>? readJournal(String pubkey) {
|
|
final raw = _prefs.getString('buzz.nip-rs.journal.v1:$pubkey');
|
|
if (raw == null) return null;
|
|
final journal = asStringObjectMap(jsonDecode(raw));
|
|
if (journal == null || journal['v'] != 1) {
|
|
throw const FormatException('Invalid read-state journal');
|
|
}
|
|
return journal;
|
|
}
|
|
|
|
Future<void> writeJournal(String pubkey, String encoded, {Object? owner}) {
|
|
final lane = (_lanes[_prefs] ??= {}).putIfAbsent(pubkey, _JournalLane.new);
|
|
Future<void> write() async {
|
|
if (owner != null && !identical(lane.owner, owner)) {
|
|
throw StateError('Read-state journal writer retired');
|
|
}
|
|
if (!await _prefs.setString('buzz.nip-rs.journal.v1:$pubkey', encoded)) {
|
|
throw StateError('Unable to persist read-state journal');
|
|
}
|
|
}
|
|
|
|
// One queue across owner replacements, including a write already in I/O.
|
|
final result = lane.saved.then(
|
|
(_) => write(),
|
|
onError: (Object _, StackTrace _) => write(),
|
|
);
|
|
lane.saved = result.then<void>(
|
|
(_) {},
|
|
onError: (Object _, StackTrace _) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
StoredReadState read(String pubkey, String relayUrl) {
|
|
// The shared marks may be another community's, and the relay keeps the
|
|
// published ones, so drop them rather than let one community claim them.
|
|
for (final prefix in const [
|
|
_contextsKeyPrefix,
|
|
_publishableKeyPrefix,
|
|
_sourceCreatedAtKeyPrefix,
|
|
]) {
|
|
_prefs.remove(_legacyKey(prefix, pubkey));
|
|
}
|
|
return StoredReadState(
|
|
contexts: _readContexts(pubkey, relayUrl),
|
|
publishableContextIds: _readPublishableContextIds(pubkey, relayUrl),
|
|
sourceCreatedAt: _readSourceCreatedAt(pubkey, relayUrl),
|
|
);
|
|
}
|
|
|
|
void write(
|
|
String pubkey,
|
|
String relayUrl,
|
|
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, relayUrl), jsonEncode(state));
|
|
_prefs.setString(
|
|
localPublishableContextKey(pubkey, relayUrl),
|
|
jsonEncode(publishableContextIds.toList()),
|
|
);
|
|
_prefs.setString(
|
|
localSourceCreatedAtKey(pubkey, relayUrl),
|
|
jsonEncode(sourceCreatedAt.map((k, v) => MapEntry(k, v.toString()))),
|
|
);
|
|
}
|
|
|
|
Map<String, int> _readContexts(String pubkey, String relayUrl) {
|
|
final raw = _prefs.getString(localReadStateKey(pubkey, relayUrl));
|
|
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, String relayUrl) {
|
|
final raw = _prefs.getString(localPublishableContextKey(pubkey, relayUrl));
|
|
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, String relayUrl) {
|
|
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey, relayUrl));
|
|
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');
|
|
}
|
|
|
|
class _JournalLane {
|
|
Object? owner;
|
|
Future<void> saved = Future.value();
|
|
}
|