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:
parent
530bf96358
commit
c0cd299408
3 changed files with 341 additions and 1 deletions
147
apps/app_seeds/lib/services/saved_search_alert_service.dart
Normal file
147
apps/app_seeds/lib/services/saved_search_alert_service.dart
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue