From 83f6b3be6c421e611d4fd82f44be06e2512c6d8d Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:03:39 -0400 Subject: [PATCH 1/2] feat: add browser notifications for transaction status updates Implements client-side browser notifications to alert users of transaction status changes throughout the bridge lifecycle. Notifications trigger on: - Transaction signing and broadcast - L1/L2 status transitions - Withdrawal confirmation and execution - Transaction failures Uses service worker and browser Notification API with no external dependencies. Users opt-in via Settings dialog. Notifications persist in OS notification center. --- packages/arb-token-bridge-ui/.eslintignore | 2 + packages/arb-token-bridge-ui/public/sw.js | 116 ++++++++++++++++++ .../src/components/App/App.tsx | 14 +++ .../components/common/NotificationOptIn.tsx | 55 +++++++++ .../src/components/common/SettingsDialog.tsx | 7 ++ .../src/hooks/useTransactionHistory.ts | 54 ++++++++ 6 files changed, 248 insertions(+) create mode 100644 packages/arb-token-bridge-ui/.eslintignore create mode 100644 packages/arb-token-bridge-ui/public/sw.js create mode 100644 packages/arb-token-bridge-ui/src/components/common/NotificationOptIn.tsx diff --git a/packages/arb-token-bridge-ui/.eslintignore b/packages/arb-token-bridge-ui/.eslintignore new file mode 100644 index 000000000..e4715e2ec --- /dev/null +++ b/packages/arb-token-bridge-ui/.eslintignore @@ -0,0 +1,2 @@ +# Service worker is plain JavaScript for browser, not part of TypeScript project +public/sw.js diff --git a/packages/arb-token-bridge-ui/public/sw.js b/packages/arb-token-bridge-ui/public/sw.js new file mode 100644 index 000000000..a38373eb5 --- /dev/null +++ b/packages/arb-token-bridge-ui/public/sw.js @@ -0,0 +1,116 @@ +// Service worker for client-side push notifications +// Handles notification display and click events + +// Helper function to get user-friendly status message +function getNotificationMessage(payload) { + const { status, depositStatus, isWithdrawal, direction } = payload; + const shortTxHash = payload.txHash.substring(0, 6) + '...' + payload.txHash.substring(payload.txHash.length - 4); + + // Map deposit status codes to messages + const depositStatusMessages = { + 1: 'L1 Transaction Pending', + 2: 'L1 Transaction Failed', + 3: 'L2 Transaction Pending', + 4: 'L2 Transaction Success', + 5: 'L2 Transaction Failed', + 6: 'Transaction Creation Failed', + 7: 'Transaction Expired', + 8: 'CCTP Transfer Processing', + 9: 'Cross-chain Transfer Processing' + }; + + // Map withdrawal statuses + const withdrawalStatusMessages = { + 'Unconfirmed': 'Withdrawal Initiated', + 'Confirmed': 'Withdrawal Confirmed - Ready to Claim', + 'Executed': 'Withdrawal Claimed Successfully', + 'Expired': 'Withdrawal Expired', + 'Failure': 'Withdrawal Failed' + }; + + let title = 'Bridge Transaction Update'; + let body = `Transaction ${shortTxHash}`; + + // Handle broadcast notification + if (status === 'BROADCAST') { + title = isWithdrawal ? '🚀 Withdrawal Initiated' : '🚀 Deposit Initiated'; + body = `Transaction ${shortTxHash} has been signed and broadcast`; + return { title, body }; + } + + // Handle withdrawal status + if (isWithdrawal && withdrawalStatusMessages[status]) { + const statusMsg = withdrawalStatusMessages[status]; + title = statusMsg; + body = `${shortTxHash} - ${statusMsg}`; + return { title, body }; + } + + // Handle deposit status + if (depositStatus && depositStatusMessages[depositStatus]) { + const statusMsg = depositStatusMessages[depositStatus]; + title = statusMsg; + body = `${shortTxHash} - ${statusMsg}`; + return { title, body }; + } + + // Generic status update + if (status) { + title = `Transaction ${status}`; + body = `${shortTxHash} - Status: ${status}`; + } + + return { title, body }; +} + +// Listen for messages from the main application +self.addEventListener('message', (event) => { + const { type, payload } = event.data; + + if (type === 'SHOW_NOTIFICATION') { + const { title, body } = getNotificationMessage(payload); + + const options = { + body, + icon: '/images/ArbitrumLogo-192.png', + badge: '/images/ArbitrumLogo-192.png', + tag: payload.txHash, // Reuse notification for same tx + renotify: true, // Alert even if tag matches existing notification + requireInteraction: false, + data: { + url: `/bridge?tab=tx_history`, + txHash: payload.txHash + } + }; + + event.waitUntil(self.registration.showNotification(title, options)); + } +}); + +// Handle notification click events +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const urlToOpen = new URL(event.notification.data.url, self.location.origin).href; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(urlToOpen); + } + }) + ); +}); + +// Basic service worker lifecycle events to ensure it activates immediately +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); diff --git a/packages/arb-token-bridge-ui/src/components/App/App.tsx b/packages/arb-token-bridge-ui/src/components/App/App.tsx index 8f5948e6a..6ff96786c 100644 --- a/packages/arb-token-bridge-ui/src/components/App/App.tsx +++ b/packages/arb-token-bridge-ui/src/components/App/App.tsx @@ -97,6 +97,20 @@ const AppContent = React.memo(() => { // apply custom themes if any useTheme(); + // Register service worker for notifications + useEffect(() => { + if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { + navigator.serviceWorker + .register('/sw.js') + .then(registration => { + console.log('Service Worker registered:', registration) + }) + .catch(err => { + console.error('Service Worker registration failed:', err) + }) + } + }, []) + if (address && isBlocked) { return ( { + const [permission, setPermission] = + useState('default') + + useEffect(() => { + if (typeof window !== 'undefined' && 'Notification' in window) { + setPermission(Notification.permission) + } + }, []) + + const handleRequestPermission = async () => { + if ('Notification' in window) { + const perm = await Notification.requestPermission() + setPermission(perm) + } + } + + if (typeof window === 'undefined' || !('Notification' in window)) { + return null + } + + if (permission === 'granted') { + return ( +

