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:
vjrj 2026-07-11 06:39:39 +02:00
parent 171daabce3
commit 6cff0d0b11
27 changed files with 1793 additions and 264 deletions

View file

@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import '../data/species_catalog.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/chat_database.dart';
import '../db/database.dart';
import '../db/encrypted_executor.dart';
import '../i18n/strings.g.dart';
@ -98,6 +99,12 @@ Future<void> configureDependencies() async {
openEncryptedExecutor(await _databaseFile(), dbKeyHex),
);
// Chat history lives in its own encrypted database (same SQLCipher key),
// isolated from the inventory schema/sync. See [Messages].
final chatDatabase = ChatDatabase(
openEncryptedExecutor(await _chatDatabaseFile(), dbKeyHex),
);
// CRDT author id: a stable per-install slice of the root seed. Kept distinct
// from the social-layer public key on purpose unifying the two would rewrite
// authorship of existing rows, a data-model decision for later.
@ -116,7 +123,9 @@ Future<void> configureDependencies() async {
deviceId: deviceId,
);
} catch (e, s) {
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s');
debugPrint(
'Social identity derivation failed; inventory-only mode: $e\n$s',
);
}
// Seed the bundled species catalog before the UI opens but only once per
@ -152,6 +161,7 @@ Future<void> configureDependencies() async {
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<ChatDatabase>(chatDatabase)
..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService)
@ -166,11 +176,14 @@ Future<void> configureDependencies() async {
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
// Per-identity stores are namespaced by the active account's scope.
..registerSingleton<MessageStore>(
MessageStore(secretStore, accountScope: scope))
MessageStore(chatDatabase, accountScope: scope),
)
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope))
ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope))
ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,
@ -292,21 +305,27 @@ Future<void> switchSocialAccount(int account) async {
// Re-register the per-identity stores under the new scope, then the identity.
getIt
..registerSingleton<MessageStore>(
MessageStore(secretStore, accountScope: scope))
MessageStore(getIt<ChatDatabase>(), accountScope: scope),
)
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope))
ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope))
ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore));
UnreadService(getIt<MessageStore>(), secretStore),
);
final social = await SocialService.fromRootSeedHex(
rootSeedHex,
account: account,
deviceId: deviceId,
);
final connection =
SocialConnection(social: social, settings: getIt<SocialSettings>());
final connection = SocialConnection(
social: social,
settings: getIt<SocialSettings>(),
);
final inbox = InboxService(
connection: connection,
selfPubkey: social.publicKeyHex,
@ -342,6 +361,11 @@ Future<File> _databaseFile() async {
return File(p.join(dir.path, 'tane_inventory.sqlite'));
}
Future<File> _chatDatabaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'tane_chat.sqlite'));
}
/// Picks the Tesseract language pack(s) for the current locale(s), limited to
/// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen
/// language, then the device's ordered locales.