Skip to content

Commit 83f6b3b

Browse files
committed
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.
1 parent db2b6c4 commit 83f6b3b

File tree

6 files changed

+248
-0
lines changed

6 files changed

+248
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Service worker is plain JavaScript for browser, not part of TypeScript project
2+
public/sw.js
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Service worker for client-side push notifications
2+
// Handles notification display and click events
3+
4+
// Helper function to get user-friendly status message
5+
function getNotificationMessage(payload) {
6+
const { status, depositStatus, isWithdrawal, direction } = payload;
7+
const shortTxHash = payload.txHash.substring(0, 6) + '...' + payload.txHash.substring(payload.txHash.length - 4);
8+
9+
// Map deposit status codes to messages
10+
const depositStatusMessages = {
11+
1: 'L1 Transaction Pending',
12+
2: 'L1 Transaction Failed',
13+
3: 'L2 Transaction Pending',
14+
4: 'L2 Transaction Success',
15+
5: 'L2 Transaction Failed',
16+
6: 'Transaction Creation Failed',
17+
7: 'Transaction Expired',
18+
8: 'CCTP Transfer Processing',
19+
9: 'Cross-chain Transfer Processing'
20+
};
21+
22+
// Map withdrawal statuses
23+
const withdrawalStatusMessages = {
24+
'Unconfirmed': 'Withdrawal Initiated',
25+
'Confirmed': 'Withdrawal Confirmed - Ready to Claim',
26+
'Executed': 'Withdrawal Claimed Successfully',
27+
'Expired': 'Withdrawal Expired',
28+
'Failure': 'Withdrawal Failed'
29+
};
30+
31+
let title = 'Bridge Transaction Update';
32+
let body = `Transaction ${shortTxHash}`;
33+
34+
// Handle broadcast notification
35+
if (status === 'BROADCAST') {
36+
title = isWithdrawal ? '🚀 Withdrawal Initiated' : '🚀 Deposit Initiated';
37+
body = `Transaction ${shortTxHash} has been signed and broadcast`;
38+
return { title, body };
39+
}
40+
41+
// Handle withdrawal status
42+
if (isWithdrawal && withdrawalStatusMessages[status]) {
43+
const statusMsg = withdrawalStatusMessages[status];
44+
title = statusMsg;
45+
body = `${shortTxHash} - ${statusMsg}`;
46+
return { title, body };
47+
}
48+
49+
// Handle deposit status
50+
if (depositStatus && depositStatusMessages[depositStatus]) {
51+
const statusMsg = depositStatusMessages[depositStatus];
52+
title = statusMsg;
53+
body = `${shortTxHash} - ${statusMsg}`;
54+
return { title, body };
55+
}
56+
57+
// Generic status update
58+
if (status) {
59+
title = `Transaction ${status}`;
60+
body = `${shortTxHash} - Status: ${status}`;
61+
}
62+
63+
return { title, body };
64+
}
65+
66+
// Listen for messages from the main application
67+
self.addEventListener('message', (event) => {
68+
const { type, payload } = event.data;
69+
70+
if (type === 'SHOW_NOTIFICATION') {
71+
const { title, body } = getNotificationMessage(payload);
72+
73+
const options = {
74+
body,
75+
icon: '/images/ArbitrumLogo-192.png',
76+
badge: '/images/ArbitrumLogo-192.png',
77+
tag: payload.txHash, // Reuse notification for same tx
78+
renotify: true, // Alert even if tag matches existing notification
79+
requireInteraction: false,
80+
data: {
81+
url: `/bridge?tab=tx_history`,
82+
txHash: payload.txHash
83+
}
84+
};
85+
86+
event.waitUntil(self.registration.showNotification(title, options));
87+
}
88+
});
89+
90+
// Handle notification click events
91+
self.addEventListener('notificationclick', (event) => {
92+
event.notification.close();
93+
const urlToOpen = new URL(event.notification.data.url, self.location.origin).href;
94+
95+
event.waitUntil(
96+
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
97+
for (const client of clientList) {
98+
if (client.url === urlToOpen && 'focus' in client) {
99+
return client.focus();
100+
}
101+
}
102+
if (self.clients.openWindow) {
103+
return self.clients.openWindow(urlToOpen);
104+
}
105+
})
106+
);
107+
});
108+
109+
// Basic service worker lifecycle events to ensure it activates immediately
110+
self.addEventListener('install', () => {
111+
self.skipWaiting();
112+
});
113+
114+
self.addEventListener('activate', (event) => {
115+
event.waitUntil(self.clients.claim());
116+
});

