Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 262 additions & 13 deletions src/hooks/useNotificationSW.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNotificationsStore } from '@/stores/notificationsStore';
import {
subscribeToRelay,
unsubscribeFromRelay,
testRelayConnectivity,
DEFAULT_RELAY_URL,
} from '@/lib/pushRelay';

interface SWMessage {
type: string;
Expand All @@ -16,28 +22,117 @@ interface SWMessage {
};
}

export interface WebPushState {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
relayUrl: string;
relayReachable: boolean;
}

export interface UseNotificationSWReturn {
state: WebPushState;
requestPermission: () => Promise<boolean>;
subscribe: (metaAddress: string, relayUrl?: string) => Promise<void>;
unsubscribe: (metaAddress: string) => Promise<void>;
testRelay: (relayUrl?: string) => Promise<boolean>;
updateRelayUrl: (url: string) => void;
}

const STORAGE_KEY_RELAY_URL = 'wraith:push-relay-url';
const STORAGE_KEY_SUBSCRIBED = 'wraith:push-subscribed';

/**
* Registers the Stellar notification service worker and listens for
* WRAITH_NOTIFICATION messages from it, persisting them into the
* notifications store.
* Registers the Stellar notification service worker and manages Web Push
* subscription lifecycle for stealth payment alerts.
*
* Features:
* - Service worker registration and message handling
* - Web Push subscription management
* - Privacy-first relay integration (only meta-address hash)
* - User-configurable relay URL
* - Permission request and state management
*
* Should be mounted once at the app root level.
*/
export function useNotificationSW() {
export function useNotificationSW(): UseNotificationSWReturn {
const addNotification = useNotificationsStore((state) => state.addNotification);
const swRef = useRef<ServiceWorkerRegistration | null>(null);

const [state, setState] = useState<WebPushState>({
supported: false,
permission: 'default',
subscribed: false,
loading: true,
error: null,
relayUrl: localStorage.getItem(STORAGE_KEY_RELAY_URL) || DEFAULT_RELAY_URL,
relayReachable: false,
});

// Check browser support
useEffect(() => {
const supported =
'serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window;
setState((prev) => ({ ...prev, supported, loading: false }));
}, []);

// Check subscription state from localStorage
useEffect(() => {
const subscribed = localStorage.getItem(STORAGE_KEY_SUBSCRIBED) === 'true';
setState((prev) => ({ ...prev, subscribed }));
}, []);

// Check notification permission
useEffect(() => {
if (state.supported) {
setState((prev) => ({ ...prev, permission: Notification.permission }));
}
}, [state.supported]);

// Register service worker
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
if (!state.supported) return;

let cancelled = false;

async function registerSW() {
try {
const registration = await navigator.serviceWorker.register(
new URL('../sw/stellar-notification-sw.ts', import.meta.url),
{ type: 'module' },
);

// Register the SW (Vite bundles SW files referenced via URL constructor)
navigator.serviceWorker
.register(new URL('../sw/stellar-notification-sw.ts', import.meta.url), { type: 'module' })
.catch((err) => {
// Non-fatal — notifications simply won't fire in this environment
if (cancelled) return;

swRef.current = registration;

// Listen for permission changes
if ('permissions' in navigator) {
const permissionStatus = await (navigator as any).permissions.query({
name: 'notifications',
});
permissionStatus.onchange = () => {
setState((prev) => ({ ...prev, permission: Notification.permission }));
};
}
} catch (err) {
if (cancelled) return;
console.warn('[wraith] SW registration failed:', err);
});
setState((prev) => ({ ...prev, error: 'Service worker registration failed' }));
}
}

// Listen for WRAITH_NOTIFICATION messages posted by the SW
registerSW();

return () => {
cancelled = true;
};
}, [state.supported]);

// Listen for WRAITH_NOTIFICATION messages posted by the SW
useEffect(() => {
const handler = (event: MessageEvent<SWMessage>) => {
if (
event.data?.type !== 'WRAITH_NOTIFICATION' ||
Expand All @@ -54,4 +149,158 @@ export function useNotificationSW() {
navigator.serviceWorker.removeEventListener('message', handler);
};
}, [addNotification]);

// Request notification permission
const requestPermission = useCallback(async (): Promise<boolean> => {
if (!state.supported) return false;

try {
const permission = await Notification.requestPermission();
setState((prev) => ({ ...prev, permission }));
return permission === 'granted';
} catch (error) {
console.error('Permission request failed:', error);
setState((prev) => ({
...prev,
error: error instanceof Error ? error.message : 'Permission request failed',
}));
return false;
}
}, [state.supported]);

// Subscribe to Web Push
const subscribe = useCallback(
async (metaAddress: string, relayUrl?: string) => {
if (!state.supported || !swRef.current) {
throw new Error('Web Push not supported or service worker not registered');
}

if (state.permission !== 'granted') {
const granted = await requestPermission();
if (!granted) {
throw new Error('Notification permission denied');
}
}

setState((prev) => ({ ...prev, loading: true, error: null }));

try {
// Check if already subscribed
const existingSubscription = await swRef.current.pushManager.getSubscription();
if (existingSubscription) {
console.log('[useNotificationSW] Already subscribed to push service');
} else {
// Subscribe to push service with VAPID key
// Note: In production, this should be a proper VAPID public key
// For demo purposes, we'll skip VAPID and use no-ops
const subscription = await swRef.current.pushManager.subscribe({
userVisibleOnly: true,
// applicationServerKey: new Uint8Array([...]), // Add proper VAPID key in production
});

console.log('[useNotificationSW] Subscribed to push service');
}

// Get the subscription for relay registration
const subscription = await swRef.current.pushManager.getSubscription();
if (!subscription) {
throw new Error('Failed to get push subscription');
}

// Subscribe to relay
const relayUrlToUse = relayUrl || state.relayUrl;
const response = await subscribeToRelay(subscription, metaAddress, {
relayUrl: relayUrlToUse,
chain: 'stellar',
});

if (!response.success) {
throw new Error(response.error || 'Failed to subscribe to relay');
}

localStorage.setItem(STORAGE_KEY_SUBSCRIBED, 'true');
setState((prev) => ({
...prev,
subscribed: true,
loading: false,
error: null,
}));
} catch (error) {
console.error('Subscription failed:', error);
setState((prev) => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Subscription failed',
}));
throw error;
}
},
[state.supported, state.permission, state.relayUrl, requestPermission],
);

