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 } diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 6fa7b5b7d..12c1f4b11 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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(() => { + 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', }} > -
- {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 = [ diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 6a32f8b44..a1a8d6eae 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -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 = (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/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 6b2c54c6e..7f25ad901 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'; @@ -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={ { - 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 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/app/utils/translation/langUtils.test.ts b/src/app/utils/translation/langUtils.test.ts index c58de1454..a618170da 100644 --- a/src/app/utils/translation/langUtils.test.ts +++ b/src/app/utils/translation/langUtils.test.ts @@ -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/); +}); diff --git a/src/app/utils/translation/langUtils.ts b/src/app/utils/translation/langUtils.ts index 9ae545f37..e260aff95 100644 --- a/src/app/utils/translation/langUtils.ts +++ b/src/app/utils/translation/langUtils.ts @@ -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 diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 74e8dee4a..72f1d0854 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -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',