Compare commits

..
Author SHA1 Message Date
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 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 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
9 changed files with 29 additions and 100 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@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.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==",
"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==",
"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.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -16,7 +16,6 @@ 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';
@@ -40,21 +39,6 @@ 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();
@@ -69,13 +53,11 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
}, []);
useEffect(() => {
if (!anchor || forkDevices) return undefined;
if (!anchor) return undefined;
refresh();
navigator.mediaDevices.addEventListener('devicechange', refresh);
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
}, [anchor, refresh, forkDevices]);
const shown = forkDevices ?? devices;
}, [anchor, refresh]);
const choose = (id: string) => {
embed.control.setAudioOutput(id);
@@ -108,14 +90,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
aria-label="Audio output"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{shown.length === 0 && (
{devices.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{shown.map((d) => {
const isSelected =
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
{devices.map((d) => {
const isSelected = selected ? d.id === selected : d.id === 'default';
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,27 +54,6 @@ 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;
@@ -238,25 +217,6 @@ 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,12 +453,6 @@ 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) => {
@@ -1,24 +0,0 @@
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