Compare commits

...
Author SHA1 Message Date
jared a937831e6b Merge pull request 'fix(calls): the call-bar output menu uses the call frame's devices (#310)' (#417) from fix/audio-output-frame-ids into lotus
CI / Build & Quality Checks (push) Successful in 1m50s
CI / Trigger Desktop Build (push) Waiting to run
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Waiting to run
CI / Secret scan (gitleaks) (push) Successful in 6s
2026-10-19 01:13:19 -04:00
jared 46da76c21b Merge pull request 'feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)' (#414) from fix/pip-minimise into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 01:00:16 -04:00
jared a20accd446 Merge pull request 'fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)' (#409) from fix/translate-unavailable-copy into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 00:47:26 -04:00
jared 080831d4ea Merge pull request 'fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)' (#394) from fix/status-expiry-account-data into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 00:35:05 -04:00
jared 2815767d47 Merge branch 'lotus' into fix/audio-output-frame-ids
CI / Build & Quality Checks (pull_request) Successful in 1m51s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m56s
2026-10-18 23:40:49 -04:00
jared 7ef24c14e0 Merge branch 'lotus' into fix/pip-minimise
CI / Build & Quality Checks (pull_request) Successful in 2m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m11s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m54s
2026-10-18 23:40:47 -04:00
jared ca284a75e3 Merge branch 'lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m19s
2026-10-18 23:40:46 -04:00
Lotus CI 708e5c2c14 Merge remote-tracking branch 'origin/lotus' into fix/pip-minimise
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-18 23:40:18 -04:00
Lotus CI b07eb35159 Merge remote-tracking branch 'origin/lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
# Conflicts:
#	src/app/utils/translation/langUtils.ts
2026-10-18 23:37:54 -04:00
Lotus CIandClaude Opus 5.5 338b24c43f chore(call): pin element-call-embedded 0.25.0-lotus.24 (frame audio outputs, #310)
CI / Build & Quality Checks (pull_request) Failing after 2m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:26:44 -04:00
Lotus CI 562198860f Merge remote-tracking branch 'origin/lotus' into fix/audio-output-frame-ids 2026-10-18 15:24:58 -04:00
Lotus CIandClaude Opus 5.5 c4c54dad7a feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)
CI / Build & Quality Checks (pull_request) Successful in 2m51s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m22s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:08:36 -04:00
Lotus CIandClaude Opus 5.5 f41de57893 fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m15s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:19:48 -04:00
Lotus CIandClaude Opus 5.5 ef7690135d 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:48:47 -04:00
10 changed files with 210 additions and 18 deletions
+4 -4
View File
@@ -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": {
+1 -1
View File
@@ -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",
+92 -7
View File
@@ -90,6 +90,9 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
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;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
@@ -1003,6 +1006,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<boolean>(() => {
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);
@@ -1103,6 +1128,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 => {
@@ -1421,19 +1466,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',
}}
>
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<div
style={{
display: 'flex',
gap: config.space.S100,
alignItems: 'center',
...(pipMinimized ? { width: '100%' } : {}),
}}
>
{/* Leftmost: the top-right corner is a resize handle. */}
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
aria-pressed={pipMinimized}
onClick={(e) => {
e.stopPropagation();
togglePipMinimized();
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
color: '#fff',
}}
>
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
</IconButton>
{!pipMinimized && document.fullscreenEnabled && (
<IconButton
type="button"
size="300"
@@ -1465,14 +1541,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}}
>
↗ Return to call
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
</div>
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
@@ -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<OutputDevice[]>([]);
const [selected, setSelected] = useState<string | undefined>(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"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 && (
{shown.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{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 (
<MenuItem
key={d.id}
@@ -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';
@@ -2756,7 +2757,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={
<SettingsSelect
+40
View File
@@ -54,6 +54,27 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
export type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
export function parseAudioOutputState(
data: unknown,
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
const list = Array.isArray(available)
? available.filter(
(o): o is AudioOutputOption =>
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;
@@ -217,6 +238,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);
+6
View File
@@ -453,6 +453,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) => {
@@ -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);
});
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
translationUnavailableText,
normalizeLang,
sameLanguage,
languageName,
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
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/);
});
+10
View File
@@ -86,6 +86,16 @@ 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.';
/** Pure: why a manual Translate did nothing (#287). */
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
from