diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 14cbbab4b..3d788a0a6 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -20,6 +20,8 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { isTauri } from '../../../hooks/useTauri'; +import { useWebPushConfig, webPushSupported } from '../../../hooks/useWebPush'; function EmailNotification() { const mx = useMatrixClient(); @@ -197,6 +199,9 @@ function PauseNotifications() { export function SystemNotification() { const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [backgroundPush, setBackgroundPush] = useSetting(settingsAtom, 'backgroundPush'); + const webPushConfig = useWebPushConfig(); + const showBackgroundPush = !isTauri() && webPushSupported() && webPushConfig !== undefined; const [isNotificationSounds, setIsNotificationSounds] = useSetting( settingsAtom, 'isNotificationSounds', @@ -247,6 +252,19 @@ export function SystemNotification() { ) } /> + {showBackgroundPush && ( + + } + /> + )} ; + /** + * [Gitea #202] Web Push gateway per homeserver: + * { "": { gatewayUrl, appId, vapidPublicKey } }. `gatewayUrl` is + * the push gateway's `/_matrix/push/v1/notify` URL, `appId` the Sygnal app + * id, `vapidPublicKey` that app's VAPID public key (base64url). Homeservers + * not listed never register a pusher. Web only (the desktop app notifies + * natively while running). + */ + webPush?: Record; + /** * Absolute https URL of the public web app (e.g. https://chat.lotusguild.org). * The desktop app sets it so it can hand calls it can't make to the browser. diff --git a/src/app/hooks/useWebPush.ts b/src/app/hooks/useWebPush.ts new file mode 100644 index 000000000..33eddd6aa --- /dev/null +++ b/src/app/hooks/useWebPush.ts @@ -0,0 +1,154 @@ +import { useEffect, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { IPusherRequest, MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; +import { useClientConfig } from './useClientConfig'; +import { getNotificationState, usePermissionState } from './usePermission'; +import { isTauri } from './useTauri'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; +import { notificationSnoozeUntilAtom } from '../state/notificationSnooze'; +import { + base64UrlToBytes, + buildHttpPusher, + parseStoredPusher, + pusherDeviceName, + sameServerKey, + subscriptionKeys, + WEB_PUSH_STORAGE_KEY, + WebPushConfig, + webPushConfigFor, +} from '../utils/webPush'; + +/** Browser support for Web Push (iOS: only in the installed PWA). */ +export const webPushSupported = (): boolean => + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window; + +/** The gateway configured for the signed-in account's homeserver, if any. */ +export function useWebPushConfig(): WebPushConfig | undefined { + const mx = useMatrixClient(); + const { webPush } = useClientConfig(); + return useMemo(() => webPushConfigFor(webPush, mx.getDomain()), [webPush, mx]); +} + +const readStored = () => { + try { + return parseStoredPusher(localStorage.getItem(WEB_PUSH_STORAGE_KEY)); + } catch { + return undefined; + } +}; + +const removePusher = (mx: MatrixClient, appId: string, pushkey: string): Promise => + mx + .setPusher({ kind: null, app_id: appId, pushkey } as unknown as IPusherRequest) + .catch(() => undefined); + +async function register(mx: MatrixClient, config: WebPushConfig): Promise { + const userId = mx.getUserId(); + if (!userId) return; + const registration = await navigator.serviceWorker.ready; + let sub = await registration.pushManager.getSubscription(); + if (sub && !sameServerKey(sub.options.applicationServerKey, config.vapidPublicKey)) { + await sub.unsubscribe(); + sub = null; + } + if (!sub) { + sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: base64UrlToBytes(config.vapidPublicKey), + }); + } + const keys = subscriptionKeys(sub.toJSON()); + if (!keys) return; + + // A previous registration of this browser for the same account with another + // key or app id would otherwise keep receiving (and failing) pushes. + const stored = readStored(); + if ( + stored && + stored.userId === userId && + (stored.pushkey !== keys.p256dh || stored.appId !== config.appId) + ) { + await removePusher(mx, stored.appId, stored.pushkey); + } + + const lang = (navigator.language || 'en').split('-')[0] || 'en'; + await mx.setPusher( + buildHttpPusher( + config, + keys, + userId, + pusherDeviceName(navigator.userAgent), + lang, + ) as unknown as IPusherRequest, + ); + localStorage.setItem( + WEB_PUSH_STORAGE_KEY, + JSON.stringify({ appId: config.appId, pushkey: keys.p256dh, userId }), + ); +} + +async function unregister(mx: MatrixClient): Promise { + const stored = readStored(); + if (!stored) return; + if (stored.userId === mx.getUserId()) await removePusher(mx, stored.appId, stored.pushkey); + localStorage.removeItem(WEB_PUSH_STORAGE_KEY); + const registration = await navigator.serviceWorker.getRegistration(); + const sub = await registration?.pushManager.getSubscription(); + await sub?.unsubscribe(); +} + +/** + * [Gitea #202] Background notifications for the web app. + * + * - Mirrors the notification prefs (desktop notifications, background push, + * pause, quiet hours) into the service worker, which shows pushes while no + * Lotus tab is open. + * - While the homeserver has a gateway configured (config.json `webPush`), push + * is supported, permission is granted and both settings are on: subscribes + * this browser and registers it as an HTTP pusher. Otherwise removes + * whatever this browser registered before. + * + * Not used in the desktop app, which notifies natively while running. + */ +export function useWebPush(): void { + const mx = useMatrixClient(); + const config = useWebPushConfig(); + const permission = usePermissionState('notifications', getNotificationState()); + const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [backgroundPush] = useSetting(settingsAtom, 'backgroundPush'); + const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); + const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); + const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); + const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); + + const active = !isTauri() && webPushSupported(); + const enabled = showNotifications && backgroundPush; + + useEffect(() => { + if (!active) return; + navigator.serviceWorker.ready + .then((registration) => + registration.active?.postMessage({ + type: 'setPushPrefs', + prefs: { enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd }, + }), + ) + .catch(() => undefined); + }, [active, enabled, snoozeUntil, quietHoursEnabled, quietHoursStart, quietHoursEnd]); + + const wanted = active && !!config && permission === 'granted' && enabled; + useEffect(() => { + if (!active) return; + const run = wanted && config ? register(mx, config) : unregister(mx); + run.catch((e) => { + // Push service unreachable, permission revoked meanwhile, etc. The app + // still notifies while open; nothing to surface beyond the console. + console.warn('[web push]', e); + }); + }, [active, wanted, config, mx]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d1f152cc4..a33a36475 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -79,6 +79,7 @@ import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpd import { settingsRequestAtom } from '../../state/settingsRequest'; import { isNetworkUpdateError } from '../../utils/updateErrors'; import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri'; +import { useWebPush } from '../../hooks/useWebPush'; import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt'; import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; @@ -614,17 +615,36 @@ function MessageNotifications() { if (!('serviceWorker' in navigator)) return undefined; const onMessage = (event: MessageEvent) => { const data = event.data ?? {}; - if (data.type === 'notificationClick' && typeof data.path === 'string') { - // On desktop, raise the native window — a service-worker/WebView2 - // `client.focus()` only focuses the document, not the OS window. No-op - // outside Tauri. - invokeTauri('focus_main_window'); - navigate(data.path); + if (data.type !== 'notificationClick') return; + // [Gitea #202] Push notifications name the room, not a path: only the page + // knows whether it's a DM. + let path: string | undefined; + if (typeof data.path === 'string') path = data.path; + else if (typeof data.roomId === 'string') { + const eventId = typeof data.eventId === 'string' ? data.eventId : undefined; + path = mDirects.has(data.roomId) + ? getDirectRoomPath(data.roomId, eventId) + : getHomeRoomPath(data.roomId, eventId); } + if (!path) return; + // On desktop, raise the native window — a service-worker/WebView2 + // `client.focus()` only focuses the document, not the OS window. No-op + // outside Tauri. + invokeTauri('focus_main_window'); + navigate(path); }; navigator.serviceWorker.addEventListener('message', onMessage); return () => navigator.serviceWorker.removeEventListener('message', onMessage); - }, [navigate]); + }, [navigate, mDirects]); + + // [Gitea #202] A notification clicked while no tab was open opened this one; + // ask the worker where it was meant to go (it holds the target for a minute). + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.ready + .then((registration) => registration.active?.postMessage({ type: 'getPendingClick' })) + .catch(() => undefined); + }, []); const playSound = useCallback(() => { const audioElement = audioRef.current; @@ -810,6 +830,12 @@ type ClientNonUIFeaturesProps = { children: ReactNode; }; +// [Gitea #202] Web Push registration + prefs for the service worker (web only). +function WebPushFeature() { + useWebPush(); + return null; +} + function DeepLinkNavigator() { useDeepLinkNavigate(); return null; @@ -1165,6 +1191,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b512d5725..bdde53375 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -223,6 +223,9 @@ export interface Settings { legacyUsernameColor: boolean; showNotifications: boolean; + // [Gitea #202] Web only: notifications while no Lotus tab is open, via Web + // Push and the homeserver's push gateway. Device-local (per browser). + backgroundPush: boolean; isNotificationSounds: boolean; messageSoundId: 'notification' | 'invite' | 'call' | 'none'; inviteSoundId: 'notification' | 'invite' | 'call' | 'none'; @@ -356,6 +359,7 @@ const defaultSettings: Settings = { legacyUsernameColor: false, showNotifications: true, + backgroundPush: true, isNotificationSounds: true, messageSoundId: 'notification', inviteSoundId: 'invite', diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 205b24887..7bca115cd 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -28,6 +28,7 @@ export type SyncedSettingsContent = { export const DEVICE_LOCAL_KEYS: ReadonlySet = new Set([ 'settingsSync', 'globalCallHotkeys', + 'backgroundPush', 'pageZoom', 'mediaAutoLoad', 'pauseAnimations', diff --git a/src/app/utils/webPush.test.ts b/src/app/utils/webPush.test.ts new file mode 100644 index 000000000..16c71a2bc --- /dev/null +++ b/src/app/utils/webPush.test.ts @@ -0,0 +1,128 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + base64UrlToBytes, + buildHttpPusher, + bytesToBase64Url, + parseStoredPusher, + pusherDeviceName, + sameServerKey, + subscriptionKeys, + webPushConfigFor, +} from './webPush'; + +// A real-shaped VAPID public key (65-byte uncompressed P-256 point, base64url). +const VAPID = + 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'; +const CONFIG = { + 'matrix.example.org': { + gatewayUrl: 'https://push.example.org/_matrix/push/v1/notify', + appId: 'org.example.chat.web', + vapidPublicKey: VAPID, + }, +}; + +test('config is per homeserver and only used when well-formed', () => { + assert.deepEqual(webPushConfigFor(CONFIG, 'matrix.example.org'), CONFIG['matrix.example.org']); + assert.equal(webPushConfigFor(CONFIG, 'matrix.org'), undefined); + assert.equal(webPushConfigFor(undefined, 'matrix.example.org'), undefined); + assert.equal(webPushConfigFor(CONFIG, null), undefined); + const bad = (over: Record) => + webPushConfigFor({ s: { ...CONFIG['matrix.example.org'], ...over } }, 's'); + assert.equal(bad({ gatewayUrl: 'http://push.example.org/notify' }), undefined); + assert.equal(bad({ gatewayUrl: 'not a url' }), undefined); + assert.equal(bad({ appId: '' }), undefined); + assert.equal(bad({ vapidPublicKey: 'short' }), undefined); + assert.equal( + bad({ vapidPublicKey: 'has spaces and +/= that are not base64url!!!!!!!!!!!!' }), + undefined, + ); +}); + +test('base64url round-trips and decodes the VAPID key to 65 bytes', () => { + const bytes = base64UrlToBytes(VAPID); + assert.equal(bytes.length, 65); + assert.equal(bytes[0], 0x04); + assert.equal(bytesToBase64Url(bytes), VAPID); + assert.equal(bytesToBase64Url(bytes.buffer), VAPID); +}); + +test('an existing subscription is reused only with the same server key', () => { + const key = base64UrlToBytes(VAPID).buffer; + assert.equal(sameServerKey(key, VAPID), true); + assert.equal(sameServerKey(key, `${VAPID}=`), true); + assert.equal(sameServerKey(new Uint8Array([4, 1, 2]).buffer, VAPID), false); + assert.equal(sameServerKey(null, VAPID), false); +}); + +test('subscription keys are taken from PushSubscription.toJSON()', () => { + const json = { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + keys: { p256dh: 'P256', auth: 'AUTH' }, + }; + assert.deepEqual(subscriptionKeys(json), { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + p256dh: 'P256', + auth: 'AUTH', + }); + assert.equal(subscriptionKeys({ ...json, endpoint: 'http://insecure' }), undefined); + assert.equal(subscriptionKeys({ endpoint: json.endpoint, keys: { p256dh: 'P' } }), undefined); + assert.equal(subscriptionKeys(null), undefined); +}); + +test('the pusher is a Sygnal webpush pusher keyed by p256dh', () => { + const pusher = buildHttpPusher( + CONFIG['matrix.example.org'], + { endpoint: 'https://push.service/abc', p256dh: 'P256', auth: 'AUTH' }, + '@me:example.org', + 'Lotus Chat (Firefox on Linux)', + 'en', + ); + assert.deepEqual(pusher, { + kind: 'http', + app_id: 'org.example.chat.web', + pushkey: 'P256', + app_display_name: 'Lotus Chat', + device_display_name: 'Lotus Chat (Firefox on Linux)', + lang: 'en', + data: { + url: 'https://push.example.org/_matrix/push/v1/notify', + endpoint: 'https://push.service/abc', + auth: 'AUTH', + events_only: true, + default_payload: { user_id: '@me:example.org' }, + }, + append: true, + }); +}); + +test('device names', () => { + assert.equal( + pusherDeviceName('Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0'), + 'Lotus Chat (Firefox on Linux)', + ); + assert.equal( + pusherDeviceName( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Edg/140.0', + ), + 'Lotus Chat (Edge on Windows)', + ); + assert.equal( + pusherDeviceName( + 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Mobile Safari/537.36', + ), + 'Lotus Chat (Chrome on Android)', + ); + assert.equal(pusherDeviceName(''), 'Lotus Chat (Browser)'); +}); + +test('stored pusher parsing', () => { + assert.deepEqual(parseStoredPusher('{"appId":"a","pushkey":"k","userId":"@u:x"}'), { + appId: 'a', + pushkey: 'k', + userId: '@u:x', + }); + assert.equal(parseStoredPusher(null), undefined); + assert.equal(parseStoredPusher('nope'), undefined); + assert.equal(parseStoredPusher('{"appId":"a"}'), undefined); +}); diff --git a/src/app/utils/webPush.ts b/src/app/utils/webPush.ts new file mode 100644 index 000000000..be9139dd1 --- /dev/null +++ b/src/app/utils/webPush.ts @@ -0,0 +1,174 @@ +/** + * [Gitea #202] Web Push: pure helpers for registering this browser with the + * homeserver as an HTTP pusher that points at our push gateway (Sygnal, + * webpush kind). `useWebPush` drives them; the service worker's `push` + * handler (src/swPush.ts) shows what arrives. + */ + +export type WebPushConfig = { + /** The gateway's notify URL, e.g. https://push.example.org/_matrix/push/v1/notify */ + gatewayUrl: string; + /** Sygnal app id for the web client. */ + appId: string; + /** That app's VAPID public key, base64url (uncompressed P-256 point). */ + vapidPublicKey: string; +}; + +const isHttpsUrl = (v: unknown): v is string => { + if (typeof v !== 'string') return false; + try { + return new URL(v).protocol === 'https:'; + } catch { + return false; + } +}; + +/** + * The gateway configured for this homeserver, or `undefined` (then nothing is + * registered). Malformed entries are ignored rather than half-used. + */ +export function webPushConfigFor( + webPush: Record | undefined, + serverName: string | null | undefined, +): WebPushConfig | undefined { + if (!webPush || !serverName) return undefined; + const raw = webPush[serverName]; + if (!raw || typeof raw !== 'object') return undefined; + const c = raw as Record; + if (!isHttpsUrl(c.gatewayUrl)) return undefined; + if (typeof c.appId !== 'string' || c.appId === '') return undefined; + if (typeof c.vapidPublicKey !== 'string' || !/^[A-Za-z0-9_-]{40,}$/.test(c.vapidPublicKey)) { + return undefined; + } + return { gatewayUrl: c.gatewayUrl, appId: c.appId, vapidPublicKey: c.vapidPublicKey }; +} + +/** base64url (no padding) → bytes, for `applicationServerKey`. */ +export function base64UrlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(new ArrayBuffer(bin.length)); + for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i); + return out; +} + +/** bytes → base64url (no padding). */ +export function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string { + const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let bin = ''; + arr.forEach((b) => { + bin += String.fromCharCode(b); + }); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Whether an existing subscription was made with this VAPID key. */ +export function sameServerKey( + subscriptionKey: ArrayBuffer | null | undefined, + vapidPublicKey: string, +): boolean { + if (!subscriptionKey) return false; + return bytesToBase64Url(subscriptionKey) === vapidPublicKey.replace(/=+$/, ''); +} + +export type SubscriptionKeys = { endpoint: string; p256dh: string; auth: string }; + +/** The parts of a `PushSubscription.toJSON()` a pusher needs, or `undefined`. */ +export function subscriptionKeys(json: unknown): SubscriptionKeys | undefined { + if (!json || typeof json !== 'object') return undefined; + const j = json as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } }; + if (!isHttpsUrl(j.endpoint)) return undefined; + const { p256dh, auth } = j.keys ?? {}; + if (typeof p256dh !== 'string' || typeof auth !== 'string' || !p256dh || !auth) return undefined; + return { endpoint: j.endpoint, p256dh, auth }; +} + +export type HttpPusherRequest = { + kind: 'http'; + app_id: string; + pushkey: string; + app_display_name: string; + device_display_name: string; + lang: string; + data: { + url: string; + endpoint: string; + auth: string; + /** Sygnal: skip count-only pushes (browsers want a notification per push). */ + events_only: boolean; + /** Merged into every push payload: which account it's for. */ + default_payload: { user_id: string }; + }; + append: boolean; +}; + +/** + * The `/pushers/set` body for a Sygnal webpush pusher: the pushkey is the + * subscription's p256dh key, the endpoint and auth secret go in `data`. + * `append` keeps the same browser registered for another account too. + */ +export function buildHttpPusher( + config: WebPushConfig, + keys: SubscriptionKeys, + userId: string, + deviceName: string, + lang: string, +): HttpPusherRequest { + return { + kind: 'http', + app_id: config.appId, + pushkey: keys.p256dh, + app_display_name: 'Lotus Chat', + device_display_name: deviceName, + lang, + data: { + url: config.gatewayUrl, + endpoint: keys.endpoint, + auth: keys.auth, + events_only: true, + default_payload: { user_id: userId }, + }, + append: true, + }; +} + +/** A short "Lotus Chat (Firefox on Linux)" style name for the pusher list. */ +export function pusherDeviceName(userAgent: string): string { + const browser = + (/Edg\//.test(userAgent) && 'Edge') || + (/Firefox\//.test(userAgent) && 'Firefox') || + (/Chrome\//.test(userAgent) && 'Chrome') || + (/Safari\//.test(userAgent) && 'Safari') || + 'Browser'; + const os = + (/Android/.test(userAgent) && 'Android') || + (/iPhone|iPad|iPod/.test(userAgent) && 'iOS') || + (/Windows/.test(userAgent) && 'Windows') || + (/Mac OS X/.test(userAgent) && 'macOS') || + (/Linux/.test(userAgent) && 'Linux') || + ''; + return os ? `Lotus Chat (${browser} on ${os})` : `Lotus Chat (${browser})`; +} + +/** What this browser registered, so it can be removed again. */ +export type StoredPusher = { appId: string; pushkey: string; userId: string }; + +export const WEB_PUSH_STORAGE_KEY = 'lotus_web_push_pusher_v1'; + +export function parseStoredPusher(raw: string | null): StoredPusher | undefined { + if (!raw) return undefined; + try { + const v = JSON.parse(raw) as Partial; + if ( + typeof v.appId === 'string' && + typeof v.pushkey === 'string' && + typeof v.userId === 'string' + ) { + return { appId: v.appId, pushkey: v.pushkey, userId: v.userId }; + } + } catch { + // fall through + } + return undefined; +} diff --git a/src/sw.ts b/src/sw.ts index 3be49b5e2..23a310f4d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -3,6 +3,16 @@ import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare'; import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply'; +import { + buildPushNotification, + isPushQuiet, + parsePushPrefs, + PENDING_CLICK_MAX_AGE_MS, + PUSH_PREFS_KEY, + PushPayload, + PushPrefs, + SW_STATE_CACHE, +} from './swPush'; export type {}; declare const self: ServiceWorkerGlobalScope & { @@ -142,6 +152,99 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { setSession(client.id, accessToken, baseUrl, userId); cleanupDeadClients(); } + + // [Gitea #202] The page mirrors its notification prefs here so pushes that + // arrive with no tab open still honour pause / quiet hours. + if (type === 'setPushPrefs') { + event.waitUntil(savePushPrefs(parsePushPrefs(event.data.prefs))); + } + + // [Gitea #202] A window opened by a notification click asks where to go once + // its router is listening. + if (type === 'getPendingClick') { + const pending = takePendingClick(); + if (pending) client.postMessage({ type: 'notificationClick', ...pending }); + } +}); + +// --------------------------------------------------------------------------- +// [Gitea #202] Web Push +// --------------------------------------------------------------------------- + +async function savePushPrefs(prefs: PushPrefs): Promise { + try { + const cache = await caches.open(SW_STATE_CACHE); + await cache.put( + PUSH_PREFS_KEY, + new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }), + ); + } catch { + // Storage unavailable: pushes fall back to the defaults. + } +} + +async function loadPushPrefs(): Promise { + try { + const cache = await caches.open(SW_STATE_CACHE); + const res = await cache.match(PUSH_PREFS_KEY); + return parsePushPrefs(res ? await res.json() : undefined); + } catch { + return parsePushPrefs(undefined); + } +} + +type ClickTarget = { path?: string; roomId?: string; eventId?: string }; +let pendingClick: (ClickTarget & { at: number }) | undefined; + +function takePendingClick(): ClickTarget | undefined { + const pending = pendingClick; + pendingClick = undefined; + if (!pending || Date.now() - pending.at > PENDING_CLICK_MAX_AGE_MS) return undefined; + const { path, roomId, eventId } = pending; + return { path, roomId, eventId }; +} + +async function setAppBadge(count: number | undefined): Promise { + const nav = self.navigator as WorkerNavigator & { + setAppBadge?: (n?: number) => Promise; + clearAppBadge?: () => Promise; + }; + if (typeof count !== 'number') return; + try { + if (count > 0) await nav.setAppBadge?.(count); + else await nav.clearAppBadge?.(); + } catch { + // Badging unsupported or not allowed. + } +} + +self.addEventListener('push', (event: PushEvent) => { + event.waitUntil( + (async () => { + let payload: PushPayload = {}; + try { + payload = (event.data?.json() ?? {}) as PushPayload; + } catch { + return; + } + await setAppBadge(payload.unread); + + const built = buildPushNotification(payload, self.registration.scope); + if (!built) return; + + // A focused Lotus tab already shows this as an in-app toast. + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (windows.some((c) => (c as WindowClient).focused)) return; + + const prefs = await loadPushPrefs(); + if (!prefs.enabled) return; + const quiet = isPushQuiet(prefs, new Date()); + await self.registration.showNotification(built.title, { + ...built.options, + silent: quiet, + } as NotificationOptions); + })(), + ); }); /** @@ -154,6 +257,10 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); const data = event.notification.data ?? {}; const path = typeof data.path === 'string' ? data.path : undefined; + // [Gitea #202] Push notifications carry the room instead of a path: only the + // page knows whether it's a DM. + const roomId = typeof data.roomId === 'string' ? data.roomId : undefined; + const eventId = typeof data.eventId === 'string' ? data.eventId : undefined; // [Gitea #203] Reply action: send it and stop — no need to raise the app. if (event.action === 'reply' && typeof data.roomId === 'string') { @@ -202,12 +309,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { windowClients[0]; if (client) { await client.focus(); - if (path) client.postMessage({ type: 'notificationClick', path }); + if (path || roomId) + client.postMessage({ type: 'notificationClick', path, roomId, eventId }); return; } // No app window open — open one at the app root (the client routes from // there). Router config (hash vs browser) is page-owned, so we don't try - // to deep-link the URL here. + // to deep-link the URL here; the new window asks for the target + // (`getPendingClick`) once it can route. + if (path || roomId) pendingClick = { path, roomId, eventId, at: Date.now() }; if (self.clients.openWindow) { await self.clients.openWindow(self.registration.scope); } diff --git a/src/swPush.test.ts b/src/swPush.test.ts new file mode 100644 index 000000000..65b3f7933 --- /dev/null +++ b/src/swPush.test.ts @@ -0,0 +1,140 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildPushNotification, + DEFAULT_PUSH_PREFS, + isPushQuiet, + parsePushPrefs, + PushPayload, +} from './swPush'; + +const SCOPE = 'https://chat.example.org/'; +const msg = (over: Partial = {}): PushPayload => ({ + room_id: '!r:x', + event_id: '$e', + room_name: 'General', + sender: '@a:x', + sender_display_name: 'Alice', + type: 'm.room.message', + content: { msgtype: 'm.text', body: 'hello there' }, + user_id: '@me:x', + ...over, +}); + +test('count-only pushes show nothing', () => { + assert.equal(buildPushNotification({ unread: 3 }, SCOPE), null); + assert.equal(buildPushNotification({ room_id: '!r:x', unread: 0 }, SCOPE), null); +}); + +test('a room message: room title, sender and text, room tag, reply action', () => { + const n = buildPushNotification(msg(), SCOPE)!; + assert.equal(n.title, 'General'); + assert.equal(n.options.body, 'Alice: hello there'); + assert.equal(n.options.tag, '!r:x'); + assert.equal(n.options.icon, 'https://chat.example.org/public/res/lotus.png'); + assert.deepEqual(n.options.data, { + roomId: '!r:x', + eventId: '$e', + threadId: undefined, + userId: '@me:x', + }); + assert.equal(n.options.actions?.[0]?.action, 'reply'); +}); + +test('a DM (no room name): sender as title, text as body', () => { + const n = buildPushNotification(msg({ room_name: undefined }), SCOPE)!; + assert.equal(n.title, 'Alice'); + assert.equal(n.options.body, 'hello there'); +}); + +test('encrypted rooms never show content and offer no reply', () => { + const n = buildPushNotification( + msg({ + type: 'm.room.encrypted', + content: { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'x' }, + }), + SCOPE, + )!; + assert.equal(n.title, 'General'); + assert.equal(n.options.body, 'Alice'); + assert.equal(n.options.actions, undefined); + const dm = buildPushNotification( + msg({ type: 'm.room.encrypted', room_name: undefined, content: undefined }), + SCOPE, + )!; + assert.equal(dm.title, 'Alice'); + assert.equal(dm.options.body, 'New message'); +}); + +test('thread replies coalesce per thread', () => { + const n = buildPushNotification( + msg({ + content: { + msgtype: 'm.text', + body: 'in thread', + 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' }, + }, + }), + SCOPE, + )!; + assert.equal(n.options.tag, '!r:x:$root'); + assert.equal(n.options.data.threadId, '$root'); +}); + +test('media messages are described, long text is cut', () => { + assert.equal( + buildPushNotification(msg({ content: { msgtype: 'm.image', body: 'cat.png' } }), SCOPE)!.options + .body, + 'Alice: sent an image', + ); + const long = buildPushNotification( + msg({ content: { msgtype: 'm.text', body: 'x'.repeat(500) } }), + SCOPE, + )!; + assert.equal((long.options.body ?? '').length, 120); +}); + +test('invites go to the invites page', () => { + const n = buildPushNotification( + msg({ type: 'm.room.member', membership: 'invite', user_is_target: true, content: undefined }), + SCOPE, + )!; + assert.equal(n.title, 'Invitation'); + assert.equal(n.options.body, 'Alice invited you to General'); + assert.equal(n.options.tag, 'lotus-invites'); + assert.equal(n.options.data.path, '/inbox/invites/'); +}); + +test('calls ring with their own tag and stay until handled', () => { + const n = buildPushNotification(msg({ type: 'org.matrix.msc4075.rtc.notification' }), SCOPE)!; + assert.equal(n.options.body, 'Alice started a call'); + assert.equal(n.options.tag, '!r:x:call'); + assert.equal(n.options.requireInteraction, true); +}); + +test('unknown event types still show a notification', () => { + const n = buildPushNotification(msg({ type: 'm.reaction', content: undefined }), SCOPE)!; + assert.equal(n.options.body, 'New activity'); +}); + +test('prefs from the page are validated', () => { + assert.deepEqual(parsePushPrefs(null), DEFAULT_PUSH_PREFS); + assert.deepEqual(parsePushPrefs({ enabled: 'yes', snoozeUntil: '5', quietHoursEnabled: 1 }), { + ...DEFAULT_PUSH_PREFS, + }); + assert.equal(parsePushPrefs({ enabled: false }).enabled, false); +}); + +test('quiet when paused or inside quiet hours', () => { + const at = (h: number, m = 0): Date => new Date(2026, 10, 9, h, m); + assert.equal(isPushQuiet(DEFAULT_PUSH_PREFS, at(12)), false); + assert.equal(isPushQuiet({ ...DEFAULT_PUSH_PREFS, snoozeUntil: at(13).getTime() }, at(12)), true); + assert.equal( + isPushQuiet({ ...DEFAULT_PUSH_PREFS, snoozeUntil: at(11).getTime() }, at(12)), + false, + ); + const quiet = { ...DEFAULT_PUSH_PREFS, quietHoursEnabled: true }; + assert.equal(isPushQuiet(quiet, at(23)), true); + assert.equal(isPushQuiet(quiet, at(6, 30)), true); + assert.equal(isPushQuiet(quiet, at(12)), false); +}); diff --git a/src/swPush.ts b/src/swPush.ts new file mode 100644 index 000000000..c174502ef --- /dev/null +++ b/src/swPush.ts @@ -0,0 +1,219 @@ +/** + * [Gitea #202] Web Push: pure helpers for the service worker's `push` handler. + * + * The homeserver sends a push to our gateway (Sygnal, webpush kind), which + * relays the Matrix notification to the browser's push service, encrypted to + * this browser's subscription. Sygnal's payload is the notification's fields, + * flattened: room_id, room_name, event_id, sender, sender_display_name, type, + * content (unencrypted rooms only, size-capped), membership, user_is_target, + * unread / missed_calls, plus the pusher's `default_payload` (our user_id). + * + * Kept free of React / jotai so the service worker bundle stays small. + */ +import { isSnoozeActive } from './app/utils/snooze'; +import { isWithinTimeWindow } from './app/utils/timeWindow'; + +export type PushPayload = { + room_id?: string; + room_name?: string; + room_alias?: string; + event_id?: string; + sender?: string; + sender_display_name?: string; + type?: string; + content?: Record; + membership?: string; + user_is_target?: boolean; + unread?: number; + missed_calls?: number; + /** From the pusher's `default_payload`: the account this push is for. */ + user_id?: string; +}; + +/** Notification preferences the page mirrors into the worker. */ +export type PushPrefs = { + /** Settings → Notifications → Desktop notifications. */ + enabled: boolean; + /** Pause notifications: epoch ms, `0` = not paused. */ + snoozeUntil: number; + quietHoursEnabled: boolean; + quietHoursStart: string; + quietHoursEnd: string; +}; + +export const DEFAULT_PUSH_PREFS: PushPrefs = { + enabled: true, + snoozeUntil: 0, + quietHoursEnabled: false, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', +}; + +/** Accept only well-formed prefs from a page message; fill the rest in. */ +export function parsePushPrefs(raw: unknown): PushPrefs { + const p = (raw && typeof raw === 'object' ? raw : {}) as Record; + return { + enabled: typeof p.enabled === 'boolean' ? p.enabled : DEFAULT_PUSH_PREFS.enabled, + snoozeUntil: typeof p.snoozeUntil === 'number' ? p.snoozeUntil : 0, + quietHoursEnabled: p.quietHoursEnabled === true, + quietHoursStart: + typeof p.quietHoursStart === 'string' + ? p.quietHoursStart + : DEFAULT_PUSH_PREFS.quietHoursStart, + quietHoursEnd: + typeof p.quietHoursEnd === 'string' ? p.quietHoursEnd : DEFAULT_PUSH_PREFS.quietHoursEnd, + }; +} + +/** + * Paused or in quiet hours: the notification still has to be shown (browsers + * require a visible notification per push), but silently. + */ +export function isPushQuiet(prefs: PushPrefs, now: Date): boolean { + return ( + isSnoozeActive(prefs.snoozeUntil, now.getTime()) || + (prefs.quietHoursEnabled && isWithinTimeWindow(prefs.quietHoursStart, prefs.quietHoursEnd, now)) + ); +} + +export type PushNotificationData = { + roomId?: string; + eventId?: string; + threadId?: string; + userId?: string; + /** Set when the target is known without the page (invites). */ + path?: string; +}; + +export type BuiltNotification = { + title: string; + options: NotificationOptions & { + data: PushNotificationData; + actions?: { action: string; title: string; type?: 'button' | 'text'; placeholder?: string }[]; + }; +}; + +const CALL_TYPES = new Set([ + 'm.call.invite', + 'm.rtc.notification', + 'org.matrix.msc4075.rtc.notification', + 'org.matrix.msc4075.call.notify', +]); + +const BODY_MAX = 120; +const INVITES_PATH = '/inbox/invites/'; + +const str = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; + +/** One-line description of a message's content; never for encrypted rooms. */ +function messageText(content: Record | undefined): string | undefined { + if (!content) return undefined; + switch (content.msgtype) { + case 'm.image': + return 'sent an image'; + case 'm.video': + return 'sent a video'; + case 'm.audio': + return 'sent an audio message'; + case 'm.file': + return 'sent a file'; + case 'm.location': + return 'shared a location'; + default: + return str(content.body); + } +} + +function threadOf(content: Record | undefined): string | undefined { + const rel = content?.['m.relates_to']; + if (!rel || typeof rel !== 'object') return undefined; + const r = rel as Record; + return r.rel_type === 'm.thread' ? str(r.event_id) : undefined; +} + +/** + * The notification to show for a push, or `null` for a count-only push (no + * event). Mirrors the in-app OS notifications (ClientNonUIFeatures): app logo + * as icon, the room as the coalescing tag (`room:thread` for threads), no + * message text from encrypted rooms (it can't be decrypted here anyway), and + * an inline Reply action only for unencrypted rooms. + */ +export function buildPushNotification(p: PushPayload, scope: string): BuiltNotification | null { + const eventId = str(p.event_id); + const roomId = str(p.room_id); + if (!eventId || !roomId) return null; + + const logo = `${scope.replace(/\/?$/, '/')}public/res/lotus.png`; + const who = str(p.sender_display_name) ?? str(p.sender) ?? 'Someone'; + const room = str(p.room_name) ?? str(p.room_alias); + const userId = str(p.user_id); + const base = { icon: logo, badge: logo }; + + if (p.membership === 'invite' && p.user_is_target) { + return { + title: 'Invitation', + options: { + ...base, + body: room ? `${who} invited you to ${room}` : `${who} invited you`, + tag: 'lotus-invites', + data: { path: INVITES_PATH, userId }, + }, + }; + } + + if (p.type && CALL_TYPES.has(p.type)) { + return { + title: room ?? who, + options: { + ...base, + body: room ? `${who} started a call` : 'Incoming call', + tag: `${roomId}:call`, + requireInteraction: true, + data: { roomId, eventId, userId }, + }, + }; + } + + if (p.type === 'm.room.encrypted') { + return { + title: room ?? who, + options: { + ...base, + body: room ? who : 'New message', + tag: roomId, + data: { roomId, eventId, userId }, + }, + }; + } + + if (p.type === 'm.room.message' || p.type === 'm.sticker') { + const text = p.type === 'm.sticker' ? 'sent a sticker' : messageText(p.content); + const threadId = threadOf(p.content); + let body = 'New message'; + if (text) body = (room ? `${who}: ${text}` : text).slice(0, BODY_MAX); + else if (room) body = who; + return { + title: room ?? who, + options: { + ...base, + body, + tag: threadId ? `${roomId}:${threadId}` : roomId, + actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }], + data: { roomId, eventId, threadId, userId }, + }, + }; + } + + return { + title: room ?? who, + options: { ...base, body: 'New activity', tag: roomId, data: { roomId, eventId, userId } }, + }; +} + +/** Cache Storage location where the worker keeps the page's prefs. */ +export const SW_STATE_CACHE = 'lotus-sw-state-v1'; +export const PUSH_PREFS_KEY = '/__lotus/push-prefs'; + +/** A notification click waiting for a freshly opened window to ask for it. */ +export const PENDING_CLICK_MAX_AGE_MS = 60_000;