CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 12m9s
The GIF button needs a Giphy key in the client config. Without one (the desktop app until now), switching "GIF Picker" on did nothing and nothing said why. With no key the tile now explains it and the switch is disabled; with a key it is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2882 lines
104 KiB
TypeScript
2882 lines
104 KiB
TypeScript
import React, {
|
||
ChangeEventHandler,
|
||
FocusEventHandler,
|
||
FormEventHandler,
|
||
KeyboardEventHandler,
|
||
MouseEventHandler,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
as,
|
||
Box,
|
||
Button,
|
||
Chip,
|
||
color,
|
||
config,
|
||
Header,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Input,
|
||
Menu,
|
||
MenuItem,
|
||
PopOut,
|
||
RectCords,
|
||
Scroll,
|
||
Spinner,
|
||
Switch,
|
||
Text,
|
||
toRem,
|
||
} from 'folds';
|
||
import { isKeyHotkey } from 'is-hotkey';
|
||
import { HexColorPicker } from 'react-colorful';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import {
|
||
draggable,
|
||
dropTargetForElements,
|
||
monitorForElements,
|
||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
|
||
import {
|
||
attachClosestEdge,
|
||
extractClosestEdge,
|
||
Edge,
|
||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||
import { useAtom } from 'jotai';
|
||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||
import { SequenceCard } from '../../../components/sequence-card';
|
||
import {
|
||
DENOISE_MODELS,
|
||
isMLDenoiseSupported,
|
||
ML_DENOISE_REQUIREMENTS,
|
||
} from '../../../utils/lotusDenoiseUtils';
|
||
import { useSetting } from '../../../state/hooks/settings';
|
||
import { useSettingsSyncActions } from '../../../hooks/useSettingsSync';
|
||
import {
|
||
CallAudioBitrate,
|
||
ChatBackground,
|
||
ComposerToolbarButtonKey,
|
||
ComposerToolbarSettings,
|
||
DateFormat,
|
||
DenoiseModelId,
|
||
MessageLayout,
|
||
MessageSpacing,
|
||
NoiseSuppressionMode,
|
||
normalizeComposerToolbarOrder,
|
||
RingtoneId,
|
||
ScreenshareBitrate,
|
||
ScreenshareFramerate,
|
||
Settings,
|
||
settingsAtom,
|
||
} from '../../../state/settings';
|
||
import {
|
||
AUDIO_BITRATE_OPTIONS,
|
||
SCREENSHARE_BITRATE_OPTIONS,
|
||
SCREENSHARE_FRAMERATE_OPTIONS,
|
||
} from '../../../utils/callQuality';
|
||
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
||
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
|
||
import { SettingTile } from '../../../components/setting-tile';
|
||
import { KeySymbol } from '../../../utils/key-symbol';
|
||
import { isMacOS } from '../../../utils/user-agent';
|
||
import {
|
||
DarkTheme,
|
||
LightTheme,
|
||
Theme,
|
||
ThemeKind,
|
||
useSystemThemeKind,
|
||
useTheme,
|
||
useThemeNames,
|
||
useThemes,
|
||
} from '../../../hooks/useTheme';
|
||
import { stopPropagation } from '../../../utils/keyboard';
|
||
import { pickerInputStyle } from '../../../utils/datetimeInput';
|
||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||
import {
|
||
TRANSLATE_TARGET_LANGUAGES,
|
||
normalizeLang,
|
||
isSupportedTargetLang,
|
||
} from '../../../utils/translation/langUtils';
|
||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||
import { SequenceCardStyle } from '../styles.css';
|
||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
|
||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||
import { DenoiseTester } from './DenoiseTester';
|
||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||
import { StorageUsage } from './StorageUsage';
|
||
import { hapticsSupported } from '../../../utils/haptics';
|
||
|
||
/**
|
||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
|
||
* (mounted in App.tsx) applies `set_decorations` when this flips.
|
||
*/
|
||
function DesktopChromeSetting() {
|
||
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
||
if (!isTauriEnv()) return null;
|
||
// Persist the flag, then reload so the window layout is rebuilt from scratch.
|
||
// Toggling live reflows the whole app while the room timeline is mounted, which
|
||
// resizes its virtualized scroll container and triggers runaway back-pagination
|
||
// (the "screen expands + auto-scrolls into the past" bug). A reload applies the
|
||
// chrome cleanly against a fresh, correct layout.
|
||
const handleToggle = (value: boolean) => {
|
||
setCustomChrome(value);
|
||
window.location.reload();
|
||
};
|
||
return (
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Custom Window Chrome (Beta)"
|
||
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||
/>
|
||
</SequenceCard>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* P6-1 — "Launch on login" toggle (desktop only). Renders nothing in the
|
||
* browser. Reads the current state from the `autostart` plugin on mount and
|
||
* enables/disables it via the plugin commands when flipped. Not backed by an
|
||
* atom — the OS registration is the source of truth, mirrored into local state.
|
||
*/
|
||
function AutostartSetting() {
|
||
const [enabled, setEnabled] = useState(false);
|
||
// [cinny-desktop #3] null until the native side answers (older builds don't
|
||
// have the command, so the switch just stays hidden there).
|
||
const [startMinimized, setStartMinimized] = useState<boolean | null>(null);
|
||
// [cinny-desktop #5] "ask" | "tray" | "quit"; null on builds without it.
|
||
const [closeBehavior, setCloseBehavior] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
tauriInvoke()?.('plugin:autostart|is_enabled')
|
||
.then((value) => setEnabled(value === true))
|
||
.catch(() => undefined);
|
||
tauriInvoke()?.('get_close_behavior')
|
||
.then((value) => {
|
||
if (typeof value === 'string') setCloseBehavior(value);
|
||
})
|
||
.catch(() => undefined);
|
||
tauriInvoke()?.('get_start_minimized')
|
||
.then((value) => {
|
||
if (typeof value === 'boolean') setStartMinimized(value);
|
||
})
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
const handleChange = (value: boolean) => {
|
||
invokeTauri(value ? 'plugin:autostart|enable' : 'plugin:autostart|disable');
|
||
setEnabled(value);
|
||
};
|
||
|
||
const handleCloseBehavior = (value: string) => {
|
||
invokeTauri('set_close_behavior', { value });
|
||
setCloseBehavior(value);
|
||
};
|
||
|
||
const handleStartMinimized = (value: boolean) => {
|
||
invokeTauri('set_start_minimized', { value });
|
||
setStartMinimized(value);
|
||
};
|
||
|
||
if (!isTauriEnv()) return null;
|
||
return (
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Launch on login"
|
||
description="Start Lotus Chat automatically when you sign in to your computer."
|
||
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
|
||
/>
|
||
{closeBehavior !== null && (
|
||
<SettingTile
|
||
title="When I close the window"
|
||
description="Keep Lotus Chat running in the system tray (messages, notifications and calls keep arriving), or quit it. During a call, closing always keeps it running."
|
||
after={
|
||
<SettingsSelect
|
||
value={closeBehavior}
|
||
onChange={handleCloseBehavior}
|
||
aria-label="When I close the window"
|
||
options={[
|
||
{ value: 'tray', label: 'Keep running in the tray' },
|
||
{ value: 'quit', label: 'Quit Lotus Chat' },
|
||
{ value: 'ask', label: 'Ask me' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
{enabled && startMinimized !== null && (
|
||
<SettingTile
|
||
title="Start minimized"
|
||
description="When it starts at login, keep Lotus Chat in the system tray instead of opening the window. Notifications still arrive; click the tray icon to open it."
|
||
after={
|
||
<Switch variant="Primary" value={startMinimized} onChange={handleStartMinimized} />
|
||
}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
);
|
||
}
|
||
|
||
type ThemeSelectorProps = {
|
||
themeNames: Record<string, string>;
|
||
themes: Theme[];
|
||
selected: Theme;
|
||
onSelect: (theme: Theme) => void;
|
||
};
|
||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||
<Menu {...props} ref={ref}>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{themes.map((theme) => (
|
||
<MenuItem
|
||
key={theme.id}
|
||
size="300"
|
||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => onSelect(theme)}
|
||
>
|
||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
),
|
||
);
|
||
|
||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||
const themes = useThemes();
|
||
const themeNames = useThemeNames();
|
||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||
|
||
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleThemeSelect = (theme: Theme) => {
|
||
setThemeId(theme.id);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={disabled ? undefined : handleThemeMenu}
|
||
aria-disabled={disabled}
|
||
>
|
||
<Text size="T300">{themeNames[selectedTheme.id] ?? selectedTheme.id}</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={themes}
|
||
selected={selectedTheme}
|
||
onSelect={handleThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SystemThemePreferences() {
|
||
const themeKind = useSystemThemeKind();
|
||
const themeNames = useThemeNames();
|
||
const themes = useThemes();
|
||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||
|
||
const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
|
||
const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
|
||
|
||
const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
|
||
const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
|
||
|
||
const [ltCords, setLTCords] = useState<RectCords>();
|
||
const [dtCords, setDTCords] = useState<RectCords>();
|
||
|
||
const handleLightThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setLTCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
const handleDarkThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setDTCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleLightThemeSelect = (theme: Theme) => {
|
||
setLightThemeId(theme.id);
|
||
setLTCords(undefined);
|
||
};
|
||
|
||
const handleDarkThemeSelect = (theme: Theme) => {
|
||
setDarkThemeId(theme.id);
|
||
setDTCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<Box wrap="Wrap" gap="400">
|
||
<SettingTile
|
||
title="Light Theme:"
|
||
after={
|
||
<Chip
|
||
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
|
||
outlined={themeKind === ThemeKind.Light}
|
||
radii="Pill"
|
||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||
onClick={handleLightThemeMenu}
|
||
>
|
||
<Text size="B300">{themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}</Text>
|
||
</Chip>
|
||
}
|
||
/>
|
||
<PopOut
|
||
anchor={ltCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setLTCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={lightThemes}
|
||
selected={selectedLightTheme}
|
||
onSelect={handleLightThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
<SettingTile
|
||
title="Dark Theme:"
|
||
after={
|
||
<Chip
|
||
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
|
||
outlined={themeKind === ThemeKind.Dark}
|
||
radii="Pill"
|
||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||
onClick={handleDarkThemeMenu}
|
||
>
|
||
<Text size="B300">{themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}</Text>
|
||
</Chip>
|
||
}
|
||
/>
|
||
<PopOut
|
||
anchor={dtCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setDTCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={darkThemes}
|
||
selected={selectedDarkTheme}
|
||
onSelect={handleDarkThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function PageZoomInput() {
|
||
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
|
||
|
||
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||
setCurrentZoom(evt.target.value);
|
||
};
|
||
|
||
// Shared by Enter and blur so a typed-but-unconfirmed value is never
|
||
// silently dropped when the input loses focus.
|
||
const commitZoom = (value: string) => {
|
||
const newZoom = parseInt(value, 10);
|
||
if (Number.isNaN(newZoom)) return;
|
||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||
setPageZoom(safeZoom);
|
||
setCurrentZoom(safeZoom.toString());
|
||
};
|
||
|
||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||
if (isKeyHotkey('escape', evt)) {
|
||
evt.stopPropagation();
|
||
setCurrentZoom(pageZoom.toString());
|
||
}
|
||
if (
|
||
isKeyHotkey('enter', evt) &&
|
||
'value' in evt.target &&
|
||
typeof evt.target.value === 'string'
|
||
) {
|
||
commitZoom(evt.target.value);
|
||
}
|
||
};
|
||
|
||
const handleZoomBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||
commitZoom(evt.target.value);
|
||
};
|
||
|
||
return (
|
||
<Input
|
||
style={{ width: toRem(100) }}
|
||
aria-label="Page zoom percentage"
|
||
variant={pageZoom === parseInt(currentZoom, 10) ? 'Secondary' : 'Success'}
|
||
size="300"
|
||
radii="300"
|
||
type="number"
|
||
min="75"
|
||
max="150"
|
||
value={currentZoom}
|
||
onChange={handleZoomChange}
|
||
onKeyDown={handleZoomEnter}
|
||
onBlur={handleZoomBlur}
|
||
after={<Text size="T300">%</Text>}
|
||
outlined
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Appearance() {
|
||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
|
||
settingsAtom,
|
||
'perMessageProfiles',
|
||
);
|
||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
||
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
||
const [nightLightSchedule, setNightLightSchedule] = useSetting(
|
||
settingsAtom,
|
||
'nightLightSchedule',
|
||
);
|
||
const [nightLightStart, setNightLightStart] = useSetting(settingsAtom, 'nightLightStart');
|
||
const [nightLightEnd, setNightLightEnd] = useSetting(settingsAtom, 'nightLightEnd');
|
||
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
|
||
settingsAtom,
|
||
'glassmorphismSidebar',
|
||
);
|
||
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
|
||
settingsAtom,
|
||
'mentionHighlightColor',
|
||
);
|
||
const [customAccentColor, setCustomAccentColor] = useSetting(settingsAtom, 'customAccentColor');
|
||
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
||
settingsAtom,
|
||
'seasonalThemeOverride',
|
||
);
|
||
const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Appearance</Text>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="System Theme"
|
||
description="Choose between light and dark theme based on system preference."
|
||
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
|
||
/>
|
||
{systemTheme && <SystemThemePreferences />}
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Theme"
|
||
description="Theme to use when system theme is not enabled."
|
||
after={<SelectTheme disabled={systemTheme} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Monochrome Mode"
|
||
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<DesktopChromeSetting />
|
||
<AutostartSetting />
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Twitter Emoji"
|
||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||
</SequenceCard>
|
||
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Chat Background"
|
||
description="Pattern applied behind the message timeline."
|
||
/>
|
||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||
<ChatBgGrid />
|
||
</Box>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Pause Background Animations"
|
||
description="Stop background animation to reduce motion or improve performance."
|
||
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Seasonal Theme"
|
||
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
|
||
/>
|
||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||
<SeasonalBgGrid
|
||
value={seasonalThemeOverride ?? 'auto'}
|
||
onChange={(v) => {
|
||
setSeasonalThemeOverride(v);
|
||
// Any active seasonal mode (incl. "auto") is mutually exclusive
|
||
// with a chat background — else picking it is a silent no-op, since
|
||
// SeasonalEffect suppresses the overlay while a background is set.
|
||
// Only "off" leaves the background alone.
|
||
if (v !== 'off') setChatBackground('none');
|
||
}}
|
||
/>
|
||
</Box>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Show Profile on Every Message"
|
||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||
after={
|
||
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Night Light"
|
||
description="Reduce blue light with a warm orange tint overlay."
|
||
after={
|
||
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
|
||
}
|
||
/>
|
||
{nightLightEnabled && (
|
||
<Box
|
||
direction="Column"
|
||
gap="300"
|
||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||
>
|
||
<Box direction="Column" gap="100">
|
||
<Text size="T200" priority="300">
|
||
Intensity: {nightLightOpacity}%
|
||
</Text>
|
||
<input
|
||
aria-label="Night light intensity"
|
||
type="range"
|
||
min={5}
|
||
max={80}
|
||
value={nightLightOpacity}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setNightLightOpacity(parseInt(e.target.value, 10))
|
||
}
|
||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||
/>
|
||
</Box>
|
||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||
<Box direction="Column">
|
||
<Text size="T300">Schedule</Text>
|
||
<Text size="T200" priority="300">
|
||
Only tint during the hours below
|
||
</Text>
|
||
</Box>
|
||
<Switch
|
||
variant="Primary"
|
||
value={nightLightSchedule}
|
||
onChange={setNightLightSchedule}
|
||
/>
|
||
</Box>
|
||
{nightLightSchedule && (
|
||
<Box gap="200">
|
||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||
<Text as="label" htmlFor="night-light-start" size="T200" priority="400">
|
||
From
|
||
</Text>
|
||
<input
|
||
id="night-light-start"
|
||
type="time"
|
||
value={nightLightStart}
|
||
onChange={(e) => setNightLightStart(e.target.value)}
|
||
style={pickerInputStyle(color, config)}
|
||
/>
|
||
</Box>
|
||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||
<Text as="label" htmlFor="night-light-end" size="T200" priority="400">
|
||
To
|
||
</Text>
|
||
<input
|
||
id="night-light-end"
|
||
type="time"
|
||
value={nightLightEnd}
|
||
onChange={(e) => setNightLightEnd(e.target.value)}
|
||
style={pickerInputStyle(color, config)}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
{nightLightSchedule && nightLightStart === nightLightEnd && (
|
||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||
Start and end times are the same, so the tint never turns on. Set different times.
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Glassmorphism Sidebar"
|
||
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={glassmorphismSidebar}
|
||
onChange={setGlassmorphismSidebar}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Lotus Terminal Mode"
|
||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
||
after={
|
||
<Box direction="Row" gap="200" alignItems="Center">
|
||
{lotusTerminal && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetBootSequence();
|
||
runLotusBootSequence(true);
|
||
}}
|
||
title="Replay boot sequence"
|
||
style={{
|
||
background: 'transparent',
|
||
border: '1px solid var(--accent-orange-border)',
|
||
color: 'var(--accent-orange)',
|
||
fontSize: '0.65rem',
|
||
padding: '0.2rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
letterSpacing: '0.1em',
|
||
textTransform: 'uppercase',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
▶ Boot
|
||
</button>
|
||
)}
|
||
<Switch variant="Primary" value={lotusTerminal} onChange={setLotusTerminal} />
|
||
</Box>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="UI Font"
|
||
description="Font used throughout the interface."
|
||
after={
|
||
<SettingsSelect
|
||
value={fontFamily ?? 'inter'}
|
||
onChange={(v) =>
|
||
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||
}
|
||
options={[
|
||
{ value: 'system', label: 'System Default' },
|
||
{ value: 'inter', label: 'Inter (default)' },
|
||
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||
{ value: 'fira-code', label: 'Fira Code' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="@Mention Highlight Color"
|
||
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||
after={
|
||
<HexColorPickerPopOut
|
||
picker={
|
||
<HexColorPicker
|
||
color={mentionHighlightColor || '#4caf50'}
|
||
onChange={setMentionHighlightColor}
|
||
/>
|
||
}
|
||
onRemove={mentionHighlightColor ? () => setMentionHighlightColor('') : undefined}
|
||
>
|
||
{(openPicker, opened) => (
|
||
<Button
|
||
type="button"
|
||
aria-pressed={opened}
|
||
onClick={openPicker}
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
before={
|
||
<span
|
||
style={{
|
||
width: toRem(16),
|
||
height: toRem(16),
|
||
borderRadius: config.radii.R300,
|
||
background: mentionHighlightColor || color.Primary.Main,
|
||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||
display: 'inline-block',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<Text size="B300">{mentionHighlightColor ? 'Change' : 'Pick'}</Text>
|
||
</Button>
|
||
)}
|
||
</HexColorPickerPopOut>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Custom Accent Color"
|
||
description={
|
||
lotusTerminal
|
||
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
|
||
: 'Recolors the app accent (buttons, active states, links, selected states). Leave empty to use the theme default.'
|
||
}
|
||
after={
|
||
<HexColorPickerPopOut
|
||
picker={
|
||
<HexColorPicker
|
||
color={customAccentColor || color.Primary.Main}
|
||
onChange={setCustomAccentColor}
|
||
/>
|
||
}
|
||
onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined}
|
||
>
|
||
{(openPicker, opened) => (
|
||
<Button
|
||
type="button"
|
||
aria-pressed={opened}
|
||
onClick={openPicker}
|
||
disabled={lotusTerminal}
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
before={
|
||
<span
|
||
style={{
|
||
width: toRem(16),
|
||
height: toRem(16),
|
||
borderRadius: config.radii.R300,
|
||
background: customAccentColor || color.Primary.Main,
|
||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||
display: 'inline-block',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<Text size="B300">{customAccentColor ? 'Change' : 'Pick'}</Text>
|
||
</Button>
|
||
)}
|
||
</HexColorPickerPopOut>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type DateHintProps = {
|
||
hasChanges: boolean;
|
||
handleReset: () => void;
|
||
};
|
||
function DateHint({ hasChanges, handleReset }: DateHintProps) {
|
||
const [anchor, setAnchor] = useState<RectCords>();
|
||
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
|
||
|
||
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||
setAnchor(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
return (
|
||
<PopOut
|
||
anchor={anchor}
|
||
position="Top"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setAnchor(undefined),
|
||
clickOutsideDeactivates: true,
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
|
||
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||
<Text size="L400">Formatting</Text>
|
||
</Header>
|
||
|
||
<Box direction="Column">
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Year</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
YY
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}
|
||
Two-digit year
|
||
</Text>{' '}
|
||
</Text>
|
||
<Text size="T300">
|
||
YYYY
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Four-digit year
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Month</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
M
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}The month
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
MM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-digit month
|
||
</Text>{' '}
|
||
</Text>
|
||
<Text size="T300">
|
||
MMM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Short month name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
MMMM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Full month name
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Day of the Month</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
D
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Day of the month
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
DD
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-digit day of the month
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Day of the Week</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
d
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Day of the week (Sunday = 0)
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
dd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-letter day name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
ddd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Short day name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
dddd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Full day name
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
>
|
||
{hasChanges ? (
|
||
<IconButton
|
||
onClick={handleReset}
|
||
type="reset"
|
||
variant="Secondary"
|
||
size="300"
|
||
radii="300"
|
||
aria-label="Reset to default"
|
||
>
|
||
<Icon src={Icons.Cross} size="100" />
|
||
</IconButton>
|
||
) : (
|
||
<IconButton
|
||
onClick={handleOpenMenu}
|
||
type="button"
|
||
variant="Secondary"
|
||
size="300"
|
||
radii="300"
|
||
aria-label="Setting info"
|
||
aria-pressed={!!anchor}
|
||
>
|
||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||
</IconButton>
|
||
)}
|
||
</PopOut>
|
||
);
|
||
}
|
||
|
||
type CustomDateFormatProps = {
|
||
value: string;
|
||
onChange: (format: string) => void;
|
||
};
|
||
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
|
||
const [dateFormatCustom, setDateFormatCustom] = useState(value);
|
||
|
||
useEffect(() => {
|
||
setDateFormatCustom(value);
|
||
}, [value]);
|
||
|
||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||
const format = evt.currentTarget.value;
|
||
setDateFormatCustom(format);
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setDateFormatCustom(value);
|
||
};
|
||
|
||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||
evt.preventDefault();
|
||
|
||
const target = evt.target as HTMLFormElement | undefined;
|
||
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
|
||
const format = customDateFormatInput?.value;
|
||
if (!format) return;
|
||
|
||
onChange(format);
|
||
};
|
||
|
||
const hasChanges = dateFormatCustom !== value;
|
||
return (
|
||
<SettingTile>
|
||
<Box as="form" onSubmit={handleSubmit} gap="200">
|
||
<Box grow="Yes" direction="Column">
|
||
<Input
|
||
required
|
||
name="customDateFormatInput"
|
||
aria-label="Custom date format"
|
||
value={dateFormatCustom}
|
||
onChange={handleChange}
|
||
maxLength={16}
|
||
autoComplete="off"
|
||
variant="Secondary"
|
||
radii="300"
|
||
style={{ paddingRight: config.space.S200 }}
|
||
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
|
||
/>
|
||
</Box>
|
||
<Button
|
||
size="400"
|
||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||
outlined
|
||
radii="300"
|
||
disabled={!hasChanges}
|
||
type="submit"
|
||
>
|
||
<Text size="B400">Save</Text>
|
||
</Button>
|
||
</Box>
|
||
</SettingTile>
|
||
);
|
||
}
|
||
|
||
type PresetDateFormatProps = {
|
||
value: string;
|
||
onChange: (format: string) => void;
|
||
};
|
||
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const dateFormatItems = useDateFormatItems();
|
||
|
||
const getDisplayDate = (format: string): string =>
|
||
format !== '' ? dayjs().format(format) : 'Custom';
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (format: DateFormat) => {
|
||
onChange(format);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{dateFormatItems.map((item) => (
|
||
<MenuItem
|
||
key={item.format}
|
||
size="300"
|
||
variant={value === item.format ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.format)}
|
||
>
|
||
<Text size="T300">{getDisplayDate(item.format)}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SelectDateFormat() {
|
||
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
|
||
const customDateFormat = selectedDateFormat === '';
|
||
|
||
const handlePresetChange = (format: string) => {
|
||
setSelectedDateFormat(format);
|
||
if (format !== '') {
|
||
setDateFormatString(format);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<SettingTile
|
||
title="Date Format"
|
||
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||
/>
|
||
{customDateFormat && (
|
||
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DateAndTime() {
|
||
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Date & Time</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="24-Hour Time Format"
|
||
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SelectDateFormat />
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
|
||
showFormat: 'Format',
|
||
showEmoji: 'Emoji',
|
||
showSticker: 'Sticker',
|
||
showGif: 'GIF',
|
||
showLocation: 'Location',
|
||
showPoll: 'Poll',
|
||
showVoice: 'Voice',
|
||
showSchedule: 'Schedule',
|
||
};
|
||
|
||
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
|
||
|
||
type ComposerToolbarButtonRowProps = {
|
||
buttonKey: ComposerToolbarButtonKey;
|
||
index: number;
|
||
active: boolean;
|
||
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||
};
|
||
|
||
function ComposerToolbarButtonRow({
|
||
buttonKey,
|
||
index,
|
||
active,
|
||
onToggle,
|
||
}: ComposerToolbarButtonRowProps) {
|
||
const rowRef = useRef<HTMLDivElement>(null);
|
||
const handleRef = useRef<HTMLButtonElement>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||
|
||
useEffect(() => {
|
||
const element = rowRef.current;
|
||
const dragHandle = handleRef.current;
|
||
if (!element || !dragHandle) return undefined;
|
||
|
||
return combine(
|
||
draggable({
|
||
element,
|
||
dragHandle,
|
||
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
|
||
onDragStart: () => setDragging(true),
|
||
onDrop: () => setDragging(false),
|
||
}),
|
||
dropTargetForElements({
|
||
element,
|
||
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||
getData: ({ input }) =>
|
||
attachClosestEdge(
|
||
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
|
||
{ element, input, allowedEdges: ['top', 'bottom'] },
|
||
),
|
||
getIsSticky: () => true,
|
||
onDrag: ({ self, source }) => {
|
||
if (source.data.buttonKey === buttonKey) {
|
||
setClosestEdge(null);
|
||
return;
|
||
}
|
||
setClosestEdge(extractClosestEdge(self.data));
|
||
},
|
||
onDragLeave: () => setClosestEdge(null),
|
||
onDrop: () => setClosestEdge(null),
|
||
}),
|
||
);
|
||
}, [buttonKey, index]);
|
||
|
||
let boxShadow: string | undefined;
|
||
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
|
||
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
|
||
|
||
return (
|
||
<Box
|
||
ref={rowRef}
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{
|
||
padding: `${config.space.S200} ${config.space.S400}`,
|
||
opacity: dragging ? 0.5 : undefined,
|
||
boxShadow,
|
||
}}
|
||
>
|
||
<IconButton
|
||
ref={handleRef}
|
||
size="300"
|
||
radii="300"
|
||
variant="SurfaceVariant"
|
||
style={{ cursor: 'grab' }}
|
||
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
|
||
>
|
||
<Icon size="100" src={Icons.VerticalDots} />
|
||
</IconButton>
|
||
<Text style={{ flexGrow: 1 }} size="T300">
|
||
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
|
||
</Text>
|
||
<Chip
|
||
variant={active ? 'Primary' : 'Secondary'}
|
||
outlined={active}
|
||
radii="Pill"
|
||
onClick={() => onToggle(buttonKey)}
|
||
aria-pressed={active}
|
||
>
|
||
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
|
||
</Chip>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type ComposerToolbarReorderProps = {
|
||
order: ComposerToolbarButtonKey[];
|
||
buttons: ComposerToolbarSettings;
|
||
onReorder: (startIndex: number, finishIndex: number) => void;
|
||
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||
};
|
||
|
||
function ComposerToolbarReorder({
|
||
order,
|
||
buttons,
|
||
onReorder,
|
||
onToggle,
|
||
}: ComposerToolbarReorderProps) {
|
||
useEffect(
|
||
() =>
|
||
monitorForElements({
|
||
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||
onDrop: ({ location, source }) => {
|
||
const target = location.current.dropTargets[0];
|
||
if (!target) return;
|
||
const startIndex = source.data.index;
|
||
const indexOfTarget = target.data.index;
|
||
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
|
||
const closestEdgeOfTarget = extractClosestEdge(target.data);
|
||
|
||
// Insert relative to the target row, then compensate for the source
|
||
// row being removed from its original position.
|
||
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
|
||
if (startIndex < finishIndex) finishIndex -= 1;
|
||
|
||
if (finishIndex === startIndex) return;
|
||
onReorder(startIndex, finishIndex);
|
||
},
|
||
}),
|
||
[onReorder],
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column">
|
||
{order.map((key, index) => (
|
||
<ComposerToolbarButtonRow
|
||
key={key}
|
||
buttonKey={key}
|
||
index={index}
|
||
active={buttons[key]}
|
||
onToggle={onToggle}
|
||
/>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function Editor() {
|
||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
|
||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||
settingsAtom,
|
||
'composerToolbarButtons',
|
||
);
|
||
|
||
const composerToolbarOrder = useMemo(
|
||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||
[composerToolbarButtons?.order],
|
||
);
|
||
|
||
const toggleToolbarButton = useCallback(
|
||
(key: ComposerToolbarButtonKey) => {
|
||
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
|
||
},
|
||
[setComposerToolbarButtons],
|
||
);
|
||
|
||
const reorderToolbarButtons = useCallback(
|
||
(startIndex: number, finishIndex: number) => {
|
||
setComposerToolbarButtons((current) => ({
|
||
...current,
|
||
order: reorder({
|
||
list: normalizeComposerToolbarOrder(current.order),
|
||
startIndex,
|
||
finishIndex,
|
||
}),
|
||
}));
|
||
},
|
||
[setComposerToolbarButtons],
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Editor</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="ENTER for Newline"
|
||
description={`Use ${
|
||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||
} + ENTER to send message and ENTER for newline.`}
|
||
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Markdown Formatting"
|
||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Offer to Format Pasted Code"
|
||
description="When something you paste looks like code, offer to turn it into a code block. Nothing changes unless you accept."
|
||
after={<Switch variant="Primary" value={offerCodePaste} onChange={setOfferCodePaste} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Formatting Toolbar"
|
||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="300"
|
||
>
|
||
<SettingTile
|
||
title="Composer Toolbar"
|
||
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||
/>
|
||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||
<ComposerToolbarReorder
|
||
order={composerToolbarOrder}
|
||
buttons={composerToolbarButtons}
|
||
onReorder={reorderToolbarButtons}
|
||
onToggle={toggleToolbarButton}
|
||
/>
|
||
</Box>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function Privacy() {
|
||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
|
||
settingsAtom,
|
||
'privateReadReceipts',
|
||
);
|
||
const [warnOnUnverifiedDevices, setWarnOnUnverifiedDevices] = useSetting(
|
||
settingsAtom,
|
||
'warnOnUnverifiedDevices',
|
||
);
|
||
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Privacy</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Strip Tracking Parameters from Links"
|
||
description="Remove utm_, fbclid, YouTube si= and other ad/analytics identifiers from links you paste or send, and from links shown in chat. Runs entirely on this device."
|
||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Remove Photo Metadata Before Sending"
|
||
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
|
||
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Typing & Read Receipts"
|
||
description="Turn off both typing status and read receipts to keep your activity private."
|
||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Private Read Receipts"
|
||
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={privateReadReceipts}
|
||
onChange={setPrivateReadReceipts}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Online Status"
|
||
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
|
||
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Warn before sending to unverified devices"
|
||
description="Show a warning in the composer when sending to a room with unverified devices."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={warnOnUnverifiedDevices}
|
||
onChange={setWarnOnUnverifiedDevices}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||
// device-local (never synced) so switching it off here sticks.
|
||
function SettingsSyncSection() {
|
||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||
const [note, setNote] = useState<string | null>(null);
|
||
|
||
const run = async (action: 'push' | 'clear') => {
|
||
setBusy(action);
|
||
setNote(null);
|
||
try {
|
||
if (action === 'push') {
|
||
await pushNow();
|
||
setNote('Pushed — other devices will pick these settings up on their next sync.');
|
||
} else {
|
||
await clearRemote();
|
||
setNote('Cleared — devices keep their current settings until one pushes again.');
|
||
}
|
||
} catch {
|
||
setNote('Failed — check your connection and try again.');
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Sync</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Sync Settings Across Devices"
|
||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||
/>
|
||
</SequenceCard>
|
||
{settingsSync && (
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Use This Device's Settings Everywhere"
|
||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||
after={
|
||
<Box gap="200">
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
outlined
|
||
radii="300"
|
||
disabled={busy !== null}
|
||
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
|
||
onClick={() => run('push')}
|
||
>
|
||
<Text size="B300">Push now</Text>
|
||
</Button>
|
||
<Button
|
||
size="300"
|
||
variant="Critical"
|
||
fill="None"
|
||
radii="300"
|
||
disabled={busy !== null}
|
||
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
|
||
onClick={() => run('clear')}
|
||
>
|
||
<Text size="B300">Clear synced copy</Text>
|
||
</Button>
|
||
</Box>
|
||
}
|
||
/>
|
||
{note && (
|
||
<Text
|
||
size="T200"
|
||
priority="300"
|
||
style={{ padding: `0 ${config.space.S300} ${config.space.S300}` }}
|
||
>
|
||
{note}
|
||
</Text>
|
||
)}
|
||
</SequenceCard>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
|
||
const [listening, setListening] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const startListening = useCallback(() => {
|
||
if (listening) return;
|
||
setError(null);
|
||
setListening(true);
|
||
const onKey = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
if (e.code === 'Escape') {
|
||
// Escape always cancels the rebind without changing the key.
|
||
} else if (!isBindableCallKey(e.code)) {
|
||
setError('That key can’t be bound — it’s needed for keyboard navigation.');
|
||
} else if (otherKey && e.code === otherKey) {
|
||
setError('That key is already bound to the other call shortcut.');
|
||
} else {
|
||
setter(e.code);
|
||
}
|
||
setListening(false);
|
||
window.removeEventListener('keydown', onKey, true);
|
||
listenerRef.current = null;
|
||
};
|
||
listenerRef.current = onKey;
|
||
window.addEventListener('keydown', onKey, true);
|
||
}, [listening, setter, otherKey]);
|
||
|
||
return { listening, startListening, error };
|
||
}
|
||
|
||
const keyLabel = (code: string) =>
|
||
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
|
||
|
||
function Calls() {
|
||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
|
||
settingsAtom,
|
||
'callNoiseSuppression',
|
||
);
|
||
const [callDenoiseModel, setCallDenoiseModel] = useSetting(settingsAtom, 'callDenoiseModel');
|
||
const [callDenoiseNativeNS, setCallDenoiseNativeNS] = useSetting(
|
||
settingsAtom,
|
||
'callDenoiseNativeNS',
|
||
);
|
||
const [callDenoiseGate, setCallDenoiseGate] = useSetting(settingsAtom, 'callDenoiseGate');
|
||
const [callDenoiseGateThreshold, setCallDenoiseGateThreshold] = useSetting(
|
||
settingsAtom,
|
||
'callDenoiseGateThreshold',
|
||
);
|
||
|
||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||
const [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
|
||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||
settingsAtom,
|
||
'callJoinLeaveSound',
|
||
);
|
||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||
|
||
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
|
||
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
|
||
settingsAtom,
|
||
'screenshareBitrate',
|
||
);
|
||
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
|
||
settingsAtom,
|
||
'screenshareFramerate',
|
||
);
|
||
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||
|
||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||
setCallJoinLeaveSound(value);
|
||
if (value !== 'off') playCallJoinSound(value);
|
||
};
|
||
|
||
const handleRingtoneChange = (value: RingtoneId) => {
|
||
setRingtoneId(value);
|
||
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||
};
|
||
|
||
const pttBind = useKeyBind(setPttKey, deafenKey);
|
||
const deafenBind = useKeyBind(setDeafenKey, pttKey);
|
||
|
||
const mlSupported = isMLDenoiseSupported();
|
||
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Calls</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Join with Camera On"
|
||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Noise Suppression"
|
||
description={
|
||
<Box direction="Column" gap="200">
|
||
<Text>
|
||
Filter background noise from your mic during calls. Browser-native uses the built-in
|
||
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
||
</Text>
|
||
|
||
{!mlSupported && (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="T200" priority="400">
|
||
ML options are not supported in this browser.
|
||
</Text>
|
||
<Box as="ul" style={{ paddingLeft: '20px', margin: 0 }}>
|
||
{ML_DENOISE_REQUIREMENTS.map((req) => (
|
||
<Text as="li" key={req} size="T200">
|
||
{req}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
}
|
||
after={
|
||
<SettingsSelect<NoiseSuppressionMode>
|
||
value={callNoiseSuppression}
|
||
onChange={setCallNoiseSuppression}
|
||
options={[
|
||
{ value: 'off', label: 'Off' },
|
||
{ value: 'browser', label: 'Browser-native' },
|
||
{
|
||
value: 'ml',
|
||
label: 'ML (Advanced)',
|
||
disabled: !mlSupported,
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
{callNoiseSuppression === 'ml' && (
|
||
<Box
|
||
direction="Column"
|
||
gap="400"
|
||
style={{
|
||
padding: '16px',
|
||
marginTop: '8px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
background: color.Surface.Container,
|
||
}}
|
||
>
|
||
{/* ── Model selection ───────────────────────────────────────── */}
|
||
<Box direction="Column" gap="200">
|
||
<Text size="L400">Model</Text>
|
||
<SettingTile
|
||
title="ML Model"
|
||
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
|
||
after={
|
||
<SettingsSelect<DenoiseModelId>
|
||
value={callDenoiseModel}
|
||
onChange={setCallDenoiseModel}
|
||
options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
|
||
/>
|
||
}
|
||
/>
|
||
{selectedDenoiseModel && (
|
||
<Box
|
||
direction="Column"
|
||
gap="200"
|
||
style={{
|
||
padding: '12px',
|
||
borderRadius: '8px',
|
||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
background: color.SurfaceVariant.Container,
|
||
}}
|
||
>
|
||
<Text size="T300">{selectedDenoiseModel.name}</Text>
|
||
<Text size="T200" priority="300">
|
||
{selectedDenoiseModel.description}
|
||
</Text>
|
||
<Box direction="Row" gap="400" wrap="Wrap">
|
||
{(
|
||
[
|
||
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
|
||
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
|
||
{ label: 'Transients', value: selectedDenoiseModel.transients },
|
||
{ label: 'Download', value: selectedDenoiseModel.binarySize },
|
||
] as const
|
||
).map((stat) => (
|
||
<Box key={stat.label} direction="Column" gap="100">
|
||
<Text size="T200" priority="300">
|
||
{stat.label}
|
||
</Text>
|
||
<Text size="T300">{stat.value}</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<details>
|
||
<summary style={{ cursor: 'pointer' }}>
|
||
<Text as="span" size="T200" priority="300">
|
||
Compare all models
|
||
</Text>
|
||
</summary>
|
||
<Box direction="Column" gap="100" style={{ overflowX: 'auto', marginTop: '8px' }}>
|
||
<Box
|
||
direction="Row"
|
||
gap="100"
|
||
style={{
|
||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
paddingBottom: '4px',
|
||
}}
|
||
>
|
||
<Box style={{ width: '160px' }}>
|
||
<Text size="T200" priority="300">
|
||
Model
|
||
</Text>
|
||
</Box>
|
||
<Box style={{ width: '80px' }}>
|
||
<Text size="T200" priority="300">
|
||
CPU
|
||
</Text>
|
||
</Box>
|
||
<Box style={{ width: '90px' }}>
|
||
<Text size="T200" priority="300">
|
||
Quality
|
||
</Text>
|
||
</Box>
|
||
<Box grow="Yes">
|
||
<Text size="T200" priority="300">
|
||
Transients
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
{DENOISE_MODELS.map((model) => (
|
||
<Box key={model.id} direction="Row" gap="100">
|
||
<Box style={{ width: '160px' }}>
|
||
<Text size="T200">{model.name}</Text>
|
||
</Box>
|
||
<Box style={{ width: '80px' }}>
|
||
<Text size="T200">{model.cpuUsage}</Text>
|
||
</Box>
|
||
<Box style={{ width: '90px' }}>
|
||
<Text size="T200">{model.voiceQuality}</Text>
|
||
</Box>
|
||
<Box grow="Yes">
|
||
<Text size="T200">{model.transients}</Text>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</details>
|
||
|
||
<Text size="T200" priority="400">
|
||
Note: Applying changes requires rejoining the call.
|
||
</Text>
|
||
</Box>
|
||
|
||
{/* ── Enhancement toggles ───────────────────────────────────── */}
|
||
<Box
|
||
direction="Column"
|
||
gap="300"
|
||
style={{
|
||
paddingTop: '12px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Enhancements</Text>
|
||
<SettingTile
|
||
title="Series Suppression"
|
||
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={callDenoiseNativeNS}
|
||
onChange={setCallDenoiseNativeNS}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<SettingTile
|
||
title="Noise Gate"
|
||
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
|
||
after={
|
||
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
|
||
}
|
||
/>
|
||
|
||
{callDenoiseGate && (
|
||
<Box direction="Column" gap="100">
|
||
<Box direction="Row" justifyContent="SpaceBetween">
|
||
<Text size="T200">Gate Threshold</Text>
|
||
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
|
||
</Box>
|
||
<input
|
||
aria-label="Noise gate threshold"
|
||
type="range"
|
||
min="-100"
|
||
max="0"
|
||
step="1"
|
||
value={callDenoiseGateThreshold}
|
||
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
|
||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* ── Test & calibrate ──────────────────────────────────────── */}
|
||
<Box
|
||
direction="Column"
|
||
gap="200"
|
||
style={{
|
||
paddingTop: '12px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Test & calibrate</Text>
|
||
<Text size="T200" priority="300">
|
||
Audition the selected model and tune the gate without joining a call. Changes to the
|
||
settings above apply the next time you start a monitor or play a clip.
|
||
</Text>
|
||
<DenoiseTester
|
||
model={callDenoiseModel}
|
||
useGate={callDenoiseGate}
|
||
gateThreshold={callDenoiseGateThreshold}
|
||
nativeNS={callDenoiseNativeNS}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="After a Restart"
|
||
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
|
||
after={
|
||
<SettingsSelect<'ask' | 'auto' | 'off'>
|
||
value={callRejoin}
|
||
onChange={setCallRejoin}
|
||
options={[
|
||
{ value: 'ask', label: 'Ask' },
|
||
{ value: 'auto', label: 'Rejoin automatically' },
|
||
{ value: 'off', label: 'Do nothing' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
{hapticsSupported() && (
|
||
<SettingTile
|
||
title="Haptic Feedback"
|
||
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
|
||
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
|
||
/>
|
||
)}
|
||
<SettingTile
|
||
title="Push to Talk"
|
||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||
/>
|
||
{pttMode && (
|
||
<SettingTile
|
||
title="PTT Key"
|
||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||
after={
|
||
<Button
|
||
size="300"
|
||
variant={pttBind.listening ? 'Warning' : 'Secondary'}
|
||
fill={pttBind.listening ? 'Solid' : 'Soft'}
|
||
radii="300"
|
||
outlined
|
||
onClick={pttBind.startListening}
|
||
style={{ minWidth: '90px' }}
|
||
>
|
||
<Text size="B300">{pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}</Text>
|
||
</Button>
|
||
}
|
||
/>
|
||
)}
|
||
<SettingTile
|
||
title="Push to Deafen"
|
||
description={
|
||
deafenBind.error ??
|
||
(deafenHotkey
|
||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
||
!isSafeGlobalToggleKey(deafenKey)
|
||
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||
}. Bind an F-key or Numpad key to make it work everywhere.`
|
||
: ''
|
||
}`
|
||
: 'Off — no key toggles deafen. Use the headphone button in the call bar.')
|
||
}
|
||
after={
|
||
<Box alignItems="Center" gap="200">
|
||
<Switch variant="Primary" value={deafenHotkey} onChange={setDeafenHotkey} />
|
||
{deafenHotkey && (
|
||
<Button
|
||
size="300"
|
||
variant={deafenBind.listening ? 'Warning' : 'Secondary'}
|
||
fill={deafenBind.listening ? 'Solid' : 'Soft'}
|
||
radii="300"
|
||
outlined
|
||
onClick={deafenBind.startListening}
|
||
style={{ minWidth: '90px' }}
|
||
>
|
||
<Text size="B300">
|
||
{deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)}
|
||
</Text>
|
||
</Button>
|
||
)}
|
||
</Box>
|
||
}
|
||
/>
|
||
{isTauriEnv() && (
|
||
<SettingTile
|
||
title="Hotkeys Work Outside the Window"
|
||
description="Keep Push to Talk and Push to Deafen working while a game or another app has focus. The keys are only watched during a call and are never blocked from other apps. Windows only; some anti-cheat systems may prevent it."
|
||
after={
|
||
<Switch variant="Primary" value={globalCallHotkeys} onChange={setGlobalCallHotkeys} />
|
||
}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="Warn me when I talk while muted"
|
||
description="Shows a one-time 'You're muted' nudge with an Unmute button when the mic hears you but is muted. Never while Push to Talk is on."
|
||
after={
|
||
<Switch variant="Primary" value={mutedTalkWarning} onChange={setMutedTalkWarning} />
|
||
}
|
||
/>
|
||
<SettingTile
|
||
title="AFK Auto-Mute"
|
||
description="Automatically mute your microphone when you have been silent in a call."
|
||
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||
/>
|
||
{afkAutoMute && (
|
||
<SettingTile
|
||
title="Idle Timeout"
|
||
description="How long to wait before auto-muting."
|
||
after={
|
||
<SettingsSelect
|
||
value={String(afkTimeoutMinutes ?? 10)}
|
||
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
|
||
options={[
|
||
{ value: '1', label: '1 minute' },
|
||
{ value: '5', label: '5 minutes' },
|
||
{ value: '10', label: '10 minutes' },
|
||
{ value: '20', label: '20 minutes' },
|
||
{ value: '30', label: '30 minutes' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Ringtone"
|
||
description="Sound played for incoming calls. Selecting an option plays a preview."
|
||
after={
|
||
<SettingsSelect
|
||
value={ringtoneId}
|
||
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
|
||
options={RINGTONE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||
<Box
|
||
direction="Row"
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||
>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
step="5"
|
||
value={ringtoneVolume}
|
||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||
aria-label="Ringtone volume"
|
||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||
/>
|
||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||
{ringtoneVolume}%
|
||
</Text>
|
||
</Box>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Join & Leave Sounds"
|
||
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
|
||
after={
|
||
<SettingsSelect
|
||
value={callJoinLeaveSound}
|
||
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
|
||
options={[
|
||
{ value: 'off', label: 'Off' },
|
||
{ value: 'chime', label: 'Chime' },
|
||
{ value: 'soft', label: 'Soft' },
|
||
{ value: 'retro', label: 'Retro' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Microphone Bitrate"
|
||
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||
after={
|
||
<SettingsSelect<CallAudioBitrate>
|
||
value={callAudioBitrate}
|
||
onChange={setCallAudioBitrate}
|
||
options={AUDIO_BITRATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Screenshare Bitrate"
|
||
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||
after={
|
||
<SettingsSelect<ScreenshareBitrate>
|
||
value={screenshareBitrate}
|
||
onChange={setScreenshareBitrate}
|
||
options={SCREENSHARE_BITRATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Screenshare Framerate"
|
||
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||
after={
|
||
<SettingsSelect<ScreenshareFramerate>
|
||
value={screenshareFramerate}
|
||
onChange={setScreenshareFramerate}
|
||
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Soundboard"
|
||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||
after={
|
||
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
|
||
}
|
||
/>
|
||
{soundboardEnabled && (
|
||
<>
|
||
<SettingTile title="Soundboard Volume" />
|
||
<Box
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||
>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={soundboardVolume}
|
||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||
style={{ flexGrow: 1 }}
|
||
aria-label="Soundboard volume"
|
||
/>
|
||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||
{soundboardVolume}%
|
||
</Text>
|
||
</Box>
|
||
</>
|
||
)}
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const SEASONAL_OPTIONS: {
|
||
value: Settings['seasonalThemeOverride'];
|
||
label: string;
|
||
emoji: string;
|
||
}[] = [
|
||
{ value: 'auto', label: 'Auto', emoji: '🗓' },
|
||
{ value: 'off', label: 'Off', emoji: '×' },
|
||
{ value: 'newyear', label: 'New Year', emoji: '🎆' },
|
||
{ value: 'lunar', label: 'Lunar New Year', emoji: '🏮' },
|
||
{ value: 'valentines', label: "Valentine's", emoji: '💖' },
|
||
{ value: 'stpatricks', label: "St. Patrick's", emoji: '🍀' },
|
||
{ value: 'aprilfools', label: 'April Fools', emoji: '?' },
|
||
{ value: 'earthday', label: 'Earth Day', emoji: '🌱' },
|
||
{ value: 'autumn', label: 'Autumn', emoji: '🍂' },
|
||
{ value: 'halloween', label: 'Halloween', emoji: '🎃' },
|
||
{ value: 'christmas', label: 'Christmas', emoji: '❄️' },
|
||
{ value: 'arcade', label: 'Arcade Day', emoji: '👾' },
|
||
{ value: 'deepspace', label: 'Deep Space', emoji: '🚀' },
|
||
];
|
||
|
||
function SeasonalBgGrid({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: Settings['seasonalThemeOverride'];
|
||
onChange: (v: Settings['seasonalThemeOverride']) => void;
|
||
}) {
|
||
return (
|
||
<Box
|
||
grow="Yes"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||
gap: config.space.S200,
|
||
}}
|
||
>
|
||
{SEASONAL_OPTIONS.map((opt) => {
|
||
const selected = value === opt.value;
|
||
const isSpecial = opt.value === 'auto' || opt.value === 'off';
|
||
return (
|
||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label={opt.label}
|
||
aria-pressed={selected}
|
||
data-selected={selected}
|
||
className={BgSwatchStyle}
|
||
onClick={() => onChange(opt.value)}
|
||
style={{
|
||
position: 'relative',
|
||
width: toRem(76),
|
||
height: toRem(56),
|
||
backgroundColor: '#030508',
|
||
}}
|
||
>
|
||
{!isSpecial && <SeasonalPreview theme={opt.value as SeasonTheme} />}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundImage:
|
||
opt.value === 'auto'
|
||
? 'linear-gradient(135deg, rgba(255,100,0,0.2), rgba(255,200,0,0.2), rgba(0,200,100,0.2), rgba(0,100,255,0.2))'
|
||
: undefined,
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
{isSpecial && (
|
||
<span style={{ fontSize: '22px', opacity: opt.value === 'off' ? 0.4 : 1 }}>
|
||
{opt.emoji}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
<Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}>
|
||
{opt.label}
|
||
</Text>
|
||
{(opt.value === 'auto' || !isSpecial) && (
|
||
<Text size="T200" style={{ opacity: 0.6, textAlign: 'center' }}>
|
||
{opt.value === 'auto'
|
||
? 'By calendar'
|
||
: SEASON_DATE_RANGES[opt.value as SeasonTheme]}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function ChatBgGrid() {
|
||
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||
const [, setSeasonalThemeOverride] = useSetting(settingsAtom, 'seasonalThemeOverride');
|
||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||
const theme = useTheme();
|
||
const isDark = theme.kind === ThemeKind.Dark;
|
||
const reduced = useReducedMotion();
|
||
|
||
return (
|
||
<Box
|
||
grow="Yes"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||
gap: config.space.S200,
|
||
}}
|
||
>
|
||
{BG_OPTIONS.map((opt) => (
|
||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label={opt.label}
|
||
aria-pressed={chatBackground === opt.value}
|
||
data-selected={chatBackground === opt.value}
|
||
className={BgSwatchStyle}
|
||
onClick={() => {
|
||
setChatBackground(opt.value as ChatBackground);
|
||
if (opt.value !== 'none') setSeasonalThemeOverride('off');
|
||
}}
|
||
style={{
|
||
width: toRem(76),
|
||
height: toRem(50),
|
||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
|
||
}}
|
||
/>
|
||
<Text
|
||
size="T200"
|
||
style={chatBackground === opt.value ? { color: color.Primary.Main } : undefined}
|
||
>
|
||
{opt.label}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function SelectMessageLayout() {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||
const messageLayoutItems = useMessageLayoutItems();
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (layout: MessageLayout) => {
|
||
setMessageLayout(layout);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{messageLayoutItems.map((item) => (
|
||
<MenuItem
|
||
key={item.layout}
|
||
size="300"
|
||
variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.layout)}
|
||
>
|
||
<Text size="T300">{item.name}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SelectMessageSpacing() {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||
const messageSpacingItems = useMessageSpacingItems();
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (layout: MessageSpacing) => {
|
||
setMessageSpacing(layout);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{messageSpacingItems.map((item) => (
|
||
<MenuItem
|
||
key={item.spacing}
|
||
size="300"
|
||
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.spacing)}
|
||
>
|
||
<Text size="T300">{item.name}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Messages() {
|
||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||
settingsAtom,
|
||
'legacyUsernameColor',
|
||
);
|
||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||
settingsAtom,
|
||
'hideMembershipEvents',
|
||
);
|
||
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
|
||
settingsAtom,
|
||
'hideNickAvatarEvents',
|
||
);
|
||
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||
settingsAtom,
|
||
'enforceRetentionLocally',
|
||
);
|
||
const [translateTargetLang, setTranslateTargetLang] = useSetting(
|
||
settingsAtom,
|
||
'translateTargetLang',
|
||
);
|
||
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||
// No Giphy key in this build's config: the switch can't show a GIF button.
|
||
const gifAvailable = !!useClientConfig().gifApiKey;
|
||
const translationSupported = chromeTranslationEngine.isSupported();
|
||
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||
? normalizeLang(translateTargetLang)
|
||
: 'en';
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Messages</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Legacy Username Color"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={legacyUsernameColor}
|
||
onChange={setLegacyUsernameColor}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Membership Change"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={hideMembershipEvents}
|
||
onChange={setHideMembershipEvents}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Profile Change"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={hideNickAvatarEvents}
|
||
onChange={setHideNickAvatarEvents}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Disable Media Auto Load"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={!mediaAutoLoad}
|
||
onChange={(v) => setMediaAutoLoad(!v)}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="URL Preview"
|
||
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
|
||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="URL Preview in Encrypted Rooms"
|
||
description={
|
||
<Box direction="Column" gap="100">
|
||
<Text size="T200" priority="300">
|
||
Your homeserver fetches link previews on your behalf. It cannot decrypt your
|
||
messages, but will see every URL you preview in encrypted rooms, potentially
|
||
revealing conversation topics.
|
||
</Text>
|
||
<Chip
|
||
variant="Warning"
|
||
fill="Soft"
|
||
radii="300"
|
||
size="400"
|
||
style={{ alignSelf: 'flex-start' }}
|
||
>
|
||
<Text size="T200">Privacy risk — enabled by default</Text>
|
||
</Chip>
|
||
</Box>
|
||
}
|
||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Inline Media Players"
|
||
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
|
||
after={
|
||
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="GIF Picker"
|
||
description={
|
||
gifAvailable
|
||
? 'Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default.'
|
||
: "GIF search isn't set up for this version of the app (no Giphy key in its configuration), so there is no GIF button even when this is on."
|
||
}
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={gifPickerEnabled && gifAvailable}
|
||
onChange={setGifPickerEnabled}
|
||
disabled={!gifAvailable}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Show Hidden Events"
|
||
after={
|
||
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Enforce Message Retention"
|
||
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={enforceRetentionLocally}
|
||
onChange={setEnforceRetentionLocally}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Translate Messages Into"
|
||
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.'
|
||
}
|
||
after={
|
||
<SettingsSelect
|
||
value={selectedTargetLang}
|
||
onChange={(v) => setTranslateTargetLang(v)}
|
||
disabled={!translationSupported}
|
||
aria-label="Translate messages into"
|
||
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
|
||
/>
|
||
}
|
||
/>
|
||
{translationSupported && (
|
||
<SettingTile
|
||
title="Auto-translate Incoming Messages"
|
||
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
|
||
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const formatMb = (bytes: number): string => `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||
|
||
function updateProgressText(progress: UpdateProgress | undefined): string {
|
||
if (!progress) return 'Checking for the update…';
|
||
if (progress.phase === 'installing') return 'Installing — Lotus Chat will restart in a moment…';
|
||
if (progress.phase === 'retrying') {
|
||
return `The update server didn’t respond. Trying again in ${progress.waitSecs} s (attempt ${progress.attempt + 1} of ${progress.maxAttempts})…`;
|
||
}
|
||
const attempt =
|
||
progress.attempt > 1 ? ` (attempt ${progress.attempt} of ${progress.maxAttempts})` : '';
|
||
const { downloaded, total } = progress;
|
||
if (!total) return `Downloading update… ${formatMb(downloaded)}${attempt}`;
|
||
const pct = Math.min(100, Math.floor((downloaded / total) * 100));
|
||
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
|
||
}
|
||
|
||
function AppUpdates() {
|
||
const { isTauri, status, check, install } = useTauriUpdater();
|
||
if (!isTauri) return null;
|
||
|
||
const description =
|
||
status.state === 'checking'
|
||
? 'Checking for updates...'
|
||
: status.state === 'up-to-date'
|
||
? 'Lotus Chat is up to date.'
|
||
: status.state === 'available'
|
||
? `Update available: v${status.version}`
|
||
: status.state === 'installing'
|
||
? updateProgressText(status.progress)
|
||
: status.state === 'error'
|
||
? describeUpdateError(status.phase, status.message)
|
||
: 'Check for a new version of Lotus Chat.';
|
||
|
||
const retry = () => {
|
||
if (status.state === 'error' && status.phase !== 'check') install();
|
||
else check();
|
||
};
|
||
|
||
const after =
|
||
status.state === 'available' ? (
|
||
<Button size="300" radii="300" onClick={() => install()}>
|
||
<Text size="B300">Install & Restart</Text>
|
||
</Button>
|
||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||
<Spinner variant="Secondary" size="200" />
|
||
) : status.state === 'error' ? (
|
||
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||
<Text size="B300">Try again</Text>
|
||
</Button>
|
||
{status.phase !== 'check' && (
|
||
<Button
|
||
size="300"
|
||
radii="300"
|
||
variant="Secondary"
|
||
fill="None"
|
||
outlined
|
||
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||
>
|
||
<Text size="B300">Download installer</Text>
|
||
</Button>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||
<Text size="B300">Check</Text>
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">App Updates</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||
{status.state === 'error' && (
|
||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||
Details: {status.message}
|
||
</Text>
|
||
)}
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type GeneralProps = {
|
||
requestClose: () => void;
|
||
};
|
||
export function General({ requestClose }: GeneralProps) {
|
||
return (
|
||
<Page>
|
||
<PageHeader outlined={false}>
|
||
<Box grow="Yes" gap="200">
|
||
<Box grow="Yes" alignItems="Center" gap="200">
|
||
<Text size="H3" truncate>
|
||
General
|
||
</Text>
|
||
</Box>
|
||
<Box shrink="No">
|
||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||
<Icon src={Icons.Cross} />
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
</PageHeader>
|
||
<Box grow="Yes">
|
||
<Scroll hideTrack visibility="Hover">
|
||
<PageContent>
|
||
<Box direction="Column" gap="700">
|
||
<Appearance />
|
||
<DateAndTime />
|
||
<Editor />
|
||
<Messages />
|
||
<Privacy />
|
||
<StorageUsage />
|
||
<SettingsSyncSection />
|
||
<Calls />
|
||
<AppUpdates />
|
||
</Box>
|
||
</PageContent>
|
||
</Scroll>
|
||
</Box>
|
||
</Page>
|
||
);
|
||
}
|