feat(chat): usable 1:1 chat — bottom-anchored, Drift-backed, dated
- Anchor the message list to the bottom (`reverse: true`) so new messages stay in view instead of landing below the fold. - Move chat history from the OS keystore (O(n²) JSON blob, silent 200-msg cap) to a separate encrypted Drift/SQLCipher DB (`ChatDatabase`): indexed append, uncapped history, dedup as a unique-key invariant. It's an ephemeral per-device cache, isolated from the inventory schema, its migrations, and its sync. No data migration (pre-release). - Add day separators (Today/Yesterday/locale date) and a per-bubble time, all via ICU (12/24h per locale; Localizations locale maps Asturian → Spanish for intl date symbols). - Add peer avatars (deterministic colour from the pubkey + name initial), surface send failures that were previously silent, and make bubble text selectable (addresses, links). - New i18n keys in en/es/pt/ast; tests for grouping, formatting, avatars, scroll anchoring, storage and send errors. Docs: docs/design/chat-storage.md + open-decisions.md.
This commit is contained in:
parent
44337497d0
commit
68b04ea409
27 changed files with 1793 additions and 264 deletions
54
apps/app_seeds/lib/db/chat_database.dart
Normal file
54
apps/app_seeds/lib/db/chat_database.dart
Normal 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());
|
||||
}
|
||||
654
apps/app_seeds/lib/db/chat_database.g.dart
Normal file
654
apps/app_seeds/lib/db/chat_database.g.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
|
|||
import '../data/species_catalog.dart';
|
||||
import '../data/species_repository.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
import '../db/chat_database.dart';
|
||||
import '../db/database.dart';
|
||||
import '../db/encrypted_executor.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
|
@ -98,6 +99,12 @@ Future<void> configureDependencies() async {
|
|||
openEncryptedExecutor(await _databaseFile(), dbKeyHex),
|
||||
);
|
||||
|
||||
// Chat history lives in its own encrypted database (same SQLCipher key),
|
||||
// isolated from the inventory schema/sync. See [Messages].
|
||||
final chatDatabase = ChatDatabase(
|
||||
openEncryptedExecutor(await _chatDatabaseFile(), dbKeyHex),
|
||||
);
|
||||
|
||||
// CRDT author id: a stable per-install slice of the root seed. Kept distinct
|
||||
// from the social-layer public key on purpose — unifying the two would rewrite
|
||||
// authorship of existing rows, a data-model decision for later.
|
||||
|
|
@ -116,7 +123,9 @@ Future<void> configureDependencies() async {
|
|||
deviceId: deviceId,
|
||||
);
|
||||
} catch (e, s) {
|
||||
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s');
|
||||
debugPrint(
|
||||
'Social identity derivation failed; inventory-only mode: $e\n$s',
|
||||
);
|
||||
}
|
||||
|
||||
// Seed the bundled species catalog before the UI opens — but only once per
|
||||
|
|
@ -152,6 +161,7 @@ Future<void> configureDependencies() async {
|
|||
getIt
|
||||
..registerSingleton<SecureKeyStore>(keyStore)
|
||||
..registerSingleton<AppDatabase>(database)
|
||||
..registerSingleton<ChatDatabase>(chatDatabase)
|
||||
..registerSingleton<SpeciesRepository>(speciesRepository)
|
||||
..registerSingleton<VarietyRepository>(varietyRepository)
|
||||
..registerSingleton<FileService>(fileService)
|
||||
|
|
@ -166,11 +176,14 @@ Future<void> configureDependencies() async {
|
|||
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||
// Per-identity stores are namespaced by the active account's scope.
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(secretStore, accountScope: scope))
|
||||
MessageStore(chatDatabase, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope))
|
||||
ProfileStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope))
|
||||
ProfileCache(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(
|
||||
repository: varietyRepository,
|
||||
|
|
@ -292,21 +305,27 @@ Future<void> switchSocialAccount(int account) async {
|
|||
// Re-register the per-identity stores under the new scope, then the identity.
|
||||
getIt
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(secretStore, accountScope: scope))
|
||||
MessageStore(getIt<ChatDatabase>(), accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope))
|
||||
ProfileStore(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope))
|
||||
ProfileCache(secretStore, accountScope: scope),
|
||||
)
|
||||
..registerSingleton<UnreadService>(
|
||||
UnreadService(getIt<MessageStore>(), secretStore));
|
||||
UnreadService(getIt<MessageStore>(), secretStore),
|
||||
);
|
||||
|
||||
final social = await SocialService.fromRootSeedHex(
|
||||
rootSeedHex,
|
||||
account: account,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
final connection =
|
||||
SocialConnection(social: social, settings: getIt<SocialSettings>());
|
||||
final connection = SocialConnection(
|
||||
social: social,
|
||||
settings: getIt<SocialSettings>(),
|
||||
);
|
||||
final inbox = InboxService(
|
||||
connection: connection,
|
||||
selfPubkey: social.publicKeyHex,
|
||||
|
|
@ -342,6 +361,11 @@ Future<File> _databaseFile() async {
|
|||
return File(p.join(dir.path, 'tane_inventory.sqlite'));
|
||||
}
|
||||
|
||||
Future<File> _chatDatabaseFile() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return File(p.join(dir.path, 'tane_chat.sqlite'));
|
||||
}
|
||||
|
||||
/// Picks the Tesseract language pack(s) for the current locale(s), limited to
|
||||
/// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen
|
||||
/// language, then the device's ordered locales.
|
||||
|
|
|
|||
70
apps/app_seeds/lib/domain/chat_timeline.dart
Normal file
70
apps/app_seeds/lib/domain/chat_timeline.dart
Normal 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);
|
||||
|
|
@ -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á",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<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';
|
||||
final ChatDatabase _db;
|
||||
final String _scope;
|
||||
|
||||
/// Messages exchanged with [peerPubkey], oldest first.
|
||||
Future<List<PrivateMessage>> 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<Map<String, dynamic>>())
|
||||
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<bool> 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<bool> _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<List<ChatSummary>> conversations() async {
|
||||
final summaries = <ChatSummary>[];
|
||||
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 = <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 summaries;
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<String>> _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<void> _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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChatScreen> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_unread =
|
||||
getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : null;
|
||||
_unread = getIt.isRegistered<UnreadService>()
|
||||
? getIt<UnreadService>()
|
||||
: 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<ChatScreen> {
|
|||
final session = await widget.connection.session();
|
||||
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
||||
final referents = await widget.trustReferents?.all() ?? const <String>{};
|
||||
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<ChatScreen> {
|
|||
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<ChatScreen> {
|
|||
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<void> 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<MessagesCubit, ChatState>(
|
||||
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<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: [
|
||||
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<MessagesCubit>();
|
||||
return BlocBuilder<MessagesCubit, ChatState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
59
apps/app_seeds/lib/ui/peer_avatar.dart
Normal file
59
apps/app_seeds/lib/ui/peer_avatar.dart
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue