Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9b7f68b7 | ||
|
|
6648ca928d | ||
|
|
258ffc88be | ||
|
|
fe70616412 |
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -86,6 +86,7 @@ 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;
|
||||
@@ -814,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).
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user