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
88
docs/design/chat-storage.md
Normal file
88
docs/design/chat-storage.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 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.
|
||||
|
||||
## 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.
|
||||
|
|
@ -23,6 +23,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
|
|||
- **Estrategia de relays:** app-as-relay oportunista + proximidad física + relays comunitarios. → network-trust §3
|
||||
- **Parámetros de la red de confianza — RESUELTO/IMPLEMENTADO (2026-07-10).** Modelo **membresía Duniter completa**: regla pura `WebOfTrust.membersWith(seeds, WotParams)` con `WotParams` (sigQty/stepMax/sigValidity) por defecto Ğ1 (5/5/1año) **configurables** (`WotSettings`, keystore) — una red joven los afloja y aprieta al crecer. Cold-start honesto (sin inventar identidades): referentes "semilla" desde un asset empaquetado (vacío hasta curar fundadores reales) ∪ referentes que el usuario añade por npub/QR (`TrustReferents`). Caducidad ya la ponía el transporte (`certify` con `expiration`). UI: badge por `TrustTier` (miembro de la red > en tu círculo > avalado > desconocido) en el chat, y pantalla "Red de confianza" (gestión de raíces + parámetros) desde el perfil. Certificación por colectivo queda para sharing-model §6. → network-trust §2
|
||||
- **Mensajería:** alcance v1 (1:1 atada a oferta), apoyo en NIP-17. → network-trust §4
|
||||
- **Almacenamiento del historial de chat — RESUELTO/IMPLEMENTADO (2026-07-11).** El historial 1:1 se guarda en una **BD Drift/SQLCipher aparte** (`tane_chat.sqlite` / `ChatDatabase`), no en el keystore (era un blob JSON por conversación con read-modify-write O(n²), tope silencioso de 200 e índice manual). BD separada a propósito: el chat es **caché de red efímera por dispositivo**, no inventario — sin metadatos CRDT, fuera de la serie de migraciones del inventario, fuera del sync/backup. `append` = un insert indexado (dedup por clave única), historial sin tope. Pre-release: no se migran los blobs viejos del keystore. Mismo antipatrón (a escala trivial) en `OfferOutbox`/`TrustReferents`/`SocialSettings` → keystore aceptable ahí; solo el chat crece con el uso. → [chat-storage.md](chat-storage.md)
|
||||
- **Reputación** atada a un trato/oferta cerrada (evitar reseñas falsas). → sharing-model §6
|
||||
- **Precio:** ¿monedas comunitarias / de tiempo además de dinero? → sharing-model §6
|
||||
- **Integración Ğ1 (moneda libre):** niveles 1–2 (precio en Ğ1 + enlace a cartera Ğecko/Cesium²/Ğ1nkgo) en la capa social; nivel 3 (reusar la WoT de Ğ1 como fuente de confianza) como estudio aparte. → [g1-integration.md](g1-integration.md)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue