tane/apps/app_seeds/lib/services/message_store.dart
vjrj 7f1c520960 feat(block2): messages inbox + drawer 'Chat' link (was unreachable)
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.
2026-07-10 12:26:33 +02:00

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