feat(social): add SavedSearchAlertService + notification alerts channel

One discovery subscription over the shared connection for the user's
current zone; matches each incoming offer against saved searches and
fires a text-free OS alert on the new 'alerts' channel for fresh matches.
Skips own/blocked/hidden/non-active offers and dedups via seen keys. The
replay-on-connect doubles as the catch-up scan. Notification payloads are
now prefixed so taps route to chat or the saved search.
This commit is contained in:
vjrj 2026-07-17 12:42:41 +02:00
parent 530bf96358
commit c0cd299408
3 changed files with 341 additions and 1 deletions

View file

@ -0,0 +1,147 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/foundation.dart';
import 'discovery_area.dart';
import 'notification_service.dart';
import 'saved_searches_store.dart';
import 'social_connection.dart';
import 'social_service.dart' show SocialSession;
import 'social_settings.dart';
/// Builds the notification title for a fresh match on [search]. Injected so the
/// app can supply a localized string while tests stay i18n-free.
typedef AlertTitle = String Function(SavedSearch search);
/// App-wide listener that turns incoming market offers into saved-search alerts
/// (Wallapop's "búsquedas favoritas"). One discovery subscription over the
/// SHARED [SocialConnection] for the user's whole current zone; each offer is
/// matched locally against every saved search.
///
/// The discover stream replays a relay's stored offers before going live, so
/// this doubles as the catch-up scan on app start the per-search seen-key
/// dedup in [SavedSearchesStore] keeps replays and edits silent, so only
/// genuinely new matches notify. It re-subscribes when the connection
/// reconnects and when the user's search area changes.
///
/// Foreground only background/push delivery is a later, larger concern.
class SavedSearchAlertService {
SavedSearchAlertService({
required SocialConnection connection,
required String selfPubkey,
required SavedSearchesStore store,
required SocialSettings settings,
NotificationService? notifications,
AlertTitle? alertTitle,
}) : _connection = connection,
_selfPubkey = selfPubkey,
_store = store,
_settings = settings,
_notifications = notifications,
_alertTitle = alertTitle ?? ((s) => s.label);
final SocialConnection _connection;
final String _selfPubkey;
final SavedSearchesStore _store;
final SocialSettings _settings;
final NotificationService? _notifications;
final AlertTitle _alertTitle;
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<Offer>? _offersSub;
SocialSession? _session;
String _prefix = '';
bool _started = false;
/// Begins listening. Subscribe to the connection's sessions BEFORE it starts
/// connecting, so the first session is caught too.
void start() {
if (_started) return;
_started = true;
_sessionsSub = _connection.sessions.listen(_onSession);
final current = _connection.current;
if (current != null) _onSession(current);
}
/// Re-subscribes discovery, e.g. after the user changes their search area.
/// Cheap no-op when the session and prefix are unchanged.
Future<void> refreshArea() async {
final session = _session;
if (session != null) await _bind(session, force: true);
}
void _onSession(SocialSession? session) {
if (identical(session, _session) && _offersSub != null) return;
_session = session;
if (session == null) {
unawaited(_offersSub?.cancel());
_offersSub = null;
return;
}
unawaited(_bind(session, force: true));
}
/// (Re)binds the offer subscription to [session] over the user's current
/// zone. Skips re-subscribing when nothing changed unless [force].
Future<void> _bind(SocialSession session, {bool force = false}) async {
final area = await _settings.areaGeohash();
if (area.isEmpty) {
// No zone set yet nothing to search. Drop any stale subscription.
await _offersSub?.cancel();
_offersSub = null;
_prefix = '';
return;
}
final prefix = searchPrefix(area, await _settings.searchPrecision());
if (!force && prefix == _prefix && _offersSub != null) return;
await _offersSub?.cancel();
_prefix = prefix;
_offersSub = session.offers
.discover(DiscoveryQuery(geohashPrefix: prefix))
.listen(
ingest,
onError: (_) {}, // handled by the connection's reconnect
);
}
/// Matches one incoming [offer] against every saved search and fires an alert
/// for each fresh match. A testable seam (no relay).
@visibleForTesting
Future<void> ingest(Offer offer) async {
// Never alert on your own listings.
if (offer.authorPubkeyHex == _selfPubkey) return;
// Only live offers are worth surfacing.
if (offer.status != OfferLifecycle.active) return;
// Respect the same moderation the market applies.
if (await _settings.isBlocked(offer.authorPubkeyHex)) return;
final hidden = await _settings.hiddenOfferKeys();
if (hidden.contains(SocialSettings.offerKey(offer.authorPubkeyHex, offer.id))) {
return;
}
final searches = await _store.list();
if (searches.isEmpty) return;
final key = SavedSearchesStore.keyOf(offer);
for (final search in searches) {
if (!search.matches(offer)) continue;
final isNew = await _store.markMatched(search.id, key);
if (isNew) {
await _notifications?.showSearchAlert(
searchId: search.id,
title: _alertTitle(search),
);
}
}
}
Future<void> stop() async {
_started = false;
await _sessionsSub?.cancel();
_sessionsSub = null;
await _offersSub?.cancel();
_offersSub = null;
_session = null;
_prefix = '';
}
}