Compare commits

..
Author SHA1 Message Date
jaredandClaude Opus 5.5 38871d3e56 fix(desktop): show the Lotus titlebar only when the native frame is really gone
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Trigger Desktop Build (pull_request) Blocked by required conditions
CI / Docker image build & smoke test (pull_request) Blocked by required conditions
CI / Playwright smoke (e2e) (pull_request) Blocked by required conditions
CI / Secret scan (gitleaks) (pull_request) Successful in 23s
CI / Build & Quality Checks (pull_request) Successful in 6m38s
On Linux / Wayland the desktop can't strip the window frame at runtime,
so turning on custom window chrome drew the Lotus titlebar inside KDE's
frame (two sets of window buttons). The desktop's `set_custom_chrome`
now answers whether the frame is stripped right now; the titlebar and
layout follow that answer (falling back to the setting for older
desktop builds), so there's never two titlebars or none. The setting
says it applies on next start on Linux / Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-09 21:42:27 -05:00
jared 30b5a7d6af Merge pull request 'feat(calls): in-game voice overlay for the desktop app (cinny-desktop #25)' (#488) from feat/voice-overlay into lotus
CI / Secret scan (gitleaks) (push) Successful in 1m6s
CI / Build & Quality Checks (push) Successful in 4m37s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) In progress
2026-11-09 21:31:32 -05:00
jaredandClaude Opus 5.5 01254291e5 feat(calls): in-game voice overlay for the desktop app (cinny-desktop #25)
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 23s
CI / Build & Quality Checks (pull_request) Successful in 5m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 23m21s
Settings → Calls → "In-game overlay (beta)", desktop only, off by
default, device-local. While a call is joined it sends the call
snapshot to the desktop's new `set_overlay_state` command, which shows
a small always-on-top, click-through list while Lotus isn't focused.

- utils/callOverlay: pure snapshot builder. Everyone in the call, you
  first, one row per person across devices (speaking if any device is,
  muted only if all are), your own mute/deafen/PTT. In PTT mode you're
  not shown as Muted while the key is up.
- useTauriOverlay: live io.lotus.call_state participants plus control
  state, coalesced to at most one send per 100 ms (latest always
  lands), unchanged snapshots skipped, cleared on unmount.
- Overlay position setting (four corners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-11-09 17:25:36 -05:00
20 changed files with 443 additions and 1005 deletions
@@ -7,6 +7,7 @@ import { useTauriNetwork } from '../hooks/useTauriNetwork';
import { useTauriToastActions } from '../hooks/useTauriToastActions';
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
import { useTauriDnd } from '../hooks/useTauriDnd';
import { useTauriOverlay } from '../hooks/useTauriOverlay';
/**
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
@@ -25,5 +26,6 @@ export function TauriDesktopFeatures(): null {
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
useTauriOverlay(); // cinny-desktop #25 in-game voice overlay
return null;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { MouseEvent, ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { Text } from 'folds';
import { customWindowChromeAtom } from '../../state/customWindowChrome';
import { customChromeShownAtom } from '../../state/customWindowChrome';
import { invokeTauri, isTauri } from '../../hooks/useTauri';
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.
*/
export function TitleBar() {
const enabled = useAtomValue(customWindowChromeAtom);
const enabled = useAtomValue(customChromeShownAtom);
if (!isTauri() || !enabled) return null;
+29 -1
View File
@@ -134,6 +134,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import type { OverlayCorner } from '../../../utils/callOverlay';
import { isBindableCallKey } from '../../../utils/callKeybind';
import { StorageUsage } from './StorageUsage';
import { hapticsSupported } from '../../../utils/haptics';
@@ -159,7 +160,7 @@ function DesktopChromeSetting() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom window chrome (beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
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."
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
/>
</SequenceCard>
@@ -1894,6 +1895,8 @@ export function Calls() {
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
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 [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
@@ -2345,6 +2348,31 @@ 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() && (
<SettingTile
title="Haptic feedback"
@@ -20,8 +20,6 @@ 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();
@@ -199,9 +197,6 @@ 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',
@@ -252,19 +247,6 @@ 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}
-10
View File
@@ -42,16 +42,6 @@ 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.
+101
View File
@@ -0,0 +1,101 @@
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) });
}
},
[],
);
}
+23 -6
View File
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { invokeTauri, isTauri } from './useTauri';
import { useAtomValue, useSetAtom } from 'jotai';
import { customWindowChromeAtom, nativeChromeActiveAtom } from '../state/customWindowChrome';
import { isTauri, tauriInvoke } from './useTauri';
/**
* P5-47 — drive the native window frame from the `customWindowChromeAtom`.
@@ -11,12 +11,29 @@ import { invokeTauri, isTauri } from './useTauri';
* 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
* 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 {
const enabled = useAtomValue(customWindowChromeAtom);
const setNativeActive = useSetAtom(nativeChromeActiveAtom);
useEffect(() => {
if (!isTauri()) return;
invokeTauri('set_custom_chrome', { enabled });
}, [enabled]);
const invoke = isTauri() ? tauriInvoke() : undefined;
if (!invoke) return undefined;
let cancelled = false;
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]);
}
-154
View File
@@ -1,154 +0,0 @@
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]);
}
+2 -2
View File
@@ -31,7 +31,7 @@ import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { customChromeShownAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
@@ -110,7 +110,7 @@ function TauriEffects() {
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
const customChrome = useAtomValue(customChromeShownAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
+7 -34
View File
@@ -79,7 +79,6 @@ 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';
@@ -615,36 +614,17 @@ function MessageNotifications() {
if (!('serviceWorker' in navigator)) return undefined;
const onMessage = (event: MessageEvent) => {
const data = event.data ?? {};
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 (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 (!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, 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);
}, []);
}, [navigate]);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
@@ -830,12 +810,6 @@ 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;
@@ -1191,7 +1165,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<StatusExpiryMonitor />
<InviteNotifications />
<MessageNotifications />
<WebPushFeature />
<ReminderMonitor />
<RetentionSweeper />
<TauriUpdateFeature />
+18
View File
@@ -1,3 +1,4 @@
import { atom } from 'jotai';
import {
atomWithLocalStorage,
getLocalStorageItem,
@@ -20,3 +21,20 @@ export const customWindowChromeAtom = atomWithLocalStorage<boolean>(
(key) => getLocalStorageItem<boolean>(key, false),
(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),
);
+10 -4
View File
@@ -1,5 +1,6 @@
import { atom } from 'jotai';
import { isSupportedTargetLang } from '../utils/translation/langUtils';
import type { OverlayCorner } from '../utils/callOverlay';
const STORAGE_KEY = 'settings';
export type DateFormat =
@@ -223,9 +224,6 @@ 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';
@@ -290,6 +288,12 @@ export interface Settings {
// (a game) has focus, via a non-consuming key poll. Device-local.
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;
composerToolbarButtons: ComposerToolbarSettings;
@@ -359,7 +363,6 @@ const defaultSettings: Settings = {
legacyUsernameColor: false,
showNotifications: true,
backgroundPush: true,
isNotificationSounds: true,
messageSoundId: 'notification',
inviteSoundId: 'invite',
@@ -417,6 +420,9 @@ const defaultSettings: Settings = {
globalCallHotkeys: true,
callOverlay: false,
callOverlayCorner: 'top-left',
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
+124
View File
@@ -0,0 +1,124 @@
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'],
);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* [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);
+2 -1
View File
@@ -28,7 +28,8 @@ export type SyncedSettingsContent = {
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
'settingsSync',
'globalCallHotkeys',
'backgroundPush',
'callOverlay',
'callOverlayCorner',
'pageZoom',
'mediaAutoLoad',
'pauseAnimations',
-128
View File
@@ -1,128 +0,0 @@
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);
});
-174
View File
@@ -1,174 +0,0 @@
/**
* [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;
}
+2 -112
View File
@@ -3,16 +3,6 @@
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 & {
@@ -152,99 +142,6 @@ 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);
})(),
);
});
/**
@@ -257,10 +154,6 @@ 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') {
@@ -309,15 +202,12 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
windowClients[0];
if (client) {
await client.focus();
if (path || roomId)
client.postMessage({ type: 'notificationClick', path, roomId, eventId });
if (path) client.postMessage({ type: 'notificationClick', path });
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; the new window asks for the target
// (`getPendingClick`) once it can route.
if (path || roomId) pendingClick = { path, roomId, eventId, at: Date.now() };
// to deep-link the URL here.
if (self.clients.openWindow) {
await self.clients.openWindow(self.registration.scope);
}
-140
View File
@@ -1,140 +0,0 @@
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
View File
@@ -1,219 +0,0 @@
/**
* [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;