Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar
# Conflicts: # package-lock.json # package.json # src/app/plugins/call/CallControl.ts # src/app/plugins/call/CallEmbed.ts
This commit is contained in:
@@ -16,6 +16,7 @@ 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';
|
||||
|
||||
@@ -39,6 +40,21 @@ 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();
|
||||
@@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return undefined;
|
||||
if (!anchor || forkDevices) return undefined;
|
||||
refresh();
|
||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||
}, [anchor, refresh]);
|
||||
}, [anchor, refresh, forkDevices]);
|
||||
|
||||
const shown = forkDevices ?? devices;
|
||||
|
||||
const choose = (id: string) => {
|
||||
embed.control.setAudioOutput(id);
|
||||
@@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
aria-label="Audio output"
|
||||
>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{devices.length === 0 && (
|
||||
{shown.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No audio outputs found.
|
||||
</Text>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
||||
{shown.map((d) => {
|
||||
const isSelected =
|
||||
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
||||
return (
|
||||
<MenuItem
|
||||
key={d.id}
|
||||
|
||||
@@ -33,16 +33,9 @@ import { About } from './about';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
export { SettingsPages };
|
||||
|
||||
type SettingsMenuItem = {
|
||||
page: SettingsPages;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
|
||||
import {
|
||||
describePresenceError,
|
||||
setPresenceWithRetry,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -67,6 +71,8 @@ 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;
|
||||
@@ -365,8 +371,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
|
||||
|
||||
const CLEAR_AFTER_OPTIONS = [
|
||||
{ label: 'Never', value: '0' },
|
||||
@@ -508,12 +513,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,8 +562,9 @@ 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({
|
||||
writePresence(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Settings';
|
||||
export { SettingsPages } from './pages';
|
||||
export { Settings } from './SettingsLazy';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 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,
|
||||
}
|
||||
Reference in New Issue
Block a user