Skip to content

Commit ac5db7e

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 f43ef09 commit ac5db7e

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
@@ -99,6 +99,20 @@ const AppContent = React.memo(() => {
9999
// apply custom themes if any
100100
useTheme()
101101

102+
// Register service worker for notifications
103+
useEffect(() => {
104+
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
105+
navigator.serviceWorker
106+
.register('/sw.js')
107+
.then(registration => {
108+
console.log('Service Worker registered:', registration)
109+
})
110+
.catch(err => {
111+
console.error('Service Worker registration failed:', err)
112+
})
113+
}
114+
}, [])
115+
102116
if (address && isBlocked) {
103117
return (
104118
<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
@@ -8,6 +8,7 @@ import { SidePanel } from './SidePanel'
88
import { useArbQueryParams } from '../../hooks/useArbQueryParams'
99
import { ExternalLink } from './ExternalLink'
1010
import { ORBIT_QUICKSTART_LINK } from '../../constants'
11+
import { NotificationOptIn } from './NotificationOptIn'
1112

1213
const SectionTitle = ({
1314
className,
@@ -61,6 +62,12 @@ export const SettingsDialog = () => {
6162
/>
6263
</div>
6364

65+
{/* Browser Notifications */}
66+
<div className="w-full">
67+
<SectionTitle>Notifications</SectionTitle>
68+
<NotificationOptIn />
69+
</div>
70+
6471
{/* Add custom chain */}
6572
<div className="w-full transition-opacity">
6673
<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
@@ -749,12 +749,66 @@ export const useTransactionHistory = (
749749

750750
return [tx, ...currentNewTransactions]
751751
})
752+
753+
// Send broadcast notification when transaction is signed
754+
if (
755+
typeof window !== 'undefined' &&
756+
'Notification' in window &&
757+
Notification.permission === 'granted'
758+
) {
759+
navigator.serviceWorker.ready
760+
.then(registration => {
761+
registration.active?.postMessage({
762+
type: 'SHOW_NOTIFICATION',
763+
payload: {
764+
txHash: tx.txId,
765+
status: 'BROADCAST',
766+
direction: tx.direction,
767+
isWithdrawal: tx.isWithdrawal
768+
}
769+
})
770+
})
771+
.catch(err =>
772+
console.error('Failed to send broadcast notification:', err)
773+
)
774+
}
752775
},
753776
[mutateNewTransactionsData]
754777
)
755778

756779
const updateCachedTransaction = useCallback(
757780
(newTx: MergedTransaction) => {
781+
// Find the old transaction from cache to detect status changes
782+
const oldTx = newTransactionsData?.find(tx =>
783+
isSameTransaction(tx, newTx)
784+
)
785+
786+
// Notify on status changes
787+
if (
788+
oldTx &&
789+
oldTx.status !== newTx.status &&
790+
typeof window !== 'undefined' &&
791+
'Notification' in window &&
792+
Notification.permission === 'granted'
793+
) {
794+
navigator.serviceWorker.ready
795+
.then(registration => {
796+
registration.active?.postMessage({
797+
type: 'SHOW_NOTIFICATION',
798+
payload: {
799+
txHash: newTx.txId,
800+
status: newTx.status,
801+
depositStatus: newTx.depositStatus,
802+
direction: newTx.direction,
803+
isWithdrawal: newTx.isWithdrawal,
804+
isCctp: newTx.isCctp,
805+
isLifi: newTx.isLifi
806+
}
807+
})
808+
})
809+
.catch(err => console.error('Failed to send notification:', err))
810+
}
811+
758812
// check if tx is a new transaction initiated by the user, and update it
759813
const foundInNewTransactions =
760814
typeof newTransactionsData?.find(oldTx =>

0 commit comments

Comments
 (0)