tane/apps/app_seeds/lib/services/message_store.dart
vjrj 68b04ea409 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.
2026-07-11 06:40:50 +02:00

122 lines
4.3 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import '../db/chat_database.dart';
/// 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;
}
/// 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 {
MessageStore(this._db, {String accountScope = ''}) : _scope = accountScope;
final ChatDatabase _db;
final String _scope;
/// Messages exchanged with [peerPubkey], oldest first.
Future<List<PrivateMessage>> history(String peerPubkey) async {
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]. 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) {
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;
});
}
/// 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 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),
),
);
}
return out;
}
PrivateMessage _toMessage(Message r) => PrivateMessage(
fromPubkey: r.fromPubkey,
text: r.body,
at: DateTime.fromMillisecondsSinceEpoch(r.sentAt),
);
}