Files
cinny/src/app/features/settings/general/General.tsx
T
jared 4a87588435 feat(calls): selectable incoming-call ringtone (#4)
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the
incoming-call ring is no longer hardcoded to call.ogg. The three synth
styles are generated in-browser via a new utils/ringtones.ts module
(mirroring the existing callSounds.ts WebAudio pattern), so no new binary
assets are bundled; 'classic' keeps the existing call.ogg clip and 'none'
is a silent, visual-only incoming-call UI.

- ringtones.ts: startRingtone() loops until stopped; previewRingtone()
  plays a single non-looping preview and auto-cancels the prior preview.
- IncomingCall: ring driven by the setting; <audio> element removed.
- Settings > Calls: Ringtone selector with on-select preview, beside the
  existing Ringtone Volume slider.
- settings.ts: persisted value whitelisted back to a known id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:25:32 -04:00

2095 lines
72 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,
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 { 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 {
ChatBackground,
ComposerToolbarSettings,
DateFormat,
DenoiseModelId,
MessageLayout,
MessageSpacing,
NoiseSuppressionMode,
RingtoneId,
Settings,
settingsAtom,
} from '../../../state/settings';
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
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 { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
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>
}
/>
</>
);
}
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
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">{selectedLabel}</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 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</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 [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>
<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. Preview below — 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
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>
</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>
);
}
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 toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
};
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
{ key: 'showFormat', label: 'Format' },
{ key: 'showEmoji', label: 'Emoji' },
{ key: 'showSticker', label: 'Sticker' },
{ key: 'showGif', label: 'GIF' },
{ key: 'showLocation', label: 'Location' },
{ key: 'showPoll', label: 'Poll' },
{ key: 'showVoice', label: 'Voice' },
{ key: 'showSchedule', label: 'Schedule' },
];
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="Tap a button to show or hide it in the message composer."
/>
<Box
wrap="Wrap"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
{TOOLBAR_CHIPS.map(({ key, label }) => {
const active = composerToolbarButtons?.[key] ?? true;
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
</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 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: '1px solid var(--border-color)',
background: 'var(--bg-card)',
}}
>
{/* ── 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: '1px solid var(--border-color)',
background: 'var(--bg-input)',
}}
>
<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: '1px solid var(--border-color)',
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: '1px solid var(--border-color)' }}
>
<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
type="range"
min="-100"
max="0"
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
/>
</Box>
)}
</Box>
{/* ── Test & calibrate ──────────────────────────────────────── */}
<Box
direction="Column"
gap="200"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
>
<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: 'var(--accent-orange)' }}
/>
<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>
</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 wrap="Wrap" gap="200">
{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>
</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 wrap="Wrap" gap="200">
{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>
);
}