Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b0cf9c47 |
@@ -7,7 +7,6 @@ import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
|||||||
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
import { useTauriDnd } from '../hooks/useTauriDnd';
|
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||||
import { useTauriOverlay } from '../hooks/useTauriOverlay';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
@@ -26,6 +25,5 @@ export function TauriDesktopFeatures(): null {
|
|||||||
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||||
useTauriOverlay(); // cinny-desktop #25 in-game voice overlay
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { MouseEvent, ReactNode } from 'react';
|
import React, { MouseEvent, ReactNode } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Text } from 'folds';
|
import { Text } from 'folds';
|
||||||
import { customChromeShownAtom } from '../../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../../state/customWindowChrome';
|
||||||
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
||||||
import * as css from './TitleBar.css';
|
import * as css from './TitleBar.css';
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
|||||||
* real traffic lights — are stripped while custom chrome is on.
|
* real traffic lights — are stripped while custom chrome is on.
|
||||||
*/
|
*/
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const enabled = useAtomValue(customChromeShownAtom);
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
if (!isTauri() || !enabled) return null;
|
if (!isTauri() || !enabled) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ import { playCallJoinSound } from '../../../utils/callSounds';
|
|||||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
import type { OverlayCorner } from '../../../utils/callOverlay';
|
|
||||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||||
import { StorageUsage } from './StorageUsage';
|
import { StorageUsage } from './StorageUsage';
|
||||||
import { hapticsSupported } from '../../../utils/haptics';
|
import { hapticsSupported } from '../../../utils/haptics';
|
||||||
@@ -160,7 +159,7 @@ function DesktopChromeSetting() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Custom window chrome (beta)"
|
title="Custom window chrome (beta)"
|
||||||
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply. On Linux (Wayland) it takes effect the next time Lotus Chat starts."
|
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||||||
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
@@ -1895,8 +1894,6 @@ export function Calls() {
|
|||||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||||
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||||||
const [callOverlay, setCallOverlay] = useSetting(settingsAtom, 'callOverlay');
|
|
||||||
const [callOverlayCorner, setCallOverlayCorner] = useSetting(settingsAtom, 'callOverlayCorner');
|
|
||||||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
|
const [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
|
||||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
@@ -2348,31 +2345,6 @@ export function Calls() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isTauriEnv() && (
|
|
||||||
<SettingTile
|
|
||||||
title="In-game overlay (beta)"
|
|
||||||
description="While you're in a call and another app or game is in front, show a small list of who's in the call and who's talking, plus your own mute, deafen and push-to-talk state. It never takes focus and clicks pass through it. Uses no game hooks. Windows only; games in exclusive fullscreen draw over it, so use borderless or windowed mode."
|
|
||||||
after={<Switch variant="Primary" value={callOverlay} onChange={setCallOverlay} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isTauriEnv() && callOverlay && (
|
|
||||||
<SettingTile
|
|
||||||
title="Overlay position"
|
|
||||||
description="Which corner of the screen the overlay sits in."
|
|
||||||
after={
|
|
||||||
<SettingsSelect<OverlayCorner>
|
|
||||||
value={callOverlayCorner}
|
|
||||||
onChange={setCallOverlayCorner}
|
|
||||||
options={[
|
|
||||||
{ value: 'top-left', label: 'Top left' },
|
|
||||||
{ value: 'top-right', label: 'Top right' },
|
|
||||||
{ value: 'bottom-left', label: 'Bottom left' },
|
|
||||||
{ value: 'bottom-right', label: 'Bottom right' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hapticsSupported() && (
|
{hapticsSupported() && (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Haptic feedback"
|
title="Haptic feedback"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm
|
|||||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { isTauri } from '../../../hooks/useTauri';
|
||||||
|
import { useWebPushConfig, webPushSupported } from '../../../hooks/useWebPush';
|
||||||
|
|
||||||
function EmailNotification() {
|
function EmailNotification() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -197,6 +199,9 @@ function PauseNotifications() {
|
|||||||
export function SystemNotification() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
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(
|
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds',
|
'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>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export type ClientConfig = {
|
|||||||
*/
|
*/
|
||||||
statusPages?: Record<string, unknown>;
|
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).
|
* 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.
|
* The desktop app sets it so it can hand calls it can't make to the browser.
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { callEmbedAtom } from '../state/callEmbed';
|
|
||||||
import { useCallControlState } from '../plugins/call';
|
|
||||||
import type { LotusCallParticipant } from '../plugins/call/CallEmbed';
|
|
||||||
import { useSetting } from '../state/hooks/settings';
|
|
||||||
import { settingsAtom } from '../state/settings';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
|
||||||
import { useCallJoined } from './useCallEmbed';
|
|
||||||
import { invokeTauri, isTauri } from './useTauri';
|
|
||||||
import { getMemberName } from '../utils/room';
|
|
||||||
import {
|
|
||||||
buildOverlaySnapshot,
|
|
||||||
inactiveOverlay,
|
|
||||||
OverlaySnapshot,
|
|
||||||
overlaySnapshotKey,
|
|
||||||
} from '../utils/callOverlay';
|
|
||||||
|
|
||||||
/** At most this often (ms) while speaking flips; the last state always lands. */
|
|
||||||
const SEND_INTERVAL_MS = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [cinny-desktop #25] Feeds the desktop app's in-game voice overlay: while a
|
|
||||||
* call is joined and Settings → Calls → In-game overlay is on, sends who's in
|
|
||||||
* the call, who's speaking and your own mute / deafen / PTT state to the native
|
|
||||||
* `set_overlay_state` command. The desktop decides when to show it (Lotus not
|
|
||||||
* focused). No-op in the browser.
|
|
||||||
*/
|
|
||||||
export function useTauriOverlay(): void {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
|
||||||
const joined = useCallJoined(callEmbed);
|
|
||||||
const { microphone, sound } = useCallControlState(callEmbed?.control);
|
|
||||||
const [enabled] = useSetting(settingsAtom, 'callOverlay');
|
|
||||||
const [corner] = useSetting(settingsAtom, 'callOverlayCorner');
|
|
||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
|
||||||
|
|
||||||
// Live per-participant state from the fork's io.lotus.call_state, only
|
|
||||||
// tracked while the overlay could show.
|
|
||||||
const [participants, setParticipants] = useState<readonly LotusCallParticipant[]>([]);
|
|
||||||
const tracking = isTauri() && enabled && joined && callEmbed !== undefined;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tracking || !callEmbed) {
|
|
||||||
setParticipants([]);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const sync = (): void => setParticipants(callEmbed.getLotusParticipants() ?? []);
|
|
||||||
sync();
|
|
||||||
return callEmbed.onLotusCallState(sync);
|
|
||||||
}, [tracking, callEmbed]);
|
|
||||||
|
|
||||||
const snapshot: OverlaySnapshot = useMemo(() => {
|
|
||||||
if (!tracking || !callEmbed) return inactiveOverlay(corner);
|
|
||||||
const { room } = callEmbed;
|
|
||||||
return buildOverlaySnapshot({
|
|
||||||
enabled,
|
|
||||||
joined,
|
|
||||||
corner,
|
|
||||||
myUserId: mx.getUserId(),
|
|
||||||
participants,
|
|
||||||
nameOf: (userId) => getMemberName(room, userId),
|
|
||||||
muted: !microphone,
|
|
||||||
deafened: !sound,
|
|
||||||
pttMode,
|
|
||||||
});
|
|
||||||
}, [tracking, callEmbed, enabled, joined, corner, mx, participants, microphone, sound, pttMode]);
|
|
||||||
|
|
||||||
// Coalesced send: skip unchanged snapshots, send at most every
|
|
||||||
// SEND_INTERVAL_MS, and always deliver the latest one.
|
|
||||||
const key = overlaySnapshotKey(snapshot);
|
|
||||||
const latest = useRef(snapshot);
|
|
||||||
latest.current = snapshot;
|
|
||||||
const sentKey = useRef<string | null>(null);
|
|
||||||
const lastSentAt = useRef(0);
|
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTauri() || key === sentKey.current) return;
|
|
||||||
const send = (): void => {
|
|
||||||
timer.current = undefined;
|
|
||||||
const s = latest.current;
|
|
||||||
sentKey.current = overlaySnapshotKey(s);
|
|
||||||
lastSentAt.current = Date.now();
|
|
||||||
invokeTauri('set_overlay_state', { state: s });
|
|
||||||
};
|
|
||||||
if (timer.current !== undefined) return;
|
|
||||||
const wait = SEND_INTERVAL_MS - (Date.now() - lastSentAt.current);
|
|
||||||
if (wait <= 0) send();
|
|
||||||
else timer.current = setTimeout(send, wait);
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
// Hide the overlay when this unmounts (logout, client teardown).
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (timer.current !== undefined) clearTimeout(timer.current);
|
|
||||||
if (isTauri()) {
|
|
||||||
invokeTauri('set_overlay_state', { state: inactiveOverlay(latest.current.corner) });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { customWindowChromeAtom, nativeChromeActiveAtom } from '../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
import { isTauri, tauriInvoke } from './useTauri';
|
import { invokeTauri, isTauri } from './useTauri';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
|
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
|
||||||
@@ -11,29 +11,12 @@ import { isTauri, tauriInvoke } from './useTauri';
|
|||||||
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
|
* web `<TitleBar/>` can take over, `enabled = false` restores the native frame.
|
||||||
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
|
* No-op in the browser (`isTauri()` guard), so it's safe to call unconditionally
|
||||||
* from the app shell.
|
* from the app shell.
|
||||||
*
|
|
||||||
* The command answers whether the native frame is stripped *now* (on Linux /
|
|
||||||
* Wayland the frame only changes at the next launch); that drives whether the
|
|
||||||
* Lotus titlebar is shown. Older desktop builds answer nothing, so the setting
|
|
||||||
* is used as before.
|
|
||||||
*/
|
*/
|
||||||
export function useTauriWindowChrome(): void {
|
export function useTauriWindowChrome(): void {
|
||||||
const enabled = useAtomValue(customWindowChromeAtom);
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
const setNativeActive = useSetAtom(nativeChromeActiveAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const invoke = isTauri() ? tauriInvoke() : undefined;
|
if (!isTauri()) return;
|
||||||
if (!invoke) return undefined;
|
invokeTauri('set_custom_chrome', { enabled });
|
||||||
let cancelled = false;
|
}, [enabled]);
|
||||||
invoke('set_custom_chrome', { enabled })
|
|
||||||
.then((active) => {
|
|
||||||
if (!cancelled) setNativeActive(typeof active === 'boolean' ? active : enabled);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setNativeActive(enabled);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [enabled, setNativeActive]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
|||||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
import { isTauri } from '../hooks/useTauri';
|
import { isTauri } from '../hooks/useTauri';
|
||||||
import { TitleBar } from '../features/desktop/TitleBar';
|
import { TitleBar } from '../features/desktop/TitleBar';
|
||||||
import { customChromeShownAtom } from '../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../state/customWindowChrome';
|
||||||
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
@@ -110,7 +110,7 @@ function TauriEffects() {
|
|||||||
// default, and always in the browser) this returns children unchanged, so there
|
// default, and always in the browser) this returns children unchanged, so there
|
||||||
// is zero layout impact for everyone else.
|
// is zero layout impact for everyone else.
|
||||||
function DesktopChrome({ children }: { children: ReactNode }) {
|
function DesktopChrome({ children }: { children: ReactNode }) {
|
||||||
const customChrome = useAtomValue(customChromeShownAtom);
|
const customChrome = useAtomValue(customWindowChromeAtom);
|
||||||
useTauriWindowChrome();
|
useTauriWindowChrome();
|
||||||
const useChrome = isTauri() && customChrome;
|
const useChrome = isTauri() && customChrome;
|
||||||
// Keep the wrapper element structure STABLE across the toggle so flipping the
|
// Keep the wrapper element structure STABLE across the toggle so flipping the
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpd
|
|||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
||||||
|
import { useWebPush } from '../../hooks/useWebPush';
|
||||||
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
||||||
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
|
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
@@ -614,17 +615,36 @@ function MessageNotifications() {
|
|||||||
if (!('serviceWorker' in navigator)) return undefined;
|
if (!('serviceWorker' in navigator)) return undefined;
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
const data = event.data ?? {};
|
const data = event.data ?? {};
|
||||||
if (data.type === 'notificationClick' && typeof data.path === 'string') {
|
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
|
// On desktop, raise the native window — a service-worker/WebView2
|
||||||
// `client.focus()` only focuses the document, not the OS window. No-op
|
// `client.focus()` only focuses the document, not the OS window. No-op
|
||||||
// outside Tauri.
|
// outside Tauri.
|
||||||
invokeTauri('focus_main_window');
|
invokeTauri('focus_main_window');
|
||||||
navigate(data.path);
|
navigate(path);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
navigator.serviceWorker.addEventListener('message', onMessage);
|
navigator.serviceWorker.addEventListener('message', onMessage);
|
||||||
return () => navigator.serviceWorker.removeEventListener('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 playSound = useCallback(() => {
|
||||||
const audioElement = audioRef.current;
|
const audioElement = audioRef.current;
|
||||||
@@ -810,6 +830,12 @@ type ClientNonUIFeaturesProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #202] Web Push registration + prefs for the service worker (web only).
|
||||||
|
function WebPushFeature() {
|
||||||
|
useWebPush();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function DeepLinkNavigator() {
|
function DeepLinkNavigator() {
|
||||||
useDeepLinkNavigate();
|
useDeepLinkNavigate();
|
||||||
return null;
|
return null;
|
||||||
@@ -1165,6 +1191,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<StatusExpiryMonitor />
|
<StatusExpiryMonitor />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<WebPushFeature />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
<RetentionSweeper />
|
<RetentionSweeper />
|
||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { atom } from 'jotai';
|
|
||||||
import {
|
import {
|
||||||
atomWithLocalStorage,
|
atomWithLocalStorage,
|
||||||
getLocalStorageItem,
|
getLocalStorageItem,
|
||||||
@@ -21,20 +20,3 @@ export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
|
|||||||
(key) => getLocalStorageItem<boolean>(key, false),
|
(key) => getLocalStorageItem<boolean>(key, false),
|
||||||
(key, value) => setLocalStorageItem(key, value),
|
(key, value) => setLocalStorageItem(key, value),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* [cinny-desktop: Linux custom chrome] Whether the native frame is **actually**
|
|
||||||
* stripped right now, as reported by the desktop's `set_custom_chrome`. It can
|
|
||||||
* differ from the setting: on Linux / Wayland the frame only changes when the
|
|
||||||
* app restarts. `null` until the desktop has answered (or on desktop builds
|
|
||||||
* that don't report it).
|
|
||||||
*/
|
|
||||||
export const nativeChromeActiveAtom = atom<boolean | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the Lotus titlebar only when the native frame is really gone (never two
|
|
||||||
* titlebars, never none): the desktop's report, else the setting.
|
|
||||||
*/
|
|
||||||
export const customChromeShownAtom = atom<boolean>(
|
|
||||||
(get) => get(nativeChromeActiveAtom) ?? get(customWindowChromeAtom),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { isSupportedTargetLang } from '../utils/translation/langUtils';
|
import { isSupportedTargetLang } from '../utils/translation/langUtils';
|
||||||
import type { OverlayCorner } from '../utils/callOverlay';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'settings';
|
const STORAGE_KEY = 'settings';
|
||||||
export type DateFormat =
|
export type DateFormat =
|
||||||
@@ -224,6 +223,9 @@ export interface Settings {
|
|||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
|
||||||
showNotifications: 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;
|
isNotificationSounds: boolean;
|
||||||
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
|
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
|
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
@@ -288,12 +290,6 @@ export interface Settings {
|
|||||||
// (a game) has focus, via a non-consuming key poll. Device-local.
|
// (a game) has focus, via a non-consuming key poll. Device-local.
|
||||||
globalCallHotkeys: boolean;
|
globalCallHotkeys: boolean;
|
||||||
|
|
||||||
// [cinny-desktop #25] Desktop (Windows) only: the in-game voice overlay, a
|
|
||||||
// small always-on-top list of who's in the call and who's talking, shown
|
|
||||||
// while Lotus isn't focused. Off by default while in beta. Device-local.
|
|
||||||
callOverlay: boolean;
|
|
||||||
callOverlayCorner: OverlayCorner;
|
|
||||||
|
|
||||||
pauseAnimations: boolean;
|
pauseAnimations: boolean;
|
||||||
|
|
||||||
composerToolbarButtons: ComposerToolbarSettings;
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
@@ -363,6 +359,7 @@ const defaultSettings: Settings = {
|
|||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
|
backgroundPush: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
messageSoundId: 'notification',
|
messageSoundId: 'notification',
|
||||||
inviteSoundId: 'invite',
|
inviteSoundId: 'invite',
|
||||||
@@ -420,9 +417,6 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
globalCallHotkeys: true,
|
globalCallHotkeys: true,
|
||||||
|
|
||||||
callOverlay: false,
|
|
||||||
callOverlayCorner: 'top-left',
|
|
||||||
|
|
||||||
pauseAnimations: false,
|
pauseAnimations: false,
|
||||||
|
|
||||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { buildOverlaySnapshot, inactiveOverlay, OverlayInput } from './callOverlay';
|
|
||||||
|
|
||||||
const names: Record<string, string> = { '@me:x': 'Me', '@a:x': 'Alice', '@b:x': 'Bob' };
|
|
||||||
|
|
||||||
const base = (over: Partial<OverlayInput> = {}): OverlayInput => ({
|
|
||||||
enabled: true,
|
|
||||||
joined: true,
|
|
||||||
corner: 'top-left',
|
|
||||||
myUserId: '@me:x',
|
|
||||||
participants: [
|
|
||||||
{ userId: '@me:x', speaking: false, audioEnabled: true },
|
|
||||||
{ userId: '@a:x', speaking: true, audioEnabled: true },
|
|
||||||
{ userId: '@b:x', speaking: false, audioEnabled: false },
|
|
||||||
],
|
|
||||||
nameOf: (id) => names[id] ?? id,
|
|
||||||
muted: false,
|
|
||||||
deafened: false,
|
|
||||||
pttMode: false,
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
test('inactive when the setting is off or there is no joined call', () => {
|
|
||||||
assert.deepEqual(buildOverlaySnapshot(base({ enabled: false })), inactiveOverlay('top-left'));
|
|
||||||
assert.deepEqual(
|
|
||||||
buildOverlaySnapshot(base({ joined: false, corner: 'bottom-right' })),
|
|
||||||
inactiveOverlay('bottom-right'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('everyone is listed, you first, speakers and muted people marked', () => {
|
|
||||||
const s = buildOverlaySnapshot(base());
|
|
||||||
assert.equal(s.active, true);
|
|
||||||
assert.deepEqual(
|
|
||||||
s.people.map((p) => [p.name, p.speaking, p.muted, p.isSelf]),
|
|
||||||
[
|
|
||||||
['Me', false, false, true],
|
|
||||||
['Alice', true, false, false],
|
|
||||||
['Bob', false, true, false],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('you are listed first even when the fork reports you later', () => {
|
|
||||||
const s = buildOverlaySnapshot(
|
|
||||||
base({
|
|
||||||
participants: [
|
|
||||||
{ userId: '@a:x', speaking: false, audioEnabled: true },
|
|
||||||
{ userId: '@me:x', speaking: true, audioEnabled: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
s.people.map((p) => p.userId),
|
|
||||||
['@me:x', '@a:x'],
|
|
||||||
);
|
|
||||||
assert.equal(s.people[0].speaking, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('several devices of one person become one row', () => {
|
|
||||||
const s = buildOverlaySnapshot(
|
|
||||||
base({
|
|
||||||
participants: [
|
|
||||||
{ userId: '@a:x', speaking: false, audioEnabled: false },
|
|
||||||
{ userId: '@a:x', speaking: true, audioEnabled: true },
|
|
||||||
{ userId: '@b:x', speaking: false, audioEnabled: false },
|
|
||||||
{ userId: '@b:x', speaking: false, audioEnabled: false },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const alice = s.people.find((p) => p.userId === '@a:x');
|
|
||||||
const bob = s.people.find((p) => p.userId === '@b:x');
|
|
||||||
assert.equal(s.people.filter((p) => p.userId === '@a:x').length, 1);
|
|
||||||
assert.deepEqual([alice?.speaking, alice?.muted], [true, false]);
|
|
||||||
assert.deepEqual([bob?.speaking, bob?.muted], [false, true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('your own mute and deafen state', () => {
|
|
||||||
const muted = buildOverlaySnapshot(base({ muted: true }));
|
|
||||||
assert.equal(muted.muted, true);
|
|
||||||
assert.equal(muted.people[0].muted, true);
|
|
||||||
|
|
||||||
const deaf = buildOverlaySnapshot(
|
|
||||||
base({
|
|
||||||
deafened: true,
|
|
||||||
participants: [{ userId: '@me:x', speaking: true, audioEnabled: true }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.equal(deaf.deafened, true);
|
|
||||||
assert.equal(deaf.people[0].muted, true);
|
|
||||||
assert.equal(deaf.people[0].speaking, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('push-to-talk: not shown as muted while the key is up', () => {
|
|
||||||
const s = buildOverlaySnapshot(base({ pttMode: true, muted: true }));
|
|
||||||
assert.equal(s.ptt, true);
|
|
||||||
assert.equal(s.muted, false);
|
|
||||||
assert.equal(s.people[0].muted, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('you are still listed before the fork reports you', () => {
|
|
||||||
const s = buildOverlaySnapshot(base({ participants: [] }));
|
|
||||||
assert.deepEqual(
|
|
||||||
s.people.map((p) => [p.name, p.isSelf]),
|
|
||||||
[['Me', true]],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-user ids from the fork are ignored', () => {
|
|
||||||
const s = buildOverlaySnapshot(
|
|
||||||
base({
|
|
||||||
myUserId: null,
|
|
||||||
participants: [
|
|
||||||
{ userId: 'screen:1', speaking: true, audioEnabled: true },
|
|
||||||
{ userId: '@a:x', speaking: false, audioEnabled: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
s.people.map((p) => p.userId),
|
|
||||||
['@a:x'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* [cinny-desktop #25] In-game voice overlay: the call snapshot the desktop app
|
|
||||||
* draws in its always-on-top overlay window. Pure, so it can be tested without
|
|
||||||
* a call; `useTauriOverlay` feeds it live state and sends the result to the
|
|
||||||
* native `set_overlay_state` command.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type OverlayCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
||||||
|
|
||||||
export const OVERLAY_CORNERS: readonly OverlayCorner[] = [
|
|
||||||
'top-left',
|
|
||||||
'top-right',
|
|
||||||
'bottom-left',
|
|
||||||
'bottom-right',
|
|
||||||
];
|
|
||||||
|
|
||||||
export type OverlayPerson = {
|
|
||||||
userId: string;
|
|
||||||
name: string;
|
|
||||||
speaking: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
isSelf: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Mirrors `OverlayState` in cinny-desktop `src-tauri/src/native/overlay.rs`. */
|
|
||||||
export type OverlaySnapshot = {
|
|
||||||
active: boolean;
|
|
||||||
corner: OverlayCorner;
|
|
||||||
muted: boolean;
|
|
||||||
deafened: boolean;
|
|
||||||
ptt: boolean;
|
|
||||||
people: OverlayPerson[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The subset of an `io.lotus.call_state` participant the overlay needs. */
|
|
||||||
export type OverlayParticipant = {
|
|
||||||
userId: string;
|
|
||||||
speaking: boolean;
|
|
||||||
audioEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OverlayInput = {
|
|
||||||
/** Settings → Calls → In-game overlay. */
|
|
||||||
enabled: boolean;
|
|
||||||
/** In a joined call. */
|
|
||||||
joined: boolean;
|
|
||||||
corner: OverlayCorner;
|
|
||||||
myUserId: string | null;
|
|
||||||
/** Per device, in the fork's order (stable while people stay). */
|
|
||||||
participants: readonly OverlayParticipant[];
|
|
||||||
nameOf: (userId: string) => string;
|
|
||||||
/** Own mic is off (`microphone === false`). */
|
|
||||||
muted: boolean;
|
|
||||||
/** Own call audio is off (`sound === false`). */
|
|
||||||
deafened: boolean;
|
|
||||||
pttMode: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const inactiveOverlay = (corner: OverlayCorner): OverlaySnapshot => ({
|
|
||||||
active: false,
|
|
||||||
corner,
|
|
||||||
muted: false,
|
|
||||||
deafened: false,
|
|
||||||
ptt: false,
|
|
||||||
people: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the snapshot: everyone in the call (you first, then everyone else in
|
|
||||||
* call order), one row per person even with several devices (speaking if any
|
|
||||||
* device is, muted only if every device is), and your own mute / deafen / PTT
|
|
||||||
* state for the header.
|
|
||||||
*
|
|
||||||
* In push-to-talk mode the mic is "off" whenever the key isn't held, so you
|
|
||||||
* are not shown as Muted then: the header says PTT, and your row lights up
|
|
||||||
* while you transmit.
|
|
||||||
*/
|
|
||||||
export function buildOverlaySnapshot(input: OverlayInput): OverlaySnapshot {
|
|
||||||
const { corner } = input;
|
|
||||||
if (!input.enabled || !input.joined) return inactiveOverlay(corner);
|
|
||||||
|
|
||||||
const byUser = new Map<string, { speaking: boolean; muted: boolean }>();
|
|
||||||
input.participants.forEach((p) => {
|
|
||||||
if (!p.userId.startsWith('@')) return;
|
|
||||||
const prev = byUser.get(p.userId);
|
|
||||||
byUser.set(p.userId, {
|
|
||||||
speaking: (prev?.speaking ?? false) || p.speaking,
|
|
||||||
muted: (prev?.muted ?? true) && !p.audioEnabled,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const selfMuted = input.pttMode ? false : input.muted;
|
|
||||||
const people: OverlayPerson[] = [];
|
|
||||||
const me = input.myUserId;
|
|
||||||
if (me) {
|
|
||||||
const mine = byUser.get(me);
|
|
||||||
people.push({
|
|
||||||
userId: me,
|
|
||||||
name: input.nameOf(me),
|
|
||||||
speaking: (mine?.speaking ?? false) && !selfMuted && !input.deafened,
|
|
||||||
muted: selfMuted || input.deafened,
|
|
||||||
isSelf: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
byUser.forEach((state, userId) => {
|
|
||||||
if (userId === me) return;
|
|
||||||
people.push({ userId, name: input.nameOf(userId), ...state, isSelf: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
active: true,
|
|
||||||
corner,
|
|
||||||
muted: selfMuted,
|
|
||||||
deafened: input.deafened,
|
|
||||||
ptt: input.pttMode,
|
|
||||||
people,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Key for skipping sends when nothing visible changed. */
|
|
||||||
export const overlaySnapshotKey = (s: OverlaySnapshot): string => JSON.stringify(s);
|
|
||||||
@@ -28,8 +28,7 @@ export type SyncedSettingsContent = {
|
|||||||
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
|
||||||
'settingsSync',
|
'settingsSync',
|
||||||
'globalCallHotkeys',
|
'globalCallHotkeys',
|
||||||
'callOverlay',
|
'backgroundPush',
|
||||||
'callOverlayCorner',
|
|
||||||
'pageZoom',
|
'pageZoom',
|
||||||
'mediaAutoLoad',
|
'mediaAutoLoad',
|
||||||
'pauseAnimations',
|
'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 { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
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 {};
|
export type {};
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
@@ -142,6 +152,99 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|||||||
setSession(client.id, accessToken, baseUrl, userId);
|
setSession(client.id, accessToken, baseUrl, userId);
|
||||||
cleanupDeadClients();
|
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();
|
event.notification.close();
|
||||||
const data = event.notification.data ?? {};
|
const data = event.notification.data ?? {};
|
||||||
const path = typeof data.path === 'string' ? data.path : undefined;
|
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.
|
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
||||||
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
||||||
@@ -202,12 +309,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
windowClients[0];
|
windowClients[0];
|
||||||
if (client) {
|
if (client) {
|
||||||
await client.focus();
|
await client.focus();
|
||||||
if (path) client.postMessage({ type: 'notificationClick', path });
|
if (path || roomId)
|
||||||
|
client.postMessage({ type: 'notificationClick', path, roomId, eventId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No app window open — open one at the app root (the client routes from
|
// 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
|
// 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) {
|
if (self.clients.openWindow) {
|
||||||
await self.clients.openWindow(self.registration.scope);
|
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