Chat was only reachable via an offer's 'Message' button, and the drawer 'Chat' sat on 'coming soon' — so messaging/trust were effectively hidden. - MessageStore keeps a conversation index (the keystore has no key enumeration) and exposes conversations() — peers newest-active-first with their last line. - ChatListScreen: the messages inbox (tap a conversation → /chat/:pubkey). - Drawer 'Chat' is now a live destination -> /messages (gated like Market). - i18n en/es/pt. conversations() covered by a plain test (no widget hang). Analyzer clean.
103 lines
3.2 KiB
Dart
103 lines
3.2 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
|
|
import '../security/secret_store.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({
|
|
required this.peerPubkey,
|
|
required this.lastText,
|
|
required this.lastAt,
|
|
});
|
|
|
|
final String peerPubkey;
|
|
final String lastText;
|
|
final DateTime lastAt;
|
|
}
|
|
|
|
class MessageStore {
|
|
MessageStore(this._store);
|
|
|
|
final SecretStore _store;
|
|
static const _prefix = 'tane.social.chat.';
|
|
|
|
/// Index of peers we have a conversation with (the keystore is key/value with
|
|
/// no key enumeration, so we track the list ourselves).
|
|
static const _indexKey = 'tane.social.chats';
|
|
|
|
/// Keep only the most recent [_cap] messages per conversation, to bound the
|
|
/// keystore entry size.
|
|
static const _cap = 200;
|
|
|
|
String _key(String peerPubkey) => '$_prefix$peerPubkey';
|
|
|
|
/// 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),
|
|
),
|
|
];
|
|
}
|
|
|
|
/// Appends [message] to the conversation with [peerPubkey] (trimming to the
|
|
/// cap) and records the peer in the conversation index.
|
|
Future<void> append(String peerPubkey, PrivateMessage message) async {
|
|
final next = [...await history(peerPubkey), 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);
|
|
}
|
|
|
|
/// Conversations, most-recently-active first (for the messages inbox).
|
|
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,
|
|
));
|
|
}
|
|
summaries.sort((a, b) => b.lastAt.compareTo(a.lastAt));
|
|
return summaries;
|
|
}
|
|
|
|
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'));
|
|
}
|
|
}
|