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

View file

@ -0,0 +1,148 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/saved_search_alert_service.dart';
import 'package:tane/services/saved_searches_store.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import '../support/test_support.dart';
/// Records search-alert calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final searchIds = <String>[];
final titles = <String>[];
@override
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
searchIds.add(searchId);
titles.add(title);
}
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const myPubkey = 'me';
// A connection that never actually opens ingest doesn't use it.
Future<SocialConnection> offlineConnection() async => SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused in ingest tests'),
online: const Stream.empty(),
);
late SavedSearchesStore store;
late SocialSettings settings;
late _RecordingNotifications notifications;
late SavedSearchAlertService service;
setUp(() async {
store = SavedSearchesStore(InMemorySecretStore());
settings = SocialSettings(InMemorySecretStore());
notifications = _RecordingNotifications();
service = SavedSearchAlertService(
connection: await offlineConnection(),
selfPubkey: myPubkey,
store: store,
settings: settings,
notifications: notifications,
alertTitle: (s) => 'New match: ${s.label}',
);
});
tearDown(() async {
await service.stop();
await store.close();
});
Offer offer({
String id = 'o1',
String author = 'grower',
String summary = 'Heirloom tomato seeds',
OfferType type = OfferType.gift,
OfferLifecycle status = OfferLifecycle.active,
String? category = 'Solanaceae',
bool isOrganic = false,
}) =>
Offer(
id: id,
authorPubkeyHex: author,
summary: summary,
type: type,
status: status,
approxGeohash: 'u09',
category: category,
isOrganic: isOrganic,
);
SavedSearch search(String id, {String query = ''}) =>
SavedSearch(id: id, label: id, query: query);
test('a matching offer fires one alert and records the match', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, ['tom']);
expect(notifications.titles, ['New match: tom']);
expect(await store.newMatchCount('tom'), 1);
});
test('a non-matching offer is silent', () async {
await store.save(search('pep', query: 'pepper'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a replayed offer does not re-alert', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
await service.ingest(offer()); // relay replays the stored event
expect(notifications.searchIds, ['tom']);
});
test('one offer can match several searches', () async {
await store.save(search('all'));
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, containsAll(<String>['all', 'tom']));
expect(notifications.searchIds, hasLength(2));
});
test('my own offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(author: myPubkey));
expect(notifications.searchIds, isEmpty);
});
test('non-active offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(status: OfferLifecycle.closed));
expect(notifications.searchIds, isEmpty);
});
test('offers from a blocked author never alert', () async {
await settings.block('grower');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a hidden (reported) offer never alerts', () async {
await settings.hideOffer(authorPubkeyHex: 'grower', offerId: 'o1');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('with no saved searches nothing fires', () async {
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
}