Files
cinny/src/app/features/settings/general/General.tsx
T
jared 4380041014 feat(a11y): label form controls + overlays (P3-4)
Accessible names for ~15 controls that lacked them: invite/join/create-room/
account-data/image-pack/private-note/power-level inputs (visible <label htmlFor>
where a label exists, else aria-label); the two range sliders (night-light
intensity, noise-gate threshold); the soundboard file input; media <video>
elements; and the Media Gallery (region) + Search (dialog) overlays. Hidden
notification/preview <audio> marked aria-hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:45:21 -04:00

2389 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
ChangeEventHandler,
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 { 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 {
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 { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
/**
* 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;
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 — toggles instantly."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
/>
</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);
};
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'
) {
const newZoom = parseInt(evt.target.value, 10);
if (Number.isNaN(newZoom)) return;
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
setPageZoom(safeZoom);
setCurrentZoom(safeZoom.toString());
}
};
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}
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 [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 />
<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);
if (v !== 'auto' && 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="100"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<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>
)}
</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 [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="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',
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Privacy</Text>
<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>
);
}
function useKeyBind(setter: (code: string) => void) {
const [listening, setListening] = useState(false);
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect(
() => () => {
if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true);
},
[],
);
const startListening = useCallback(() => {
if (listening) return;
setListening(true);
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code !== 'Escape') setter(e.code);
setListening(false);
window.removeEventListener('keydown', onKey, true);
listenerRef.current = null;
};
listenerRef.current = onKey;
window.addEventListener('keydown', onKey, true);
}, [listening, setter]);
return { listening, startListening };
}
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 [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
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);
const deafenBind = useKeyBind(setDeafenKey);
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 &amp; 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="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="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="Toggle speaker mute during a call. Press Escape to cancel rebind."
after={
<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>
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<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."
after={
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
<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"
after={
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
<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;
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),
}}
/>
<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 [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
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="Show Hidden Events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
/>
</SequenceCard>
</Box>
);
}
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'
? 'Installing update, the app will restart shortly...'
: status.state === 'error'
? `Update check failed: ${status.message}`
: 'Check for a new version of Lotus Chat.';
const after =
status.state === 'available' ? (
<Button size="300" radii="300" onClick={install}>
<Text size="B300">Install &amp; Restart</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : (
<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} />
</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 />
<Calls />
<AppUpdates />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}