tane/apps/app_seeds/lib/services/message_store.dart
vjrj a109a84714 feat(identity): switch social identity, all from the one backup
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).
2026-07-10 20:22:24 +02:00

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'));
}
}