feat(chat): usable 1:1 chat — bottom-anchored, Drift-backed, dated

- Anchor the message list to the bottom (`reverse: true`) so new messages
  stay in view instead of landing below the fold.
- Move chat history from the OS keystore (O(n²) JSON blob, silent 200-msg
  cap) to a separate encrypted Drift/SQLCipher DB (`ChatDatabase`):
  indexed append, uncapped history, dedup as a unique-key invariant. It's
  an ephemeral per-device cache, isolated from the inventory schema, its
  migrations, and its sync. No data migration (pre-release).
- Add day separators (Today/Yesterday/locale date) and a per-bubble time,
  all via ICU (12/24h per locale; Localizations locale maps Asturian →
  Spanish for intl date symbols).
- Add peer avatars (deterministic colour from the pubkey + name initial),
  surface send failures that were previously silent, and make bubble text
  selectable (addresses, links).
- New i18n keys in en/es/pt/ast; tests for grouping, formatting, avatars,
  scroll anchoring, storage and send errors.

Docs: docs/design/chat-storage.md + open-decisions.md.
This commit is contained in:
vjrj 2026-07-11 06:39:39 +02:00
parent 44337497d0
commit 68b04ea409
27 changed files with 1793 additions and 264 deletions

View file

@ -1,14 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import '../security/secret_store.dart';
import '../db/chat_database.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({
@ -22,109 +16,107 @@ class ChatSummary {
final DateTime lastAt;
}
/// Persists 1:1 chat history in the encrypted [ChatDatabase] (so no plaintext at
/// rest). Append is O(log n) a single indexed insert, not a read-modify-write
/// of the whole conversation and history is uncapped: nothing is silently
/// dropped. De-duplication is a DB invariant (the [Messages] unique key), so a
/// gift wrap re-delivered by a relay never piles up.
///
/// [accountScope] namespaces rows per social identity (empty = the original
/// identity's legacy scope). See [socialAccountScope].
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.';
MessageStore(this._db, {String accountScope = ''}) : _scope = 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';
final ChatDatabase _db;
final String _scope;
/// 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),
),
];
final rows =
await (_db.select(_db.messages)
..where(
(m) =>
m.accountScope.equals(_scope) &
m.peerPubkey.equals(peerPubkey),
)
// Tie-break equal timestamps by insertion order (id) so history
// is stable matches the old append-order behaviour.
..orderBy([
(m) => OrderingTerm(expression: m.sentAt),
(m) => OrderingTerm(expression: m.id),
]))
.get();
return [for (final r in rows) _toMessage(r)];
}
/// 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.
/// Appends [message] to the conversation with [peerPubkey]. Idempotent: an
/// identical message (same sender, timestamp and text) is dropped instead of
/// duplicated, since a relay re-delivers stored gift wraps on every
/// (re)subscribe. 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;
final at = message.at.millisecondsSinceEpoch;
// SELECT-then-INSERT in a transaction so the dedup check and the write are
// atomic; the unique key is the backstop. Both hit the conversation index.
return _db.transaction(() async {
final existing =
await (_db.select(_db.messages)
..where(
(m) =>
m.accountScope.equals(_scope) &
m.peerPubkey.equals(peerPubkey) &
m.fromPubkey.equals(message.fromPubkey) &
m.sentAt.equals(at) &
m.body.equals(message.text),
)
..limit(1))
.getSingleOrNull();
if (existing != null) return false;
await _db
.into(_db.messages)
.insert(
MessagesCompanion.insert(
accountScope: _scope,
peerPubkey: peerPubkey,
fromPubkey: message.fromPubkey,
body: message.text,
sentAt: at,
),
);
return true;
});
}
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).
/// Conversations, most-recently-active first (for the messages inbox), each
/// with its latest message. One indexed scan; the last message per peer is
/// picked in Dart (peer counts are small).
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,
));
final rows =
await (_db.select(_db.messages)
..where((m) => m.accountScope.equals(_scope))
..orderBy([
(m) =>
OrderingTerm(expression: m.sentAt, mode: OrderingMode.desc),
(m) => OrderingTerm(expression: m.id, mode: OrderingMode.desc),
]))
.get();
final seen = <String>{};
final out = <ChatSummary>[];
for (final r in rows) {
if (!seen.add(r.peerPubkey)) continue; // first (newest) per peer wins
out.add(
ChatSummary(
peerPubkey: r.peerPubkey,
lastText: r.body,
lastAt: DateTime.fromMillisecondsSinceEpoch(r.sentAt),
),
);
}
summaries.sort((a, b) => b.lastAt.compareTo(a.lastAt));
return summaries;
return out;
}
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'));
}
PrivateMessage _toMessage(Message r) => PrivateMessage(
fromPubkey: r.fromPubkey,
text: r.body,
at: DateTime.fromMillisecondsSinceEpoch(r.sentAt),
);
}