Compare commits

..
Author SHA1 Message Date
jared 2a9b7f68b7 Merge branch 'lotus' into fix/call-disconnected-bar
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m15s
2026-10-18 23:40:50 -04:00
Lotus CIandClaude Opus 5.5 6648ca928d chore(call): pin element-call-embedded 0.25.0-lotus.25 (call_error / reconnect, #318)
CI / Build & Quality Checks (pull_request) Failing after 2m51s
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 11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:42:35 -04:00
Lotus CI 258ffc88be Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-18 15:39:59 -04:00
Lotus CIandClaude Opus 5.5 fe70616412 fix(calls): the call bar shows "Call disconnected" and reconnects when sync is back (#318)
After a homeserver stall Element Call showed "Connection lost" while the
call bar still looked joined. Handle the fork's io.lotus.call_error:
show "Call disconnected" with a Reconnect button (io.lotus.reconnect),
and reconnect automatically once sync is back.

Fixes #318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:55:29 -04:00
23 changed files with 173 additions and 301 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.25",
"@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.25",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.25/element-call-embedded-0.25.0-lotus.25.tgz",
"integrity": "sha512-Vvw6B+QoG0/qFJcBzhlAbE+vnk83DikpeeF2xbygpoHfmG9r1H/mS47V91jAMH2vmNbGiALwFlLbJjEET43A2g==",
"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.25",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+9 -92
View File
@@ -86,13 +86,11 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
import { useReconnectWhenSynced } from '../hooks/useCallError';
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;
@@ -817,6 +815,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
useReconnectWhenSynced(embed.control);
useDenoiseNotices(embed);
// [Gitea #43] The in-frame screenshare button follows the same room policy
// as the host bar's (hidden where the server would refuse the share).
@@ -1006,28 +1005,6 @@ 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);
@@ -1128,26 +1105,6 @@ 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 => {
@@ -1466,50 +1423,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
position: 'absolute',
inset: 0,
zIndex: 1,
// Minimised: cover the frame so only the bar shows.
background: pipMinimized ? color.Surface.Container : 'transparent',
background: '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',
...(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 && (
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<IconButton
type="button"
size="300"
@@ -1541,23 +1467,14 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}}
>
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
↗ Return to call
</div>
</div>
</div>
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
+35 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { Box, Button, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
import { useCallError } from '../../hooks/useCallError';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -42,6 +43,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const clockSkew = useAtomValue(clockSkewAtom);
const compact = screenSize === ScreenSize.Mobile;
// [Gitea #318] The call dropped ("Connection lost" in the frame).
const callError = useCallError(callEmbed.control);
const memberVisible = callJoined && callMembers.length > 0;
@@ -65,6 +68,25 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{!compact && (
<>
<CallRoomName room={room} />
{callError && (
<>
<StatusDivider />
<Text size="T200" truncate role="alert" style={{ color: color.Critical.Main }}>
Call disconnected
</Text>
{callError.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</>
)}
{clockSkew.warning && clockSkew.skewMs !== null && (
<>
<StatusDivider />
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<Box grow="Yes" alignItems="Center" gap="200">
<CallRoomName room={room} />
{callError?.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</Box>
)}
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
+9 -2
View File
@@ -33,9 +33,16 @@ import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { SettingsPages } from './pages';
export { SettingsPages };
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
type SettingsMenuItem = {
page: SettingsPages;
@@ -1,43 +0,0 @@
import React, { lazy, Suspense } from 'react';
import { Box, Spinner, config } from 'folds';
import type { SettingsPages } from './pages';
// Settings is a large, rarely-open dialog (all its pages, pickers, the
// denoise tester…). Load it on first open instead of in the entry chunk, and
// warm the chunk once the app is idle so opening it rarely waits.
const loadSettings = () => import('./Settings');
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
if (typeof window !== 'undefined') {
const warm = () => {
loadSettings().catch(() => undefined);
};
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
else setTimeout(warm, 5_000);
}
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
};
export function Settings(props: SettingsProps) {
return (
<Suspense
fallback={
// Settings opens inside a FocusTrap, which needs a tabbable node.
<Box
tabIndex={0}
role="status"
aria-label="Loading settings"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S700, outline: 'none' }}
>
<Spinner variant="Secondary" size="400" />
</Box>
}
>
<SettingsDialog {...props} />
</Suspense>
);
}
+4 -10
View File
@@ -67,8 +67,6 @@ import {
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
import { writeStatusExpiry } from '../../../utils/statusExpiry';
type ProfileProps = {
profile: UserProfile;
@@ -367,7 +365,8 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
);
}
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
@@ -509,16 +508,12 @@ function ProfileStatus() {
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
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);
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, msg).catch(() => undefined);
}
},
[saveStatus, userId, mx],
[saveStatus, userId],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -558,7 +553,6 @@ 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,7 +108,6 @@ import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
@@ -2757,7 +2756,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.'
: translationUnavailableText(isTauriEnv())
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
}
after={
<SettingsSelect
+1 -2
View File
@@ -1,2 +1 @@
export { SettingsPages } from './pages';
export { Settings } from './SettingsLazy';
export * from './Settings';
-11
View File
@@ -1,11 +0,0 @@
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
* page without pulling the dialog into the entry chunk. */
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { SyncState } from 'matrix-js-sdk';
import { CALL_ERROR_CHANGED, CallControl } from '../plugins/call/CallControl';
import { CallErrorState } from '../plugins/call/callError';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
/** [Gitea #318] The fork's current call error (null while the call is fine). */
export function useCallError(control: CallControl): CallErrorState {
const [err, setErr] = useState(control.callError);
useEffect(() => {
const on = () => setErr(control.callError);
on();
control.on(CALL_ERROR_CHANGED, on);
return () => {
control.off(CALL_ERROR_CHANGED, on);
};
}, [control]);
return err;
}
/**
* [Gitea #318] After a homeserver stall the call drops with "Connection
* lost". Once sync is back, reconnect automatically (once per drop).
*/
export function useReconnectWhenSynced(control: CallControl): void {
const mx = useMatrixClient();
const err = useCallError(control);
const tried = useRef<CallErrorState>(null);
useSyncState(
mx,
useCallback(
(state: SyncState | null) => {
if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
tried.current = err;
control.reconnect();
},
[err, control],
),
);
}
+7 -22
View File
@@ -63,7 +63,7 @@ import {
migrateLocalMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
@@ -92,7 +92,6 @@ 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
@@ -437,7 +436,12 @@ 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 clearStatus = () => {
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
@@ -447,31 +451,12 @@ 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
+24
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -54,6 +55,8 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
export const CALL_ERROR_CHANGED = 'call_error_changed';
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -212,6 +215,27 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._audioOutputId;
}
// [Gitea #318] The fork's error page ("Connection lost") is up; the host
// kept showing the call as joined with controls that did nothing.
private _callError: CallErrorState = null;
public get callError(): CallErrorState {
return this._callError;
}
/** The fork's `io.lotus.call_error` report. */
public onCallError(data: unknown) {
const next = parseCallError(data);
if (next === undefined) return;
this._callError = next;
this.emit(CALL_ERROR_CHANGED);
}
/** Press the fork's Reconnect / Try again. */
public reconnect(): void {
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
+6
View File
@@ -453,6 +453,12 @@ export class CallEmbed {
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #318] The fork's error page ("Connection lost") is up / cleared.
this.disposables.push(
this.listenAction('io.lotus.call_error', (evt) => {
this.control.onCallError((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) => {
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseCallError } from './callError';
test('parseCallError (#318)', () => {
assert.deepEqual(parseCallError({ code: 'CONNECTION_LOST_ERROR', recoverable: true }), {
code: 'CONNECTION_LOST_ERROR',
recoverable: true,
});
assert.deepEqual(parseCallError({ code: 'E2EE_NOT_SUPPORTED' }), {
code: 'E2EE_NOT_SUPPORTED',
recoverable: false,
});
assert.equal(parseCallError({ code: null }), null);
assert.equal(parseCallError({}), undefined);
assert.equal(parseCallError('x'), undefined);
});
+14
View File
@@ -0,0 +1,14 @@
/**
* [Gitea #318] The fork's `io.lotus.call_error`: its error page is showing
* (`{ code, recoverable }`) or was cleared (`{ code: null }`).
*/
export type CallErrorState = { code: string; recoverable: boolean } | null;
/** Pure: parse a report; undefined for garbage (ignored). */
export function parseCallError(data: unknown): CallErrorState | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { code, recoverable } = data as { code?: unknown; recoverable?: unknown };
if (code === null) return null;
if (typeof code !== 'string' || !code) return undefined;
return { code, recoverable: recoverable === true };
}
-22
View File
@@ -1,22 +0,0 @@
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
@@ -1,46 +0,0 @@
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);
}
-3
View File
@@ -1,3 +0,0 @@
/** localStorage keys for this device's copy of the status message and its expiry. */
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
@@ -1,7 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
translationUnavailableText,
normalizeLang,
sameLanguage,
languageName,
@@ -54,9 +53,3 @@ 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,16 +86,6 @@ 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,9 +26,6 @@ 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',
-19
View File
@@ -251,25 +251,6 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/immer')) return 'immer';
if (id.includes('node_modules/folds')) return 'folds';
if (id.includes('node_modules/emojibase')) return 'emojibase';
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
// maths are needed at startup but are big, stable libraries: keep them in
// their own cacheable chunks instead of wherever the chunk graph drops them
// (once Settings became lazy they landed in the entry chunk).
if (
id.includes('node_modules/sanitize-html') ||
id.includes('node_modules/postcss') ||
id.includes('node_modules/htmlparser2') ||
id.includes('node_modules/domhandler') ||
id.includes('node_modules/domutils') ||
id.includes('node_modules/dom-serializer') ||
id.includes('node_modules/entities') ||
id.includes('node_modules/parse-srcset') ||
id.includes('node_modules/nanoid') ||
id.includes('node_modules/picocolors') ||
id.includes('node_modules/source-map-js')
)
return 'sanitize-html';
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
export default defineConfig({