tane/docs/design/chat-storage.md
vjrj 6cff0d0b11 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

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 v9 bump, no drift_schema_v9.json, no new migration_test case). ChatDatabase is schemaVersion = 1, created fresh; there is nothing to migrate.
  • It never trips the inventory sync stream (AppDatabase.tableUpdates() feeds SyncService); 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:

  • append is a single indexed insert (dedup-checked in a transaction), not a whole-conversation rewrite. Returns true only when newly stored.
  • history is uncapped, ordered by sentAt then id (stable insertion tie-break for equal timestamps).
  • conversations is 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.

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.