- 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.
4.4 KiB
Chat storage — spec
Where 1:1 chat history lives on each device, and why it is a separate encrypted database from the inventory.
Problem
Chat history was persisted as a JSON blob per conversation in the OS keystore
(flutter_secure_storage), via MessageStore. That is the wrong tool once a
conversation is used:
- Read-modify-write on every message. Each incoming/sent message read the whole conversation JSON, decoded it, checked duplicates in O(n), re-encoded the whole list, and wrote it back — O(n) per message, O(n²) over a conversation.
- Silent data loss. A hard cap of 200 messages per peer dropped the oldest without warning. It was a recent-cache, not storage.
- Keystore misuse. The OS keystore (Android
EncryptedSharedPreferences, iOS Keychain) is for small secrets (keys, tokens), not growing blobs. - Manual index. Peers were tracked in a
\n-joined string because the keystore can't enumerate keys; the inbox (conversations()) then read every peer's full blob just to get its last message.
Decision
Move chat history to an encrypted Drift/SQLCipher database, in its own file
(tane_chat.sqlite) — ChatDatabase, separate from the inventory
AppDatabase (tane_inventory.sqlite). Same SQLCipher key (from the OS
keystore), so "no plaintext at rest" holds.
Why a separate database, not a new table in AppDatabase
Chat is not inventory. It is an ephemeral, per-device cache of what the
relays delivered; it never enters an inventory snapshot, a .tanemaki backup,
or the device-to-device sync. Keeping it out of AppDatabase means:
- It carries no CRDT metadata (no HLC
updatedAt, tombstones,lastAuthor,schemaRowVersion) — those are meaningless for a network cache. - It stays out of the inventory's versioned migration series (no
v9bump, nodrift_schema_v9.json, no newmigration_testcase).ChatDatabaseisschemaVersion = 1, created fresh; there is nothing to migrate. - It never trips the inventory sync stream (
AppDatabase.tableUpdates()feedsSyncService); a message arriving can't spuriously republish the inventory.
Schema — Messages
One flat, indexed table (see apps/app_seeds/lib/db/chat_database.dart):
| column | notes |
|---|---|
id |
autoincrement — local rowid, also the stable tie-break for equal timestamps |
accountScope |
namespaces rows per social identity (was the keystore key prefix) |
peerPubkey |
the conversation |
fromPubkey |
sender (self vs peer → bubble side) |
body |
message text |
sentAt |
ms since epoch (NIP-17 timestamp) — ordering + read mark |
- Index
(accountScope, peerPubkey, sentAt)serves history and the inbox. - Unique key
(accountScope, peerPubkey, fromPubkey, sentAt, body)makes de-duplication a DB invariant: a relay re-delivers the same gift wrap on every (re)subscribe, and an identical row simply isn't inserted twice.
Behaviour vs. the old store
MessageStore keeps its public API (history / append / conversations), so
MessagesCubit, InboxService, UnreadService and the inbox screen are
unchanged. What changes:
appendis a single indexed insert (dedup-checked in a transaction), not a whole-conversation rewrite. Returnstrueonly when newly stored.historyis uncapped, ordered bysentAtthenid(stable insertion tie-break for equal timestamps).conversationsis one indexed scan; the newest message per peer is picked in Dart (peer counts are small).
Not migrating old data
Pre-release: there are no users, so the old keystore blobs are not migrated.
The keystore keys (tane.social.chat.*, tane.social.chats) are simply
abandoned. If that changes before release, a one-shot import can be added.
Related, deliberately not changed
The same keystore-blob anti-pattern exists, at trivial scale, in OfferOutbox
(pending lot ids), TrustReferents (WoT referent set) and SocialSettings
(relay URLs). These are small, bounded, rarely-written lists — the keystore is
acceptable there. Only chat grows with everyday use, so only chat moved. If any
of those grows unexpectedly, the same Drift treatment applies.