Adds pseudonymous, switchable social identities derived from the SAME root seed via an account index (NostrKeyDerivation.deriveFromSeed(seed, account)). HKDF is one-way so accounts are unlinkable to the Ğ1 key; account 0 is the original identity, byte-for-byte unchanged (no rotation for current users), and every account regenerates from the single seed — so switching adds nothing to back up. - SocialAccountStore: keystore-backed active account + max created. - Per-identity stores (chats, profile, name cache) namespaced by account scope (account 0 = legacy keys, no migration) so identities never mix. - switchSocialAccount() re-derives the identity, re-scopes the stores and restarts the inbox listener; RestartWidget rebuilds the tree to pick up the new social singletons. DB/inventory untouched. - Profile 'Your identities' switcher: list, create, switch (with a note that messages/contacts are kept separate per identity). i18n en/es/pt/ast. Tests: account-indexed derivation (legacy 0 unchanged, accounts distinct yet deterministic, negatives rejected); SocialAccountStore; per-identity store scope isolation. Resolves the flagged 'change identity' decision (open-decisions §B). Known: switching resets navigation to home (full tree rebuild).
130 lines
4.6 KiB
Dart
130 lines
4.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
|
|
import '../security/secret_store.dart';
|
|
|
|
/// Persists a 1:1 chat history, keystore-backed (so no plaintext at rest). A
|
|
/// capped, per-peer JSON list — recent history survives leaving the chat. Small
|
|
/// and simple on purpose; the encrypted Drift DB is the eventual home if chats
|
|
/// grow large.
|
|
/// A conversation preview for the messages inbox.
|
|
class ChatSummary {
|
|
const ChatSummary({
|
|
required this.peerPubkey,
|
|
required this.lastText,
|
|
required this.lastAt,
|
|
});
|
|
|
|
final String peerPubkey;
|
|
final String lastText;
|
|
final DateTime lastAt;
|
|
}
|
|
|
|
class MessageStore {
|
|
/// [accountScope] namespaces the keys per social identity (empty = the
|
|
/// original identity's legacy keys). See [socialAccountScope].
|
|
MessageStore(this._store, {String accountScope = ''})
|
|
: _base = accountScope.isEmpty ? 'tane.social.' : 'tane.social.$accountScope.';
|
|
|
|
final SecretStore _store;
|
|
final String _base;
|
|
|
|
/// Serializes [append]'s read-modify-write. Several sources now write the same
|
|
/// conversation concurrently (the app-wide inbox listener and an open chat's
|
|
/// own subscription), so without this two near-simultaneous messages could
|
|
/// read the same history and the second write would clobber the first.
|
|
Future<void> _writeTail = Future.value();
|
|
|
|
/// Index of peers we have a conversation with (the keystore is key/value with
|
|
/// no key enumeration, so we track the list ourselves).
|
|
String get _indexKey => '${_base}chats';
|
|
|
|
/// Keep only the most recent [_cap] messages per conversation, to bound the
|
|
/// keystore entry size.
|
|
static const _cap = 200;
|
|
|
|
String _key(String peerPubkey) => '${_base}chat.$peerPubkey';
|
|
|
|
/// Messages exchanged with [peerPubkey], oldest first.
|
|
Future<List<PrivateMessage>> history(String peerPubkey) async {
|
|
final raw = await _store.read(_key(peerPubkey));
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
final list = jsonDecode(raw) as List;
|
|
return [
|
|
for (final m in list.cast<Map<String, dynamic>>())
|
|
PrivateMessage(
|
|
fromPubkey: m['from'] as String,
|
|
text: m['text'] as String,
|
|
at: DateTime.fromMillisecondsSinceEpoch(m['at'] as int),
|
|
),
|
|
];
|
|
}
|
|
|
|
/// Appends [message] to the conversation with [peerPubkey] (trimming to the
|
|
/// cap) and records the peer in the conversation index. Idempotent: a relay
|
|
/// re-delivers stored gift wraps on every (re)subscribe, so an identical
|
|
/// message (same sender, timestamp and text) is dropped instead of duplicated.
|
|
/// Returns true only when the message was newly stored.
|
|
Future<bool> append(String peerPubkey, PrivateMessage message) {
|
|
// Chain onto the write tail so concurrent appends run one at a time.
|
|
final result = _writeTail.then((_) => _appendLocked(peerPubkey, message));
|
|
_writeTail = result.then((_) {}, onError: (_) {});
|
|
return result;
|
|
}
|
|
|
|
Future<bool> _appendLocked(String peerPubkey, PrivateMessage message) async {
|
|
final existing = await history(peerPubkey);
|
|
final isDup = existing.any((m) =>
|
|
m.fromPubkey == message.fromPubkey &&
|
|
m.text == message.text &&
|
|
m.at.millisecondsSinceEpoch == message.at.millisecondsSinceEpoch);
|
|
if (isDup) return false;
|
|
final next = [...existing, message];
|
|
final capped =
|
|
next.length > _cap ? next.sublist(next.length - _cap) : next;
|
|
await _store.write(
|
|
_key(peerPubkey),
|
|
jsonEncode([
|
|
for (final m in capped)
|
|
{
|
|
'from': m.fromPubkey,
|
|
'text': m.text,
|
|
'at': m.at.millisecondsSinceEpoch,
|
|
},
|
|
]),
|
|
);
|
|
await _rememberPeer(peerPubkey);
|
|
return true;
|
|
}
|
|
|
|
/// Conversations, most-recently-active first (for the messages inbox).
|
|
Future<List<ChatSummary>> conversations() async {
|
|
final summaries = <ChatSummary>[];
|
|
for (final peer in await _peers()) {
|
|
final history = await this.history(peer);
|
|
if (history.isEmpty) continue;
|
|
final last = history.last;
|
|
summaries.add(ChatSummary(
|
|
peerPubkey: peer,
|
|
lastText: last.text,
|
|
lastAt: last.at,
|
|
));
|
|
}
|
|
summaries.sort((a, b) => b.lastAt.compareTo(a.lastAt));
|
|
return summaries;
|
|
}
|
|
|
|
Future<List<String>> _peers() async {
|
|
final raw = await _store.read(_indexKey);
|
|
if (raw == null || raw.isEmpty) return const [];
|
|
return raw.split('\n').where((s) => s.isNotEmpty).toList();
|
|
}
|
|
|
|
Future<void> _rememberPeer(String peer) async {
|
|
final peers = await _peers();
|
|
if (peers.contains(peer)) return;
|
|
await _store.write(_indexKey, [...peers, peer].join('\n'));
|
|
}
|
|
}
|