diff --git a/apps/app_seeds/lib/services/saved_searches_store.dart b/apps/app_seeds/lib/services/saved_searches_store.dart new file mode 100644 index 0000000..57b5e04 --- /dev/null +++ b/apps/app_seeds/lib/services/saved_searches_store.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:commons_core/commons_core.dart'; + +import '../security/secret_store.dart'; + +/// A person's saved market searches with their alert bookkeeping — the query +/// and facets they want to be notified about, Wallapop-style. Keystore-backed +/// (no plaintext at rest) and namespaced by the active identity's +/// [accountScope], like [SavedOffersStore]. +/// +/// Per search we also persist: +/// - [_seenKeysField]: offer coordinates (`authorPubkeyHex:id`) already alerted +/// on, so a relay replaying stored/edited events never re-notifies. Capped at +/// [_seenCap] (oldest dropped) to keep the blob small. +/// - [_newKeysField]: matches the user hasn't looked at yet — the badge source, +/// cleared when they open the search. +class SavedSearchesStore { + SavedSearchesStore(this._store, {String accountScope = ''}) + : _key = accountScope.isEmpty + ? 'tane.social.saved_searches' + : 'tane.social.$accountScope.saved_searches'; + + final SecretStore _store; + final String _key; + + static const _seenCap = 500; + static const _seenKeysField = 'seenKeys'; + static const _newKeysField = 'newKeys'; + + final _changes = StreamController.broadcast(); + + /// Emits whenever the stored searches or their alert counts change, so open + /// screens and badges can refresh. + Stream get changes => _changes.stream; + + /// The stable per-offer key used for dedup: its addressable coordinate + /// (`authorPubkeyHex:id`), matching [SavedOffersStore.keyOf]. + static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}'; + + /// Every saved search, newest first (by creation time). + Future> list() async { + final entries = await _rawList() + ..sort((a, b) => + ((b['createdAt'] as int?) ?? 0).compareTo((a['createdAt'] as int?) ?? 0)); + return [for (final e in entries) SavedSearch.fromJson(e)]; + } + + /// Whether a search with [id] exists. + Future exists(String id) async => + (await _rawList()).any((e) => e['id'] == id); + + /// Saves [search] (idempotent on its id). [seedSeenKeys] pre-marks offers the + /// user has already seen (the current on-screen matches), so they aren't + /// alerted about listings that were visible when they saved the search. + Future save( + SavedSearch search, { + Iterable seedSeenKeys = const [], + }) async { + final list = await _rawList(); + final existing = _find(list, search.id); + final seen = { + ...?(existing?[_seenKeysField] as List?)?.cast(), + ...seedSeenKeys, + }; + list + ..removeWhere((e) => e['id'] == search.id) + ..add({ + ...search.toJson(), + _seenKeysField: _capped(seen.toList()), + // Keep any unread matches on re-save (e.g. an edit), else start empty. + _newKeysField: + (existing?[_newKeysField] as List?)?.cast() ?? const [], + }); + await _writeRaw(list); + } + + /// Removes the search with [id]; no-op if absent. + Future remove(String id) async { + final list = await _rawList()..removeWhere((e) => e['id'] == id); + await _writeRaw(list); + } + + /// Records that [offerKey] matched search [id]: adds it to both the seen set + /// (so it never re-alerts) and the unread set (so the badge shows it). + /// Returns true when this is a fresh match worth notifying about; false when + /// the key was already seen (a replay/echo) or the search is gone. + Future markMatched(String id, String offerKey) async { + final list = await _rawList(); + final entry = _find(list, id); + if (entry == null) return false; + final seen = (entry[_seenKeysField] as List?)?.cast() ?? const []; + if (seen.contains(offerKey)) return false; + entry[_seenKeysField] = _capped([...seen, offerKey]); + final unread = (entry[_newKeysField] as List?)?.cast() ?? const []; + if (!unread.contains(offerKey)) { + entry[_newKeysField] = [...unread, offerKey]; + } + await _writeRaw(list); + return true; + } + + /// Clears the unread matches for search [id] (the user opened it). + Future markViewed(String id) async { + final list = await _rawList(); + final entry = _find(list, id); + if (entry == null) return; + if (((entry[_newKeysField] as List?) ?? const []).isEmpty) return; + entry[_newKeysField] = const []; + await _writeRaw(list); + } + + /// Unread match count for a single search. + Future newMatchCount(String id) async { + final entry = _find(await _rawList(), id); + return ((entry?[_newKeysField] as List?) ?? const []).length; + } + + /// Total unread matches across every saved search (the app-level badge). + Future totalNewMatchCount() async { + var total = 0; + for (final e in await _rawList()) { + total += ((e[_newKeysField] as List?) ?? const []).length; + } + return total; + } + + static Map? _find( + List> list, + String id, + ) { + for (final e in list) { + if (e['id'] == id) return e; + } + return null; + } + + /// Keeps only the newest [_seenCap] keys (list is append-ordered, so drop + /// from the front). + static List _capped(List keys) => keys.length <= _seenCap + ? keys + : keys.sublist(keys.length - _seenCap); + + Future>> _rawList() async { + final raw = await _store.read(_key); + if (raw == null || raw.isEmpty) return []; + return (jsonDecode(raw) as List) + .cast>() + .map((e) => Map.from(e)) + .toList(); + } + + Future _writeRaw(List> list) async { + await _store.write(_key, jsonEncode(list)); + if (!_changes.isClosed) _changes.add(null); + } + + Future close() async { + await _changes.close(); + } +} diff --git a/apps/app_seeds/test/services/saved_searches_store_test.dart b/apps/app_seeds/test/services/saved_searches_store_test.dart new file mode 100644 index 0000000..b4723ca --- /dev/null +++ b/apps/app_seeds/test/services/saved_searches_store_test.dart @@ -0,0 +1,97 @@ +import 'package:tane/services/saved_searches_store.dart'; +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/test_support.dart'; + +void main() { + late SavedSearchesStore store; + + setUp(() => store = SavedSearchesStore(InMemorySecretStore())); + tearDown(() => store.close()); + + SavedSearch search(String id, {String query = '', int createdAt = 0}) => + SavedSearch(id: id, label: id, query: query, createdAt: createdAt); + + test('saves and lists newest first', () async { + await store.save(search('a', createdAt: 100)); + await store.save(search('b', createdAt: 200)); + final list = await store.list(); + expect(list.map((s) => s.id), ['b', 'a']); + }); + + test('save is idempotent on id', () async { + await store.save(search('a', query: 'one')); + await store.save(search('a', query: 'two')); + final list = await store.list(); + expect(list, hasLength(1)); + expect(list.single.query, 'two'); + }); + + test('remove deletes a search', () async { + await store.save(search('a')); + await store.remove('a'); + expect(await store.list(), isEmpty); + expect(await store.exists('a'), isFalse); + }); + + test('markMatched reports a fresh match once, then dedups replays', () async { + await store.save(search('a')); + expect(await store.markMatched('a', 'author:o1'), isTrue); + // Same key again = replay/echo, not a new alert. + expect(await store.markMatched('a', 'author:o1'), isFalse); + expect(await store.markMatched('a', 'author:o2'), isTrue); + expect(await store.newMatchCount('a'), 2); + }); + + test('markMatched returns false for an unknown search', () async { + expect(await store.markMatched('ghost', 'author:o1'), isFalse); + }); + + test('seeded seen keys suppress alerts for already-visible offers', () async { + await store.save(search('a'), seedSeenKeys: ['author:o1']); + expect(await store.markMatched('a', 'author:o1'), isFalse); + expect(await store.newMatchCount('a'), 0); + }); + + test('markViewed clears unread but keeps seen (no re-alert)', () async { + await store.save(search('a')); + await store.markMatched('a', 'author:o1'); + await store.markViewed('a'); + expect(await store.newMatchCount('a'), 0); + // Still seen: the same offer won't alert again. + expect(await store.markMatched('a', 'author:o1'), isFalse); + }); + + test('totalNewMatchCount sums across searches', () async { + await store.save(search('a')); + await store.save(search('b')); + await store.markMatched('a', 'author:o1'); + await store.markMatched('b', 'author:o2'); + await store.markMatched('b', 'author:o3'); + expect(await store.totalNewMatchCount(), 3); + }); + + test('changes stream fires on mutations', () async { + final events = []; + final sub = store.changes.listen(events.add); + await store.save(search('a')); + await store.markMatched('a', 'author:o1'); + await store.markViewed('a'); + await store.remove('a'); + await Future.delayed(Duration.zero); + expect(events, hasLength(4)); + await sub.cancel(); + }); + + test('account scopes are isolated', () async { + final secret = InMemorySecretStore(); + final s1 = SavedSearchesStore(secret, accountScope: 'acct1'); + final s2 = SavedSearchesStore(secret, accountScope: 'acct2'); + await s1.save(search('a')); + expect(await s1.list(), hasLength(1)); + expect(await s2.list(), isEmpty); + await s1.close(); + await s2.close(); + }); +}