Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
179be38e2c | ||
|
|
e4b2405a19 | ||
|
|
c8576cbb1f |
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@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.24",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz",
|
||||
"integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==",
|
||||
"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==",
|
||||
"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.24",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
@@ -40,21 +39,6 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
||||
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
|
||||
|
||||
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
|
||||
// the call iframe. Older forks don't report one; fall back to our own.
|
||||
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
|
||||
useEffect(() => {
|
||||
const onState = () => {
|
||||
setForkDevices(embed.control.audioOutputs);
|
||||
setSelected(embed.control.audioOutputId);
|
||||
};
|
||||
onState();
|
||||
embed.control.on(AUDIO_OUTPUT_STATE, onState);
|
||||
return () => {
|
||||
embed.control.off(AUDIO_OUTPUT_STATE, onState);
|
||||
};
|
||||
}, [embed]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
@@ -69,13 +53,11 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor || forkDevices) return undefined;
|
||||
if (!anchor) return undefined;
|
||||
refresh();
|
||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||
}, [anchor, refresh, forkDevices]);
|
||||
|
||||
const shown = forkDevices ?? devices;
|
||||
}, [anchor, refresh]);
|
||||
|
||||
const choose = (id: string) => {
|
||||
embed.control.setAudioOutput(id);
|
||||
@@ -108,14 +90,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
aria-label="Audio output"
|
||||
>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{shown.length === 0 && (
|
||||
{devices.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No audio outputs found.
|
||||
</Text>
|
||||
)}
|
||||
{shown.map((d) => {
|
||||
const isSelected =
|
||||
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
||||
{devices.map((d) => {
|
||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
||||
return (
|
||||
<MenuItem
|
||||
key={d.id}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,27 +54,6 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
||||
};
|
||||
}
|
||||
|
||||
export type AudioOutputOption = { id: string; label: string };
|
||||
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
||||
|
||||
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
|
||||
export function parseAudioOutputState(
|
||||
data: unknown,
|
||||
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
|
||||
if (typeof data !== 'object' || data === null) return undefined;
|
||||
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
|
||||
const list = Array.isArray(available)
|
||||
? available.filter(
|
||||
(o): o is AudioOutputOption =>
|
||||
typeof o === 'object' &&
|
||||
o !== null &&
|
||||
typeof (o as AudioOutputOption).id === 'string' &&
|
||||
typeof (o as AudioOutputOption).label === 'string',
|
||||
)
|
||||
: undefined;
|
||||
return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list };
|
||||
}
|
||||
|
||||
export class CallControl extends EventEmitter implements CallControlState {
|
||||
private state: CallControlState;
|
||||
|
||||
@@ -238,25 +217,6 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.sendAudioOutput(deviceId);
|
||||
}
|
||||
|
||||
// [Gitea #310] deviceIds are hashed per frame/origin, so ids from the
|
||||
// host's own enumerateDevices() mean nothing inside the call iframe. The
|
||||
// fork reports its outputs (ids valid in the frame) and its effective
|
||||
// selection; the call bar's menu uses those when present.
|
||||
private _audioOutputs: AudioOutputOption[] | undefined;
|
||||
|
||||
public get audioOutputs(): AudioOutputOption[] | undefined {
|
||||
return this._audioOutputs;
|
||||
}
|
||||
|
||||
/** The fork's `io.lotus.audio_output_state` report. */
|
||||
public onAudioOutputState(data: unknown) {
|
||||
const report = parseAudioOutputState(data);
|
||||
if (!report) return;
|
||||
this._audioOutputs = report.available;
|
||||
if (report.deviceId !== undefined) this._audioOutputId = report.deviceId;
|
||||
this.emit(AUDIO_OUTPUT_STATE);
|
||||
}
|
||||
|
||||
private sendAudioOutput(deviceId: string): void {
|
||||
if (!this.joined) return;
|
||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||
|
||||
@@ -453,12 +453,6 @@ export class CallEmbed {
|
||||
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #310] The fork's own output list + selection (frame-valid ids).
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.audio_output_state', (evt) => {
|
||||
this.control.onAudioOutputState((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) => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAudioOutputState } from './CallControl';
|
||||
|
||||
test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => {
|
||||
assert.deepEqual(
|
||||
parseAudioOutputState({
|
||||
deviceId: 'f2',
|
||||
available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null],
|
||||
}),
|
||||
{
|
||||
deviceId: 'f2',
|
||||
available: [
|
||||
{ id: '', label: 'Default' },
|
||||
{ id: 'f2', label: 'Headset' },
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseAudioOutputState({ deviceId: null }), {
|
||||
deviceId: undefined,
|
||||
available: undefined,
|
||||
});
|
||||
assert.equal(parseAudioOutputState('x'), undefined);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user