packages/arb-token-bridge-ui/src/components/App/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ const AppContent = React.memo(() => {
9797
// apply custom themes if any
9898
useTheme();
9999

100+
// Register service worker for notifications
101+
useEffect(() => {
102+
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
103+
navigator.serviceWorker
104+
.register('/sw.js')
105+
.then(registration => {
106+
console.log('Service Worker registered:', registration)
107+
})
108+
.catch(err => {
109+
console.error('Service Worker registration failed:', err)
110+
})
111+
}
112+
}, [])
113+
100114
if (address && isBlocked) {
101115
return (
102116
<BlockedDialog
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client'
2+
3+
import { useState, useEffect } from 'react'
4+
import { Button } from './Button'
5+
6+
export const NotificationOptIn = () => {
7+
const [permission, setPermission] =
8+
useState<NotificationPermission>('default')
9+
10+
useEffect(() => {
11+
if (typeof window !== 'undefined' && 'Notification' in window) {
12+
setPermission(Notification.permission)
13+
}
14+
}, [])
15+
16+
const handleRequestPermission = async () => {
17+
if ('Notification' in window) {
18+
const perm = await Notification.requestPermission()
19+
setPermission(perm)
20+
}
21+
}
22+
23+
if (typeof window === 'undefined' || !('Notification' in window)) {
24+
return null
25+
}
26+
27+
if (permission === 'granted') {
28+
return (
29+
<p className="text-sm text-green-400">
30+
Browser notifications are enabled for transaction updates.
31+
</p>
32+
)
33+
}
34+
35+
if (permission === 'denied') {
36+
return (
37+
<p className="text-sm text-orange-400">
38+
Browser notifications are blocked. Please enable them in your browser
39+
settings to receive transaction updates.
40+
</p>
41+
)
42+
}
43+
44+
return (
45+
<div className="flex flex-col gap-2">
46+
<p className="text-sm text-white/70">
47+
Enable browser notifications to get updates when your transactions are
48+
completed.
49+
</p>
50+
<Button variant="secondary" onClick={handleRequestPermission}>
51+
Enable Notifications
52+
</Button>
53+
</div>
54+
)
55+
}

packages/arb-token-bridge-ui/src/components/common/SettingsDialog.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useArbQueryParams } from '../../hooks/useArbQueryParams';
66
import { statsLocalStorageKey } from '../MainContent/ArbitrumStats';
77
import { AddCustomChain } from './AddCustomChain';
88
import { ExternalLink } from './ExternalLink';
9+
import { NotificationOptIn } from './NotificationOptIn';
910
import { SidePanel } from './SidePanel';
1011
import { Switch } from './atoms/Switch';
1112

@@ -57,6 +58,12 @@ export const SettingsDialog = () => {
5758
/>
5859
</div>
5960

61+
{/* Browser Notifications */}
62+
<div className="w-full">
63+
<SectionTitle>Notifications</SectionTitle>
64+
<NotificationOptIn />
65+
</div>
66+
6067
{/* Add custom chain */}
6168
<div className="w-full transition-opacity">
6269
<SectionTitle className="mb-1">Add Custom Orbit Chain</SectionTitle>

packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,12 +667,66 @@ export const useTransactionHistory = (
667667

668668
return [tx, ...currentNewTransactions];
669669
});
670+
671+
// Send broadcast notification when transaction is signed
672+
if (
673+
typeof window !== 'undefined' &&
674+
'Notification' in window &&
675+
Notification.permission === 'granted'
676+
) {
677+
navigator.serviceWorker.ready
678+
.then(registration => {
679+
registration.active?.postMessage({
680+
type: 'SHOW_NOTIFICATION',
681+
payload: {
682+
txHash: tx.txId,
683+
status: 'BROADCAST',
684+
direction: tx.direction,
685+
isWithdrawal: tx.isWithdrawal
686+
}
687+
})
688+
})
689+
.catch(err =>
690+
console.error('Failed to send broadcast notification:', err)
691+
)
692+
}
670693
},
671694
[mutateNewTransactionsData],
672695
);
673696

674697
const updateCachedTransaction = useCallback(
675698
(newTx: MergedTransaction) => {
699+
// Find the old transaction from cache to detect status changes
700+
const oldTx = newTransactionsData?.find(tx =>
701+
isSameTransaction(tx, newTx)
702+
)
703+
704+
// Notify on status changes
705+
if (
706+
oldTx &&
707+
oldTx.status !== newTx.status &&
708+
typeof window !== 'undefined' &&
709+
'Notification' in window &&
710+
Notification.permission === 'granted'
711+
) {
712+
navigator.serviceWorker.ready
713+
.then(registration => {
714+
registration.active?.postMessage({
715+
type: 'SHOW_NOTIFICATION',
716+
payload: {
717+
txHash: newTx.txId,
718+
status: newTx.status,
719+
depositStatus: newTx.depositStatus,
720+
direction: newTx.direction,
721+
isWithdrawal: newTx.isWithdrawal,
722+
isCctp: newTx.isCctp,
723+
isLifi: newTx.isLifi
724+
}
725+
})
726+
})
727+
.catch(err => console.error('Failed to send notification:', err))
728+
}
729+
676730
// check if tx is a new transaction initiated by the user, and update it
677731
const foundInNewTransactions =
678732
typeof newTransactionsData?.find((oldTx) => isSameTransaction(oldTx, newTx)) !==

0 commit comments

Comments
 (0)