// Unsubscribe from Web Push
const unsubscribe = useCallback(
async (metaAddress: string) => {
if (!swRef.current) return;

setState((prev) => ({ ...prev, loading: true, error: null }));

try {
const subscription = await swRef.current.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from relay
await unsubscribeFromRelay(subscription, metaAddress, {
relayUrl: state.relayUrl,
chain: 'stellar',
});

// Unsubscribe from push service
await subscription.unsubscribe();
}

localStorage.removeItem(STORAGE_KEY_SUBSCRIBED);
setState((prev) => ({
...prev,
subscribed: false,
loading: false,
error: null,
}));
} catch (error) {
console.error('Unsubscribe failed:', error);
setState((prev) => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Unsubscribe failed',
}));
throw error;
}
},
[state.relayUrl],
);

// Test relay connectivity
const testRelay = useCallback(
async (relayUrl?: string): Promise<boolean> => {
const relayUrlToUse = relayUrl || state.relayUrl;
const result = await testRelayConnectivity({ relayUrl: relayUrlToUse });
setState((prev) => ({ ...prev, relayReachable: result.reachable }));
return result.reachable;
},
[state.relayUrl],
);

// Update relay URL
const updateRelayUrl = useCallback((url: string) => {
localStorage.setItem(STORAGE_KEY_RELAY_URL, url);
setState((prev) => ({ ...prev, relayUrl: url }));
}, []);

return {
state,
requestPermission,
subscribe,
unsubscribe,
testRelay,
updateRelayUrl,
};
}
45 changes: 43 additions & 2 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"scanFailed": "Scan failed",
"expectedConfirmation": "Expected confirmation",
"seconds_approx": "~5 seconds",
"balance": "Balance"
"balance": "Balance",
"update": "Update",
"test": "Test"
},
"horizen": {
"network": "Horizen Testnet / ETH",
Expand All @@ -95,7 +97,46 @@
"announcerContractName": "Soroban",
"networkFeeAmount": "100 stroops",
"recipientPlaceholder": "st:xlm:...",
"validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)"
"validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)",
"scanningStrategy": "Scanning Strategy",
"scanningStrategyDescription": "Choose how the Stellar receive scanner filters announcements. Changes take effect immediately on the next scan.",
"strategyFast": "Fast",
"strategyFastDescription": "View-tag only prefilter. Skips expensive shared secret computation.",
"strategyFastTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.",
"strategyBalanced": "Balanced",
"strategyBalancedDescription": "View-tag prefilter + full scan. Default behavior.",
"strategyBalancedTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.",
"strategyFull": "Full",
"strategyFullDescription": "Ignore view-tag. Full shared secret computation on all announcements.",
"strategyFullTooltip": "Most thorough scan but slowest. Ignores view-tag optimization.",
"activeStrategy": "Active Strategy",
"changeInSettings": "change in Settings",
"webPush": "Web Push Notifications",
"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).",
"webPushNotSupported": "Web Push is not supported in this browser. Please use Chrome, Edge, or Firefox on desktop or Android.",
"webPushPermissionDenied": "Notification permission denied. Enable notifications in your browser settings to use Web Push.",
"relayUrl": "Relay URL",
"selfHostableRelay": "Self-hostable relay endpoint. Default: {{defaultUrl}}",
"status": "Status",
"subscribed": "Subscribed",
"notSubscribed": "Not Subscribed",
"subscribedNotice": "You will receive stealth payment alerts via Web Push even when the app is closed.",
"subscribeToAlerts": "Subscribe to Alerts",
"unsubscribe": "Unsubscribe",
"subscribing": "Subscribing...",
"unsubscribing": "Unsubscribing...",
"testing": "Testing...",
"privacyNotice": "Privacy Notice",
"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.",
"noActiveMetaAddress": "No active meta-address. Derive keys on the Receive page first.",
"subscribeSuccess": "Successfully subscribed to stealth payment alerts via Web Push.",
"subscribeFailed": "Failed to subscribe to Web Push.",
"unsubscribeSuccess": "Successfully unsubscribed from Web Push notifications.",
"unsubscribeFailed": "Failed to unsubscribe from Web Push.",
"relayReachable": "Relay is reachable and healthy.",
"relayNotReachable": "Relay is not reachable. Check the URL and try again.",
"relayUrlUpdated": "Relay URL updated. Test connectivity to verify.",
"relayTestFailed": "Failed to test relay connectivity."
},
"solana": {
"network": "Solana Devnet / SOL",
Expand Down
Loading
Loading