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
|
|
@ -25,7 +25,16 @@ class NotificationService {
|
||||||
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
|
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
|
||||||
void Function(String peerPubkey)? onTapChat;
|
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 _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 {
|
static bool get _platformSupported {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
|
|
@ -57,7 +66,12 @@ class NotificationService {
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (response) {
|
onDidReceiveNotificationResponse: (response) {
|
||||||
final payload = response.payload;
|
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
|
await _plugin
|
||||||
|
|
@ -100,4 +114,35 @@ class NotificationService {
|
||||||
await _plugin.show(peerPubkey.hashCode, title, null, details,
|
await _plugin.show(peerPubkey.hashCode, title, null, details,
|
||||||
payload: peerPubkey);
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue