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.
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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