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> 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 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> 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 = {}; final out = []; 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), ); }