diff --git a/src/App.tsx b/src/App.tsx
index df500fb..c234703 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,12 +7,16 @@ import Receive from '@/pages/Receive';
import Privacy from '@/pages/Privacy';
import { HelpButton } from '@/components/HelpButton';
import Vault from '@/pages/Vault';
+import Notifications from '@/pages/Notifications';
+import { useNotificationSW } from '@/hooks/useNotificationSW';
import Schedule from '@/pages/Schedule';
import StellarSplit from '@/pages/StellarSplit';
import Names from '@/pages/Names';
import Activity from '@/pages/Activity';
export function App() {
+ useNotificationSW();
+
return (
@@ -24,6 +28,7 @@ export function App() {
} />
} />
} />
+
} />
} />
} />
} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 7c4e8a5..463a632 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -5,12 +5,14 @@ import { ChainSwitcher } from './ChainSwitcher';
import { WalletConnect } from './WalletConnect';
import { LocaleSwitcher } from './LocaleSwitcher';
import { useTheme } from '@/context/ThemeContext';
+import { useNotificationsStore } from '@/stores/notificationsStore';
export function Header() {
const location = useLocation();
const { t } = useTranslation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { theme, toggleTheme } = useTheme();
+ const unreadCount = useNotificationsStore((state) => state.unreadCount());
const navLinks = [
{ to: '/send', label: t('nav.send') },
@@ -39,13 +41,18 @@ export function Header() {
{link.label}
+ {link.to === '/notifications' && unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
))}
@@ -122,13 +129,18 @@ export function Header() {
key={link.to}
to={link.to}
onClick={() => setMobileMenuOpen(false)}
- className={`px-4 py-2.5 font-heading text-[10px] uppercase tracking-widest transition-colors ${
+ className={`relative flex items-center gap-1.5 px-4 py-2.5 font-heading text-[10px] uppercase tracking-widest transition-colors ${
location.pathname === link.to
? 'border-b-[1.5px] border-tertiary text-on-surface'
: 'border-b-[1.5px] border-transparent text-outline hover:text-on-surface-variant'
}`}
>
{link.label}
+ {link.to === '/notifications' && unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
))}
diff --git a/src/hooks/useNotificationSW.ts b/src/hooks/useNotificationSW.ts
new file mode 100644
index 0000000..fdbc2c4
--- /dev/null
+++ b/src/hooks/useNotificationSW.ts
@@ -0,0 +1,57 @@
+import { useEffect } from 'react';
+import { useNotificationsStore } from '@/stores/notificationsStore';
+
+interface SWMessage {
+ type: string;
+ channel: string;
+ payload: {
+ id: string;
+ title: string;
+ body: string;
+ timestamp: number;
+ amount?: string;
+ asset?: string;
+ sender?: string;
+ data?: Record
;
+ };
+}
+
+/**
+ * Registers the Stellar notification service worker and listens for
+ * WRAITH_NOTIFICATION messages from it, persisting them into the
+ * notifications store.
+ *
+ * Should be mounted once at the app root level.
+ */
+export function useNotificationSW() {
+ const addNotification = useNotificationsStore((state) => state.addNotification);
+
+ useEffect(() => {
+ if (!('serviceWorker' in navigator)) return;
+
+ // 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
+ console.warn('[wraith] SW registration failed:', err);
+ });
+
+ // Listen for WRAITH_NOTIFICATION messages posted by the SW
+ const handler = (event: MessageEvent) => {
+ if (
+ event.data?.type !== 'WRAITH_NOTIFICATION' ||
+ event.data?.channel !== 'wraith-notifications'
+ ) {
+ return;
+ }
+ const { id, title, body, timestamp, amount, asset, sender, data } = event.data.payload;
+ addNotification({ id, title, body, timestamp, amount, asset, sender, data });
+ };
+
+ navigator.serviceWorker.addEventListener('message', handler);
+ return () => {
+ navigator.serviceWorker.removeEventListener('message', handler);
+ };
+ }, [addNotification]);
+}
diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx
new file mode 100644
index 0000000..755f823
--- /dev/null
+++ b/src/pages/Notifications.tsx
@@ -0,0 +1,291 @@
+import { useState, useMemo } from 'react';
+import { useNotificationsStore, type NotificationEntry } from '@/stores/notificationsStore';
+
+// ─── helpers ────────────────────────────────────────────────────────────────
+
+function formatTs(ts: number): string {
+ return new Date(ts).toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function toDateInputValue(ms: number): string {
+ // Produces "YYYY-MM-DD" in local time for
+ const d = new Date(ms);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+function dateInputToStartOfDay(value: string): number {
+ // Parse "YYYY-MM-DD" as local midnight → epoch ms
+ const [y, m, d] = value.split('-').map(Number);
+ return new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
+}
+
+function dateInputToEndOfDay(value: string): number {
+ const [y, m, d] = value.split('-').map(Number);
+ return new Date(y, m - 1, d, 23, 59, 59, 999).getTime();
+}
+
+// ─── notification row ───────────────────────────────────────────────────────
+
+function NotificationRow({
+ n,
+ onMarkRead,
+ onMarkUnread,
+ onRemove,
+}: {
+ n: NotificationEntry;
+ onMarkRead: (id: string) => void;
+ onMarkUnread: (id: string) => void;
+ onRemove: (id: string) => void;
+}) {
+ return (
+
+
+
+ {!n.read && }
+
+ {n.title}
+
+
+
+ {formatTs(n.timestamp)}
+
+
+
+
{n.body}
+
+ {(n.amount || n.asset || n.sender) && (
+
+ {n.amount && (
+
+ Amount
+ {n.amount}
+
+ )}
+ {n.asset && (
+
+ Asset
+ {n.asset}
+
+ )}
+ {n.sender && (
+
+ Sender
+
+ {n.sender.length > 28
+ ? `${n.sender.slice(0, 10)}…${n.sender.slice(-10)}`
+ : n.sender}
+
+
+ )}
+
+ )}
+
+
+ {n.read ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+// ─── page ───────────────────────────────────────────────────────────────────
+
+export default function Notifications() {
+ const { notifications, markRead, markUnread, markAllRead, removeNotification, clearAll, search } =
+ useNotificationsStore();
+
+ const [query, setQuery] = useState('');
+ const [sinceDate, setSinceDate] = useState('');
+ const [untilDate, setUntilDate] = useState('');
+ const [showUnreadOnly, setShowUnreadOnly] = useState(false);
+
+ const unreadCount = useMemo(() => notifications.filter((n) => !n.read).length, [notifications]);
+
+ const filtered = useMemo(() => {
+ const since = sinceDate ? dateInputToStartOfDay(sinceDate) : undefined;
+ const until = untilDate ? dateInputToEndOfDay(untilDate) : undefined;
+ const results = search({ query: query || undefined, since, until });
+ if (showUnreadOnly) return results.filter((n) => !n.read);
+ return results;
+ }, [search, query, sinceDate, untilDate, showUnreadOnly, notifications]);
+
+ const today = toDateInputValue(Date.now());
+
+ return (
+
+ {/* ── heading ── */}
+
+
+ Notification History
+
+
+
+ Notifications
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+
+ {unreadCount > 0 && (
+
+ )}
+ {notifications.length > 0 && (
+
+ )}
+
+
+
+
+ {/* ── search + filter ── */}
+
+
+ {/* ── results ── */}
+
+
+
+ {filtered.length} result{filtered.length !== 1 ? 's' : ''}
+
+
+
+ {notifications.length === 0 && (
+
+ No notifications yet. Stealth payment alerts from the service worker will appear here.
+
+ )}
+
+ {notifications.length > 0 && filtered.length === 0 && (
+
+ No notifications match the current filters.
+
+ )}
+
+
+ {filtered.map((n) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/stores/notificationsStore.ts b/src/stores/notificationsStore.ts
new file mode 100644
index 0000000..cb1c426
--- /dev/null
+++ b/src/stores/notificationsStore.ts
@@ -0,0 +1,100 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface NotificationEntry {
+ id: string;
+ title: string;
+ body: string;
+ /** epoch ms */
+ timestamp: number;
+ read: boolean;
+ /** amount string e.g. "12.5" */
+ amount?: string;
+ /** asset/token ticker e.g. "XLM" */
+ asset?: string;
+ /** stealth address or sender hint */
+ sender?: string;
+ /** arbitrary extra data from the SW push payload */
+ data?: Record;
+}
+
+interface NotificationsState {
+ notifications: NotificationEntry[];
+ addNotification: (n: Omit) => void;
+ markRead: (id: string) => void;
+ markUnread: (id: string) => void;
+ markAllRead: () => void;
+ removeNotification: (id: string) => void;
+ clearAll: () => void;
+ /** Derived: number of unread notifications */
+ unreadCount: () => number;
+ /**
+ * Search across title, body, amount, asset, and sender fields.
+ * Optionally filter by `since` (epoch ms) and `until` (epoch ms).
+ */
+ search: (opts: { query?: string; since?: number; until?: number }) => NotificationEntry[];
+}
+
+export const useNotificationsStore = create()(
+ persist(
+ (set, get) => ({
+ notifications: [],
+
+ addNotification: (n) =>
+ set((state) => {
+ // Deduplicate by id
+ if (state.notifications.find((x) => x.id === n.id)) return state;
+ return {
+ notifications: [{ ...n, read: false }, ...state.notifications],
+ };
+ }),
+
+ markRead: (id) =>
+ set((state) => ({
+ notifications: state.notifications.map((n) => (n.id === id ? { ...n, read: true } : n)),
+ })),
+
+ markUnread: (id) =>
+ set((state) => ({
+ notifications: state.notifications.map((n) => (n.id === id ? { ...n, read: false } : n)),
+ })),
+
+ markAllRead: () =>
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({ ...n, read: true })),
+ })),
+
+ removeNotification: (id) =>
+ set((state) => ({
+ notifications: state.notifications.filter((n) => n.id !== id),
+ })),
+
+ clearAll: () => set({ notifications: [] }),
+
+ unreadCount: () => get().notifications.filter((n) => !n.read).length,
+
+ search: ({ query, since, until }) => {
+ const { notifications } = get();
+ const q = query?.toLowerCase().trim();
+
+ return notifications.filter((n) => {
+ if (since !== undefined && n.timestamp < since) return false;
+ if (until !== undefined && n.timestamp > until) return false;
+
+ if (!q) return true;
+
+ return (
+ n.title.toLowerCase().includes(q) ||
+ n.body.toLowerCase().includes(q) ||
+ (n.amount !== undefined && n.amount.toLowerCase().includes(q)) ||
+ (n.asset !== undefined && n.asset.toLowerCase().includes(q)) ||
+ (n.sender !== undefined && n.sender.toLowerCase().includes(q))
+ );
+ });
+ },
+ }),
+ {
+ name: 'wraith-notifications-storage',
+ },
+ ),
+);
diff --git a/src/sw/stellar-notification-sw.ts b/src/sw/stellar-notification-sw.ts
index 12a3720..9272365 100644
--- a/src/sw/stellar-notification-sw.ts
+++ b/src/sw/stellar-notification-sw.ts
@@ -1,23 +1,57 @@
-///
-declare const self: ServiceWorkerGlobalScope;
-
/**
- * Service Worker for Stellar Payment Notifications
+ * stellar-notification-sw.ts
+ *
+ * Stellar stealth-payment push notification service worker.
*
- * This service worker handles:
- * - Periodic background sync to scan for new stealth payments
- * - Showing notifications when new payments are detected
- * - Handling notification clicks to open the app
- * - Managing IndexedDB storage for encrypted viewing keys
+ * Responsibilities:
+ * 1. Receive push events and show browser notifications.
+ * 2. Persist each notification into the app's zustand store by posting a
+ * message to all controlled clients so the React app can call
+ * `addNotification` on the next load (or immediately if a tab is open).
+ * 3. Periodic background sync to scan for new stealth payments.
+ * 4. IndexedDB storage for encrypted viewing keys.
+ * 5. Handle REGISTER_VIEWING_KEY / UNREGISTER_VIEWING_KEY messages from the
+ * client so background scanning knows which keys to watch.
+ *
+ * Push payload (JSON):
+ * {
+ * id: string, // unique notification id (e.g. tx hash / stealth address)
+ * title: string,
+ * body: string,
+ * amount?: string, // e.g. "12.5"
+ * asset?: string, // e.g. "XLM"
+ * sender?: string, // stealth / ephemeral address
+ * data?: Record
+ * }
*/
+///
+export {};
+
+declare const self: ServiceWorkerGlobalScope;
+
+// ─── constants ────────────────────────────────────────────────────────────────
+
+const NOTIFICATION_CHANNEL = 'wraith-notifications';
const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL';
const STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org';
const DB_NAME = 'wraith-stellar-notifications';
const DB_VERSION = 1;
const STORE_NAME = 'viewing-keys';
const SYNC_TAG = 'stellar-payment-scan';
-const SYNC_INTERVAL_MINUTES = 15; // Check every 15 minutes
+const SYNC_INTERVAL_MINUTES = 15;
+
+// ─── types ────────────────────────────────────────────────────────────────────
+
+interface PushPayload {
+ id: string;
+ title: string;
+ body: string;
+ amount?: string;
+ asset?: string;
+ sender?: string;
+ data?: Record;
+}
interface StoredViewingKey {
publicKey: string;
@@ -34,14 +68,13 @@ interface NotificationData {
timestamp: number;
}
-// IndexedDB helpers
+// ─── IndexedDB helpers ────────────────────────────────────────────────────────
+
function openDB(): Promise {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
-
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
-
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
@@ -52,17 +85,6 @@ function openDB(): Promise {
});
}
-async function getViewingKey(db: IDBDatabase, publicKey: string): Promise {
- return new Promise((resolve, reject) => {
- const transaction = db.transaction([STORE_NAME], 'readonly');
- const store = transaction.objectStore(STORE_NAME);
- const request = store.get(publicKey);
-
- request.onerror = () => reject(request.error);
- request.onsuccess = () => resolve(request.result || null);
- });
-}
-
async function updateLastScannedLedger(
db: IDBDatabase,
publicKey: string,
@@ -72,7 +94,6 @@ async function updateLastScannedLedger(
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(publicKey);
-
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const data = request.result as StoredViewingKey;
@@ -89,18 +110,14 @@ async function updateLastScannedLedger(
});
}
-// Stellar RPC helpers
+// ─── Stellar RPC helpers ──────────────────────────────────────────────────────
+
async function fetchLatestLedger(): Promise {
const response = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- method: 'getLatestLedger',
- }),
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger' }),
});
-
const data = await response.json();
return data.result?.sequence || 0;
}
@@ -108,7 +125,7 @@ async function fetchLatestLedger(): Promise {
async function fetchAnnouncementEvents(
startLedger: number,
contractId: string = ANNOUNCER_CONTRACT,
-): Promise<{ events: any[]; latestLedger: number }> {
+): Promise<{ events: unknown[]; latestLedger: number }> {
const response = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -123,182 +140,168 @@ async function fetchAnnouncementEvents(
},
}),
});
-
const data = await response.json();
const events = data.result?.events || [];
const latestLedger = await fetchLatestLedger();
-
return { events, latestLedger };
}
-// Simple decryption using Web Crypto API
-async function decryptData(encryptedHex: string, key: CryptoKey): Promise {
- const encryptedData = hexToBytes(encryptedHex);
-
- // Extract IV (first 12 bytes) and ciphertext
- const iv = encryptedData.slice(0, 12);
- const ciphertext = encryptedData.slice(12);
-
- const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
-
- return new Uint8Array(decrypted);
-}
+// ─── push payload helpers ─────────────────────────────────────────────────────
-function hexToBytes(hex: string): Uint8Array {
- const bytes = new Uint8Array(hex.length / 2);
- for (let i = 0; i < bytes.length; i++) {
- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
+function parsePushPayload(event: PushEvent): PushPayload {
+ try {
+ const json = event.data?.json() as Partial | undefined;
+ if (json && json.title) {
+ return {
+ id: json.id ?? `sw-${Date.now()}`,
+ title: json.title,
+ body: json.body ?? '',
+ amount: json.amount,
+ asset: json.asset,
+ sender: json.sender,
+ data: json.data,
+ };
+ }
+ } catch {
+ // ignore parse errors — fall through to default
}
- return bytes;
-}
-
-function bytesToHex(bytes: Uint8Array): string {
- return Array.from(bytes)
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-}
-
-// Import the Wraith SDK functions (will be loaded dynamically)
-async function loadWraithSDK() {
- // In a real implementation, we'd need to bundle the SDK or use importScripts
- // For now, we'll implement a simplified version of the scanning logic
- return null;
-}
-
-// Show notification for new payment
-async function showPaymentNotification(match: any): Promise {
- const options: NotificationOptions = {
- body: `New stealth payment detected at ${match.stealthAddress.slice(0, 8)}...`,
- icon: '/icon-192.png',
- badge: '/badge-72.png',
- tag: match.stealthAddress,
- data: {
- stealthAddress: match.stealthAddress,
- timestamp: Date.now(),
- } as NotificationData,
- requireInteraction: false,
- silent: false,
+ return {
+ id: `sw-${Date.now()}`,
+ title: 'New stealth payment detected',
+ body: 'Open Wraith to view payment details.',
};
+}
- await self.registration.showNotification('New Stellar Payment', options);
+/** Broadcast to every open tab so the React store gets persisted immediately. */
+async function broadcastToClients(payload: PushPayload): Promise {
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ for (const client of clients) {
+ client.postMessage({
+ type: 'WRAITH_NOTIFICATION',
+ channel: NOTIFICATION_CHANNEL,
+ payload: {
+ ...payload,
+ timestamp: Date.now(),
+ },
+ });
+ }
}
-// Background sync handler
-async function handleSync(event: ExtendableEvent): Promise {
- if (!event.tag) return;
+// ─── background sync ──────────────────────────────────────────────────────────
+async function handleSync(_event: ExtendableEvent): Promise {
try {
const db = await openDB();
const allKeys = await new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
-
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
for (const storedKey of allKeys) {
- // In a real implementation, we would:
- // 1. Decrypt the viewing key using the stored encryption key
- // 2. Scan for announcements since lastScannedLedger
- // 3. Match announcements against the viewing key
- // 4. Show notifications for new matches
- // 5. Update lastScannedLedger
-
- // For now, we'll implement a simplified version
const startLedger = storedKey.lastScannedLedger || 1;
const { events, latestLedger } = await fetchAnnouncementEvents(startLedger);
if (events.length > 0) {
- // In production, we would decrypt and scan here
- // For demo purposes, we'll just show a notification if we found events
- console.log(`Found ${events.length} events for ${storedKey.publicKey}`);
-
- // TODO: Implement actual scanning with decrypted keys
- // This requires the Wraith SDK to be available in the service worker
+ // TODO: decrypt viewing key and run full SDK scan once SDK is
+ // available in SW context. For now we surface a generic alert.
+ console.log(`[wraith-sw] Found ${events.length} events for ${storedKey.publicKey}`);
}
await updateLastScannedLedger(db, storedKey.publicKey, latestLedger);
}
- await db.close();
+ db.close();
} catch (error) {
- console.error('Background sync error:', error);
+ console.error('[wraith-sw] Background sync error:', error);
}
}
-// Service worker installation
-self.addEventListener('install', (event) => {
- console.log('Stellar notification SW installing');
- event.waitUntil(self.skipWaiting());
-});
+// ─── push event ───────────────────────────────────────────────────────────────
+
+self.addEventListener('push', (event: PushEvent) => {
+ const payload = parsePushPayload(event);
+
+ const lines: string[] = [payload.body];
+ if (payload.amount && payload.asset) {
+ lines.push(`Amount: ${payload.amount} ${payload.asset}`);
+ } else if (payload.amount) {
+ lines.push(`Amount: ${payload.amount}`);
+ }
+ if (payload.sender) {
+ const short =
+ payload.sender.length > 24
+ ? `${payload.sender.slice(0, 10)}…${payload.sender.slice(-10)}`
+ : payload.sender;
+ lines.push(`From: ${short}`);
+ }
+
+ const notificationOptions: NotificationOptions = {
+ body: lines.join('\n'),
+ icon: '/favicon-32x32.png',
+ badge: '/favicon-16x16.png',
+ tag: payload.id,
+ data: {
+ id: payload.id,
+ stealthAddress: payload.sender,
+ amount: payload.amount,
+ asset: payload.asset,
+ sender: payload.sender,
+ timestamp: Date.now(),
+ ...payload.data,
+ } as NotificationData & Record,
+ };
-// Service worker activation
-self.addEventListener('activate', (event) => {
- console.log('Stellar notification SW activating');
event.waitUntil(
Promise.all([
- self.clients.claim(),
- // Register periodic sync (Chrome only)
- (async () => {
- if ('periodicSync' in self.registration) {
- try {
- await (self.registration as any).periodicSync.register(SYNC_TAG, {
- minInterval: SYNC_INTERVAL_MINUTES * 60 * 1000,
- });
- console.log('Periodic sync registered');
- } catch (error) {
- console.error('Failed to register periodic sync:', error);
- }
- }
- })(),
+ self.registration.showNotification(payload.title, notificationOptions),
+ broadcastToClients(payload),
]),
);
});
-// Handle background sync
-self.addEventListener('sync', (event) => {
+// ─── background sync event ────────────────────────────────────────────────────
+
+self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === SYNC_TAG) {
event.waitUntil(handleSync(event));
}
});
-// Handle notification clicks
-self.addEventListener('notificationclick', (event) => {
- const notification = event.notification;
- const data = notification.data as NotificationData;
+// ─── notification click ───────────────────────────────────────────────────────
- notification.close();
+self.addEventListener('notificationclick', (event: NotificationEvent) => {
+ const data = event.notification.data as NotificationData | undefined;
+ event.notification.close();
- // Open the app and navigate to the receive page
event.waitUntil(
- self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
- // Focus existing window if available
- for (const client of clients) {
- if (client.url.includes('/receive') || client.url.includes('/stellar')) {
- client.focus();
- // Post message to navigate to specific match
- client.postMessage({
- type: 'NAVIGATE_TO_MATCH',
- stealthAddress: data.stealthAddress,
- });
- return;
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
+ // Focus existing Wraith tab and navigate to /notifications
+ const existing = clientList.find((c) => c.url.includes(self.location.origin) && 'focus' in c);
+ if (existing) {
+ (existing as WindowClient).focus();
+ (existing as WindowClient).navigate('/notifications');
+ // Also post match info so the page can pre-highlight it
+ if (data?.stealthAddress) {
+ existing.postMessage({ type: 'NAVIGATE_TO_MATCH', stealthAddress: data.stealthAddress });
}
+ return;
}
-
- // Open new window
- if (clients.openWindow) {
- return clients.openWindow('/receive?match=' + data.stealthAddress);
- }
+ const dest = data?.stealthAddress
+ ? `/notifications?match=${data.stealthAddress}`
+ : '/notifications';
+ return self.clients.openWindow(dest);
}),
);
});
-// Handle messages from client
-self.addEventListener('message', (event) => {
+// ─── message handler ──────────────────────────────────────────────────────────
+
+self.addEventListener('message', (event: ExtendableMessageEvent) => {
const { type, publicKey, encryptedViewingKey, encryptedSpendingPubKey, encryptedSpendingScalar } =
- event.data;
+ event.data ?? {};
if (type === 'REGISTER_VIEWING_KEY') {
event.waitUntil(
@@ -307,27 +310,22 @@ self.addEventListener('message', (event) => {
const db = await openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
-
- const data: StoredViewingKey = {
+ const entry: StoredViewingKey = {
publicKey,
encryptedViewingKey,
encryptedSpendingPubKey,
encryptedSpendingScalar,
timestamp: Date.now(),
};
-
await new Promise((resolve, reject) => {
- const request = store.put(data);
+ const request = store.put(entry);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
-
- await db.close();
-
- // Respond to client
+ db.close();
(event.source as Client)?.postMessage({ type: 'VIEWING_KEY_REGISTERED' });
} catch (error) {
- console.error('Failed to register viewing key:', error);
+ console.error('[wraith-sw] Failed to register viewing key:', error);
(event.source as Client)?.postMessage({
type: 'VIEWING_KEY_ERROR',
error: error instanceof Error ? error.message : 'Unknown error',
@@ -344,46 +342,72 @@ self.addEventListener('message', (event) => {
const db = await openDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
-
await new Promise((resolve, reject) => {
const request = store.delete(publicKey);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
-
- await db.close();
+ db.close();
// Unregister periodic sync if no keys remain
- const allKeys = await new Promise((resolve, reject) => {
- const tx = db.transaction([STORE_NAME], 'readonly');
+ const db2 = await openDB();
+ const remaining = await new Promise((resolve, reject) => {
+ const tx = db2.transaction([STORE_NAME], 'readonly');
const st = tx.objectStore(STORE_NAME);
const req = st.getAll();
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result || []);
});
-
- if (allKeys.length === 0 && 'periodicSync' in self.registration) {
- await (self.registration as any).periodicSync.unregister(SYNC_TAG);
+ db2.close();
+
+ if (remaining.length === 0 && 'periodicSync' in self.registration) {
+ await (
+ self.registration as unknown as {
+ periodicSync: { unregister: (tag: string) => Promise };
+ }
+ ).periodicSync.unregister(SYNC_TAG);
}
(event.source as Client)?.postMessage({ type: 'VIEWING_KEY_UNREGISTERED' });
} catch (error) {
- console.error('Failed to unregister viewing key:', error);
+ console.error('[wraith-sw] Failed to unregister viewing key:', error);
}
})(),
);
}
if (type === 'TRIGGER_SCAN') {
- // Manual trigger for testing
event.waitUntil(handleSync(event as unknown as ExtendableEvent));
}
});
-// Handle push notifications (future enhancement)
-self.addEventListener('push', (event) => {
- // Could be used for server-sent notifications
- // For now, we rely on periodic background sync
+// ─── install / activate ───────────────────────────────────────────────────────
+
+self.addEventListener('install', () => {
+ self.skipWaiting();
});
-export {};
+self.addEventListener('activate', (event: ExtendableEvent) => {
+ event.waitUntil(
+ Promise.all([
+ self.clients.claim(),
+ (async () => {
+ if ('periodicSync' in self.registration) {
+ try {
+ await (
+ self.registration as unknown as {
+ periodicSync: {
+ register: (tag: string, opts: { minInterval: number }) => Promise;
+ };
+ }
+ ).periodicSync.register(SYNC_TAG, {
+ minInterval: SYNC_INTERVAL_MINUTES * 60 * 1000,
+ });
+ } catch {
+ // periodicSync not supported in this environment — silently skip
+ }
+ }
+ })(),
+ ]),
+ );
+});