feat: introduce notification system and claude notifications (#450)

* feat: introduce notification system and claude notifications

* fix(sw): prevent caching of API requests and WebSocket upgrades

* default to false for webpush notifications and translations for the button

* fix: notifications orchestrator and add a notification when  first enabled

* fix: remove unused state update and dependency in settings controller hook

* fix: show notifications settings tab

* fix: add notifications for response completion for all providers

* feat: show session name in notification and don't reload tab on clicking
--- the notification

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Haileyesus <something@gmail.com>
This commit is contained in:
Simos Mikelatos
2026-03-13 16:59:09 +01:00
committed by GitHub
parent 6f6dacad5e
commit 45e71a0e73
36 changed files with 1629 additions and 69 deletions

103
src/hooks/useWebPush.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
type WebPushState = {
permission: NotificationPermission | 'unsupported';
isSubscribed: boolean;
isLoading: boolean;
subscribe: () => Promise<void>;
unsubscribe: () => Promise<void>;
};
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function useWebPush(): WebPushState {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
return 'unsupported';
}
return Notification.permission;
});
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Check existing subscription on mount
useEffect(() => {
if (permission === 'unsupported') return;
navigator.serviceWorker.ready.then((registration) => {
registration.pushManager.getSubscription().then((sub) => {
setIsSubscribed(sub !== null);
});
}).catch(() => {
// SW not ready yet
});
}, [permission]);
const subscribe = useCallback(async () => {
if (permission === 'unsupported') return;
setIsLoading(true);
try {
const perm = await Notification.requestPermission();
setPermission(perm);
if (perm !== 'granted') return;
const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');
const { publicKey } = await keyRes.json();
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
});
const subJson = subscription.toJSON();
await authenticatedFetch('/api/settings/push/subscribe', {
method: 'POST',
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
}),
});
setIsSubscribed(true);
} catch (err) {
console.error('Push subscribe failed:', err);
} finally {
setIsLoading(false);
}
}, [permission]);
const unsubscribe = useCallback(async () => {
setIsLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
const endpoint = subscription.endpoint;
await subscription.unsubscribe();
await authenticatedFetch('/api/settings/push/unsubscribe', {
method: 'POST',
body: JSON.stringify({ endpoint }),
});
}
setIsSubscribed(false);
} catch (err) {
console.error('Push unsubscribe failed:', err);
} finally {
setIsLoading(false);
}
}, []);
return { permission, isSubscribed, isLoading, subscribe, unsubscribe };
}