diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 82ae76d..7b6a37e 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -9,6 +9,7 @@ import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; import 'services/auto_backup_service.dart'; import 'services/coarse_location.dart'; +import 'services/message_store.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; @@ -38,6 +39,7 @@ class TaneApp extends StatelessWidget { this.socialSettings, this.location, this.outbox, + this.messageStore, this.showIntro = false, this.autoBackup, super.key, @@ -49,6 +51,7 @@ class TaneApp extends StatelessWidget { socialSettings, location, outbox, + messageStore, ); final VarietyRepository repository; @@ -65,6 +68,9 @@ class TaneApp extends StatelessWidget { /// Optional offline outbox for the market's "share my seeds". final OfferOutbox? outbox; + + /// Optional persistence for chat history. + final MessageStore? messageStore; final bool showIntro; /// Drives silent periodic backups off the app lifecycle. Null in widget tests @@ -80,6 +86,7 @@ class TaneApp extends StatelessWidget { SocialSettings? socialSettings, CoarseLocationProvider? location, OfferOutbox? outbox, + MessageStore? messageStore, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', @@ -106,6 +113,7 @@ class TaneApp extends StatelessWidget { social: social, settings: socialSettings, peerPubkey: state.pathParameters['pubkey']!, + messageStore: messageStore, ), ), GoRoute( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index c882254..28e828d 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -27,6 +27,7 @@ import '../services/ocr/tesseract_label_extractor.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; +import '../services/message_store.dart'; import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -90,6 +91,7 @@ Future configureDependencies() async { ..registerSingleton(socialService) ..registerSingleton(SocialSettings(secretStore)) ..registerSingleton(OfferOutbox(secretStore)) + ..registerSingleton(MessageStore(secretStore)) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index a4d025e..f7dcce5 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -7,6 +7,7 @@ import 'di/injector.dart'; import 'i18n/strings.g.dart'; import 'services/auto_backup_service.dart'; import 'services/coarse_location.dart'; +import 'services/message_store.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; @@ -27,6 +28,7 @@ Future main() async { socialSettings: getIt(), location: const GeolocatorCoarseLocation(), outbox: getIt(), + messageStore: getIt(), showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() ? getIt() diff --git a/apps/app_seeds/lib/services/message_store.dart b/apps/app_seeds/lib/services/message_store.dart new file mode 100644 index 0000000..18d0fd2 --- /dev/null +++ b/apps/app_seeds/lib/services/message_store.dart @@ -0,0 +1,56 @@ +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. +class MessageStore { + MessageStore(this._store); + + final SecretStore _store; + static const _prefix = 'tane.social.chat.'; + + /// 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> 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>()) + 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). + Future 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, + }, + ]), + ); + } +} diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart index 9c7bdb7..e080e94 100644 --- a/apps/app_seeds/lib/state/messages_cubit.dart +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -4,6 +4,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../services/message_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -46,11 +47,14 @@ class MessagesCubit extends Cubit { this._transport, { required this.peerPubkey, required this.selfPubkey, + MessageStore? store, Future Function()? onDispose, - }) : _onDispose = onDispose, + }) : _store = store, + _onDispose = onDispose, super(const ChatState()); final MessageTransport? _transport; + final MessageStore? _store; final String peerPubkey; final String selfPubkey; final Future Function()? _onDispose; @@ -58,17 +62,26 @@ class MessagesCubit extends Cubit { bool get isOnline => _transport != null; - /// Subscribes to incoming messages from [peerPubkey]. - void start() { + /// Subscribes to incoming messages, then loads any saved history. Subscribing + /// first (before the async history load) avoids dropping an event that arrives + /// during the load. + Future start() async { final transport = _transport; - if (transport == null) return; - _sub = transport.inbox().listen( - (message) { - if (message.fromPubkey != peerPubkey) return; // another conversation - emit(state.copyWith(messages: [...state.messages, message])); - }, - onError: (Object e) => emit(state.copyWith(error: () => '$e')), - ); + if (transport != null) { + _sub = transport.inbox().listen( + (message) async { + if (message.fromPubkey != peerPubkey) return; // another conversation + await _store?.append(peerPubkey, message); + emit(state.copyWith(messages: [...state.messages, message])); + }, + onError: (Object e) => emit(state.copyWith(error: () => '$e')), + ); + } + // History is older than anything that arrives now, so prepend it. + final history = await _store?.history(peerPubkey); + if (history != null && history.isNotEmpty) { + emit(state.copyWith(messages: [...history, ...state.messages])); + } } /// Sends [text] to the peer and appends it optimistically. @@ -79,16 +92,15 @@ class MessagesCubit extends Cubit { emit(state.copyWith(sending: true, error: () => null)); try { await transport.send(toPubkey: peerPubkey, text: trimmed); + final mine = PrivateMessage( + fromPubkey: selfPubkey, + text: trimmed, + at: DateTime.now(), + ); + await _store?.append(peerPubkey, mine); emit(state.copyWith( sending: false, - messages: [ - ...state.messages, - PrivateMessage( - fromPubkey: selfPubkey, - text: trimmed, - at: DateTime.now(), - ), - ], + messages: [...state.messages, mine], )); } catch (e) { emit(state.copyWith(sending: false, error: () => '$e')); diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 962b825..5f6f8f7 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../i18n/strings.g.dart'; +import '../services/message_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/messages_cubit.dart'; @@ -18,6 +19,7 @@ class ChatScreen extends StatefulWidget { required this.social, required this.settings, required this.peerPubkey, + this.messageStore, super.key, }); @@ -25,6 +27,9 @@ class ChatScreen extends StatefulWidget { final SocialSettings settings; final String peerPubkey; + /// Optional persistence for chat history (keystore-backed); null in tests. + final MessageStore? messageStore; + @override State createState() => _ChatScreenState(); } @@ -59,9 +64,12 @@ class _ChatScreenState extends State { return; } final self = widget.social.publicKeyHex; - final messages = MessagesCubit(session?.messages, - peerPubkey: widget.peerPubkey, selfPubkey: self) - ..start(); + final messages = MessagesCubit( + session?.messages, + peerPubkey: widget.peerPubkey, + selfPubkey: self, + store: widget.messageStore, + )..start(); final trust = TrustCubit(session?.trust, peerPubkey: widget.peerPubkey, selfPubkey: self); unawaited(trust.load()); diff --git a/apps/app_seeds/test/services/message_store_test.dart b/apps/app_seeds/test/services/message_store_test.dart new file mode 100644 index 0000000..0ae890c --- /dev/null +++ b/apps/app_seeds/test/services/message_store_test.dart @@ -0,0 +1,46 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; + +import '../support/test_support.dart'; + +void main() { + late MessageStore store; + setUp(() => store = MessageStore(InMemorySecretStore())); + + PrivateMessage msg(String from, String text, int atMs) => + PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + + test('history is empty for an unknown peer', () async { + expect(await store.history('peer'), isEmpty); + }); + + test('append then history round-trips, oldest first', () async { + await store.append('peer', msg('peer', 'hi', 1000)); + await store.append('peer', msg('me', 'hello', 2000)); + final history = await store.history('peer'); + expect(history.map((m) => m.text), ['hi', 'hello']); + expect(history.first.fromPubkey, 'peer'); + expect(history.last.at.millisecondsSinceEpoch, 2000); + }); + + test('conversations are kept separate per peer', () async { + await store.append('a', msg('a', 'toA', 1)); + await store.append('b', msg('b', 'toB', 1)); + expect((await store.history('a')).single.text, 'toA'); + expect((await store.history('b')).single.text, 'toB'); + }); + + test('history is capped to the most recent 200', () async { + for (var i = 0; i < 210; i++) { + await store.append('peer', msg('me', 'm$i', i)); + } + final history = await store.history('peer'); + expect(history, hasLength(200)); + expect(history.first.text, 'm10'); // oldest 10 dropped + expect(history.last.text, 'm209'); + }); +} diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart index d0d8e00..96ee0ce 100644 --- a/apps/app_seeds/test/state/messages_cubit_test.dart +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; import 'package:tane/state/messages_cubit.dart'; +import '../support/test_support.dart'; + /// In-memory [MessageTransport]: records sends, lets a test push inbox messages. class FakeMessageTransport implements MessageTransport { final List<({String to, String text})> sent = []; @@ -80,6 +83,33 @@ void main() { await cubit.close(); }); + test('loads saved history on start and persists across a new cubit', + () async { + final store = MessageStore(InMemorySecretStore()); + await store.append( + peer, msg(peer, 'earlier')); // a message from a previous session + + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, + peerPubkey: peer, selfPubkey: me, store: store); + await cubit.start(); + expect(cubit.state.messages.map((m) => m.text), ['earlier']); + + await cubit.send('hi'); // persisted + transport.receive(msg(peer, 'reply')); // persisted + await pumpEventQueue(); + expect(cubit.state.messages.map((m) => m.text), ['earlier', 'hi', 'reply']); + await cubit.close(); + + // A fresh cubit (even offline) sees the saved conversation. + final reopened = + MessagesCubit(null, peerPubkey: peer, selfPubkey: me, store: store); + await reopened.start(); + expect(reopened.state.messages.map((m) => m.text), + ['earlier', 'hi', 'reply']); + await reopened.close(); + }); + test('offline (no transport) never throws', () async { final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me)..start(); expect(cubit.isOnline, isFalse);