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.
This commit is contained in:
parent
5b1066a224
commit
530bf96358
2 changed files with 259 additions and 0 deletions
162
apps/app_seeds/lib/services/saved_searches_store.dart
Normal file
162
apps/app_seeds/lib/services/saved_searches_store.dart
Normal file
|
|
@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/app_seeds/test/services/saved_searches_store_test.dart
Normal file
97
apps/app_seeds/test/services/saved_searches_store_test.dart
Normal file
|
|
@ -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 = <void>[];
|
||||||
|
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<void>.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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue