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:
parent
171daabce3
commit
6cff0d0b11
27 changed files with 1793 additions and 264 deletions
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue