Skip to content
Draft
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
116 changes: 116 additions & 0 deletions packages/app/public/sw.js
Original file line number Diff line number Diff line change
@@ -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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notification is not showing when claiming old transaction

'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());
});
2 changes: 2 additions & 0 deletions packages/arb-token-bridge-ui/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Service worker is plain JavaScript for browser, not part of TypeScript project
public/sw.js
14 changes: 14 additions & 0 deletions packages/arb-token-bridge-ui/src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<BlockedDialog
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client'

import { useState, useEffect } from 'react'
import { Button } from './Button'

export const NotificationOptIn = () => {
const [permission, setPermission] =
useState<NotificationPermission>('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 (
<p className="text-sm text-green-400">
Browser notifications are enabled for transaction updates.
</p>
)
}

if (permission === 'denied') {
return (
<p className="text-sm text-orange-400">
Browser notifications are blocked. Please enable them in your browser
settings to receive transaction updates.
</p>
)
}

return (
<div className="flex flex-col gap-2">
<p className="text-sm text-white/70">
Enable browser notifications to get updates when your transactions are
completed.
</p>
<Button variant="secondary" onClick={handleRequestPermission}>
Enable Notifications
</Button>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -57,6 +58,12 @@ export const SettingsDialog = () => {
/>
</div>

{/* Browser Notifications */}
<div className="w-full">
<SectionTitle>Notifications</SectionTitle>
<NotificationOptIn />
</div>

{/* Add custom chain */}
<div className="w-full transition-opacity">
<SectionTitle className="mb-1">Add Custom Orbit Chain</SectionTitle>
Expand Down
54 changes: 54 additions & 0 deletions packages/arb-token-bridge-ui/src/hooks/useTransactionHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notification code probably should move out of updateCachedTransaction?

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)) !==
Expand Down