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;