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

@ -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<Set<Column>> 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());
}

View file

@ -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<int> id = GeneratedColumn<int>(
'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<String> accountScope = GeneratedColumn<String>(
'account_scope',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _peerPubkeyMeta = const VerificationMeta(
'peerPubkey',
);
@override
late final GeneratedColumn<String> peerPubkey = GeneratedColumn<String>(
'peer_pubkey',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _fromPubkeyMeta = const VerificationMeta(
'fromPubkey',
);
@override
late final GeneratedColumn<String> fromPubkey = GeneratedColumn<String>(
'from_pubkey',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _bodyMeta = const VerificationMeta('body');
@override
late final GeneratedColumn<String> body = GeneratedColumn<String>(
'body',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _sentAtMeta = const VerificationMeta('sentAt');
@override
late final GeneratedColumn<int> sentAt = GeneratedColumn<int>(
'sent_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> 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<Message> 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<GeneratedColumn> get $primaryKey => {id};
@override
List<Set<GeneratedColumn>> get uniqueKeys => [
{accountScope, peerPubkey, fromPubkey, sentAt, body},
];
@override
Message map(Map<String, dynamic> 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<Message> {
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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['account_scope'] = Variable<String>(accountScope);
map['peer_pubkey'] = Variable<String>(peerPubkey);
map['from_pubkey'] = Variable<String>(fromPubkey);
map['body'] = Variable<String>(body);
map['sent_at'] = Variable<int>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Message(
id: serializer.fromJson<int>(json['id']),
accountScope: serializer.fromJson<String>(json['accountScope']),
peerPubkey: serializer.fromJson<String>(json['peerPubkey']),
fromPubkey: serializer.fromJson<String>(json['fromPubkey']),
body: serializer.fromJson<String>(json['body']),
sentAt: serializer.fromJson<int>(json['sentAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'accountScope': serializer.toJson<String>(accountScope),
'peerPubkey': serializer.toJson<String>(peerPubkey),
'fromPubkey': serializer.toJson<String>(fromPubkey),
'body': serializer.toJson<String>(body),
'sentAt': serializer.toJson<int>(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<Message> {
final Value<int> id;
final Value<String> accountScope;
final Value<String> peerPubkey;
final Value<String> fromPubkey;
final Value<String> body;
final Value<int> 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<Message> custom({
Expression<int>? id,
Expression<String>? accountScope,
Expression<String>? peerPubkey,
Expression<String>? fromPubkey,
Expression<String>? body,
Expression<int>? 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<int>? id,
Value<String>? accountScope,
Value<String>? peerPubkey,
Value<String>? fromPubkey,
Value<String>? body,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (accountScope.present) {
map['account_scope'] = Variable<String>(accountScope.value);
}
if (peerPubkey.present) {
map['peer_pubkey'] = Variable<String>(peerPubkey.value);
}
if (fromPubkey.present) {
map['from_pubkey'] = Variable<String>(fromPubkey.value);
}
if (body.present) {
map['body'] = Variable<String>(body.value);
}
if (sentAt.present) {
map['sent_at'] = Variable<int>(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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [
messages,
messagesByConversation,
];
}
typedef $$MessagesTableCreateCompanionBuilder =
MessagesCompanion Function({
Value<int> id,
required String accountScope,
required String peerPubkey,
required String fromPubkey,
required String body,
required int sentAt,
});
typedef $$MessagesTableUpdateCompanionBuilder =
MessagesCompanion Function({
Value<int> id,
Value<String> accountScope,
Value<String> peerPubkey,
Value<String> fromPubkey,
Value<String> body,
Value<int> sentAt,
});
class $$MessagesTableFilterComposer
extends Composer<_$ChatDatabase, $MessagesTable> {
$$MessagesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get accountScope => $composableBuilder(
column: $table.accountScope,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get peerPubkey => $composableBuilder(
column: $table.peerPubkey,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get fromPubkey => $composableBuilder(
column: $table.fromPubkey,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get body => $composableBuilder(
column: $table.body,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> 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<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get accountScope => $composableBuilder(
column: $table.accountScope,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get peerPubkey => $composableBuilder(
column: $table.peerPubkey,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get fromPubkey => $composableBuilder(
column: $table.fromPubkey,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get body => $composableBuilder(
column: $table.body,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> 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<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get accountScope => $composableBuilder(
column: $table.accountScope,
builder: (column) => column,
);
GeneratedColumn<String> get peerPubkey => $composableBuilder(
column: $table.peerPubkey,
builder: (column) => column,
);
GeneratedColumn<String> get fromPubkey => $composableBuilder(
column: $table.fromPubkey,
builder: (column) => column,
);
GeneratedColumn<String> get body =>
$composableBuilder(column: $table.body, builder: (column) => column);
GeneratedColumn<int> 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<int> id = const Value.absent(),
Value<String> accountScope = const Value.absent(),
Value<String> peerPubkey = const Value.absent(),
Value<String> fromPubkey = const Value.absent(),
Value<String> body = const Value.absent(),
Value<int> sentAt = const Value.absent(),
}) => MessagesCompanion(
id: id,
accountScope: accountScope,
peerPubkey: peerPubkey,
fromPubkey: fromPubkey,
body: body,
sentAt: sentAt,
),
createCompanionCallback:
({
Value<int> 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);
}

View file

@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import '../data/species_catalog.dart'; import '../data/species_catalog.dart';
import '../data/species_repository.dart'; import '../data/species_repository.dart';
import '../data/variety_repository.dart'; import '../data/variety_repository.dart';
import '../db/chat_database.dart';
import '../db/database.dart'; import '../db/database.dart';
import '../db/encrypted_executor.dart'; import '../db/encrypted_executor.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
@ -98,6 +99,12 @@ Future<void> configureDependencies() async {
openEncryptedExecutor(await _databaseFile(), dbKeyHex), 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 // 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 // from the social-layer public key on purpose unifying the two would rewrite
// authorship of existing rows, a data-model decision for later. // authorship of existing rows, a data-model decision for later.
@ -116,7 +123,9 @@ Future<void> configureDependencies() async {
deviceId: deviceId, deviceId: deviceId,
); );
} catch (e, s) { } 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 // Seed the bundled species catalog before the UI opens but only once per
@ -152,6 +161,7 @@ Future<void> configureDependencies() async {
getIt getIt
..registerSingleton<SecureKeyStore>(keyStore) ..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database) ..registerSingleton<AppDatabase>(database)
..registerSingleton<ChatDatabase>(chatDatabase)
..registerSingleton<SpeciesRepository>(speciesRepository) ..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(varietyRepository) ..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService) ..registerSingleton<FileService>(fileService)
@ -166,11 +176,14 @@ Future<void> configureDependencies() async {
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore)) ..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
// Per-identity stores are namespaced by the active account's scope. // Per-identity stores are namespaced by the active account's scope.
..registerSingleton<MessageStore>( ..registerSingleton<MessageStore>(
MessageStore(secretStore, accountScope: scope)) MessageStore(chatDatabase, accountScope: scope),
)
..registerSingleton<ProfileStore>( ..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope)) ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>( ..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope)) ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>( ..registerSingleton<ExportImportService>(
ExportImportService( ExportImportService(
repository: varietyRepository, 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. // Re-register the per-identity stores under the new scope, then the identity.
getIt getIt
..registerSingleton<MessageStore>( ..registerSingleton<MessageStore>(
MessageStore(secretStore, accountScope: scope)) MessageStore(getIt<ChatDatabase>(), accountScope: scope),
)
..registerSingleton<ProfileStore>( ..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope)) ProfileStore(secretStore, accountScope: scope),
)
..registerSingleton<ProfileCache>( ..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope)) ProfileCache(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>( ..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore)); UnreadService(getIt<MessageStore>(), secretStore),
);
final social = await SocialService.fromRootSeedHex( final social = await SocialService.fromRootSeedHex(
rootSeedHex, rootSeedHex,
account: account, account: account,
deviceId: deviceId, deviceId: deviceId,
); );
final connection = final connection = SocialConnection(
SocialConnection(social: social, settings: getIt<SocialSettings>()); social: social,
settings: getIt<SocialSettings>(),
);
final inbox = InboxService( final inbox = InboxService(
connection: connection, connection: connection,
selfPubkey: social.publicKeyHex, selfPubkey: social.publicKeyHex,
@ -342,6 +361,11 @@ Future<File> _databaseFile() async {
return File(p.join(dir.path, 'tane_inventory.sqlite')); 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 /// 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 /// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen
/// language, then the device's ordered locales. /// language, then the device's ordered locales.

View file

@ -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<ChatTimelineItem> chatTimeline(List<PrivateMessage> messages) {
final out = <ChatTimelineItem>[];
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);

View file

@ -528,7 +528,10 @@
"empty": "Entá nun hai mensaxes — saluda", "empty": "Entá nun hai mensaxes — saluda",
"offline": "Configura'l compartir pa unviar mensaxes", "offline": "Configura'l compartir pa unviar mensaxes",
"payG1": "Pagar en Ğ1", "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": { "trust": {
"none": "Naide los avala entá", "none": "Naide los avala entá",

View file

@ -531,7 +531,10 @@
"empty": "No messages yet — say hello", "empty": "No messages yet — say hello",
"offline": "Set up sharing to send messages", "offline": "Set up sharing to send messages",
"payG1": "Pay in Ğ1", "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": { "trust": {
"none": "No one vouches for them yet", "none": "No one vouches for them yet",

View file

@ -530,7 +530,10 @@
"empty": "Aún no hay mensajes — saluda", "empty": "Aún no hay mensajes — saluda",
"offline": "Configura el compartir para enviar mensajes", "offline": "Configura el compartir para enviar mensajes",
"payG1": "Pagar en Ğ1", "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": { "trust": {
"none": "Nadie los avala aún", "none": "Nadie los avala aún",

View file

@ -527,7 +527,10 @@
"empty": "Ainda não há mensagens — diz olá", "empty": "Ainda não há mensagens — diz olá",
"offline": "Configura a partilha para enviar mensagens", "offline": "Configura a partilha para enviar mensagens",
"payG1": "Pagar em Ğ1", "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": { "trust": {
"none": "Ainda ninguém os avaliza", "none": "Ainda ninguém os avaliza",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 4 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -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 offline => 'Configura\'l compartir pa unviar mensaxes';
@override String get payG1 => 'Pagar en Ğ1'; @override String get payG1 => 'Pagar en Ğ1';
@override String get g1Copied => 'Direición Ğ1 copiada — apégala na to cartera'; @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 // Path: trust
@ -1620,6 +1623,9 @@ extension on TranslationsAst {
'chat.offline' => 'Configura\'l compartir pa unviar mensaxes', 'chat.offline' => 'Configura\'l compartir pa unviar mensaxes',
'chat.payG1' => 'Pagar en Ğ1', 'chat.payG1' => 'Pagar en Ğ1',
'chat.g1Copied' => 'Direición Ğ1 copiada — apégala na to cartera', '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.none' => 'Naide los avala entá',
'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozo a esta persona', 'trust.vouch' => 'Conozo a esta persona',

View file

@ -1437,6 +1437,15 @@ class Translations$chat$en {
/// en: 'Ğ1 address copied — paste it in your wallet' /// en: 'Ğ1 address copied — paste it in your wallet'
String get g1Copied => 'Ğ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 // Path: trust
@ -2536,6 +2545,9 @@ extension on Translations {
'chat.offline' => 'Set up sharing to send messages', 'chat.offline' => 'Set up sharing to send messages',
'chat.payG1' => 'Pay in Ğ1', 'chat.payG1' => 'Pay in Ğ1',
'chat.g1Copied' => 'Ğ1 address copied — paste it in your wallet', '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.none' => 'No one vouches for them yet',
'trust.count' => ({required Object n}) => 'Vouched for by ${n}', 'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
'trust.vouch' => 'I know this person', 'trust.vouch' => 'I know this person',

View file

@ -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 offline => 'Configura el compartir para enviar mensajes';
@override String get payG1 => 'Pagar en Ğ1'; @override String get payG1 => 'Pagar en Ğ1';
@override String get g1Copied => 'Dirección Ğ1 copiada — pégala en tu cartera'; @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 // Path: trust
@ -1624,6 +1627,9 @@ extension on TranslationsEs {
'chat.offline' => 'Configura el compartir para enviar mensajes', 'chat.offline' => 'Configura el compartir para enviar mensajes',
'chat.payG1' => 'Pagar en Ğ1', 'chat.payG1' => 'Pagar en Ğ1',
'chat.g1Copied' => 'Dirección Ğ1 copiada — pégala en tu cartera', '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.none' => 'Nadie los avala aún',
'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozco a esta persona', 'trust.vouch' => 'Conozco a esta persona',

View file

@ -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 offline => 'Configura a partilha para enviar mensagens';
@override String get payG1 => 'Pagar em Ğ1'; @override String get payG1 => 'Pagar em Ğ1';
@override String get g1Copied => 'Endereço Ğ1 copiado — cola-o na tua carteira'; @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 // Path: trust
@ -1618,6 +1621,9 @@ extension on TranslationsPt {
'chat.offline' => 'Configura a partilha para enviar mensagens', 'chat.offline' => 'Configura a partilha para enviar mensagens',
'chat.payG1' => 'Pagar em Ğ1', 'chat.payG1' => 'Pagar em Ğ1',
'chat.g1Copied' => 'Endereço Ğ1 copiado — cola-o na tua carteira', '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.none' => 'Ainda ninguém os avaliza',
'trust.count' => ({required Object n}) => 'Avalizada por ${n}', 'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
'trust.vouch' => 'Conheço esta pessoa', 'trust.vouch' => 'Conheço esta pessoa',

View file

@ -1,14 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:commons_core/commons_core.dart'; 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. /// A conversation preview for the messages inbox.
class ChatSummary { class ChatSummary {
const ChatSummary({ const ChatSummary({
@ -22,109 +16,107 @@ class ChatSummary {
final DateTime lastAt; 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 { class MessageStore {
/// [accountScope] namespaces the keys per social identity (empty = the MessageStore(this._db, {String accountScope = ''}) : _scope = accountScope;
/// original identity's legacy keys). See [socialAccountScope].
MessageStore(this._store, {String accountScope = ''})
: _base = accountScope.isEmpty ? 'tane.social.' : 'tane.social.$accountScope.';
final SecretStore _store; final ChatDatabase _db;
final String _base; final String _scope;
/// 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<void> _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';
/// Messages exchanged with [peerPubkey], oldest first. /// Messages exchanged with [peerPubkey], oldest first.
Future<List<PrivateMessage>> history(String peerPubkey) async { Future<List<PrivateMessage>> history(String peerPubkey) async {
final raw = await _store.read(_key(peerPubkey)); final rows =
if (raw == null || raw.isEmpty) return const []; await (_db.select(_db.messages)
final list = jsonDecode(raw) as List; ..where(
return [ (m) =>
for (final m in list.cast<Map<String, dynamic>>()) m.accountScope.equals(_scope) &
PrivateMessage( m.peerPubkey.equals(peerPubkey),
fromPubkey: m['from'] as String, )
text: m['text'] as String, // Tie-break equal timestamps by insertion order (id) so history
at: DateTime.fromMillisecondsSinceEpoch(m['at'] as int), // 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 /// Appends [message] to the conversation with [peerPubkey]. Idempotent: an
/// cap) and records the peer in the conversation index. Idempotent: a relay /// identical message (same sender, timestamp and text) is dropped instead of
/// re-delivers stored gift wraps on every (re)subscribe, so an identical /// duplicated, since a relay re-delivers stored gift wraps on every
/// message (same sender, timestamp and text) is dropped instead of duplicated. /// (re)subscribe. Returns true only when the message was newly stored.
/// Returns true only when the message was newly stored.
Future<bool> append(String peerPubkey, PrivateMessage message) { Future<bool> append(String peerPubkey, PrivateMessage message) {
// Chain onto the write tail so concurrent appends run one at a time. final at = message.at.millisecondsSinceEpoch;
final result = _writeTail.then((_) => _appendLocked(peerPubkey, message)); // SELECT-then-INSERT in a transaction so the dedup check and the write are
_writeTail = result.then((_) {}, onError: (_) {}); // atomic; the unique key is the backstop. Both hit the conversation index.
return result; return _db.transaction(() async {
} final existing =
await (_db.select(_db.messages)
Future<bool> _appendLocked(String peerPubkey, PrivateMessage message) async { ..where(
final existing = await history(peerPubkey); (m) =>
final isDup = existing.any((m) => m.accountScope.equals(_scope) &
m.fromPubkey == message.fromPubkey && m.peerPubkey.equals(peerPubkey) &
m.text == message.text && m.fromPubkey.equals(message.fromPubkey) &
m.at.millisecondsSinceEpoch == message.at.millisecondsSinceEpoch); m.sentAt.equals(at) &
if (isDup) return false; m.body.equals(message.text),
final next = [...existing, message]; )
final capped = ..limit(1))
next.length > _cap ? next.sublist(next.length - _cap) : next; .getSingleOrNull();
await _store.write( if (existing != null) return false;
_key(peerPubkey), await _db
jsonEncode([ .into(_db.messages)
for (final m in capped) .insert(
{ MessagesCompanion.insert(
'from': m.fromPubkey, accountScope: _scope,
'text': m.text, peerPubkey: peerPubkey,
'at': m.at.millisecondsSinceEpoch, fromPubkey: message.fromPubkey,
}, body: message.text,
]), sentAt: at,
),
); );
await _rememberPeer(peerPubkey);
return true; 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<List<ChatSummary>> conversations() async { Future<List<ChatSummary>> conversations() async {
final summaries = <ChatSummary>[]; final rows =
for (final peer in await _peers()) { await (_db.select(_db.messages)
final history = await this.history(peer); ..where((m) => m.accountScope.equals(_scope))
if (history.isEmpty) continue; ..orderBy([
final last = history.last; (m) =>
summaries.add(ChatSummary( OrderingTerm(expression: m.sentAt, mode: OrderingMode.desc),
peerPubkey: peer, (m) => OrderingTerm(expression: m.id, mode: OrderingMode.desc),
lastText: last.text, ]))
lastAt: last.at, .get();
)); final seen = <String>{};
final out = <ChatSummary>[];
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 out;
return summaries;
} }
Future<List<String>> _peers() async { PrivateMessage _toMessage(Message r) => PrivateMessage(
final raw = await _store.read(_indexKey); fromPubkey: r.fromPubkey,
if (raw == null || raw.isEmpty) return const []; text: r.body,
return raw.split('\n').where((s) => s.isNotEmpty).toList(); at: DateTime.fromMillisecondsSinceEpoch(r.sentAt),
} );
Future<void> _rememberPeer(String peer) async {
final peers = await _peers();
if (peers.contains(peer)) return;
await _store.write(_indexKey, [...peers, peer].join('\n'));
}
} }

View file

@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../di/injector.dart'; import '../di/injector.dart';
import '../domain/chat_timeline.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
@ -17,6 +18,7 @@ import '../services/unread_service.dart';
import '../services/wot_settings.dart'; import '../services/wot_settings.dart';
import '../state/messages_cubit.dart'; import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart'; import '../state/trust_cubit.dart';
import 'peer_avatar.dart';
import 'theme.dart'; import 'theme.dart';
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and /// 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 SocialConnection connection;
final String peerPubkey; 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; final MessageStore? messageStore;
/// Optional cache of peer display names; null in tests. /// Optional cache of peer display names; null in tests.
@ -70,8 +73,9 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_unread = _unread = getIt.isRegistered<UnreadService>()
getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : null; ? getIt<UnreadService>()
: null;
// Set synchronously (before any await) so a message landing during _init is // Set synchronously (before any await) so a message landing during _init is
// already suppressed. // already suppressed.
_unread?.activePeer = widget.peerPubkey; _unread?.activePeer = widget.peerPubkey;
@ -85,8 +89,7 @@ class _ChatScreenState extends State<ChatScreen> {
final session = await widget.connection.session(); final session = await widget.connection.session();
final cachedName = await widget.profileCache?.name(widget.peerPubkey); final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final referents = await widget.trustReferents?.all() ?? const <String>{}; final referents = await widget.trustReferents?.all() ?? const <String>{};
final wotParams = final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter;
await widget.wotSettings?.params() ?? WotParams.duniter;
if (!mounted) return; // shared session is owned by the connection, not us if (!mounted) return; // shared session is owned by the connection, not us
final self = widget.social.publicKeyHex; final self = widget.social.publicKeyHex;
final messages = MessagesCubit( final messages = MessagesCubit(
@ -118,8 +121,10 @@ class _ChatScreenState extends State<ChatScreen> {
final profile = await live.profile.fetch(widget.peerPubkey); final profile = await live.profile.fetch(widget.peerPubkey);
if (profile != null && mounted) { if (profile != null && mounted) {
if (profile.name.isNotEmpty) { if (profile.name.isNotEmpty) {
await widget.profileCache! await widget.profileCache!.setName(
.setName(widget.peerPubkey, profile.name); widget.peerPubkey,
profile.name,
);
} }
setState(() { setState(() {
if (profile.name.isNotEmpty) _peerName = profile.name; if (profile.name.isNotEmpty) _peerName = profile.name;
@ -203,17 +208,26 @@ class _ChatScreenState extends State<ChatScreen> {
BlocProvider.value(value: messages), BlocProvider.value(value: messages),
BlocProvider.value(value: trust), BlocProvider.value(value: trust),
], ],
child: _ChatBody(controller: _input, onSend: _send), child: _ChatBody(
controller: _input,
onSend: _send,
peerName: _peerName,
),
), ),
); );
} }
} }
class _ChatBody extends StatelessWidget { 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 TextEditingController controller;
final Future<void> Function() onSend; final Future<void> Function() onSend;
final String? peerName;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -231,31 +245,68 @@ class _ChatBody extends StatelessWidget {
), ),
); );
} }
return Column( // Surface a failed send instead of losing it silently (relay down, etc.).
return BlocListener<MessagesCubit, ChatState>(
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: [ children: [
const _TrustBanner(), const _TrustBanner(),
Expanded( Expanded(child: ChatMessageList(peerName: peerName)),
child: BlocBuilder<MessagesCubit, ChatState>( _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<MessagesCubit>();
return BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) { builder: (context, state) {
if (state.messages.isEmpty) { if (state.messages.isEmpty) {
return Center( return Center(
child: Text(t.chat.empty, child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)),
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( return ListView.builder(
reverse: true,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: state.messages.length, itemCount: items.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final m = state.messages[i]; final item = items[items.length - 1 - i];
return _Bubble(text: m.text, mine: cubit.isMine(m)); return switch (item) {
ChatDaySeparator(:final day) => _DaySeparator(day: day),
ChatMessageRow(:final message) => _MessageRow(
message: message,
mine: cubit.isMine(message),
peerName: peerName,
),
};
}, },
); );
}, },
),
),
_Composer(controller: controller, onSend: onSend),
],
); );
} }
} }
@ -315,19 +366,92 @@ class _TrustBanner extends StatelessWidget {
} }
} }
class _Bubble extends StatelessWidget { /// A centered pill introducing a new day ("Today", "Yesterday", or a
const _Bubble({required this.text, required this.mine}); /// 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; final bool mine;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Align( final localeCode = Localizations.localeOf(context).languageCode;
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, final time = chatBubbleTime(message.at, localeCode);
child: Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 4), margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75, maxWidth: MediaQuery.of(context).size.width * 0.75,
), ),
@ -335,13 +459,31 @@ class _Bubble extends StatelessWidget {
color: mine ? seedGreen : seedPrimaryContainer, color: mine ? seedGreen : seedPrimaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Column(
text, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Long text can be selected/copied useful for addresses, links.
SelectableText(
message.text,
style: TextStyle( style: TextStyle(
color: mine ? Colors.white : seedOnSurface, color: mine ? Colors.white : seedOnSurface,
fontSize: 15, 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), borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: contentPadding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 18, vertical: 10), horizontal: 18,
vertical: 10,
),
), ),
), ),
), ),

View file

@ -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();
}

View file

@ -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<ChatMessageRow>(), hasLength(2));
expect(items.whereType<ChatDaySeparator>(), 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')));
});
});
}

View file

@ -46,7 +46,7 @@ void main() {
late InboxService inbox; late InboxService inbox;
setUp(() async { setUp(() async {
store = MessageStore(InMemorySecretStore()); store = MessageStore(newTestChatDatabase());
inbox = InboxService( inbox = InboxService(
connection: await offlineConnection(), connection: await offlineConnection(),
selfPubkey: 'me', selfPubkey: 'me',

View file

@ -1,18 +1,24 @@
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/chat_database.dart';
import 'package:tane/services/message_store.dart'; import 'package:tane/services/message_store.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
void main() { void main() {
late ChatDatabase db;
late MessageStore store; 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 msg(String from, String text, int atMs) => PrivateMessage(
PrivateMessage(
fromPubkey: from, fromPubkey: from,
text: text, text: text,
at: DateTime.fromMillisecondsSinceEpoch(atMs)); at: DateTime.fromMillisecondsSinceEpoch(atMs),
);
test('history is empty for an unknown peer', () async { test('history is empty for an unknown peer', () async {
expect(await store.history('peer'), isEmpty); expect(await store.history('peer'), isEmpty);
@ -34,19 +40,25 @@ void main() {
expect((await store.history('b')).single.text, 'toB'); expect((await store.history('b')).single.text, 'toB');
}); });
test('conversations lists peers newest-first with their last message', test(
'conversations lists peers newest-first with their last message',
() async { () async {
await store.append('peerA', msg('me', 'hi A', 1000)); await store.append('peerA', msg('me', 'hi A', 1000));
await store.append('peerB', msg('peerB', 'yo B', 3000)); await store.append('peerB', msg('peerB', 'yo B', 3000));
await store.append('peerA', msg('peerA', 'back A', 2000)); await store.append('peerA', msg('peerA', 'back A', 2000));
final convos = await store.conversations(); final convos = await store.conversations();
expect(convos.map((c) => c.peerPubkey), ['peerB', 'peerA']); // 3000 > 2000 expect(convos.map((c) => c.peerPubkey), [
'peerB',
'peerA',
]); // 3000 > 2000
expect(convos.first.lastText, 'yo B'); expect(convos.first.lastText, 'yo B');
expect(convos.last.lastText, 'back A'); expect(convos.last.lastText, 'back A');
}); },
);
test('append is idempotent: a re-delivered message is not duplicated', test(
'append is idempotent: a re-delivered message is not duplicated',
() async { () async {
// Same sender + timestamp + text = the same gift wrap redelivered by a // Same sender + timestamp + text = the same gift wrap redelivered by a
// relay on resubscribe. It must not pile up. // relay on resubscribe. It must not pile up.
@ -57,12 +69,14 @@ void main() {
// A genuinely different message (later timestamp) still lands. // A genuinely different message (later timestamp) still lands.
expect(await store.append('peer', msg('peer', 'hola', 2000)), isTrue); expect(await store.append('peer', msg('peer', 'hola', 2000)), isTrue);
expect(await store.history('peer'), hasLength(2)); expect(await store.history('peer'), hasLength(2));
}); },
);
test('per-identity scope isolates conversations (0 = legacy keys)', () async { test(
final secret = InMemorySecretStore(); 'per-identity scope isolates conversations (0 = legacy scope)',
final acct0 = MessageStore(secret); // legacy / account 0 () async {
final acct1 = MessageStore(secret, accountScope: 'acct1'); final acct0 = MessageStore(db); // legacy / account 0
final acct1 = MessageStore(db, accountScope: 'acct1');
await acct0.append('peer', msg('peer', 'for identity 0', 1000)); await acct0.append('peer', msg('peer', 'for identity 0', 1000));
await acct1.append('peer', msg('peer', 'for identity 1', 2000)); await acct1.append('peer', msg('peer', 'for identity 1', 2000));
@ -72,15 +86,16 @@ void main() {
expect((await acct1.history('peer')).single.text, 'for identity 1'); expect((await acct1.history('peer')).single.text, 'for identity 1');
expect((await acct0.conversations()).single.lastText, 'for identity 0'); expect((await acct0.conversations()).single.lastText, 'for identity 0');
expect((await acct1.conversations()).single.lastText, 'for identity 1'); 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++) { for (var i = 0; i < 210; i++) {
await store.append('peer', msg('me', 'm$i', i)); await store.append('peer', msg('me', 'm$i', i));
} }
final history = await store.history('peer'); final history = await store.history('peer');
expect(history, hasLength(200)); expect(history, hasLength(210));
expect(history.first.text, 'm10'); // oldest 10 dropped expect(history.first.text, 'm0'); // the oldest survives
expect(history.last.text, 'm209'); expect(history.last.text, 'm209');
}); });
} }

View file

@ -15,7 +15,7 @@ void main() {
late UnreadService unread; late UnreadService unread;
setUp(() { setUp(() {
store = MessageStore(InMemorySecretStore()); store = MessageStore(newTestChatDatabase());
unread = UnreadService(store, InMemorySecretStore()); unread = UnreadService(store, InMemorySecretStore());
}); });

View file

@ -26,6 +26,19 @@ class FakeMessageTransport implements MessageTransport {
Future<void> close() async => _inbox.close(); Future<void> close() async => _inbox.close();
} }
/// A transport whose [send] always fails (e.g. no relay reachable).
class _FailingSendTransport implements MessageTransport {
@override
Future<void> send({required String toPubkey, required String text}) async =>
throw StateError('no relay');
@override
Stream<PrivateMessage> inbox() => const Stream.empty();
@override
Future<void> close() async {}
}
void main() { void main() {
const peer = 'aa'; const peer = 'aa';
const me = 'bb'; const me = 'bb';
@ -35,8 +48,8 @@ void main() {
test('receives messages from the peer only', () async { test('receives messages from the peer only', () async {
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); ..start();
transport.receive(msg(peer, 'hola')); transport.receive(msg(peer, 'hola'));
transport.receive(msg('cc', 'not for this chat')); // other conversation 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 { test('a full exchange keeps order and sides', () async {
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); ..start();
await cubit.send('hi'); await cubit.send('hi');
transport.receive(msg(peer, 'hello')); transport.receive(msg(peer, 'hello'));
@ -74,6 +87,19 @@ void main() {
await cubit.close(); 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 { test('empty/whitespace text is not sent', () async {
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
@ -83,38 +109,64 @@ void main() {
await cubit.close(); await cubit.close();
}); });
test('loads saved history on start and persists across a new cubit', test(
'loads saved history on start and persists across a new cubit',
() async { () async {
final store = MessageStore(InMemorySecretStore()); // 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 store = MessageStore(newTestChatDatabase());
await store.append( await store.append(
peer, msg(peer, 'earlier')); // a message from a previous session peer,
at(peer, 'earlier', DateTime(2020)),
); // from a previous session
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport, final cubit = MessagesCubit(
peerPubkey: peer, selfPubkey: me, store: store); transport,
peerPubkey: peer,
selfPubkey: me,
store: store,
);
await cubit.start(); await cubit.start();
expect(cubit.state.messages.map((m) => m.text), ['earlier']); expect(cubit.state.messages.map((m) => m.text), ['earlier']);
await cubit.send('hi'); // persisted await cubit.send('hi'); // persisted, stamped now()
transport.receive(msg(peer, 'reply')); // persisted transport.receive(at(peer, 'reply', DateTime(2100))); // persisted, later
await pumpEventQueue(); await pumpEventQueue();
expect(cubit.state.messages.map((m) => m.text), ['earlier', 'hi', 'reply']); expect(cubit.state.messages.map((m) => m.text), [
'earlier',
'hi',
'reply',
]);
await cubit.close(); await cubit.close();
// A fresh cubit (even offline) sees the saved conversation. // A fresh cubit (even offline) sees the saved conversation.
final reopened = final reopened = MessagesCubit(
MessagesCubit(null, peerPubkey: peer, selfPubkey: me, store: store); null,
peerPubkey: peer,
selfPubkey: me,
store: store,
);
await reopened.start(); await reopened.start();
expect(reopened.state.messages.map((m) => m.text), expect(reopened.state.messages.map((m) => m.text), [
['earlier', 'hi', 'reply']); 'earlier',
'hi',
'reply',
]);
await reopened.close(); await reopened.close();
}); },
);
test('a re-delivered wrap is shown once (relay resends stored events)', test(
'a re-delivered wrap is shown once (relay resends stored events)',
() async { () async {
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)
MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); ..start();
final same = msg(peer, 'hola'); // identical sender+timestamp+text final same = msg(peer, 'hola'); // identical sender+timestamp+text
transport.receive(same); transport.receive(same);
@ -123,17 +175,22 @@ void main() {
expect(cubit.state.messages, hasLength(1)); expect(cubit.state.messages, hasLength(1));
await cubit.close(); await cubit.close();
}); },
);
test('history already surfaced live is not shown twice', () async { test('history already surfaced live is not shown twice', () async {
// The stored message is ALSO handed back by the live subscription on open. // 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'); final m = msg(peer, 'hola');
await store.append(peer, m); await store.append(peer, m);
final transport = FakeMessageTransport(); final transport = FakeMessageTransport();
final cubit = MessagesCubit(transport, final cubit = MessagesCubit(
peerPubkey: peer, selfPubkey: me, store: store); transport,
peerPubkey: peer,
selfPubkey: me,
store: store,
);
unawaited(cubit.start()); unawaited(cubit.start());
transport.receive(m); // live redelivery races the history load transport.receive(m); // live redelivery races the history load
await pumpEventQueue(); await pumpEventQueue();
@ -143,7 +200,8 @@ void main() {
}); });
test('offline (no transport) never throws', () async { 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); expect(cubit.isOnline, isFalse);
await cubit.send('hi'); await cubit.send('hi');
expect(cubit.state.messages, isEmpty); expect(cubit.state.messages, isEmpty);

View file

@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_repository.dart'; import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_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/db/database.dart';
import 'package:tane/i18n/strings.g.dart'; import 'package:tane/i18n/strings.g.dart';
import 'package:tane/security/secret_store.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). /// verified separately in the SQLCipher-only security test).
AppDatabase newTestDatabase() => AppDatabase(NativeDatabase.memory()); 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 /// Unmounts the widget tree *inside* the test and drains the zero-duration
/// timer Drift schedules when its stream subscription is cancelled. Without /// timer Drift schedules when its stream subscription is cancelled. Without
/// this, flutter_test reports "a Timer is still pending after disposal". Call /// this, flutter_test reports "a Timer is still pending after disposal". Call

View file

@ -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<PrivateMessage> _inbox =
StreamController<PrivateMessage>.broadcast();
void receive(PrivateMessage message) => _inbox.add(message);
@override
Future<void> send({required String toPubkey, required String text}) async {}
@override
Stream<PrivateMessage> inbox() => _inbox.stream;
@override
Future<void> 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<void>.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);
});
}

View file

@ -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);
});
});
}

View file

@ -16,7 +16,7 @@ void main() {
late UnreadService unread; late UnreadService unread;
setUp(() { setUp(() {
store = MessageStore(InMemorySecretStore()); store = MessageStore(newTestChatDatabase());
unread = UnreadService(store, InMemorySecretStore()); unread = UnreadService(store, InMemorySecretStore());
}); });

View file

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

View file

@ -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 - **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 - **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 - **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 - **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 - **Precio:** ¿monedas comunitarias / de tiempo además de dinero? → sharing-model §6
- **Integración Ğ1 (moneda libre):** niveles 12 (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) - **Integración Ğ1 (moneda libre):** niveles 12 (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)