Reception only ran inside an open ChatScreen for a known peer, so a first message from a new peer was invisible: nothing in local storage -> nothing in the inbox list -> you never opened the chat -> you never subscribed. Add InboxService: one long-lived NIP-17 inbox subscription for the whole app (foreground), persisting every incoming message to MessageStore and firing a 'changes' signal the inbox list live-reloads on. Reconnects when the network returns; degrades to nothing offline. Started from main when a social identity exists. Make MessageStore.append idempotent (dedup by sender+timestamp+text) and serialized behind a write lock — the global listener and an open chat's own subscription now write the same conversation concurrently and relays re-deliver stored gift wraps on every resubscribe. Tests for both. Known trade-offs (follow-ups): foreground-only (no push yet); each of InboxService/ChatScreen/MarketScreen opens its own RelayPool (a shared connection is a later optimization).
128 lines
4.4 KiB
Dart
128 lines
4.4 KiB
Dart
import 'dart:async';
|
|
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;
|
|
|
|
/// 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();
|
|
|
|
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. 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.
|
|
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;
|
|
}
|
|
|
|
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).
|
|
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'));
|
|
}
|
|
}
|