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.
147 lines
5.1 KiB
Dart
147 lines
5.1 KiB
Dart
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 = '';
|
|
}
|
|
}
|