Skip to content

Commit af6160c

Browse files
authored
feat: implement Web Push pipeline for stealth payment alerts (#173)
- Add pushRelay.ts library for privacy-first relay integration - Enhance useNotificationSW.ts hook with subscription management - Update service workers with push event handling and deduplication - Add Web Push subscription UI to Settings.tsx - Implement unsubscribe functionality - Add privacy protections (meta-address hash only) - Add comprehensive i18n strings Closes #158
1 parent 8a6e3f7 commit af6160c

6 files changed

Lines changed: 1154 additions & 27 deletions

File tree

src/hooks/useNotificationSW.ts

Lines changed: 262 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { useEffect } from 'react';
1+
import { useState, useEffect, useCallback, useRef } from 'react';
22
import { useNotificationsStore } from '@/stores/notificationsStore';
3+
import {
4+
subscribeToRelay,
5+
unsubscribeFromRelay,
6+
testRelayConnectivity,
7+
DEFAULT_RELAY_URL,
8+
} from '@/lib/pushRelay';
39

410
interface SWMessage {
511
type: string;
@@ -16,28 +22,117 @@ interface SWMessage {
1622
};
1723
}
1824

25+
export interface WebPushState {
26+
supported: boolean;
27+
permission: NotificationPermission;
28+
subscribed: boolean;
29+
loading: boolean;
30+
error: string | null;
31+
relayUrl: string;
32+
relayReachable: boolean;
33+
}
34+
35+
export interface UseNotificationSWReturn {
36+
state: WebPushState;
37+
requestPermission: () => Promise<boolean>;
38+
subscribe: (metaAddress: string, relayUrl?: string) => Promise<void>;
39+
unsubscribe: (metaAddress: string) => Promise<void>;
40+
testRelay: (relayUrl?: string) => Promise<boolean>;
41+
updateRelayUrl: (url: string) => void;
42+
}
43+
44+
const STORAGE_KEY_RELAY_URL = 'wraith:push-relay-url';
45+
const STORAGE_KEY_SUBSCRIBED = 'wraith:push-subscribed';
46+
1947
/**
20-
* Registers the Stellar notification service worker and listens for
21-
* WRAITH_NOTIFICATION messages from it, persisting them into the
22-
* notifications store.
48+
* Registers the Stellar notification service worker and manages Web Push
49+
* subscription lifecycle for stealth payment alerts.
50+
*
51+
* Features:
52+
* - Service worker registration and message handling
53+
* - Web Push subscription management
54+
* - Privacy-first relay integration (only meta-address hash)
55+
* - User-configurable relay URL
56+
* - Permission request and state management
2357
*
2458
* Should be mounted once at the app root level.
2559
*/
26-
export function useNotificationSW() {
60+
export function useNotificationSW(): UseNotificationSWReturn {
2761
const addNotification = useNotificationsStore((state) => state.addNotification);
62+
const swRef = useRef<ServiceWorkerRegistration | null>(null);
63+
64+
const [state, setState] = useState<WebPushState>({
65+
supported: false,
66+
permission: 'default',
67+
subscribed: false,
68+
loading: true,
69+
error: null,
70+
relayUrl: localStorage.getItem(STORAGE_KEY_RELAY_URL) || DEFAULT_RELAY_URL,
71+
relayReachable: false,
72+
});
73+
74+
// Check browser support
75+
useEffect(() => {
76+
const supported =
77+
'serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window;
78+
setState((prev) => ({ ...prev, supported, loading: false }));
79+
}, []);
80+
81+
// Check subscription state from localStorage
82+
useEffect(() => {
83+
const subscribed = localStorage.getItem(STORAGE_KEY_SUBSCRIBED) === 'true';
84+
setState((prev) => ({ ...prev, subscribed }));
85+
}, []);
86+
87+
// Check notification permission
88+
useEffect(() => {
89+
if (state.supported) {
90+
setState((prev) => ({ ...prev, permission: Notification.permission }));
91+
}
92+
}, [state.supported]);
2893

94+
// Register service worker
2995
useEffect(() => {
30-
if (!('serviceWorker' in navigator)) return;
96+
if (!state.supported) return;
97+
98+
let cancelled = false;
99+
100+
async function registerSW() {
101+
try {
102+
const registration = await navigator.serviceWorker.register(
103+
new URL('../sw/stellar-notification-sw.ts', import.meta.url),
104+
{ type: 'module' },
105+
);
31106

32-
// Register the SW (Vite bundles SW files referenced via URL constructor)
33-
navigator.serviceWorker
34-
.register(new URL('../sw/stellar-notification-sw.ts', import.meta.url), { type: 'module' })
35-
.catch((err) => {
36-
// Non-fatal — notifications simply won't fire in this environment
107+
if (cancelled) return;
108+
109+
swRef.current = registration;
110+
111+
// Listen for permission changes
112+
if ('permissions' in navigator) {
113+
const permissionStatus = await (navigator as any).permissions.query({
114+
name: 'notifications',
115+
});
116+
permissionStatus.onchange = () => {
117+
setState((prev) => ({ ...prev, permission: Notification.permission }));
118+
};
119+
}
120+
} catch (err) {
121+
if (cancelled) return;
37122
console.warn('[wraith] SW registration failed:', err);
38-
});
123+
setState((prev) => ({ ...prev, error: 'Service worker registration failed' }));
124+
}
125+
}
39126

40-
// Listen for WRAITH_NOTIFICATION messages posted by the SW
127+
registerSW();
128+
129+
return () => {
130+
cancelled = true;
131+
};
132+
}, [state.supported]);
133+
134+
// Listen for WRAITH_NOTIFICATION messages posted by the SW
135+
useEffect(() => {
41136
const handler = (event: MessageEvent<SWMessage>) => {
42137
if (
43138
event.data?.type !== 'WRAITH_NOTIFICATION' ||
@@ -54,4 +149,158 @@ export function useNotificationSW() {
54149
navigator.serviceWorker.removeEventListener('message', handler);
55150
};
56151
}, [addNotification]);
152+
153+
// Request notification permission
154+
const requestPermission = useCallback(async (): Promise<boolean> => {
155+
if (!state.supported) return false;
156+
157+
try {
158+
const permission = await Notification.requestPermission();
159+
setState((prev) => ({ ...prev, permission }));
160+
return permission === 'granted';
161+
} catch (error) {
162+
console.error('Permission request failed:', error);
163+
setState((prev) => ({
164+
...prev,
165+
error: error instanceof Error ? error.message : 'Permission request failed',
166+
}));
167+
return false;
168+
}
169+
}, [state.supported]);
170+
171+
// Subscribe to Web Push
172+
const subscribe = useCallback(
173+
async (metaAddress: string, relayUrl?: string) => {
174+
if (!state.supported || !swRef.current) {
175+
throw new Error('Web Push not supported or service worker not registered');
176+
}
177+
178+
if (state.permission !== 'granted') {
179+
const granted = await requestPermission();
180+
if (!granted) {
181+
throw new Error('Notification permission denied');
182+
}
183+
}
184+
185+
setState((prev) => ({ ...prev, loading: true, error: null }));
186+
187+
try {
188+
// Check if already subscribed
189+
const existingSubscription = await swRef.current.pushManager.getSubscription();
190+
if (existingSubscription) {
191+
console.log('[useNotificationSW] Already subscribed to push service');
192+
} else {
193+
// Subscribe to push service with VAPID key
194+
// Note: In production, this should be a proper VAPID public key
195+
// For demo purposes, we'll skip VAPID and use no-ops
196+
const subscription = await swRef.current.pushManager.subscribe({
197+
userVisibleOnly: true,
198+
// applicationServerKey: new Uint8Array([...]), // Add proper VAPID key in production
199+
});
200+
201+
console.log('[useNotificationSW] Subscribed to push service');
202+
}
203+
204+
// Get the subscription for relay registration
205+
const subscription = await swRef.current.pushManager.getSubscription();
206+
if (!subscription) {
207+
throw new Error('Failed to get push subscription');
208+
}
209+
210+
// Subscribe to relay
211+
const relayUrlToUse = relayUrl || state.relayUrl;
212+
const response = await subscribeToRelay(subscription, metaAddress, {
213+
relayUrl: relayUrlToUse,
214+
chain: 'stellar',
215+
});
216+
217+
if (!response.success) {
218+
throw new Error(response.error || 'Failed to subscribe to relay');
219+
}
220+
221+
localStorage.setItem(STORAGE_KEY_SUBSCRIBED, 'true');
222+
setState((prev) => ({
223+
...prev,
224+
subscribed: true,
225+
loading: false,
226+
error: null,
227+
}));
228+
} catch (error) {
229+
console.error('Subscription failed:', error);
230+
setState((prev) => ({
231+
...prev,
232+
loading: false,
233+
error: error instanceof Error ? error.message : 'Subscription failed',
234+
}));
235+
throw error;
236+
}
237+
},
238+
[state.supported, state.permission, state.relayUrl, requestPermission],
239+
);
240+
241+
// Unsubscribe from Web Push
242+
const unsubscribe = useCallback(
243+
async (metaAddress: string) => {
244+
if (!swRef.current) return;
245+
246+
setState((prev) => ({ ...prev, loading: true, error: null }));
247+
248+
try {
249+
const subscription = await swRef.current.pushManager.getSubscription();
250+
if (subscription) {
251+
// Unsubscribe from relay
252+
await unsubscribeFromRelay(subscription, metaAddress, {
253+
relayUrl: state.relayUrl,
254+
chain: 'stellar',
255+
});
256+
257+
// Unsubscribe from push service
258+
await subscription.unsubscribe();
259+
}
260+
261+
localStorage.removeItem(STORAGE_KEY_SUBSCRIBED);
262+
setState((prev) => ({
263+
...prev,
264+
subscribed: false,
265+
loading: false,
266+
error: null,
267+
}));
268+
} catch (error) {
269+
console.error('Unsubscribe failed:', error);
270+
setState((prev) => ({
271+
...prev,
272+
loading: false,
273+
error: error instanceof Error ? error.message : 'Unsubscribe failed',
274+
}));
275+
throw error;
276+
}
277+
},
278+
[state.relayUrl],
279+
);
280+
281+
// Test relay connectivity
282+
const testRelay = useCallback(
283+
async (relayUrl?: string): Promise<boolean> => {
284+
const relayUrlToUse = relayUrl || state.relayUrl;
285+
const result = await testRelayConnectivity({ relayUrl: relayUrlToUse });
286+
setState((prev) => ({ ...prev, relayReachable: result.reachable }));
287+
return result.reachable;
288+
},
289+
[state.relayUrl],
290+
);
291+
292+
// Update relay URL
293+
const updateRelayUrl = useCallback((url: string) => {
294+
localStorage.setItem(STORAGE_KEY_RELAY_URL, url);
295+
setState((prev) => ({ ...prev, relayUrl: url }));
296+
}, []);
297+
298+
return {
299+
state,
300+
requestPermission,
301+
subscribe,
302+
unsubscribe,
303+
testRelay,
304+
updateRelayUrl,
305+
};
57306
}

src/i18n/en.json

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@
7171
"scanFailed": "Scan failed",
7272
"expectedConfirmation": "Expected confirmation",
7373
"seconds_approx": "~5 seconds",
74-
"balance": "Balance"
74+
"balance": "Balance",
75+
"update": "Update",
76+
"test": "Test"
7577
},
7678
"horizen": {
7779
"network": "Horizen Testnet / ETH",
@@ -95,7 +97,46 @@
9597
"announcerContractName": "Soroban",
9698
"networkFeeAmount": "100 stroops",
9799
"recipientPlaceholder": "st:xlm:...",
98-
"validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)"
100+
"validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)",
101+
"scanningStrategy": "Scanning Strategy",
102+
"scanningStrategyDescription": "Choose how the Stellar receive scanner filters announcements. Changes take effect immediately on the next scan.",
103+
"strategyFast": "Fast",
104+
"strategyFastDescription": "View-tag only prefilter. Skips expensive shared secret computation.",
105+
"strategyFastTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.",
106+
"strategyBalanced": "Balanced",
107+
"strategyBalancedDescription": "View-tag prefilter + full scan. Default behavior.",
108+
"strategyBalancedTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.",
109+
"strategyFull": "Full",
110+
"strategyFullDescription": "Ignore view-tag. Full shared secret computation on all announcements.",
111+
"strategyFullTooltip": "Most thorough scan but slowest. Ignores view-tag optimization.",
112+
"activeStrategy": "Active Strategy",
113+
"changeInSettings": "change in Settings",
114+
"webPush": "Web Push Notifications",
115+
"webPushDescription": "Receive stealth payment alerts even when the app is closed. Uses a privacy-first relay that only receives your meta-address hash (no personal data).",
116+
"webPushNotSupported": "Web Push is not supported in this browser. Please use Chrome, Edge, or Firefox on desktop or Android.",
117+
"webPushPermissionDenied": "Notification permission denied. Enable notifications in your browser settings to use Web Push.",
118+
"relayUrl": "Relay URL",
119+
"selfHostableRelay": "Self-hostable relay endpoint. Default: {{defaultUrl}}",
120+
"status": "Status",
121+
"subscribed": "Subscribed",
122+
"notSubscribed": "Not Subscribed",
123+
"subscribedNotice": "You will receive stealth payment alerts via Web Push even when the app is closed.",
124+
"subscribeToAlerts": "Subscribe to Alerts",
125+
"unsubscribe": "Unsubscribe",
126+
"subscribing": "Subscribing...",
127+
"unsubscribing": "Unsubscribing...",
128+
"testing": "Testing...",
129+
"privacyNotice": "Privacy Notice",
130+
"privacyNoticeText": "Only the SHA-256 hash of your meta-address is sent to the relay. No personal data, wallet addresses, or keys are transmitted. You can self-host the relay for complete control.",
131+
"noActiveMetaAddress": "No active meta-address. Derive keys on the Receive page first.",
132+
"subscribeSuccess": "Successfully subscribed to stealth payment alerts via Web Push.",
133+
"subscribeFailed": "Failed to subscribe to Web Push.",
134+
"unsubscribeSuccess": "Successfully unsubscribed from Web Push notifications.",
135+
"unsubscribeFailed": "Failed to unsubscribe from Web Push.",
136+
"relayReachable": "Relay is reachable and healthy.",
137+
"relayNotReachable": "Relay is not reachable. Check the URL and try again.",
138+
"relayUrlUpdated": "Relay URL updated. Test connectivity to verify.",
139+
"relayTestFailed": "Failed to test relay connectivity."
99140
},
100141
"solana": {
101142
"network": "Solana Devnet / SOL",

0 commit comments

Comments
 (0)