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