From ef7690135db0080aae5200b8e393443737ab6619 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:48:47 -0400 Subject: [PATCH 01/12] fix(calls): the call-bar output menu uses the call frame's devices (#310) deviceIds are hashed per frame/origin, so the ids the menu got from the chat page's enumerateDevices() were unknown inside the call iframe: the fork fell back to the default output while the menu showed a tick on the chosen device. Handle io.lotus.audio_output_state (also silencing the fork's "host did not ack" log) and build the menu from the fork's list and effective selection; older forks fall back to the old list. Fixes #310 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../call-status/AudioOutputButton.tsx | 29 +++++++++++--- src/app/plugins/call/CallControl.ts | 40 +++++++++++++++++++ src/app/plugins/call/CallEmbed.ts | 6 +++ src/app/plugins/call/audioOutputState.test.ts | 24 +++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/app/plugins/call/audioOutputState.test.ts diff --git a/src/app/features/call-status/AudioOutputButton.tsx b/src/app/features/call-status/AudioOutputButton.tsx index 6f687b6b9..0fee8875d 100644 --- a/src/app/features/call-status/AudioOutputButton.tsx +++ b/src/app/features/call-status/AudioOutputButton.tsx @@ -16,6 +16,7 @@ import { toRem, } from 'folds'; import { CallEmbed } from '../../plugins/call'; +import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { stopPropagation } from '../../utils/keyboard'; @@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab const [devices, setDevices] = useState([]); const [selected, setSelected] = useState(embed.control.audioOutputId); + // [Gitea #310] Prefer the fork's list: its ids are the ones valid inside + // the call iframe. Older forks don't report one; fall back to our own. + const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs); + useEffect(() => { + const onState = () => { + setForkDevices(embed.control.audioOutputs); + setSelected(embed.control.audioOutputId); + }; + onState(); + embed.control.on(AUDIO_OUTPUT_STATE, onState); + return () => { + embed.control.off(AUDIO_OUTPUT_STATE, onState); + }; + }, [embed]); + const refresh = useCallback(async () => { try { const all = await navigator.mediaDevices.enumerateDevices(); @@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab }, []); useEffect(() => { - if (!anchor) return undefined; + if (!anchor || forkDevices) return undefined; refresh(); navigator.mediaDevices.addEventListener('devicechange', refresh); return () => navigator.mediaDevices.removeEventListener('devicechange', refresh); - }, [anchor, refresh]); + }, [anchor, refresh, forkDevices]); + + const shown = forkDevices ?? devices; const choose = (id: string) => { embed.control.setAudioOutput(id); @@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab aria-label="Audio output" > - {devices.length === 0 && ( + {shown.length === 0 && ( No audio outputs found. )} - {devices.map((d) => { - const isSelected = selected ? d.id === selected : d.id === 'default'; + {shown.map((d) => { + const isSelected = + selected !== undefined ? d.id === selected : d.id === 'default' || d.id === ''; return ( + typeof o === 'object' && + o !== null && + typeof (o as AudioOutputOption).id === 'string' && + typeof (o as AudioOutputOption).label === 'string', + ) + : undefined; + return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list }; +} + export class CallControl extends EventEmitter implements CallControlState { private state: CallControlState; @@ -210,6 +231,25 @@ export class CallControl extends EventEmitter implements CallControlState { this.sendAudioOutput(deviceId); } + // [Gitea #310] deviceIds are hashed per frame/origin, so ids from the + // host's own enumerateDevices() mean nothing inside the call iframe. The + // fork reports its outputs (ids valid in the frame) and its effective + // selection; the call bar's menu uses those when present. + private _audioOutputs: AudioOutputOption[] | undefined; + + public get audioOutputs(): AudioOutputOption[] | undefined { + return this._audioOutputs; + } + + /** The fork's `io.lotus.audio_output_state` report. */ + public onAudioOutputState(data: unknown) { + const report = parseAudioOutputState(data); + if (!report) return; + this._audioOutputs = report.available; + if (report.deviceId !== undefined) this._audioOutputId = report.deviceId; + this.emit(AUDIO_OUTPUT_STATE); + } + private sendAudioOutput(deviceId: string): void { if (!this.joined) return; this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 039b024b1..34f54beba 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -439,6 +439,12 @@ export class CallEmbed { this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data); }), ); + // [Gitea #310] The fork's own output list + selection (frame-valid ids). + this.disposables.push( + this.listenAction('io.lotus.audio_output_state', (evt) => { + this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data); + }), + ); // [Gitea #43] PTT / deafen keys pressed while focus is inside the frame. this.disposables.push( this.listenAction('io.lotus.hotkey', (evt) => { diff --git a/src/app/plugins/call/audioOutputState.test.ts b/src/app/plugins/call/audioOutputState.test.ts new file mode 100644 index 000000000..7dd2f059b --- /dev/null +++ b/src/app/plugins/call/audioOutputState.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseAudioOutputState } from './CallControl'; + +test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => { + assert.deepEqual( + parseAudioOutputState({ + deviceId: 'f2', + available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null], + }), + { + deviceId: 'f2', + available: [ + { id: '', label: 'Default' }, + { id: 'f2', label: 'Headset' }, + ], + }, + ); + assert.deepEqual(parseAudioOutputState({ deviceId: null }), { + deviceId: undefined, + available: undefined, + }); + assert.equal(parseAudioOutputState('x'), undefined); +}); From c8576cbb1feb15914a4658f8c9b3e7eb7fdc15ba Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 20:37:21 -0400 Subject: [PATCH 02/12] fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300) "Clear after 1 hour" lived in that device's localStorage, so a status set on a laptop that was then closed stayed on the server until the laptop opened Lotus again. The expiry is now also kept in account data (io.lotus.status_expiry: { status_msg, expires_at }). Every device's StatusExpiryMonitor clears the status when due, but only if it is still the one that was set to expire (otherwise it just forgets the expiry). Fixes #300 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/account/Profile.tsx | 10 +++- src/app/pages/client/ClientNonUIFeatures.tsx | 27 ++++++++--- src/app/utils/statusExpiry.test.ts | 22 +++++++++ src/app/utils/statusExpiry.ts | 46 +++++++++++++++++++ src/types/matrix/accountData.ts | 4 ++ 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/app/utils/statusExpiry.test.ts create mode 100644 src/app/utils/statusExpiry.ts diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 0fa983ce9..ce8728e5d 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -67,6 +67,7 @@ import { makePresetId, normalizeLabel, } from '../../../utils/statusPresets'; +import { writeStatusExpiry } from '../../../utils/statusExpiry'; type ProfileProps = { profile: UserProfile; @@ -508,12 +509,16 @@ function ProfileStatus() { if (msg && delayMs > 0) { // Persist the expiry timestamp; the always-mounted StatusExpiryMonitor // (ClientNonUIFeatures) fires the auto-clear even when Settings is closed. - localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs)); + const expiresAt = Date.now() + delayMs; + localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt)); + // [Gitea #300] …and on every other Lotus device, via account data. + writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined); } else { localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); + writeStatusExpiry(mx, msg).catch(() => undefined); } }, - [saveStatus, userId], + [saveStatus, userId, mx], ); const handleSubmit: FormEventHandler = (evt) => { @@ -553,6 +558,7 @@ function ProfileStatus() { setStatusMsg(''); localStorage.removeItem(STATUS_MSG_KEY(userId)); localStorage.removeItem(STATUS_EXPIRY_KEY(userId)); + writeStatusExpiry(mx, '').catch(() => undefined); // Preserve the user's chosen presence when clearing the status message. mx.setPresence({ presence: presenceStateFromSetting(presenceStatus, hidePresence), diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6bf40cbe2..80fdb2a27 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -91,6 +91,7 @@ import { shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; +import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry'; // Grace period after the initial sync settles before invite notifications arm, so // the async invite-atom population lands first and isn't mistaken for new invites. @@ -427,12 +428,7 @@ function StatusExpiryMonitor() { // permanently and the status stayed set forever. Retry the write (honouring // Retry-After), and only forget the status once the server has taken it. let clearing = false; - const check = () => { - if (clearing) return; - const stored = localStorage.getItem(expiryKey); - if (!stored) return; - const ts = parseInt(stored, 10); - if (!ts || Date.now() < ts) return; + const clearStatus = () => { clearing = true; setPresenceWithRetry(mx, { presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current), @@ -442,12 +438,31 @@ function StatusExpiryMonitor() { noteLocalStatusWrite(); localStorage.removeItem(msgKey); localStorage.removeItem(expiryKey); + writeStatusExpiry(mx, '').catch(() => undefined); }) .catch(() => undefined) .finally(() => { clearing = false; }); }; + const check = () => { + if (clearing) return; + const stored = localStorage.getItem(expiryKey); + const ts = stored ? parseInt(stored, 10) : 0; + if (ts && Date.now() >= ts) { + clearStatus(); + return; + } + // [Gitea #300] An expiry set on another device (account data): clear the + // status if it is still that one, otherwise just forget the expiry. + const action = statusExpiryAction( + readStatusExpiry(mx), + mx.getUser(userId)?.presenceStatusMsg, + Date.now(), + ); + if (action === 'clear') clearStatus(); + else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined); + }; check(); // Poll often enough that a rate-limited retry lands promptly, but not so diff --git a/src/app/utils/statusExpiry.test.ts b/src/app/utils/statusExpiry.test.ts new file mode 100644 index 000000000..0f5af329b --- /dev/null +++ b/src/app/utils/statusExpiry.test.ts @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseStatusExpiry, statusExpiryAction } from './statusExpiry'; + +test('parseStatusExpiry (#300)', () => { + assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), { + statusMsg: '🍔 Lunch', + expiresAt: 5, + }); + assert.equal(parseStatusExpiry({}), undefined); + assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined); + assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined); +}); + +test('statusExpiryAction: clears only the status that was set to expire (#300)', () => { + const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 }; + assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none'); + assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear'); + assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget'); + assert.equal(statusExpiryAction(e, '', 2000), 'forget'); + assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none'); +}); diff --git a/src/app/utils/statusExpiry.ts b/src/app/utils/statusExpiry.ts new file mode 100644 index 000000000..73ad15ed0 --- /dev/null +++ b/src/app/utils/statusExpiry.ts @@ -0,0 +1,46 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { getAccountData } from './room'; + +/** + * [Gitea #300] The status message's auto-clear time, in account data so every + * Lotus device knows it. Before, only the device that set "clear after 1 hour" + * (localStorage) ever cleared it, and only while it was open. + */ +export type StatusExpiry = { statusMsg: string; expiresAt: number }; + +/** Pure: parse `io.lotus.status_expiry` content. */ +export function parseStatusExpiry(content: unknown): StatusExpiry | undefined { + const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined; + if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined; + if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined; + return { statusMsg: c.status_msg, expiresAt: c.expires_at }; +} + +/** + * Pure: what to do with a due expiry. Clear the server status only if it is + * still the one that was set to expire; if it has since changed (e.g. on + * another device), just forget the expiry. + */ +export function statusExpiryAction( + entry: StatusExpiry | undefined, + currentStatusMsg: string | undefined, + now: number, +): 'none' | 'clear' | 'forget' { + if (!entry || now < entry.expiresAt) return 'none'; + return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget'; +} + +export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined => + parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent()); + +/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */ +export async function writeStatusExpiry( + mx: MatrixClient, + statusMsg: string, + expiresAt?: number, +): Promise { + const content = + statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {}; + await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never); +} diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 472d0bd40..eb9c6a286 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,10 @@ export enum AccountDataEvent { // [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync). LotusSettings = 'io.lotus.settings', + // [Gitea #300] When the status message auto-clears: { status_msg, expires_at }, + // so any Lotus device can clear it (not only the one that set it). + LotusStatusExpiry = 'io.lotus.status_expiry', + SecretStorageDefaultKey = 'm.secret_storage.default_key', CrossSigningMaster = 'm.cross_signing.master', From f41de5789388eeb18dbffa6c078c2db1db87e26e Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 00:19:48 -0400 Subject: [PATCH 03/12] fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349) Where the on-device Translator/LanguageDetector APIs are missing, Settings said to use Chrome/Edge "or the Lotus desktop app", but the Linux and macOS apps (WebKit) lack them too, and showed the advice to people already in the app. Word it by platform. (The per-message Translate action was already hidden where unsupported.) Fixes #349 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/general/General.tsx | 3 ++- src/app/utils/translation/langUtils.test.ts | 7 +++++++ src/app/utils/translation/langUtils.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 4103ebfaa..b5090243a 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -108,6 +108,7 @@ import { TRANSLATE_TARGET_LANGUAGES, normalizeLang, isSupportedTargetLang, + translationUnavailableText, } from '../../../utils/translation/langUtils'; import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine'; import { SequenceCardStyle } from '../styles.css'; @@ -2760,7 +2761,7 @@ function Messages() { description={ translationSupported ? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.' - : 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.' + : translationUnavailableText(isTauriEnv()) } after={ { assert.equal(new Set(codes).size, codes.length); assert.ok(codes.includes('en')); }); + +test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => { + assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/); + assert.match(translationUnavailableText(true), /this version of the desktop app/); + assert.match(translationUnavailableText(false), /this browser/); +}); diff --git a/src/app/utils/translation/langUtils.ts b/src/app/utils/translation/langUtils.ts index c0738e109..6b4effedb 100644 --- a/src/app/utils/translation/langUtils.ts +++ b/src/app/utils/translation/langUtils.ts @@ -85,3 +85,13 @@ export function languageName(code: string, uiLocale = 'en'): string { export function isSupportedTargetLang(code: string): boolean { return CURATED_NAME.has(normalizeLang(code)); } + +/** + * [Gitea #349] Why translation is unavailable, by platform. The desktop app + * on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send + * people to "the Lotus desktop app" they're already using. + */ +export const translationUnavailableText = (desktopApp: boolean): string => + desktopApp + ? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.' + : 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.'; From c4c54dad7afae0cdb8a2e84fdc77f37847367287 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 01:08:36 -0400 Subject: [PATCH 04/12] feat(calls): minimise the call PiP to a small bar; phones start minimised (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PiP had no way to get out of the way short of dragging it. Add a minimise / expand button: minimised it is a 220×40 bar with the call room's name (still draggable, tap to return to the call). The choice is remembered; with no saved choice, phones start minimised. Fixes #323 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 99 ++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 17e054da5..119e8215d 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -89,6 +89,9 @@ import { zIndices } from '../styles/zIndex'; const PIP_MIN_W = 200; const PIP_MIN_H = 112; +/** [Gitea #323] Minimised PiP bar size. */ +const PIP_MINI_W = 220; +const PIP_MINI_H = 40; type Corner = 'se' | 'sw' | 'ne' | 'nw'; @@ -981,6 +984,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { ); const [pipIsFullscreen, setPipIsFullscreen] = useState(false); + // [Gitea #323] Minimised PiP: a small draggable bar (room name + expand) + // instead of the video. Remembered; phones start minimised. + const [pipMinimized, setPipMinimized] = useState(() => { + try { + const v = localStorage.getItem('pip-minimized'); + if (v !== null) return v === '1'; + } catch { + /* storage unavailable */ + } + return window.innerWidth < 600; + }); + const pipFullSizeRef = React.useRef({ width: 280, height: 158 }); + const togglePipMinimized = useCallback(() => { + setPipMinimized((m) => { + try { + localStorage.setItem('pip-minimized', m ? '0' : '1'); + } catch { + /* storage unavailable */ + } + return !m; + }); + }, []); useEffect(() => { const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); @@ -1078,6 +1103,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { } }, [pipMode, callVisible]); + // [Gitea #323] Apply the minimised / full PiP size (after the entry effect + // below has positioned and sized it). Keeps the bar on screen. + React.useEffect(() => { + const el = callEmbedRef.current; + if (!el || !pipMode) return; + if (pipMinimized) { + if (el.offsetHeight > PIP_MINI_H) { + pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight }; + } + el.style.width = `${PIP_MINI_W}px`; + el.style.height = `${PIP_MINI_H}px`; + } else { + el.style.width = `${pipFullSizeRef.current.width}px`; + el.style.height = `${pipFullSizeRef.current.height}px`; + } + const rect = el.getBoundingClientRect(); + el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`; + el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`; + }, [pipMode, pipMinimized, callEmbedRef]); + React.useEffect(() => { if (!pipMode) return; const onPipWindowResize = (): void => { @@ -1396,19 +1441,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { position: 'absolute', inset: 0, zIndex: 1, - background: 'transparent', + // Minimised: cover the frame so only the bar shows. + background: pipMinimized ? color.Surface.Container : 'transparent', cursor: 'grab', // [Gitea #43] The call's "Share your screen?" prompt is drawn // inside the frame: let the click reach its Share button. pointerEvents: pipPromptOpen ? 'none' : undefined, display: 'flex', - alignItems: 'flex-start', justifyContent: 'flex-end', + alignItems: pipMinimized ? 'center' : 'flex-start', padding: '6px', }} > -
- {document.fullscreenEnabled && ( +
+ {/* Leftmost: the top-right corner is a resize handle. */} + { + e.stopPropagation(); + togglePipMinimized(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + style={{ + background: 'rgba(0,0,0,0.65)', + backdropFilter: 'blur(4px)', + color: '#fff', + }} + > + + + {!pipMinimized && document.fullscreenEnabled && ( - ↗ Return to call + {pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
- - {(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => { + {!pipMinimized && } + {(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => { const s = corner.includes('s'); const e2 = corner.includes('e'); const dots = [ From 338b24c43fa70b69c4c7797a380cbb66ced4b060 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 15:26:44 -0400 Subject: [PATCH 05/12] chore(call): pin element-call-embedded 0.25.0-lotus.24 (frame audio outputs, #310) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67205ea29..a89d1508e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.23", + "@lotusguild/element-call-embedded": "0.25.0-lotus.24", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", @@ -2695,9 +2695,9 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, "node_modules/@lotusguild/element-call-embedded": { - "version": "0.25.0-lotus.23", - "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz", - "integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==", + "version": "0.25.0-lotus.24", + "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz", + "integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==", "dev": true }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { diff --git a/package.json b/package.json index 4bfab9a41..e14e9e2c5 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "@lotusguild/element-call-embedded": "0.25.0-lotus.23", + "@lotusguild/element-call-embedded": "0.25.0-lotus.24", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", From bc7458ac99d7c02574436f8e50abe305a51cff7a Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 18 Oct 2026 23:14:50 -0400 Subject: [PATCH 06/12] ci: re-seed the bundle budget after the October audit fix batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry chunk on lotus (d7bc6d94) is 360238 bytes gzip, past the 358317 budget seeded in September, so every PR now fails Build & Quality Checks. The growth is ~40 merged fixes (call-state, status, notification, search and settings changes), not one regression. Re-seed per the file's rule: measured sizes +10% (total 1666894 → 1833583, largest 360238 → 396262). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- scripts/bundle-budget.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/bundle-budget.json b/scripts/bundle-budget.json index 4c8289c91..6bf27aceb 100644 --- a/scripts/bundle-budget.json +++ b/scripts/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.", - "totalGzipBytes": 1731120, - "largestChunkGzipBytes": 358317 + "$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin. Re-seeded 2026-10-18 from lotus d7bc6d94 (total 1666894, largest 360238 bytes gzip) after the October audit fix batch (~40 PRs) grew the entry chunk past the old budget.", + "totalGzipBytes": 1833583, + "largestChunkGzipBytes": 396262 } From 8b6464c8b9a91a7f7ef01ebbf24a671337ecb511 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 19 Oct 2026 00:00:19 -0400 Subject: [PATCH 07/12] =?UTF-8?q?perf:=20load=20the=20Settings=20dialog=20?= =?UTF-8?q?on=20demand;=20entry=20chunk=20351.8=20=E2=86=92=20277.1=20kB?= =?UTF-8?q?=20gzip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings (every page, pickers, the denoise tester) was in the entry chunk though it only renders when opened. Load it lazily behind a focusable spinner (it opens inside a FocusTrap), and warm the chunk when the app goes idle. SettingsPages and the status localStorage keys move to tiny modules so callers don't pull the dialog back in. sanitize-html (+postcss, htmlparser2) and chroma-js get their own vendor chunks; with Settings gone the chunk graph had dropped them into the entry chunk. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/Settings.tsx | 11 +---- src/app/features/settings/SettingsLazy.tsx | 43 +++++++++++++++++++ src/app/features/settings/account/Profile.tsx | 4 +- src/app/features/settings/index.ts | 3 +- src/app/features/settings/pages.ts | 11 +++++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- src/app/utils/statusKeys.ts | 3 ++ vite.config.js | 19 ++++++++ 8 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 src/app/features/settings/SettingsLazy.tsx create mode 100644 src/app/features/settings/pages.ts create mode 100644 src/app/utils/statusKeys.ts diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 4349a41c2..751e54e0e 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -33,16 +33,9 @@ import { About } from './about'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; +import { SettingsPages } from './pages'; -export enum SettingsPages { - GeneralPage, - AccountPage, - NotificationPage, - DevicesPage, - EmojisStickersPage, - DeveloperToolsPage, - AboutPage, -} +export { SettingsPages }; type SettingsMenuItem = { page: SettingsPages; diff --git a/src/app/features/settings/SettingsLazy.tsx b/src/app/features/settings/SettingsLazy.tsx new file mode 100644 index 000000000..8764d2bdb --- /dev/null +++ b/src/app/features/settings/SettingsLazy.tsx @@ -0,0 +1,43 @@ +import React, { lazy, Suspense } from 'react'; +import { Box, Spinner, config } from 'folds'; +import type { SettingsPages } from './pages'; + +// Settings is a large, rarely-open dialog (all its pages, pickers, the +// denoise tester…). Load it on first open instead of in the entry chunk, and +// warm the chunk once the app is idle so opening it rarely waits. +const loadSettings = () => import('./Settings'); +const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings }))); +if (typeof window !== 'undefined') { + const warm = () => { + loadSettings().catch(() => undefined); + }; + if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 }); + else setTimeout(warm, 5_000); +} + +type SettingsProps = { + initialPage?: SettingsPages; + requestClose: () => void; +}; + +export function Settings(props: SettingsProps) { + return ( + + +
+ } + > + + + ); +} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 0fa983ce9..6a32f8b44 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -67,6 +67,7 @@ import { makePresetId, normalizeLabel, } from '../../../utils/statusPresets'; +import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys'; type ProfileProps = { profile: UserProfile; @@ -365,8 +366,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { ); } -export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; -export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; +export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY }; const CLEAR_AFTER_OPTIONS = [ { label: 'Never', value: '0' }, diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts index 90e269730..3b9385629 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1 +1,2 @@ -export * from './Settings'; +export { SettingsPages } from './pages'; +export { Settings } from './SettingsLazy'; diff --git a/src/app/features/settings/pages.ts b/src/app/features/settings/pages.ts new file mode 100644 index 000000000..94d9db903 --- /dev/null +++ b/src/app/features/settings/pages.ts @@ -0,0 +1,11 @@ +/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a + * page without pulling the dialog into the entry chunk. */ +export enum SettingsPages { + GeneralPage, + AccountPage, + NotificationPage, + DevicesPage, + EmojisStickersPage, + DeveloperToolsPage, + AboutPage, +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index eab713f32..519df79df 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -63,7 +63,7 @@ import { migrateLocalMuteTimers, unmuteRoom, } from '../../features/room-nav/muteTimers'; -import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; +import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys'; import { noteLocalStatusWrite } from '../../utils/ownStatusSync'; import { setPresenceWithRetry } from '../../utils/presenceWrite'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; diff --git a/src/app/utils/statusKeys.ts b/src/app/utils/statusKeys.ts new file mode 100644 index 000000000..f31cd00ea --- /dev/null +++ b/src/app/utils/statusKeys.ts @@ -0,0 +1,3 @@ +/** localStorage keys for this device's copy of the status message and its expiry. */ +export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`; +export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`; diff --git a/vite.config.js b/vite.config.js index 0d6b2ef7b..1aaf5c5f6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -251,6 +251,25 @@ const vendorChunks = (id) => { if (id.includes('node_modules/immer')) return 'immer'; if (id.includes('node_modules/folds')) return 'folds'; if (id.includes('node_modules/emojibase')) return 'emojibase'; + // HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour + // maths are needed at startup but are big, stable libraries: keep them in + // their own cacheable chunks instead of wherever the chunk graph drops them + // (once Settings became lazy they landed in the entry chunk). + if ( + id.includes('node_modules/sanitize-html') || + id.includes('node_modules/postcss') || + id.includes('node_modules/htmlparser2') || + id.includes('node_modules/domhandler') || + id.includes('node_modules/domutils') || + id.includes('node_modules/dom-serializer') || + id.includes('node_modules/entities') || + id.includes('node_modules/parse-srcset') || + id.includes('node_modules/nanoid') || + id.includes('node_modules/picocolors') || + id.includes('node_modules/source-map-js') + ) + return 'sanitize-html'; + if (id.includes('node_modules/chroma-js')) return 'chroma'; }; export default defineConfig({ From 2ac764cd45310d4a3625759126e09a3564b3be46 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 19 Oct 2026 00:28:39 -0400 Subject: [PATCH 08/12] fix(profile): private notes say they are stored unencrypted on the homeserver (#329) "Notes only visible to you" overstated it: notes live in plain account data that homeserver admins can read. Say so under the note box (and link it as the box's description): "Only you can see this in Lotus Chat. It's stored on your homeserver and isn't end-to-end encrypted." Fixes #329 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/user-profile/UserRoomProfile.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 21163f023..1d3d7e0e5 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {