Compare commits
1
Commits
lotus
...
feat/web-push
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b0cf9c47 |
@@ -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 && (
|
||||
<SettingTile
|
||||
title="Notify when Lotus is closed"
|
||||
description="Keep getting notifications for new messages, mentions and calls when no Lotus tab is open, through your browser's push service. Encrypted messages are never shown in the notification. Pause and quiet hours still apply."
|
||||
after={
|
||||
<Switch
|
||||
disabled={notifPermission !== 'granted' || !showNotifications}
|
||||
value={backgroundPush}
|
||||
onChange={setBackgroundPush}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
|
||||
@@ -42,6 +42,16 @@ export type ClientConfig = {
|
||||
*/
|
||||
statusPages?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* [Gitea #202] Web Push gateway per homeserver:
|
||||
* { "<server name>": { 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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -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<unknown> =>
|
||||
mx
|
||||
.setPusher({ kind: null, app_id: appId, pushkey } as unknown as IPusherRequest)
|
||||
.catch(() => undefined);
|
||||
|
||||
async function register(mx: MatrixClient, config: WebPushConfig): Promise<void> {
|
||||
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<void> {
|
||||
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]);
|
||||
}
|
||||
@@ -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) {
|
||||
<StatusExpiryMonitor />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<WebPushFeature />
|
||||
<ReminderMonitor />
|
||||
<RetentionSweeper />
|
||||
<TauriUpdateFeature />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -28,6 +28,7 @@ export type SyncedSettingsContent = {
|
||||
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
||||
'settingsSync',
|
||||
'globalCallHotkeys',
|
||||
'backgroundPush',
|
||||
'pageZoom',
|
||||
'mediaAutoLoad',
|
||||
'pauseAnimations',
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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);
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
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<ArrayBuffer> {
|
||||
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<StoredPusher>;
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<PushPrefs> {
|
||||
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<void> {
|
||||
const nav = self.navigator as WorkerNavigator & {
|
||||
setAppBadge?: (n?: number) => Promise<void>;
|
||||
clearAppBadge?: () => Promise<void>;
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
+219
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | undefined): string | undefined {
|
||||
const rel = content?.['m.relates_to'];
|
||||
if (!rel || typeof rel !== 'object') return undefined;
|
||||
const r = rel as Record<string, unknown>;
|
||||
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;
|
||||
Reference in New Issue
Block a user