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

@ -25,7 +25,16 @@ class NotificationService {
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
void Function(String peerPubkey)? onTapChat;
/// Called with a saved-search id when the user taps a search-alert
/// notification. The app wires this to open the saved-searches list.
void Function(String searchId)? onTapSearch;
static const _channelId = 'messages';
static const _alertsChannelId = 'alerts';
/// Tap payloads are prefixed so one handler can route by kind. A bare payload
/// (no prefix) stays a chat peer pubkey, for backward compatibility.
static const _searchPayloadPrefix = 'search:';
static bool get _platformSupported {
if (kIsWeb) return false;
@ -57,7 +66,12 @@ class NotificationService {
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
if (payload == null || payload.isEmpty) return;
if (payload.startsWith(_searchPayloadPrefix)) {
onTapSearch?.call(payload.substring(_searchPayloadPrefix.length));
} else {
onTapChat?.call(payload);
}
},
);
await _plugin
@ -100,4 +114,35 @@ class NotificationService {
await _plugin.show(peerPubkey.hashCode, title, null, details,
payload: peerPubkey);
}
/// Shows a notification for a new offer matching a saved search. [title] is a
/// generic line built by the caller (e.g. "New match: Tomatoes"); [searchId]
/// rides along so a tap opens that search. No-op on unsupported platforms.
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
if (!_supported) return;
const details = NotificationDetails(
android: AndroidNotificationDetails(
_alertsChannelId,
'Search alerts',
channelDescription: 'New offers matching your saved searches',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
);
// One notification per search (same id replaces the previous), so repeated
// matches for one search don't stack up.
await _plugin.show(
_searchPayloadPrefix.hashCode ^ searchId.hashCode,
title,
null,
details,
payload: '$_searchPayloadPrefix$searchId',
);
}
}

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 = '';
}
}