- 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.
122 lines
4.3 KiB
Dart
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),
|
|
);
|
|
}
|