tane/apps/app_seeds/lib/services/saved_searches_store.dart
vjrj 530bf96358 feat(social): add SavedSearchesStore with alert bookkeeping
Keystore-backed, account-scoped store mirroring SavedOffersStore. Tracks
per-search seen keys (dedup, capped 500) and unread match keys (badge
source), with markMatched/markViewed/newMatchCount and a changes stream.
2026-07-17 13:18:29 +02:00

162 lines
5.7 KiB
Dart

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<void>.broadcast();
/// Emits whenever the stored searches or their alert counts change, so open
/// screens and badges can refresh.
Stream<void> 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<SavedSearch>> 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<bool> 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<void> save(
SavedSearch search, {
Iterable<String> seedSeenKeys = const [],
}) async {
final list = await _rawList();
final existing = _find(list, search.id);
final seen = <String>{
...?(existing?[_seenKeysField] as List?)?.cast<String>(),
...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<String>() ?? const [],
});
await _writeRaw(list);
}
/// Removes the search with [id]; no-op if absent.
Future<void> 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<bool> 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<String>() ?? const [];
if (seen.contains(offerKey)) return false;
entry[_seenKeysField] = _capped([...seen, offerKey]);
final unread = (entry[_newKeysField] as List?)?.cast<String>() ?? 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<void> 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<int> 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<int> totalNewMatchCount() async {
var total = 0;
for (final e in await _rawList()) {
total += ((e[_newKeysField] as List?) ?? const []).length;
}
return total;
}
static Map<String, dynamic>? _find(
List<Map<String, dynamic>> 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<String> _capped(List<String> keys) => keys.length <= _seenCap
? keys
: keys.sublist(keys.length - _seenCap);
Future<List<Map<String, dynamic>>> _rawList() async {
final raw = await _store.read(_key);
if (raw == null || raw.isEmpty) return [];
return (jsonDecode(raw) as List)
.cast<Map<String, dynamic>>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
await _store.write(_key, jsonEncode(list));
if (!_changes.isClosed) _changes.add(null);
}
Future<void> close() async {
await _changes.close();
}
}