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

# Conflicts:
#	src/app/features/settings/account/Profile.tsx
This commit is contained in:
Lotus CI
2026-10-19 01:07:39 -04:00
10 changed files with 214 additions and 19 deletions
+3 -3
View File
@@ -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
}
+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 = [
@@ -68,6 +68,7 @@ import {
normalizeLabel,
} from '../../../utils/statusPresets';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
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<HTMLFormElement> = (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),
@@ -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
+21 -6
View File
@@ -92,6 +92,7 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// 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
// 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),
@@ -451,12 +447,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
+22
View File
@@ -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');
});
+46
View File
@@ -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 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
+3
View File
@@ -26,6 +26,9 @@ 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',
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
// device can lift a mute set on another.
LotusMuteTimers = 'io.lotus.mute_timers',