+ Browser notifications are enabled for transaction updates. +

+ ) + } + + if (permission === 'denied') { + return ( +

+ Browser notifications are blocked. Please enable them in your browser + settings to receive transaction updates. +

+ ) + } + + return ( +
+

+ Enable browser notifications to get updates when your transactions are + completed. +

+ +
+ ) +} diff --git a/packages/arb-token-bridge-ui/src/components/common/SettingsDialog.tsx b/packages/arb-token-bridge-ui/src/components/common/SettingsDialog.tsx index b8d5d5ff3..a23c41495 100644 --- a/packages/arb-token-bridge-ui/src/components/common/SettingsDialog.tsx +++ b/packages/arb-token-bridge-ui/src/components/common/SettingsDialog.tsx @@ -6,6 +6,7 @@ import { useArbQueryParams } from '../../hooks/useArbQueryParams'; import { statsLocalStorageKey } from '../MainContent/ArbitrumStats'; import { AddCustomChain } from './AddCustomChain'; import { ExternalLink } from './ExternalLink'; +import { NotificationOptIn } from './NotificationOptIn'; import { SidePanel } from './SidePanel'; import { Switch } from './atoms/Switch'; @@ -57,6 +58,12 @@ export const SettingsDialog = () => { /> + {/* Browser Notifications */} +
+ Notifications + +
+ {/* Add custom chain */}
Add Custom Orbit Chain diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts index d3d468fc0..bb88cc630 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts @@ -667,12 +667,66 @@ export const useTransactionHistory = ( return [tx, ...currentNewTransactions]; }); + + // Send broadcast notification when transaction is signed + if ( + typeof window !== 'undefined' && + 'Notification' in window && + Notification.permission === 'granted' + ) { + navigator.serviceWorker.ready + .then(registration => { + registration.active?.postMessage({ + type: 'SHOW_NOTIFICATION', + payload: { + txHash: tx.txId, + status: 'BROADCAST', + direction: tx.direction, + isWithdrawal: tx.isWithdrawal + } + }) + }) + .catch(err => + console.error('Failed to send broadcast notification:', err) + ) + } }, [mutateNewTransactionsData], ); const updateCachedTransaction = useCallback( (newTx: MergedTransaction) => { + // Find the old transaction from cache to detect status changes + const oldTx = newTransactionsData?.find(tx => + isSameTransaction(tx, newTx) + ) + + // Notify on status changes + if ( + oldTx && + oldTx.status !== newTx.status && + typeof window !== 'undefined' && + 'Notification' in window && + Notification.permission === 'granted' + ) { + navigator.serviceWorker.ready + .then(registration => { + registration.active?.postMessage({ + type: 'SHOW_NOTIFICATION', + payload: { + txHash: newTx.txId, + status: newTx.status, + depositStatus: newTx.depositStatus, + direction: newTx.direction, + isWithdrawal: newTx.isWithdrawal, + isCctp: newTx.isCctp, + isLifi: newTx.isLifi + } + }) + }) + .catch(err => console.error('Failed to send notification:', err)) + } + // check if tx is a new transaction initiated by the user, and update it const foundInNewTransactions = typeof newTransactionsData?.find((oldTx) => isSameTransaction(oldTx, newTx)) !== From 732214359667bde4218bb83e482d503e00b53fb4 Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:18:39 -0400 Subject: [PATCH 2/2] fix location of sw.js --- packages/{arb-token-bridge-ui => app}/public/sw.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/{arb-token-bridge-ui => app}/public/sw.js (100%) diff --git a/packages/arb-token-bridge-ui/public/sw.js b/packages/app/public/sw.js similarity index 100% rename from packages/arb-token-bridge-ui/public/sw.js rename to packages/app/public/sw.js