From 6cff0d0b111ee283ee63b7174913d5ca155c8cc9 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 06:39:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20usable=201:1=20chat=20=E2=80=94?= =?UTF-8?q?=20bottom-anchored,=20Drift-backed,=20dated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/app_seeds/lib/db/chat_database.dart | 54 ++ apps/app_seeds/lib/db/chat_database.g.dart | 654 ++++++++++++++++++ apps/app_seeds/lib/di/injector.dart | 44 +- apps/app_seeds/lib/domain/chat_timeline.dart | 70 ++ apps/app_seeds/lib/i18n/ast.i18n.json | 5 +- apps/app_seeds/lib/i18n/en.i18n.json | 5 +- apps/app_seeds/lib/i18n/es.i18n.json | 5 +- apps/app_seeds/lib/i18n/pt.i18n.json | 5 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 6 + apps/app_seeds/lib/i18n/strings_en.g.dart | 12 + apps/app_seeds/lib/i18n/strings_es.g.dart | 6 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 6 + .../app_seeds/lib/services/message_store.dart | 190 +++-- apps/app_seeds/lib/ui/chat_screen.dart | 266 +++++-- apps/app_seeds/lib/ui/peer_avatar.dart | 59 ++ .../test/domain/chat_timeline_test.dart | 120 ++++ .../test/services/inbox_service_test.dart | 2 +- .../test/services/message_store_test.dart | 99 +-- .../test/services/unread_service_test.dart | 2 +- .../test/state/messages_cubit_test.dart | 144 ++-- apps/app_seeds/test/support/test_support.dart | 4 + .../test/ui/chat_message_list_test.dart | 166 +++++ apps/app_seeds/test/ui/peer_avatar_test.dart | 38 + apps/app_seeds/test/ui/unread_badge_test.dart | 2 +- docs/design/chat-storage.md | 88 +++ docs/design/open-decisions.md | 1 + 27 files changed, 1793 insertions(+), 264 deletions(-) create mode 100644 apps/app_seeds/lib/db/chat_database.dart create mode 100644 apps/app_seeds/lib/db/chat_database.g.dart create mode 100644 apps/app_seeds/lib/domain/chat_timeline.dart create mode 100644 apps/app_seeds/lib/ui/peer_avatar.dart create mode 100644 apps/app_seeds/test/domain/chat_timeline_test.dart create mode 100644 apps/app_seeds/test/ui/chat_message_list_test.dart create mode 100644 apps/app_seeds/test/ui/peer_avatar_test.dart create mode 100644 docs/design/chat-storage.md diff --git a/apps/app_seeds/lib/db/chat_database.dart b/apps/app_seeds/lib/db/chat_database.dart new file mode 100644 index 0000000..9411178 --- /dev/null +++ b/apps/app_seeds/lib/db/chat_database.dart @@ -0,0 +1,54 @@ +import 'package:drift/drift.dart'; + +part 'chat_database.g.dart'; + +/// A 1:1 chat message, held locally so a conversation survives leaving the +/// screen and going offline. +/// +/// This is deliberately NOT a [SyncColumns] inventory row: chat history is an +/// ephemeral, per-device cache of what the relays delivered — not the user's +/// CRDT-synced inventory. It never enters an inventory snapshot, backup, or the +/// device-to-device sync, so it needs no HLC / tombstone / author metadata. It +/// lives in its own encrypted database ([ChatDatabase]) to keep it fully +/// isolated from the inventory schema, its migrations, and its sync stream. +/// +/// [accountScope] namespaces rows per social identity (empty = the original +/// identity), replacing the old per-key keystore prefix. The unique key makes +/// de-duplication a DB invariant: a relay re-delivers the same gift wrap on +/// every (re)subscribe, and an identical (scope, peer, sender, timestamp, text) +/// row is simply not inserted twice. +@TableIndex( + name: 'messages_by_conversation', + columns: {#accountScope, #peerPubkey, #sentAt}, +) +class Messages extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get accountScope => text()(); + TextColumn get peerPubkey => text()(); + TextColumn get fromPubkey => text()(); + TextColumn get body => text()(); + + /// Milliseconds since epoch — the message's own timestamp (NIP-17), used for + /// ordering and for the read/unread mark. + IntColumn get sentAt => integer()(); + + @override + List> get uniqueKeys => [ + {accountScope, peerPubkey, fromPubkey, sentAt, body}, + ]; +} + +/// The encrypted local chat cache (SQLCipher via an injected executor, exactly +/// like [AppDatabase]). Separate database on purpose — see [Messages]. There is +/// no historical schema to migrate: version 1, created fresh. +@DriftDatabase(tables: [Messages]) +class ChatDatabase extends _$ChatDatabase { + ChatDatabase(super.e); + + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => + MigrationStrategy(onCreate: (m) async => m.createAll()); +} diff --git a/apps/app_seeds/lib/db/chat_database.g.dart b/apps/app_seeds/lib/db/chat_database.g.dart new file mode 100644 index 0000000..ce9c74a --- /dev/null +++ b/apps/app_seeds/lib/db/chat_database.g.dart @@ -0,0 +1,654 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_database.dart'; + +// ignore_for_file: type=lint +class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _accountScopeMeta = const VerificationMeta( + 'accountScope', + ); + @override + late final GeneratedColumn accountScope = GeneratedColumn( + 'account_scope', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _peerPubkeyMeta = const VerificationMeta( + 'peerPubkey', + ); + @override + late final GeneratedColumn peerPubkey = GeneratedColumn( + 'peer_pubkey', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _fromPubkeyMeta = const VerificationMeta( + 'fromPubkey', + ); + @override + late final GeneratedColumn fromPubkey = GeneratedColumn( + 'from_pubkey', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sentAtMeta = const VerificationMeta('sentAt'); + @override + late final GeneratedColumn sentAt = GeneratedColumn( + 'sent_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountScope, + peerPubkey, + fromPubkey, + body, + sentAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'messages'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('account_scope')) { + context.handle( + _accountScopeMeta, + accountScope.isAcceptableOrUnknown( + data['account_scope']!, + _accountScopeMeta, + ), + ); + } else if (isInserting) { + context.missing(_accountScopeMeta); + } + if (data.containsKey('peer_pubkey')) { + context.handle( + _peerPubkeyMeta, + peerPubkey.isAcceptableOrUnknown(data['peer_pubkey']!, _peerPubkeyMeta), + ); + } else if (isInserting) { + context.missing(_peerPubkeyMeta); + } + if (data.containsKey('from_pubkey')) { + context.handle( + _fromPubkeyMeta, + fromPubkey.isAcceptableOrUnknown(data['from_pubkey']!, _fromPubkeyMeta), + ); + } else if (isInserting) { + context.missing(_fromPubkeyMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } else if (isInserting) { + context.missing(_bodyMeta); + } + if (data.containsKey('sent_at')) { + context.handle( + _sentAtMeta, + sentAt.isAcceptableOrUnknown(data['sent_at']!, _sentAtMeta), + ); + } else if (isInserting) { + context.missing(_sentAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {accountScope, peerPubkey, fromPubkey, sentAt, body}, + ]; + @override + Message map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Message( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + accountScope: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_scope'], + )!, + peerPubkey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}peer_pubkey'], + )!, + fromPubkey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}from_pubkey'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + )!, + sentAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sent_at'], + )!, + ); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(attachedDatabase, alias); + } +} + +class Message extends DataClass implements Insertable { + final int id; + final String accountScope; + final String peerPubkey; + final String fromPubkey; + final String body; + + /// Milliseconds since epoch — the message's own timestamp (NIP-17), used for + /// ordering and for the read/unread mark. + final int sentAt; + const Message({ + required this.id, + required this.accountScope, + required this.peerPubkey, + required this.fromPubkey, + required this.body, + required this.sentAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_scope'] = Variable(accountScope); + map['peer_pubkey'] = Variable(peerPubkey); + map['from_pubkey'] = Variable(fromPubkey); + map['body'] = Variable(body); + map['sent_at'] = Variable(sentAt); + return map; + } + + MessagesCompanion toCompanion(bool nullToAbsent) { + return MessagesCompanion( + id: Value(id), + accountScope: Value(accountScope), + peerPubkey: Value(peerPubkey), + fromPubkey: Value(fromPubkey), + body: Value(body), + sentAt: Value(sentAt), + ); + } + + factory Message.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Message( + id: serializer.fromJson(json['id']), + accountScope: serializer.fromJson(json['accountScope']), + peerPubkey: serializer.fromJson(json['peerPubkey']), + fromPubkey: serializer.fromJson(json['fromPubkey']), + body: serializer.fromJson(json['body']), + sentAt: serializer.fromJson(json['sentAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountScope': serializer.toJson(accountScope), + 'peerPubkey': serializer.toJson(peerPubkey), + 'fromPubkey': serializer.toJson(fromPubkey), + 'body': serializer.toJson(body), + 'sentAt': serializer.toJson(sentAt), + }; + } + + Message copyWith({ + int? id, + String? accountScope, + String? peerPubkey, + String? fromPubkey, + String? body, + int? sentAt, + }) => Message( + id: id ?? this.id, + accountScope: accountScope ?? this.accountScope, + peerPubkey: peerPubkey ?? this.peerPubkey, + fromPubkey: fromPubkey ?? this.fromPubkey, + body: body ?? this.body, + sentAt: sentAt ?? this.sentAt, + ); + Message copyWithCompanion(MessagesCompanion data) { + return Message( + id: data.id.present ? data.id.value : this.id, + accountScope: data.accountScope.present + ? data.accountScope.value + : this.accountScope, + peerPubkey: data.peerPubkey.present + ? data.peerPubkey.value + : this.peerPubkey, + fromPubkey: data.fromPubkey.present + ? data.fromPubkey.value + : this.fromPubkey, + body: data.body.present ? data.body.value : this.body, + sentAt: data.sentAt.present ? data.sentAt.value : this.sentAt, + ); + } + + @override + String toString() { + return (StringBuffer('Message(') + ..write('id: $id, ') + ..write('accountScope: $accountScope, ') + ..write('peerPubkey: $peerPubkey, ') + ..write('fromPubkey: $fromPubkey, ') + ..write('body: $body, ') + ..write('sentAt: $sentAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, accountScope, peerPubkey, fromPubkey, body, sentAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Message && + other.id == this.id && + other.accountScope == this.accountScope && + other.peerPubkey == this.peerPubkey && + other.fromPubkey == this.fromPubkey && + other.body == this.body && + other.sentAt == this.sentAt); +} + +class MessagesCompanion extends UpdateCompanion { + final Value id; + final Value accountScope; + final Value peerPubkey; + final Value fromPubkey; + final Value body; + final Value sentAt; + const MessagesCompanion({ + this.id = const Value.absent(), + this.accountScope = const Value.absent(), + this.peerPubkey = const Value.absent(), + this.fromPubkey = const Value.absent(), + this.body = const Value.absent(), + this.sentAt = const Value.absent(), + }); + MessagesCompanion.insert({ + this.id = const Value.absent(), + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }) : accountScope = Value(accountScope), + peerPubkey = Value(peerPubkey), + fromPubkey = Value(fromPubkey), + body = Value(body), + sentAt = Value(sentAt); + static Insertable custom({ + Expression? id, + Expression? accountScope, + Expression? peerPubkey, + Expression? fromPubkey, + Expression? body, + Expression? sentAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountScope != null) 'account_scope': accountScope, + if (peerPubkey != null) 'peer_pubkey': peerPubkey, + if (fromPubkey != null) 'from_pubkey': fromPubkey, + if (body != null) 'body': body, + if (sentAt != null) 'sent_at': sentAt, + }); + } + + MessagesCompanion copyWith({ + Value? id, + Value? accountScope, + Value? peerPubkey, + Value? fromPubkey, + Value? body, + Value? sentAt, + }) { + return MessagesCompanion( + id: id ?? this.id, + accountScope: accountScope ?? this.accountScope, + peerPubkey: peerPubkey ?? this.peerPubkey, + fromPubkey: fromPubkey ?? this.fromPubkey, + body: body ?? this.body, + sentAt: sentAt ?? this.sentAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountScope.present) { + map['account_scope'] = Variable(accountScope.value); + } + if (peerPubkey.present) { + map['peer_pubkey'] = Variable(peerPubkey.value); + } + if (fromPubkey.present) { + map['from_pubkey'] = Variable(fromPubkey.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (sentAt.present) { + map['sent_at'] = Variable(sentAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('accountScope: $accountScope, ') + ..write('peerPubkey: $peerPubkey, ') + ..write('fromPubkey: $fromPubkey, ') + ..write('body: $body, ') + ..write('sentAt: $sentAt') + ..write(')')) + .toString(); + } +} + +abstract class _$ChatDatabase extends GeneratedDatabase { + _$ChatDatabase(QueryExecutor e) : super(e); + $ChatDatabaseManager get managers => $ChatDatabaseManager(this); + late final $MessagesTable messages = $MessagesTable(this); + late final Index messagesByConversation = Index( + 'messages_by_conversation', + 'CREATE INDEX messages_by_conversation ON messages (account_scope, peer_pubkey, sent_at)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + messages, + messagesByConversation, + ]; +} + +typedef $$MessagesTableCreateCompanionBuilder = + MessagesCompanion Function({ + Value id, + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }); +typedef $$MessagesTableUpdateCompanionBuilder = + MessagesCompanion Function({ + Value id, + Value accountScope, + Value peerPubkey, + Value fromPubkey, + Value body, + Value sentAt, + }); + +class $$MessagesTableFilterComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sentAt => $composableBuilder( + column: $table.sentAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$MessagesTableOrderingComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sentAt => $composableBuilder( + column: $table.sentAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$MessagesTableAnnotationComposer + extends Composer<_$ChatDatabase, $MessagesTable> { + $$MessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get accountScope => $composableBuilder( + column: $table.accountScope, + builder: (column) => column, + ); + + GeneratedColumn get peerPubkey => $composableBuilder( + column: $table.peerPubkey, + builder: (column) => column, + ); + + GeneratedColumn get fromPubkey => $composableBuilder( + column: $table.fromPubkey, + builder: (column) => column, + ); + + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + GeneratedColumn get sentAt => + $composableBuilder(column: $table.sentAt, builder: (column) => column); +} + +class $$MessagesTableTableManager + extends + RootTableManager< + _$ChatDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$ChatDatabase, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + > { + $$MessagesTableTableManager(_$ChatDatabase db, $MessagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountScope = const Value.absent(), + Value peerPubkey = const Value.absent(), + Value fromPubkey = const Value.absent(), + Value body = const Value.absent(), + Value sentAt = const Value.absent(), + }) => MessagesCompanion( + id: id, + accountScope: accountScope, + peerPubkey: peerPubkey, + fromPubkey: fromPubkey, + body: body, + sentAt: sentAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String accountScope, + required String peerPubkey, + required String fromPubkey, + required String body, + required int sentAt, + }) => MessagesCompanion.insert( + id: id, + accountScope: accountScope, + peerPubkey: peerPubkey, + fromPubkey: fromPubkey, + body: body, + sentAt: sentAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$MessagesTableProcessedTableManager = + ProcessedTableManager< + _$ChatDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$ChatDatabase, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + >; + +class $ChatDatabaseManager { + final _$ChatDatabase _db; + $ChatDatabaseManager(this._db); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db, _db.messages); +} diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 04abfad..7a8209c 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -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 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 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 configureDependencies() async { getIt ..registerSingleton(keyStore) ..registerSingleton(database) + ..registerSingleton(chatDatabase) ..registerSingleton(speciesRepository) ..registerSingleton(varietyRepository) ..registerSingleton(fileService) @@ -166,11 +176,14 @@ Future configureDependencies() async { ..registerSingleton(OfferOutbox(secretStore)) // Per-identity stores are namespaced by the active account's scope. ..registerSingleton( - MessageStore(secretStore, accountScope: scope)) + MessageStore(chatDatabase, accountScope: scope), + ) ..registerSingleton( - ProfileStore(secretStore, accountScope: scope)) + ProfileStore(secretStore, accountScope: scope), + ) ..registerSingleton( - ProfileCache(secretStore, accountScope: scope)) + ProfileCache(secretStore, accountScope: scope), + ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -292,21 +305,27 @@ Future switchSocialAccount(int account) async { // Re-register the per-identity stores under the new scope, then the identity. getIt ..registerSingleton( - MessageStore(secretStore, accountScope: scope)) + MessageStore(getIt(), accountScope: scope), + ) ..registerSingleton( - ProfileStore(secretStore, accountScope: scope)) + ProfileStore(secretStore, accountScope: scope), + ) ..registerSingleton( - ProfileCache(secretStore, accountScope: scope)) + ProfileCache(secretStore, accountScope: scope), + ) ..registerSingleton( - UnreadService(getIt(), secretStore)); + UnreadService(getIt(), secretStore), + ); final social = await SocialService.fromRootSeedHex( rootSeedHex, account: account, deviceId: deviceId, ); - final connection = - SocialConnection(social: social, settings: getIt()); + final connection = SocialConnection( + social: social, + settings: getIt(), + ); final inbox = InboxService( connection: connection, selfPubkey: social.publicKeyHex, @@ -342,6 +361,11 @@ Future _databaseFile() async { return File(p.join(dir.path, 'tane_inventory.sqlite')); } +Future _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. diff --git a/apps/app_seeds/lib/domain/chat_timeline.dart b/apps/app_seeds/lib/domain/chat_timeline.dart new file mode 100644 index 0000000..1fe0862 --- /dev/null +++ b/apps/app_seeds/lib/domain/chat_timeline.dart @@ -0,0 +1,70 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:intl/intl.dart'; + +/// One row of the rendered chat: either a day separator or a message. Pure data +/// so the grouping can be unit-tested without widgets. +sealed class ChatTimelineItem { + const ChatTimelineItem(); +} + +/// A "— Today —" style divider before the first message of a calendar day. +class ChatDaySeparator extends ChatTimelineItem { + const ChatDaySeparator(this.day); + + /// Local midnight of the day this divider introduces. + final DateTime day; +} + +/// A chat bubble. +class ChatMessageRow extends ChatTimelineItem { + const ChatMessageRow(this.message); + + final PrivateMessage message; +} + +/// Groups [messages] (oldest first) into a timeline, inserting a +/// [ChatDaySeparator] before the first message of each local calendar day. +/// Result stays oldest-first. +List chatTimeline(List messages) { + final out = []; + DateTime? lastDay; + for (final m in messages) { + final day = DateTime(m.at.year, m.at.month, m.at.day); + if (lastDay == null || day != lastDay) { + out.add(ChatDaySeparator(day)); + lastDay = day; + } + out.add(ChatMessageRow(m)); + } + return out; +} + +/// The human label for a day separator: "Today"/"Yesterday" for the two most +/// recent days (localized by the caller), otherwise a locale-formatted date +/// (weekday + day + month, plus year when it differs from [now]). +/// +/// [localeCode] must be an `intl`-known locale — pass the *Localizations* locale +/// (which maps Asturian → Spanish), never the raw app locale, since `ast` has no +/// `intl` date symbols and would throw. Mirrors backup_section.dart. +String chatDayLabel( + DateTime day, { + required DateTime now, + required String today, + required String yesterday, + required String localeCode, +}) { + final startOfToday = DateTime(now.year, now.month, now.day); + final startOfDay = DateTime(day.year, day.month, day.day); + final diffDays = startOfToday.difference(startOfDay).inDays; + if (diffDays == 0) return today; + if (diffDays == 1) return yesterday; + final format = day.year == now.year + ? DateFormat.MMMMEEEEd(localeCode) + : DateFormat.yMMMMEEEEd(localeCode); + return format.format(day); +} + +/// The clock time shown on a bubble, in the locale's own 12/24-hour convention. +/// See [chatDayLabel] for the [localeCode] caveat. +String chatBubbleTime(DateTime at, String localeCode) => + DateFormat.jm(localeCode).format(at); diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index c30bd62..be8495a 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -528,7 +528,10 @@ "empty": "Entá nun hai mensaxes — saluda", "offline": "Configura'l compartir pa unviar mensaxes", "payG1": "Pagar en Ğ1", - "g1Copied": "Direición Ğ1 copiada — apégala na to cartera" + "g1Copied": "Direición Ğ1 copiada — apégala na to cartera", + "today": "Güei", + "yesterday": "Ayeri", + "sendError": "Nun se pudo unviar — comprueba la conexón" }, "trust": { "none": "Naide los avala entá", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index edd46df..a6d2814 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -531,7 +531,10 @@ "empty": "No messages yet — say hello", "offline": "Set up sharing to send messages", "payG1": "Pay in Ğ1", - "g1Copied": "Ğ1 address copied — paste it in your wallet" + "g1Copied": "Ğ1 address copied — paste it in your wallet", + "today": "Today", + "yesterday": "Yesterday", + "sendError": "Couldn't send — check your connection" }, "trust": { "none": "No one vouches for them yet", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index d933b16..6aaea98 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -530,7 +530,10 @@ "empty": "Aún no hay mensajes — saluda", "offline": "Configura el compartir para enviar mensajes", "payG1": "Pagar en Ğ1", - "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera" + "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera", + "today": "Hoy", + "yesterday": "Ayer", + "sendError": "No se pudo enviar — revisa tu conexión" }, "trust": { "none": "Nadie los avala aún", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 8cdd94b..31e8660 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -527,7 +527,10 @@ "empty": "Ainda não há mensagens — diz olá", "offline": "Configura a partilha para enviar mensagens", "payG1": "Pagar em Ğ1", - "g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira" + "g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira", + "today": "Hoje", + "yesterday": "Ontem", + "sendError": "Não foi possível enviar — verifica a tua ligação" }, "trust": { "none": "Ainda ninguém os avaliza", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 7c0e805..a7c770b 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1852 (463 per locale) +/// Strings: 1864 (466 per locale) /// -/// Built on 2026-07-11 at 04:24 UTC +/// Built on 2026-07-11 at 04:40 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 98cd34b..42aa1bd 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -770,6 +770,9 @@ class _Translations$chat$ast extends Translations$chat$en { @override String get offline => 'Configura\'l compartir pa unviar mensaxes'; @override String get payG1 => 'Pagar en Ğ1'; @override String get g1Copied => 'Direición Ğ1 copiada — apégala na to cartera'; + @override String get today => 'Güei'; + @override String get yesterday => 'Ayeri'; + @override String get sendError => 'Nun se pudo unviar — comprueba la conexón'; } // Path: trust @@ -1620,6 +1623,9 @@ extension on TranslationsAst { 'chat.offline' => 'Configura\'l compartir pa unviar mensaxes', 'chat.payG1' => 'Pagar en Ğ1', 'chat.g1Copied' => 'Direición Ğ1 copiada — apégala na to cartera', + 'chat.today' => 'Güei', + 'chat.yesterday' => 'Ayeri', + 'chat.sendError' => 'Nun se pudo unviar — comprueba la conexón', 'trust.none' => 'Naide los avala entá', 'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.vouch' => 'Conozo a esta persona', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index c9547bd..e036499 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1437,6 +1437,15 @@ class Translations$chat$en { /// en: 'Ğ1 address copied — paste it in your wallet' String get g1Copied => 'Ğ1 address copied — paste it in your wallet'; + + /// en: 'Today' + String get today => 'Today'; + + /// en: 'Yesterday' + String get yesterday => 'Yesterday'; + + /// en: 'Couldn't send — check your connection' + String get sendError => 'Couldn\'t send — check your connection'; } // Path: trust @@ -2536,6 +2545,9 @@ extension on Translations { 'chat.offline' => 'Set up sharing to send messages', 'chat.payG1' => 'Pay in Ğ1', 'chat.g1Copied' => 'Ğ1 address copied — paste it in your wallet', + 'chat.today' => 'Today', + 'chat.yesterday' => 'Yesterday', + 'chat.sendError' => 'Couldn\'t send — check your connection', 'trust.none' => 'No one vouches for them yet', 'trust.count' => ({required Object n}) => 'Vouched for by ${n}', 'trust.vouch' => 'I know this person', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 97343d0..f65c5aa 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -772,6 +772,9 @@ class _Translations$chat$es extends Translations$chat$en { @override String get offline => 'Configura el compartir para enviar mensajes'; @override String get payG1 => 'Pagar en Ğ1'; @override String get g1Copied => 'Dirección Ğ1 copiada — pégala en tu cartera'; + @override String get today => 'Hoy'; + @override String get yesterday => 'Ayer'; + @override String get sendError => 'No se pudo enviar — revisa tu conexión'; } // Path: trust @@ -1624,6 +1627,9 @@ extension on TranslationsEs { 'chat.offline' => 'Configura el compartir para enviar mensajes', 'chat.payG1' => 'Pagar en Ğ1', 'chat.g1Copied' => 'Dirección Ğ1 copiada — pégala en tu cartera', + 'chat.today' => 'Hoy', + 'chat.yesterday' => 'Ayer', + 'chat.sendError' => 'No se pudo enviar — revisa tu conexión', 'trust.none' => 'Nadie los avala aún', 'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.vouch' => 'Conozco a esta persona', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 188ea2c..532937f 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -769,6 +769,9 @@ class _Translations$chat$pt extends Translations$chat$en { @override String get offline => 'Configura a partilha para enviar mensagens'; @override String get payG1 => 'Pagar em Ğ1'; @override String get g1Copied => 'Endereço Ğ1 copiado — cola-o na tua carteira'; + @override String get today => 'Hoje'; + @override String get yesterday => 'Ontem'; + @override String get sendError => 'Não foi possível enviar — verifica a tua ligação'; } // Path: trust @@ -1618,6 +1621,9 @@ extension on TranslationsPt { 'chat.offline' => 'Configura a partilha para enviar mensagens', 'chat.payG1' => 'Pagar em Ğ1', 'chat.g1Copied' => 'Endereço Ğ1 copiado — cola-o na tua carteira', + 'chat.today' => 'Hoje', + 'chat.yesterday' => 'Ontem', + 'chat.sendError' => 'Não foi possível enviar — verifica a tua ligação', 'trust.none' => 'Ainda ninguém os avaliza', 'trust.count' => ({required Object n}) => 'Avalizada por ${n}', 'trust.vouch' => 'Conheço esta pessoa', diff --git a/apps/app_seeds/lib/services/message_store.dart b/apps/app_seeds/lib/services/message_store.dart index cf9cdaf..d08d267 100644 --- a/apps/app_seeds/lib/services/message_store.dart +++ b/apps/app_seeds/lib/services/message_store.dart @@ -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 _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> 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>()) - 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 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 _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> conversations() async { - final summaries = []; - 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 = {}; + final out = []; + 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> _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 _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), + ); } diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 4938c89..1bdcac8 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; import '../di/injector.dart'; +import '../domain/chat_timeline.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; @@ -17,6 +18,7 @@ import '../services/unread_service.dart'; import '../services/wot_settings.dart'; import '../state/messages_cubit.dart'; import '../state/trust_cubit.dart'; +import 'peer_avatar.dart'; import 'theme.dart'; /// A 1:1 encrypted chat with one peer (redesign screen 10). Private and @@ -40,7 +42,8 @@ class ChatScreen extends StatefulWidget { final SocialConnection connection; final String peerPubkey; - /// Optional persistence for chat history (keystore-backed); null in tests. + /// Optional persistence for chat history (encrypted [ChatDatabase]-backed); + /// null in tests. final MessageStore? messageStore; /// Optional cache of peer display names; null in tests. @@ -70,8 +73,9 @@ class _ChatScreenState extends State { @override void initState() { super.initState(); - _unread = - getIt.isRegistered() ? getIt() : null; + _unread = getIt.isRegistered() + ? getIt() + : null; // Set synchronously (before any await) so a message landing during _init is // already suppressed. _unread?.activePeer = widget.peerPubkey; @@ -85,8 +89,7 @@ class _ChatScreenState extends State { final session = await widget.connection.session(); final cachedName = await widget.profileCache?.name(widget.peerPubkey); final referents = await widget.trustReferents?.all() ?? const {}; - final wotParams = - await widget.wotSettings?.params() ?? WotParams.duniter; + final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter; if (!mounted) return; // shared session is owned by the connection, not us final self = widget.social.publicKeyHex; final messages = MessagesCubit( @@ -118,8 +121,10 @@ class _ChatScreenState extends State { final profile = await live.profile.fetch(widget.peerPubkey); if (profile != null && mounted) { if (profile.name.isNotEmpty) { - await widget.profileCache! - .setName(widget.peerPubkey, profile.name); + await widget.profileCache!.setName( + widget.peerPubkey, + profile.name, + ); } setState(() { if (profile.name.isNotEmpty) _peerName = profile.name; @@ -203,17 +208,26 @@ class _ChatScreenState extends State { BlocProvider.value(value: messages), BlocProvider.value(value: trust), ], - child: _ChatBody(controller: _input, onSend: _send), + child: _ChatBody( + controller: _input, + onSend: _send, + peerName: _peerName, + ), ), ); } } class _ChatBody extends StatelessWidget { - const _ChatBody({required this.controller, required this.onSend}); + const _ChatBody({ + required this.controller, + required this.onSend, + this.peerName, + }); final TextEditingController controller; final Future Function() onSend; + final String? peerName; @override Widget build(BuildContext context) { @@ -231,31 +245,68 @@ class _ChatBody extends StatelessWidget { ), ); } - return Column( - children: [ - const _TrustBanner(), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.messages.isEmpty) { - return Center( - child: Text(t.chat.empty, - style: const TextStyle(color: seedMuted)), - ); - } - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: state.messages.length, - itemBuilder: (context, i) { - final m = state.messages[i]; - return _Bubble(text: m.text, mine: cubit.isMine(m)); - }, - ); - }, - ), - ), - _Composer(controller: controller, onSend: onSend), - ], + // Surface a failed send instead of losing it silently (relay down, etc.). + return BlocListener( + listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error, + listener: (context, state) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.chat.sendError))); + }, + child: Column( + children: [ + const _TrustBanner(), + Expanded(child: ChatMessageList(peerName: peerName)), + _Composer(controller: controller, onSend: onSend), + ], + ), + ); + } +} + +/// The scrolling list of chat bubbles. Anchored at the bottom (chat +/// convention): the newest message is always in view, and new arrivals show up +/// in place instead of landing below the fold. Reads the [MessagesCubit] from +/// context. +class ChatMessageList extends StatelessWidget { + const ChatMessageList({this.peerName, super.key}); + + /// The peer's display name, for their avatar's initial (null → a glyph). + final String? peerName; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + return BlocBuilder( + builder: (context, state) { + if (state.messages.isEmpty) { + return Center( + child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)), + ); + } + // Group into bubbles + day separators (oldest-first), then render + // bottom-anchored: with `reverse: true` index 0 is the bottom-most + // (newest), so map it to the tail. Scrolling up to read history stays + // put when new messages come in. + final items = chatTimeline(state.messages); + return ListView.builder( + reverse: true, + padding: const EdgeInsets.all(12), + itemCount: items.length, + itemBuilder: (context, i) { + final item = items[items.length - 1 - i]; + return switch (item) { + ChatDaySeparator(:final day) => _DaySeparator(day: day), + ChatMessageRow(:final message) => _MessageRow( + message: message, + mine: cubit.isMine(message), + peerName: peerName, + ), + }; + }, + ); + }, ); } } @@ -278,10 +329,10 @@ class _TrustBanner extends StatelessWidget { TrustTier.networkMember => (Icons.verified, t.trust.member, true), TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true), TrustTier.vouched => ( - Icons.people_outline, - t.trust.count(n: state.certifierCount), - false, - ), + Icons.people_outline, + t.trust.count(n: state.certifierCount), + false, + ), TrustTier.unknown => (Icons.person_outline, t.trust.none, false), }; return Container( @@ -315,33 +366,124 @@ class _TrustBanner extends StatelessWidget { } } -class _Bubble extends StatelessWidget { - const _Bubble({required this.text, required this.mine}); +/// A centered pill introducing a new day ("Today", "Yesterday", or a +/// locale-formatted date). Localized via [chatDayLabel]. +class _DaySeparator extends StatelessWidget { + const _DaySeparator({required this.day}); - final String text; + final DateTime day; + + @override + Widget build(BuildContext context) { + final t = context.t; + // Localizations locale, not the raw app locale: `ast` has no intl date + // symbols and is mapped to Spanish for the framework (see app.dart). + final localeCode = Localizations.localeOf(context).languageCode; + final label = chatDayLabel( + day, + now: DateTime.now(), + today: t.chat.today, + yesterday: t.chat.yesterday, + localeCode: localeCode, + ); + return Center( + key: const Key('chat.daySeparator'), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: seedMuted.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: const TextStyle( + color: seedMuted, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +/// One message line: my messages align to the trailing edge with no avatar; +/// the peer's align to the leading edge with their avatar (1:1 convention). +class _MessageRow extends StatelessWidget { + const _MessageRow({required this.message, required this.mine, this.peerName}); + + final PrivateMessage message; + final bool mine; + final String? peerName; + + @override + Widget build(BuildContext context) { + if (mine) { + return Align( + alignment: AlignmentDirectional.centerEnd, + child: _Bubble(message: message, mine: true), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + PeerAvatar(pubkey: message.fromPubkey, name: peerName), + const SizedBox(width: 8), + Flexible(child: _Bubble(message: message, mine: false)), + ], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.message, required this.mine}); + + final PrivateMessage message; final bool mine; @override Widget build(BuildContext context) { - return Align( - alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: mine ? seedGreen : seedPrimaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - text, - style: TextStyle( - color: mine ? Colors.white : seedOnSurface, - fontSize: 15, + final localeCode = Localizations.localeOf(context).languageCode; + final time = chatBubbleTime(message.at, localeCode); + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: mine ? seedGreen : seedPrimaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Long text can be selected/copied — useful for addresses, links. + SelectableText( + message.text, + style: TextStyle( + color: mine ? Colors.white : seedOnSurface, + fontSize: 15, + ), ), - ), + const SizedBox(height: 2), + // A subtle timestamp, trailing edge (RTL-aware). + Align( + alignment: AlignmentDirectional.centerEnd, + child: Text( + time, + style: TextStyle( + color: mine ? Colors.white70 : seedMuted, + fontSize: 11, + ), + ), + ), + ], ), ); } @@ -376,8 +518,10 @@ class _Composer extends StatelessWidget { borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), ), ), ), diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart new file mode 100644 index 0000000..fae2854 --- /dev/null +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +/// A small round avatar for a chat peer. We have no profile photos yet, so it's +/// a deterministic disc coloured from the peer's pubkey, showing the first +/// letter of their [name] when known (else a person glyph). Same pubkey → same +/// colour on every device, so the peer is visually recognizable. +class PeerAvatar extends StatelessWidget { + const PeerAvatar({ + required this.pubkey, + this.name, + this.radius = 14, + super.key, + }); + + final String pubkey; + final String? name; + final double radius; + + @override + Widget build(BuildContext context) { + final letter = _initial(name); + return CircleAvatar( + radius: radius, + backgroundColor: peerAvatarColor(pubkey), + child: letter == null + ? Icon(Icons.person_outline, size: radius, color: Colors.white) + : Text( + letter, + style: TextStyle( + color: Colors.white, + fontSize: radius, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + static String? _initial(String? name) { + if (name == null) return null; + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + // characters.first handles emoji/combining marks safely. + return trimmed.characters.first.toUpperCase(); + } +} + +/// A deterministic, readable avatar colour derived from [pubkey]. Hashes the +/// key to a hue, then fixes saturation/lightness so white text stays legible on +/// top. Pure and stable — exposed for testing. +Color peerAvatarColor(String pubkey) { + // FNV-1a over the code units — cheap, stable, well-spread across hues. + var hash = 0x811c9dc5; + for (final unit in pubkey.codeUnits) { + hash = (hash ^ unit) * 0x01000193; + hash &= 0xffffffff; + } + final hue = (hash % 360).toDouble(); + return HSLColor.fromAHSL(1, hue, 0.45, 0.42).toColor(); +} diff --git a/apps/app_seeds/test/domain/chat_timeline_test.dart b/apps/app_seeds/test/domain/chat_timeline_test.dart new file mode 100644 index 0000000..5f942c2 --- /dev/null +++ b/apps/app_seeds/test/domain/chat_timeline_test.dart @@ -0,0 +1,120 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:tane/domain/chat_timeline.dart'; + +void main() { + // In the app, flutter_localizations loads intl's locale symbols; a pure test + // must initialize them itself before any DateFormat. + setUpAll(initializeDateFormatting); + + PrivateMessage at(DateTime when) => + PrivateMessage(fromPubkey: 'p', text: 't', at: when); + + group('chatTimeline', () { + test('empty in, empty out', () { + expect(chatTimeline(const []), isEmpty); + }); + + test('a day separator precedes the first message of each day', () { + final items = chatTimeline([ + at(DateTime(2026, 6, 14, 10)), + at(DateTime(2026, 6, 14, 11)), + at(DateTime(2026, 6, 15, 9)), + ]); + // sep, msg, msg, sep, msg + expect(items.map((i) => i is ChatDaySeparator), [ + true, + false, + false, + true, + false, + ]); + expect((items[0] as ChatDaySeparator).day, DateTime(2026, 6, 14)); + expect((items[3] as ChatDaySeparator).day, DateTime(2026, 6, 15)); + }); + + test('messages stay oldest-first', () { + final items = chatTimeline([ + at(DateTime(2026, 1, 1, 8)), + at(DateTime(2026, 1, 1, 9)), + ]); + expect(items.whereType(), hasLength(2)); + expect(items.whereType(), hasLength(1)); + }); + }); + + group('chatDayLabel', () { + final now = DateTime(2026, 6, 15, 12); + + test('the two most recent days use the given today/yesterday labels', () { + expect( + chatDayLabel( + DateTime(2026, 6, 15), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ), + 'Today', + ); + expect( + chatDayLabel( + DateTime(2026, 6, 14), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ), + 'Yesterday', + ); + }); + + test('older days use a locale date, not today/yesterday', () { + final label = chatDayLabel( + DateTime(2026, 6, 10), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ); + expect(label, isNot(anyOf('Today', 'Yesterday'))); + expect(label, contains('10')); // the day number + }); + + test('a previous year is shown', () { + final label = chatDayLabel( + DateTime(2024, 6, 10), + now: now, + today: 'Today', + yesterday: 'Yesterday', + localeCode: 'en', + ); + expect(label, contains('2024')); + }); + + test('respects the locale (Spanish month name)', () { + final label = chatDayLabel( + DateTime(2026, 6, 10), + now: now, + today: 'Hoy', + yesterday: 'Ayer', + localeCode: 'es', + ); + expect(label.toLowerCase(), contains('junio')); + }); + }); + + group('chatBubbleTime', () { + test('English uses a 12-hour clock', () { + final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'en'); + expect(s.toUpperCase(), contains('PM')); + }); + + test('Spanish uses a 24-hour clock', () { + final s = chatBubbleTime(DateTime(2026, 1, 1, 14, 5), 'es'); + expect(s, contains('14')); + expect(s.toUpperCase(), isNot(contains('PM'))); + }); + }); +} diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart index 648d7de..e4e271f 100644 --- a/apps/app_seeds/test/services/inbox_service_test.dart +++ b/apps/app_seeds/test/services/inbox_service_test.dart @@ -46,7 +46,7 @@ void main() { late InboxService inbox; setUp(() async { - store = MessageStore(InMemorySecretStore()); + store = MessageStore(newTestChatDatabase()); inbox = InboxService( connection: await offlineConnection(), selfPubkey: 'me', diff --git a/apps/app_seeds/test/services/message_store_test.dart b/apps/app_seeds/test/services/message_store_test.dart index 57cc22d..c11c6ed 100644 --- a/apps/app_seeds/test/services/message_store_test.dart +++ b/apps/app_seeds/test/services/message_store_test.dart @@ -1,18 +1,24 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/chat_database.dart'; import 'package:tane/services/message_store.dart'; import '../support/test_support.dart'; void main() { + late ChatDatabase db; late MessageStore store; - setUp(() => store = MessageStore(InMemorySecretStore())); + setUp(() { + db = newTestChatDatabase(); + store = MessageStore(db); + }); + tearDown(() => db.close()); - PrivateMessage msg(String from, String text, int atMs) => - PrivateMessage( - fromPubkey: from, - text: text, - at: DateTime.fromMillisecondsSinceEpoch(atMs)); + PrivateMessage msg(String from, String text, int atMs) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs), + ); test('history is empty for an unknown peer', () async { expect(await store.history('peer'), isEmpty); @@ -34,53 +40,62 @@ void main() { expect((await store.history('b')).single.text, 'toB'); }); - test('conversations lists peers newest-first with their last message', - () async { - await store.append('peerA', msg('me', 'hi A', 1000)); - await store.append('peerB', msg('peerB', 'yo B', 3000)); - await store.append('peerA', msg('peerA', 'back A', 2000)); + test( + 'conversations lists peers newest-first with their last message', + () async { + await store.append('peerA', msg('me', 'hi A', 1000)); + await store.append('peerB', msg('peerB', 'yo B', 3000)); + await store.append('peerA', msg('peerA', 'back A', 2000)); - final convos = await store.conversations(); - expect(convos.map((c) => c.peerPubkey), ['peerB', 'peerA']); // 3000 > 2000 - expect(convos.first.lastText, 'yo B'); - expect(convos.last.lastText, 'back A'); - }); + final convos = await store.conversations(); + expect(convos.map((c) => c.peerPubkey), [ + 'peerB', + 'peerA', + ]); // 3000 > 2000 + expect(convos.first.lastText, 'yo B'); + expect(convos.last.lastText, 'back A'); + }, + ); - test('append is idempotent: a re-delivered message is not duplicated', - () async { - // Same sender + timestamp + text = the same gift wrap redelivered by a - // relay on resubscribe. It must not pile up. - expect(await store.append('peer', msg('peer', 'hola', 1000)), isTrue); - expect(await store.append('peer', msg('peer', 'hola', 1000)), isFalse); - expect(await store.history('peer'), hasLength(1)); + test( + 'append is idempotent: a re-delivered message is not duplicated', + () async { + // Same sender + timestamp + text = the same gift wrap redelivered by a + // relay on resubscribe. It must not pile up. + expect(await store.append('peer', msg('peer', 'hola', 1000)), isTrue); + expect(await store.append('peer', msg('peer', 'hola', 1000)), isFalse); + expect(await store.history('peer'), hasLength(1)); - // A genuinely different message (later timestamp) still lands. - expect(await store.append('peer', msg('peer', 'hola', 2000)), isTrue); - expect(await store.history('peer'), hasLength(2)); - }); + // A genuinely different message (later timestamp) still lands. + expect(await store.append('peer', msg('peer', 'hola', 2000)), isTrue); + expect(await store.history('peer'), hasLength(2)); + }, + ); - test('per-identity scope isolates conversations (0 = legacy keys)', () async { - final secret = InMemorySecretStore(); - final acct0 = MessageStore(secret); // legacy / account 0 - final acct1 = MessageStore(secret, accountScope: 'acct1'); + test( + 'per-identity scope isolates conversations (0 = legacy scope)', + () async { + final acct0 = MessageStore(db); // legacy / account 0 + final acct1 = MessageStore(db, accountScope: 'acct1'); - await acct0.append('peer', msg('peer', 'for identity 0', 1000)); - await acct1.append('peer', msg('peer', 'for identity 1', 2000)); + await acct0.append('peer', msg('peer', 'for identity 0', 1000)); + await acct1.append('peer', msg('peer', 'for identity 1', 2000)); - // Same peer, but each identity sees only its own conversation. - expect((await acct0.history('peer')).single.text, 'for identity 0'); - expect((await acct1.history('peer')).single.text, 'for identity 1'); - expect((await acct0.conversations()).single.lastText, 'for identity 0'); - expect((await acct1.conversations()).single.lastText, 'for identity 1'); - }); + // Same peer, but each identity sees only its own conversation. + expect((await acct0.history('peer')).single.text, 'for identity 0'); + expect((await acct1.history('peer')).single.text, 'for identity 1'); + expect((await acct0.conversations()).single.lastText, 'for identity 0'); + expect((await acct1.conversations()).single.lastText, 'for identity 1'); + }, + ); - test('history is capped to the most recent 200', () async { + test('history is uncapped: nothing is silently dropped', () async { for (var i = 0; i < 210; i++) { await store.append('peer', msg('me', 'm$i', i)); } final history = await store.history('peer'); - expect(history, hasLength(200)); - expect(history.first.text, 'm10'); // oldest 10 dropped + expect(history, hasLength(210)); + expect(history.first.text, 'm0'); // the oldest survives expect(history.last.text, 'm209'); }); } diff --git a/apps/app_seeds/test/services/unread_service_test.dart b/apps/app_seeds/test/services/unread_service_test.dart index 35f33de..3470f29 100644 --- a/apps/app_seeds/test/services/unread_service_test.dart +++ b/apps/app_seeds/test/services/unread_service_test.dart @@ -15,7 +15,7 @@ void main() { late UnreadService unread; setUp(() { - store = MessageStore(InMemorySecretStore()); + store = MessageStore(newTestChatDatabase()); unread = UnreadService(store, InMemorySecretStore()); }); diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart index e20f5da..10ca735 100644 --- a/apps/app_seeds/test/state/messages_cubit_test.dart +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -26,6 +26,19 @@ class FakeMessageTransport implements MessageTransport { Future close() async => _inbox.close(); } +/// A transport whose [send] always fails (e.g. no relay reachable). +class _FailingSendTransport implements MessageTransport { + @override + Future send({required String toPubkey, required String text}) async => + throw StateError('no relay'); + + @override + Stream inbox() => const Stream.empty(); + + @override + Future close() async {} +} + void main() { const peer = 'aa'; const me = 'bb'; @@ -35,8 +48,8 @@ void main() { test('receives messages from the peer only', () async { final transport = FakeMessageTransport(); - final cubit = - MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); transport.receive(msg(peer, 'hola')); transport.receive(msg('cc', 'not for this chat')); // other conversation @@ -62,8 +75,8 @@ void main() { test('a full exchange keeps order and sides', () async { final transport = FakeMessageTransport(); - final cubit = - MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); await cubit.send('hi'); transport.receive(msg(peer, 'hello')); @@ -74,6 +87,19 @@ void main() { await cubit.close(); }); + test( + 'a failed send surfaces an error and drops the optimistic message', + () async { + final transport = _FailingSendTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send('hi'); + expect(cubit.state.error, isNotNull); + expect(cubit.state.sending, isFalse); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }, + ); + test('empty/whitespace text is not sent', () async { final transport = FakeMessageTransport(); final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); @@ -83,57 +109,88 @@ void main() { await cubit.close(); }); - test('loads saved history on start and persists across a new cubit', - () async { - final store = MessageStore(InMemorySecretStore()); - await store.append( - peer, msg(peer, 'earlier')); // a message from a previous session + test( + 'loads saved history on start and persists across a new cubit', + () async { + // History is ordered by each message's own timestamp, so use realistic + // monotonic times: 'earlier' predates the sent 'hi' (stamped now() by the + // cubit), and 'reply' comes after it. + PrivateMessage at(String from, String text, DateTime when) => + PrivateMessage(fromPubkey: from, text: text, at: when); - final transport = FakeMessageTransport(); - final cubit = MessagesCubit(transport, - peerPubkey: peer, selfPubkey: me, store: store); - await cubit.start(); - expect(cubit.state.messages.map((m) => m.text), ['earlier']); + final store = MessageStore(newTestChatDatabase()); + await store.append( + peer, + at(peer, 'earlier', DateTime(2020)), + ); // from a previous session - await cubit.send('hi'); // persisted - transport.receive(msg(peer, 'reply')); // persisted - await pumpEventQueue(); - expect(cubit.state.messages.map((m) => m.text), ['earlier', 'hi', 'reply']); - await cubit.close(); + final transport = FakeMessageTransport(); + final cubit = MessagesCubit( + transport, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); + await cubit.start(); + expect(cubit.state.messages.map((m) => m.text), ['earlier']); - // A fresh cubit (even offline) sees the saved conversation. - final reopened = - MessagesCubit(null, peerPubkey: peer, selfPubkey: me, store: store); - await reopened.start(); - expect(reopened.state.messages.map((m) => m.text), - ['earlier', 'hi', 'reply']); - await reopened.close(); - }); + await cubit.send('hi'); // persisted, stamped now() + transport.receive(at(peer, 'reply', DateTime(2100))); // persisted, later + await pumpEventQueue(); + expect(cubit.state.messages.map((m) => m.text), [ + 'earlier', + 'hi', + 'reply', + ]); + await cubit.close(); - test('a re-delivered wrap is shown once (relay resends stored events)', - () async { - final transport = FakeMessageTransport(); - final cubit = - MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + // A fresh cubit (even offline) sees the saved conversation. + final reopened = MessagesCubit( + null, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); + await reopened.start(); + expect(reopened.state.messages.map((m) => m.text), [ + 'earlier', + 'hi', + 'reply', + ]); + await reopened.close(); + }, + ); - final same = msg(peer, 'hola'); // identical sender+timestamp+text - transport.receive(same); - transport.receive(same); // relay re-delivery on the same subscription - await pumpEventQueue(); + test( + 'a re-delivered wrap is shown once (relay resends stored events)', + () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); - expect(cubit.state.messages, hasLength(1)); - await cubit.close(); - }); + final same = msg(peer, 'hola'); // identical sender+timestamp+text + transport.receive(same); + transport.receive(same); // relay re-delivery on the same subscription + await pumpEventQueue(); + + expect(cubit.state.messages, hasLength(1)); + await cubit.close(); + }, + ); test('history already surfaced live is not shown twice', () async { // The stored message is ALSO handed back by the live subscription on open. - final store = MessageStore(InMemorySecretStore()); + final store = MessageStore(newTestChatDatabase()); final m = msg(peer, 'hola'); await store.append(peer, m); final transport = FakeMessageTransport(); - final cubit = MessagesCubit(transport, - peerPubkey: peer, selfPubkey: me, store: store); + final cubit = MessagesCubit( + transport, + peerPubkey: peer, + selfPubkey: me, + store: store, + ); unawaited(cubit.start()); transport.receive(m); // live redelivery races the history load await pumpEventQueue(); @@ -143,7 +200,8 @@ void main() { }); test('offline (no transport) never throws', () async { - final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me)..start(); + final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me) + ..start(); expect(cubit.isOnline, isFalse); await cubit.send('hi'); expect(cubit.state.messages, isEmpty); diff --git a/apps/app_seeds/test/support/test_support.dart b/apps/app_seeds/test/support/test_support.dart index 3c17884..ba39a2f 100644 --- a/apps/app_seeds/test/support/test_support.dart +++ b/apps/app_seeds/test/support/test_support.dart @@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/chat_database.dart'; import 'package:tane/db/database.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/security/secret_store.dart'; @@ -19,6 +20,9 @@ import 'package:tane/ui/variety_detail_screen.dart'; /// verified separately in the SQLCipher-only security test). AppDatabase newTestDatabase() => AppDatabase(NativeDatabase.memory()); +/// A fresh in-memory chat cache for host tests (unencrypted; see above). +ChatDatabase newTestChatDatabase() => ChatDatabase(NativeDatabase.memory()); + /// Unmounts the widget tree *inside* the test and drains the zero-duration /// timer Drift schedules when its stream subscription is cancelled. Without /// this, flutter_test reports "a Timer is still pending after disposal". Call diff --git a/apps/app_seeds/test/ui/chat_message_list_test.dart b/apps/app_seeds/test/ui/chat_message_list_test.dart new file mode 100644 index 0000000..cf28685 --- /dev/null +++ b/apps/app_seeds/test/ui/chat_message_list_test.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/chat_timeline.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/state/messages_cubit.dart'; +import 'package:tane/ui/chat_screen.dart'; +import 'package:tane/ui/peer_avatar.dart'; + +/// In-memory [MessageTransport]: lets a test push inbox messages. (Same shape +/// as the one in messages_cubit_test.dart.) +class _FakeMessageTransport implements MessageTransport { + final StreamController _inbox = + StreamController.broadcast(); + + void receive(PrivateMessage message) => _inbox.add(message); + + @override + Future send({required String toPubkey, required String text}) async {} + + @override + Stream inbox() => _inbox.stream; + + @override + Future close() async => _inbox.close(); +} + +void main() { + const peer = 'aa'; + const me = 'bb'; + + PrivateMessage msg(String from, String text, int minute) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime(2026, 1, 1, 0, minute), + ); + + Widget host(MessagesCubit cubit) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: const ChatMessageList(), + ), + ), + ), + ); + } + + testWidgets('renders oldest at top, newest at the bottom of the viewport', ( + tester, + ) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive(msg(peer, 'first', 0)); + transport.receive(msg(peer, 'second', 1)); + transport.receive(msg(peer, 'third', 2)); + await tester.pumpWidget(host(cubit)); + await tester.pump(); // let the stream events flush into the cubit + + // Bottom-anchored: the newest ('third') sits lower than the oldest. + final firstY = tester.getTopLeft(find.text('first')).dy; + final thirdY = tester.getTopLeft(find.text('third')).dy; + expect(thirdY, greaterThan(firstY)); + + // One day separator (all three are the same day) and a per-bubble time. + expect(find.byKey(const Key('chat.daySeparator')), findsOneWidget); + expect( + find.text(chatBubbleTime(DateTime(2026, 1, 1, 0, 0), 'en')), + findsOneWidget, + ); + }); + + testWidgets('a separator is inserted per calendar day', (tester) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive( + PrivateMessage( + fromPubkey: peer, + text: 'day one', + at: DateTime(2026, 1, 1, 9), + ), + ); + transport.receive( + PrivateMessage( + fromPubkey: peer, + text: 'day two', + at: DateTime(2026, 1, 2, 9), + ), + ); + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + expect(find.byKey(const Key('chat.daySeparator')), findsNWidgets(2)); + }); + + testWidgets('the peer bubble carries an avatar, mine does not', ( + tester, + ) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + transport.receive(msg(peer, 'from peer', 0)); + await cubit.send('from me'); // optimistically mine (fromPubkey == me) + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + // One avatar, for the single peer message; my own bubble has none. + expect(find.byType(PeerAvatar), findsOneWidget); + }); + + testWidgets('a new message lands in view at the bottom, not below the fold', ( + tester, + ) async { + final transport = _FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me) + ..start(); + addTearDown(cubit.close); + + // Fill past a single screen so a naive top-anchored list would push the + // newest message off-screen. + for (var i = 0; i < 40; i++) { + transport.receive(msg(peer, 'line $i', i)); + } + await tester.pumpWidget(host(cubit)); + await tester.pump(); + + transport.receive(msg(peer, 'brand new', 41)); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + + // The just-arrived message is visible without any manual scroll. + expect(find.text('brand new'), findsOneWidget); + }); + + testWidgets('shows the empty note when there are no messages', ( + tester, + ) async { + final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me); + addTearDown(cubit.close); + await tester.pumpWidget(host(cubit)); + await tester.pump(); + expect(find.byType(ListView), findsNothing); + }); +} diff --git a/apps/app_seeds/test/ui/peer_avatar_test.dart b/apps/app_seeds/test/ui/peer_avatar_test.dart new file mode 100644 index 0000000..ba902df --- /dev/null +++ b/apps/app_seeds/test/ui/peer_avatar_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/peer_avatar.dart'; + +void main() { + group('peerAvatarColor', () { + test('is deterministic for the same pubkey', () { + expect(peerAvatarColor('ab' * 32), peerAvatarColor('ab' * 32)); + }); + + test('differs for different pubkeys', () { + expect(peerAvatarColor('ab' * 32), isNot(peerAvatarColor('cd' * 32))); + }); + + test('is fully opaque (legible disc)', () { + expect(peerAvatarColor('feed').a, 1.0); + }); + }); + + group('PeerAvatar', () { + Widget host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('shows the name initial when known', (tester) async { + await tester.pumpWidget(host(PeerAvatar(pubkey: 'aa', name: 'rosa'))); + expect(find.text('R'), findsOneWidget); // uppercased first letter + }); + + testWidgets('falls back to a glyph when the name is unknown', ( + tester, + ) async { + await tester.pumpWidget(host(const PeerAvatar(pubkey: 'aa'))); + expect(find.byIcon(Icons.person_outline), findsOneWidget); + expect(find.byType(Text), findsNothing); + }); + }); +} diff --git a/apps/app_seeds/test/ui/unread_badge_test.dart b/apps/app_seeds/test/ui/unread_badge_test.dart index 1c32227..b83517b 100644 --- a/apps/app_seeds/test/ui/unread_badge_test.dart +++ b/apps/app_seeds/test/ui/unread_badge_test.dart @@ -16,7 +16,7 @@ void main() { late UnreadService unread; setUp(() { - store = MessageStore(InMemorySecretStore()); + store = MessageStore(newTestChatDatabase()); unread = UnreadService(store, InMemorySecretStore()); }); diff --git a/docs/design/chat-storage.md b/docs/design/chat-storage.md new file mode 100644 index 0000000..0791a74 --- /dev/null +++ b/docs/design/chat-storage.md @@ -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. diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index 43d7b18..6ac6fc8 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -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)