Merge remote-tracking branch 'origin/lotus' into perf/lazy-settings
CI / Build & Quality Checks (pull_request) Successful in 2m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m52s
CI / Build & Quality Checks (pull_request) Successful in 2m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m52s
# Conflicts: # src/app/features/settings/account/Profile.tsx
This commit is contained in:
@@ -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.",
|
"$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": 1731120,
|
"totalGzipBytes": 1833583,
|
||||||
"largestChunkGzipBytes": 358317
|
"largestChunkGzipBytes": 396262
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
|||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
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. */
|
/** Default PiP top: just below the 64px-ish room header. */
|
||||||
const PIP_DEFAULT_TOP = 72;
|
const PIP_DEFAULT_TOP = 72;
|
||||||
|
|
||||||
@@ -1003,6 +1006,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
@@ -1103,6 +1128,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [pipMode, callVisible]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!pipMode) return;
|
if (!pipMode) return;
|
||||||
const onPipWindowResize = (): void => {
|
const onPipWindowResize = (): void => {
|
||||||
@@ -1421,19 +1466,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
background: 'transparent',
|
// Minimised: cover the frame so only the bar shows.
|
||||||
|
background: pipMinimized ? color.Surface.Container : 'transparent',
|
||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||||
// inside the frame: let the click reach its Share button.
|
// inside the frame: let the click reach its Share button.
|
||||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: pipMinimized ? 'center' : 'flex-start',
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
<div
|
||||||
{document.fullscreenEnabled && (
|
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
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1465,14 +1541,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
pointerEvents: 'none',
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PipMuteOverlay callEmbed={callEmbed} />
|
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
||||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
||||||
const s = corner.includes('s');
|
const s = corner.includes('s');
|
||||||
const e2 = corner.includes('e');
|
const e2 = corner.includes('e');
|
||||||
const dots = [
|
const dots = [
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
normalizeLabel,
|
normalizeLabel,
|
||||||
} from '../../../utils/statusPresets';
|
} from '../../../utils/statusPresets';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
||||||
|
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -508,12 +509,16 @@ function ProfileStatus() {
|
|||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
// (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 {
|
} else {
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, msg).catch(() => undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveStatus, userId],
|
[saveStatus, userId, mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -553,6 +558,7 @@ function ProfileStatus() {
|
|||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
// Preserve the user's chosen presence when clearing the status message.
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({
|
mx.setPresence({
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import {
|
|||||||
TRANSLATE_TARGET_LANGUAGES,
|
TRANSLATE_TARGET_LANGUAGES,
|
||||||
normalizeLang,
|
normalizeLang,
|
||||||
isSupportedTargetLang,
|
isSupportedTargetLang,
|
||||||
|
translationUnavailableText,
|
||||||
} from '../../../utils/translation/langUtils';
|
} from '../../../utils/translation/langUtils';
|
||||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
@@ -2756,7 +2757,7 @@ function Messages() {
|
|||||||
description={
|
description={
|
||||||
translationSupported
|
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.'
|
? '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={
|
after={
|
||||||
<SettingsSelect
|
<SettingsSelect
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import {
|
|||||||
shouldNotifyThreadReply,
|
shouldNotifyThreadReply,
|
||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
|
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// Grace period after the initial sync settles before invite notifications arm, so
|
||||||
@@ -436,12 +437,7 @@ function StatusExpiryMonitor() {
|
|||||||
// permanently and the status stayed set forever. Retry the write (honouring
|
// permanently and the status stayed set forever. Retry the write (honouring
|
||||||
// Retry-After), and only forget the status once the server has taken it.
|
// Retry-After), and only forget the status once the server has taken it.
|
||||||
let clearing = false;
|
let clearing = false;
|
||||||
const check = () => {
|
const clearStatus = () => {
|
||||||
if (clearing) return;
|
|
||||||
const stored = localStorage.getItem(expiryKey);
|
|
||||||
if (!stored) return;
|
|
||||||
const ts = parseInt(stored, 10);
|
|
||||||
if (!ts || Date.now() < ts) return;
|
|
||||||
clearing = true;
|
clearing = true;
|
||||||
setPresenceWithRetry(mx, {
|
setPresenceWithRetry(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||||
@@ -451,12 +447,31 @@ function StatusExpiryMonitor() {
|
|||||||
noteLocalStatusWrite();
|
noteLocalStatusWrite();
|
||||||
localStorage.removeItem(msgKey);
|
localStorage.removeItem(msgKey);
|
||||||
localStorage.removeItem(expiryKey);
|
localStorage.removeItem(expiryKey);
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
clearing = false;
|
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();
|
check();
|
||||||
// Poll often enough that a rate-limited retry lands promptly, but not so
|
// Poll often enough that a rate-limited retry lands promptly, but not so
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
const content =
|
||||||
|
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
|
||||||
|
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
translationUnavailableText,
|
||||||
normalizeLang,
|
normalizeLang,
|
||||||
sameLanguage,
|
sameLanguage,
|
||||||
languageName,
|
languageName,
|
||||||
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
|
|||||||
assert.equal(new Set(codes).size, codes.length);
|
assert.equal(new Set(codes).size, codes.length);
|
||||||
assert.ok(codes.includes('en'));
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ export function isSupportedTargetLang(code: string): boolean {
|
|||||||
return CURATED_NAME.has(normalizeLang(code));
|
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). */
|
/** Pure: why a manual Translate did nothing (#287). */
|
||||||
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||||||
from
|
from
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export enum AccountDataEvent {
|
|||||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||||
LotusSettings = 'io.lotus.settings',
|
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',
|
||||||
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
||||||
// device can lift a mute set on another.
|
// device can lift a mute set on another.
|
||||||
LotusMuteTimers = 'io.lotus.mute_timers',
|
LotusMuteTimers = 'io.lotus.mute_timers',
|
||||||
|
|||||||
Reference in New Issue
Block a user