2025-02-10 16:49:47 +11:00
|
|
|
|
import React, {
|
|
|
|
|
|
ChangeEventHandler,
|
2025-07-27 15:13:00 +03:00
|
|
|
|
FormEventHandler,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
KeyboardEventHandler,
|
|
|
|
|
|
MouseEventHandler,
|
2026-05-15 15:38:02 -04:00
|
|
|
|
useCallback,
|
2025-07-27 15:13:00 +03:00
|
|
|
|
useEffect,
|
2026-05-15 15:38:02 -04:00
|
|
|
|
useRef,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
useState,
|
|
|
|
|
|
} from 'react';
|
2025-07-27 15:13:00 +03:00
|
|
|
|
import dayjs from 'dayjs';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
import {
|
|
|
|
|
|
as,
|
|
|
|
|
|
Box,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Chip,
|
2026-06-01 21:30:27 -04:00
|
|
|
|
color,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
config,
|
2025-07-27 15:13:00 +03:00
|
|
|
|
Header,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
Icon,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Menu,
|
|
|
|
|
|
MenuItem,
|
|
|
|
|
|
PopOut,
|
|
|
|
|
|
RectCords,
|
|
|
|
|
|
Scroll,
|
2026-06-10 20:31:35 -04:00
|
|
|
|
Spinner,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
Switch,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
toRem,
|
|
|
|
|
|
} from 'folds';
|
|
|
|
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
|
|
|
|
import FocusTrap from 'focus-trap-react';
|
|
|
|
|
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
|
|
|
|
|
import { SequenceCard } from '../../../components/sequence-card';
|
2026-06-16 01:04:23 -04:00
|
|
|
|
import {
|
|
|
|
|
|
DENOISE_MODELS,
|
|
|
|
|
|
isMLDenoiseSupported,
|
|
|
|
|
|
ML_DENOISE_REQUIREMENTS,
|
|
|
|
|
|
} from '../../../utils/lotusDenoiseUtils';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
import { useSetting } from '../../../state/hooks/settings';
|
2026-05-21 20:49:33 -04:00
|
|
|
|
import {
|
|
|
|
|
|
ChatBackground,
|
2026-06-10 13:20:29 -04:00
|
|
|
|
ComposerToolbarSettings,
|
2026-05-21 20:49:33 -04:00
|
|
|
|
DateFormat,
|
2026-06-16 01:40:37 -04:00
|
|
|
|
DenoiseModelId,
|
2026-05-21 20:49:33 -04:00
|
|
|
|
MessageLayout,
|
|
|
|
|
|
MessageSpacing,
|
2026-06-15 20:29:59 -04:00
|
|
|
|
NoiseSuppressionMode,
|
2026-06-15 01:14:56 -04:00
|
|
|
|
Settings,
|
2026-05-21 20:49:33 -04:00
|
|
|
|
settingsAtom,
|
|
|
|
|
|
} from '../../../state/settings';
|
2026-06-15 20:29:59 -04:00
|
|
|
|
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
import { SettingTile } from '../../../components/setting-tile';
|
|
|
|
|
|
import { KeySymbol } from '../../../utils/key-symbol';
|
|
|
|
|
|
import { isMacOS } from '../../../utils/user-agent';
|
|
|
|
|
|
import {
|
|
|
|
|
|
DarkTheme,
|
|
|
|
|
|
LightTheme,
|
|
|
|
|
|
Theme,
|
|
|
|
|
|
ThemeKind,
|
|
|
|
|
|
useSystemThemeKind,
|
2026-05-13 21:51:19 -04:00
|
|
|
|
useTheme,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
useThemeNames,
|
|
|
|
|
|
useThemes,
|
|
|
|
|
|
} from '../../../hooks/useTheme';
|
|
|
|
|
|
import { stopPropagation } from '../../../utils/keyboard';
|
2026-05-13 21:51:19 -04:00
|
|
|
|
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
2026-05-13 22:44:34 -04:00
|
|
|
|
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
|
|
|
|
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
2026-06-16 01:40:37 -04:00
|
|
|
|
import { SequenceCardStyle } from '../styles.css';
|
|
|
|
|
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
|
|
|
|
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
|
|
|
|
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
2026-06-16 17:53:57 -04:00
|
|
|
|
import { DenoiseTester } from './DenoiseTester';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
|
|
|
|
|
|
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}>
|
2025-08-04 20:29:12 +05:30
|
|
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
{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>
|
2026-05-21 23:30:50 -04:00
|
|
|
|
),
|
2025-02-10 16:49:47 +11:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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="Primary"
|
|
|
|
|
|
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>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 00:50:12 -04:00
|
|
|
|
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
|
2026-06-14 12:20:47 -04:00
|
|
|
|
|
|
|
|
|
|
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"
|
2026-06-16 00:50:12 -04:00
|
|
|
|
disabled={opt.disabled}
|
|
|
|
|
|
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
2026-06-14 12:20:47 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Text size="T300">{opt.label}</Text>
|
|
|
|
|
|
</MenuItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Menu>
|
|
|
|
|
|
</FocusTrap>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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) }}
|
2026-05-21 12:03:26 -04:00
|
|
|
|
aria-label="Page zoom percentage"
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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');
|
2025-08-25 18:49:14 +05:30
|
|
|
|
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
2025-02-10 16:49:47 +11:00
|
|
|
|
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
2026-05-21 20:49:33 -04:00
|
|
|
|
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
'perMessageProfiles',
|
2026-05-21 20:49:33 -04:00
|
|
|
|
);
|
2026-05-13 22:22:06 -04:00
|
|
|
|
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
2026-06-02 15:36:45 -04:00
|
|
|
|
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
|
|
|
|
|
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
2026-06-04 22:02:18 -04:00
|
|
|
|
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'glassmorphismSidebar',
|
|
|
|
|
|
);
|
2026-06-05 11:46:16 -04:00
|
|
|
|
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
2026-06-10 15:39:35 -04:00
|
|
|
|
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'mentionHighlightColor',
|
|
|
|
|
|
);
|
|
|
|
|
|
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
2026-06-14 00:33:04 -04:00
|
|
|
|
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'seasonalThemeOverride',
|
|
|
|
|
|
);
|
2026-06-18 10:37:44 -04:00
|
|
|
|
const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
2025-02-10 16:49:47 +11:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
2025-08-25 18:49:14 +05:30
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="Monochrome Mode"
|
|
|
|
|
|
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
<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>
|
2026-05-13 21:17:59 -04:00
|
|
|
|
|
2026-05-21 20:49:33 -04:00
|
|
|
|
<SequenceCard
|
|
|
|
|
|
className={SequenceCardStyle}
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
gap="200"
|
|
|
|
|
|
>
|
2026-05-13 21:17:59 -04:00
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="Chat Background"
|
|
|
|
|
|
description="Pattern applied behind the message timeline."
|
|
|
|
|
|
/>
|
2026-05-13 21:51:19 -04:00
|
|
|
|
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
|
|
|
|
|
<ChatBgGrid />
|
|
|
|
|
|
</Box>
|
2026-05-13 21:17:59 -04:00
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
|
2026-06-05 11:46:16 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-06-15 01:14:56 -04:00
|
|
|
|
<SequenceCard
|
|
|
|
|
|
className={SequenceCardStyle}
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
gap="200"
|
|
|
|
|
|
>
|
2026-06-14 00:33:04 -04:00
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="Seasonal Theme"
|
2026-06-15 01:14:56 -04:00
|
|
|
|
description="Decorative overlays for holidays and events. Preview below — click to select."
|
2026-06-14 00:33:04 -04:00
|
|
|
|
/>
|
2026-06-15 01:14:56 -04:00
|
|
|
|
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
|
|
|
|
|
<SeasonalBgGrid
|
|
|
|
|
|
value={seasonalThemeOverride ?? 'auto'}
|
2026-06-18 10:37:44 -04:00
|
|
|
|
onChange={(v) => {
|
|
|
|
|
|
setSeasonalThemeOverride(v);
|
|
|
|
|
|
if (v !== 'auto' && v !== 'off') setChatBackground('none');
|
|
|
|
|
|
}}
|
2026-06-15 01:14:56 -04:00
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
2026-06-14 00:33:04 -04:00
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
|
2026-05-13 21:17:59 -04:00
|
|
|
|
<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."
|
2026-05-21 20:49:33 -04:00
|
|
|
|
after={
|
|
|
|
|
|
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
|
|
|
|
|
|
}
|
2026-05-13 21:17:59 -04:00
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
2026-06-02 15:36:45 -04:00
|
|
|
|
<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" style={{ opacity: 0.7 }}>
|
|
|
|
|
|
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%' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
|
2026-06-04 22:02:18 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-13 22:22:06 -04:00
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="Lotus Terminal Mode"
|
2026-05-16 02:11:52 -04:00
|
|
|
|
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
2026-05-13 22:44:34 -04:00
|
|
|
|
after={
|
2026-05-15 15:38:02 -04:00
|
|
|
|
<Box direction="Row" gap="200" alignItems="Center">
|
2026-05-13 22:44:34 -04:00
|
|
|
|
{lotusTerminal && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-05-21 20:49:33 -04:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
resetBootSequence();
|
|
|
|
|
|
runLotusBootSequence(true);
|
|
|
|
|
|
}}
|
2026-05-13 22:44:34 -04:00
|
|
|
|
title="Replay boot sequence"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: 'transparent',
|
2026-06-01 21:30:27 -04:00
|
|
|
|
border: '1px solid var(--accent-orange-border)',
|
|
|
|
|
|
color: 'var(--accent-orange)',
|
2026-05-13 22:44:34 -04:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2026-05-13 22:22:06 -04:00
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
2026-06-10 15:39:35 -04:00
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="UI Font"
|
|
|
|
|
|
description="Font used throughout the interface."
|
|
|
|
|
|
after={
|
2026-06-14 12:20:47 -04:00
|
|
|
|
<SettingsSelect
|
2026-06-10 15:39:35 -04:00
|
|
|
|
value={fontFamily ?? 'inter'}
|
2026-06-14 12:20:47 -04:00
|
|
|
|
onChange={(v) =>
|
|
|
|
|
|
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
2026-06-10 15:39:35 -04:00
|
|
|
|
}
|
2026-06-14 12:20:47 -04:00
|
|
|
|
options={[
|
|
|
|
|
|
{ value: 'system', label: 'System Default' },
|
|
|
|
|
|
{ value: 'inter', label: 'Inter (default)' },
|
|
|
|
|
|
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
|
|
|
|
|
{ value: 'fira-code', label: 'Fira Code' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-06-10 15:39:35 -04:00
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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={
|
|
|
|
|
|
<Box alignItems="Center" gap="200">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={mentionHighlightColor || '#4caf50'}
|
|
|
|
|
|
onChange={(e) => setMentionHighlightColor(e.target.value)}
|
2026-06-10 22:55:32 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
width: '36px',
|
|
|
|
|
|
height: '28px',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
padding: '2px',
|
|
|
|
|
|
}}
|
2026-06-10 15:39:35 -04:00
|
|
|
|
/>
|
|
|
|
|
|
{mentionHighlightColor && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setMentionHighlightColor('')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: 'none',
|
|
|
|
|
|
border: '1px solid var(--border-interactive-normal)',
|
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
|
padding: '2px 8px',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
color: 'inherit',
|
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Reset
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-27 15:13:00 +03:00
|
|
|
|
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"
|
2026-05-20 21:47:20 -04:00
|
|
|
|
aria-label="Reset to default"
|
2025-07-27 15:13:00 +03:00
|
|
|
|
>
|
|
|
|
|
|
<Icon src={Icons.Cross} size="100" />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={handleOpenMenu}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
2026-05-20 21:47:20 -04:00
|
|
|
|
aria-label="Setting info"
|
2025-07-27 15:13:00 +03:00
|
|
|
|
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"
|
2026-05-21 12:03:26 -04:00
|
|
|
|
aria-label="Custom date format"
|
2025-07-27 15:13:00 +03:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
function Editor() {
|
|
|
|
|
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
|
|
|
|
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
2026-05-13 23:03:14 -04:00
|
|
|
|
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
2026-06-10 13:20:29 -04:00
|
|
|
|
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'composerToolbarButtons',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
|
|
|
|
|
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
|
|
|
|
|
};
|
2025-02-10 16:49:47 +11:00
|
|
|
|
|
2026-06-10 17:15:34 -04:00
|
|
|
|
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' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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>
|
2026-05-13 23:03:14 -04:00
|
|
|
|
<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>
|
2026-06-10 22:55:32 -04:00
|
|
|
|
<SequenceCard
|
|
|
|
|
|
className={SequenceCardStyle}
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
gap="300"
|
|
|
|
|
|
>
|
2026-06-10 13:20:29 -04:00
|
|
|
|
<SettingTile
|
2026-06-10 17:15:34 -04:00
|
|
|
|
title="Composer Toolbar"
|
|
|
|
|
|
description="Tap a button to show or hide it in the message composer."
|
2026-06-10 13:20:29 -04:00
|
|
|
|
/>
|
2026-06-10 17:15:34 -04:00
|
|
|
|
<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>
|
2026-06-10 13:20:29 -04:00
|
|
|
|
</SequenceCard>
|
2026-05-28 19:31:36 -04:00
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Privacy() {
|
|
|
|
|
|
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
|
|
|
|
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
2026-06-02 19:31:30 -04:00
|
|
|
|
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'privateReadReceipts',
|
|
|
|
|
|
);
|
2026-06-03 22:13:22 -04:00
|
|
|
|
const [warnOnUnverifiedDevices, setWarnOnUnverifiedDevices] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'warnOnUnverifiedDevices',
|
|
|
|
|
|
);
|
2026-05-28 19:31:36 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Box direction="Column" gap="100">
|
|
|
|
|
|
<Text size="L400">Privacy</Text>
|
2025-02-26 21:44:53 +11:00
|
|
|
|
<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>
|
2026-06-02 19:31:30 -04:00
|
|
|
|
<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>
|
2026-05-28 19:28:52 -04:00
|
|
|
|
<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>
|
2026-06-03 22:13:22 -04:00
|
|
|
|
<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>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 00:45:43 -04:00
|
|
|
|
function useKeyBind(setter: (code: string) => void) {
|
|
|
|
|
|
const [listening, setListening] = useState(false);
|
|
|
|
|
|
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
2026-05-15 15:38:02 -04:00
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
|
() => () => {
|
2026-06-03 00:45:43 -04:00
|
|
|
|
if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true);
|
2026-05-15 15:38:02 -04:00
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[],
|
2026-05-15 15:38:02 -04:00
|
|
|
|
);
|
2026-05-14 11:07:10 -04:00
|
|
|
|
|
2026-06-03 00:45:43 -04:00
|
|
|
|
const startListening = useCallback(() => {
|
|
|
|
|
|
if (listening) return;
|
|
|
|
|
|
setListening(true);
|
2026-05-14 11:07:10 -04:00
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
2026-06-03 00:45:43 -04:00
|
|
|
|
if (e.code !== 'Escape') setter(e.code);
|
|
|
|
|
|
setListening(false);
|
2026-05-14 11:07:10 -04:00
|
|
|
|
window.removeEventListener('keydown', onKey, true);
|
2026-06-03 00:45:43 -04:00
|
|
|
|
listenerRef.current = null;
|
2026-05-14 11:07:10 -04:00
|
|
|
|
};
|
2026-06-03 00:45:43 -04:00
|
|
|
|
listenerRef.current = onKey;
|
2026-05-14 11:07:10 -04:00
|
|
|
|
window.addEventListener('keydown', onKey, true);
|
2026-06-03 00:45:43 -04:00
|
|
|
|
}, [listening, setter]);
|
|
|
|
|
|
|
|
|
|
|
|
return { listening, startListening };
|
|
|
|
|
|
}
|
2026-05-14 11:07:10 -04:00
|
|
|
|
|
2026-06-03 00:45:43 -04:00
|
|
|
|
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',
|
|
|
|
|
|
);
|
2026-06-16 00:50:12 -04:00
|
|
|
|
const [callDenoiseModel, setCallDenoiseModel] = useSetting(settingsAtom, 'callDenoiseModel');
|
|
|
|
|
|
const [callDenoiseNativeNS, setCallDenoiseNativeNS] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'callDenoiseNativeNS',
|
|
|
|
|
|
);
|
|
|
|
|
|
const [callDenoiseGate, setCallDenoiseGate] = useSetting(settingsAtom, 'callDenoiseGate');
|
|
|
|
|
|
const [callDenoiseGateThreshold, setCallDenoiseGateThreshold] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'callDenoiseGateThreshold',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-03 00:45:43 -04:00
|
|
|
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
|
|
|
|
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
|
|
|
|
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
2026-06-12 19:53:19 -04:00
|
|
|
|
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
|
|
|
|
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
2026-06-12 22:20:22 -04:00
|
|
|
|
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
'callJoinLeaveSound',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
|
|
|
|
|
setCallJoinLeaveSound(value);
|
|
|
|
|
|
if (value !== 'off') playCallJoinSound(value);
|
|
|
|
|
|
};
|
2026-06-03 00:45:43 -04:00
|
|
|
|
|
|
|
|
|
|
const pttBind = useKeyBind(setPttKey);
|
|
|
|
|
|
const deafenBind = useKeyBind(setDeafenKey);
|
2026-05-14 11:07:10 -04:00
|
|
|
|
|
2026-06-16 00:50:12 -04:00
|
|
|
|
const mlSupported = isMLDenoiseSupported();
|
2026-06-17 20:02:16 -04:00
|
|
|
|
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
2026-06-16 00:50:12 -04:00
|
|
|
|
|
2026-05-14 11:07:10 -04:00
|
|
|
|
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"
|
2026-06-16 00:50:12 -04:00
|
|
|
|
description={
|
|
|
|
|
|
<Box direction="Column" gap="200">
|
|
|
|
|
|
<Text>
|
2026-06-16 01:40:37 -04:00
|
|
|
|
Filter background noise from your mic during calls. Browser-native uses the built-in
|
2026-06-17 20:02:16 -04:00
|
|
|
|
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
2026-06-16 00:50:12 -04:00
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
|
|
{!mlSupported && (
|
|
|
|
|
|
<Box direction="Column" gap="100">
|
2026-06-16 01:40:37 -04:00
|
|
|
|
<Text size="T200" priority="400">
|
2026-06-16 00:50:12 -04:00
|
|
|
|
ML options are not supported in this browser.
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Box as="ul" style={{ paddingLeft: '20px', margin: 0 }}>
|
|
|
|
|
|
{ML_DENOISE_REQUIREMENTS.map((req) => (
|
2026-06-16 01:40:37 -04:00
|
|
|
|
<Text as="li" key={req} size="T200">
|
2026-06-16 00:50:12 -04:00
|
|
|
|
{req}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
}
|
2026-05-21 20:49:33 -04:00
|
|
|
|
after={
|
2026-06-15 20:29:59 -04:00
|
|
|
|
<SettingsSelect<NoiseSuppressionMode>
|
2026-05-21 20:49:33 -04:00
|
|
|
|
value={callNoiseSuppression}
|
|
|
|
|
|
onChange={setCallNoiseSuppression}
|
2026-06-15 20:29:59 -04:00
|
|
|
|
options={[
|
|
|
|
|
|
{ value: 'off', label: 'Off' },
|
|
|
|
|
|
{ value: 'browser', label: 'Browser-native' },
|
2026-06-16 00:50:12 -04:00
|
|
|
|
{
|
|
|
|
|
|
value: 'ml',
|
|
|
|
|
|
label: 'ML (Advanced)',
|
|
|
|
|
|
disabled: !mlSupported,
|
|
|
|
|
|
},
|
2026-06-15 20:29:59 -04:00
|
|
|
|
]}
|
2026-05-21 20:49:33 -04:00
|
|
|
|
/>
|
|
|
|
|
|
}
|
2026-05-14 11:07:10 -04:00
|
|
|
|
/>
|
2026-06-16 00:50:12 -04:00
|
|
|
|
|
|
|
|
|
|
{callNoiseSuppression === 'ml' && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
direction="Column"
|
2026-06-17 20:02:16 -04:00
|
|
|
|
gap="400"
|
2026-06-16 00:50:12 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
padding: '16px',
|
|
|
|
|
|
marginTop: '8px',
|
2026-06-17 20:02:16 -04:00
|
|
|
|
borderTop: '1px solid var(--border-color)',
|
|
|
|
|
|
background: 'var(--bg-card)',
|
2026-06-16 00:50:12 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-06-17 20:02:16 -04:00
|
|
|
|
{/* ── 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>
|
|
|
|
|
|
)}
|
2026-06-16 00:50:12 -04:00
|
|
|
|
|
2026-06-17 20:02:16 -04:00
|
|
|
|
<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>
|
2026-06-16 00:50:12 -04:00
|
|
|
|
|
2026-06-17 20:02:16 -04:00
|
|
|
|
<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} />
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-06-16 00:50:12 -04:00
|
|
|
|
|
2026-06-17 20:02:16 -04:00
|
|
|
|
{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)' }}
|
|
|
|
|
|
/>
|
2026-06-16 00:50:12 -04:00
|
|
|
|
</Box>
|
2026-06-17 20:02:16 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</Box>
|
2026-06-16 17:53:57 -04:00
|
|
|
|
|
2026-06-17 20:02:16 -04:00
|
|
|
|
{/* ── Test & calibrate ──────────────────────────────────────── */}
|
2026-06-16 17:53:57 -04:00
|
|
|
|
<Box
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
gap="200"
|
2026-06-17 20:02:16 -04:00
|
|
|
|
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
|
2026-06-16 17:53:57 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Text size="L400">Test & calibrate</Text>
|
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
|
Audition the selected model and tune the gate without joining a call. Changes to the
|
|
|
|
|
|
settings above apply the next time you start a monitor or play a clip.
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<DenoiseTester
|
|
|
|
|
|
model={callDenoiseModel}
|
|
|
|
|
|
useGate={callDenoiseGate}
|
|
|
|
|
|
gateThreshold={callDenoiseGateThreshold}
|
|
|
|
|
|
nativeNS={callDenoiseNativeNS}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Box>
|
2026-06-16 00:50:12 -04:00
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
2026-05-14 11:07:10 -04:00
|
|
|
|
</SequenceCard>
|
2026-05-21 20:49:33 -04:00
|
|
|
|
<SequenceCard
|
|
|
|
|
|
className={SequenceCardStyle}
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
gap="400"
|
|
|
|
|
|
>
|
2026-05-14 11:07:10 -04:00
|
|
|
|
<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
|
2026-06-03 00:45:43 -04:00
|
|
|
|
title="PTT Key"
|
2026-05-14 11:07:10 -04:00
|
|
|
|
description="Press a key to bind it as your push-to-talk key."
|
|
|
|
|
|
after={
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="300"
|
2026-06-03 00:45:43 -04:00
|
|
|
|
variant={pttBind.listening ? 'Warning' : 'Secondary'}
|
|
|
|
|
|
fill={pttBind.listening ? 'Solid' : 'Soft'}
|
2026-05-14 11:07:10 -04:00
|
|
|
|
radii="300"
|
|
|
|
|
|
outlined
|
2026-06-03 00:45:43 -04:00
|
|
|
|
onClick={pttBind.startListening}
|
2026-05-14 11:07:10 -04:00
|
|
|
|
style={{ minWidth: '90px' }}
|
|
|
|
|
|
>
|
2026-06-03 00:45:43 -04:00
|
|
|
|
<Text size="B300">{pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}</Text>
|
2026-05-14 11:07:10 -04:00
|
|
|
|
</Button>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-03 00:45:43 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-05-14 11:07:10 -04:00
|
|
|
|
</SequenceCard>
|
2026-06-12 19:53:19 -04:00
|
|
|
|
<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={
|
2026-06-14 12:20:47 -04:00
|
|
|
|
<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' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-06-12 19:53:19 -04:00
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SequenceCard>
|
2026-06-12 22:20:22 -04:00
|
|
|
|
<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."
|
|
|
|
|
|
after={
|
2026-06-14 12:20:47 -04:00
|
|
|
|
<SettingsSelect
|
2026-06-12 22:20:22 -04:00
|
|
|
|
value={callJoinLeaveSound}
|
2026-06-14 12:20:47 -04:00
|
|
|
|
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' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-06-12 22:20:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
2026-05-14 11:07:10 -04:00
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 20:29:59 -04:00
|
|
|
|
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: '🚀' },
|
|
|
|
|
|
];
|
2026-06-15 01:14:56 -04:00
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
onClick={() => onChange(opt.value)}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
display: 'block',
|
|
|
|
|
|
width: toRem(76),
|
|
|
|
|
|
height: toRem(56),
|
|
|
|
|
|
borderRadius: toRem(8),
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
border: selected
|
|
|
|
|
|
? `2px solid ${color.Critical.Main}`
|
|
|
|
|
|
: '2px solid rgba(128,128,128,0.25)',
|
|
|
|
|
|
padding: 0,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
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.Critical.Main } : undefined}>
|
|
|
|
|
|
{opt.label}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 21:51:19 -04:00
|
|
|
|
function ChatBgGrid() {
|
2026-05-13 21:17:59 -04:00
|
|
|
|
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
2026-06-18 10:37:44 -04:00
|
|
|
|
const [, setSeasonalThemeOverride] = useSetting(settingsAtom, 'seasonalThemeOverride');
|
2026-06-05 11:46:16 -04:00
|
|
|
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
2026-05-13 21:51:19 -04:00
|
|
|
|
const theme = useTheme();
|
|
|
|
|
|
const isDark = theme.kind === ThemeKind.Dark;
|
2026-05-13 21:17:59 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-13 21:51:19 -04:00
|
|
|
|
<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}
|
2026-06-18 10:37:44 -04:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setChatBackground(opt.value as ChatBackground);
|
|
|
|
|
|
if (opt.value !== 'none') setSeasonalThemeOverride('off');
|
|
|
|
|
|
}}
|
2026-05-13 21:51:19 -04:00
|
|
|
|
style={{
|
|
|
|
|
|
display: 'block',
|
|
|
|
|
|
width: toRem(76),
|
|
|
|
|
|
height: toRem(50),
|
|
|
|
|
|
borderRadius: toRem(8),
|
|
|
|
|
|
cursor: 'pointer',
|
2026-05-21 20:49:33 -04:00
|
|
|
|
border:
|
|
|
|
|
|
chatBackground === opt.value
|
2026-06-01 21:30:27 -04:00
|
|
|
|
? `2px solid ${color.Critical.Main}`
|
2026-05-21 20:49:33 -04:00
|
|
|
|
: '2px solid rgba(128,128,128,0.25)',
|
2026-05-13 21:51:19 -04:00
|
|
|
|
padding: 0,
|
|
|
|
|
|
overflow: 'hidden',
|
2026-06-05 11:46:16 -04:00
|
|
|
|
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
|
2026-05-13 21:17:59 -04:00
|
|
|
|
}}
|
2026-05-13 21:51:19 -04:00
|
|
|
|
/>
|
2026-06-01 21:38:35 -04:00
|
|
|
|
<Text
|
|
|
|
|
|
size="T200"
|
|
|
|
|
|
style={chatBackground === opt.value ? { color: color.Critical.Main } : undefined}
|
|
|
|
|
|
>
|
2026-05-13 21:51:19 -04:00
|
|
|
|
{opt.label}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Box>
|
2026-05-13 21:17:59 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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>
|
2025-08-04 20:29:12 +05:30
|
|
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
{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>
|
2025-08-04 20:29:12 +05:30
|
|
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
{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() {
|
2025-03-23 22:09:29 +11:00
|
|
|
|
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
'legacyUsernameColor',
|
2025-03-23 22:09:29 +11:00
|
|
|
|
);
|
2025-02-10 16:49:47 +11:00
|
|
|
|
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
'hideMembershipEvents',
|
2025-02-10 16:49:47 +11:00
|
|
|
|
);
|
|
|
|
|
|
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
|
|
|
|
|
|
settingsAtom,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
'hideNickAvatarEvents',
|
2025-02-10 16:49:47 +11:00
|
|
|
|
);
|
|
|
|
|
|
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>
|
2025-03-23 22:09:29 +11:00
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
|
|
|
|
|
title="Legacy Username Color"
|
|
|
|
|
|
after={
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
variant="Primary"
|
|
|
|
|
|
value={legacyUsernameColor}
|
|
|
|
|
|
onChange={setLegacyUsernameColor}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
2025-02-10 16:49:47 +11:00
|
|
|
|
<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"
|
2025-02-26 21:44:53 +11:00
|
|
|
|
after={
|
|
|
|
|
|
<Switch
|
|
|
|
|
|
variant="Primary"
|
|
|
|
|
|
value={!mediaAutoLoad}
|
|
|
|
|
|
onChange={(v) => setMediaAutoLoad(!v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
2025-02-10 16:49:47 +11:00
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
2026-06-01 21:30:27 -04:00
|
|
|
|
title="URL Preview"
|
|
|
|
|
|
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
|
2025-02-10 16:49:47 +11:00
|
|
|
|
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SequenceCard>
|
|
|
|
|
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
|
|
|
|
<SettingTile
|
2026-06-01 21:30:27 -04:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 20:31:35 -04:00
|
|
|
|
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 & 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
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">
|
2026-05-20 21:54:33 -04:00
|
|
|
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
2025-02-10 16:49:47 +11:00
|
|
|
|
<Icon src={Icons.Cross} />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</PageHeader>
|
|
|
|
|
|
<Box grow="Yes">
|
|
|
|
|
|
<Scroll hideTrack visibility="Hover">
|
|
|
|
|
|
<PageContent>
|
|
|
|
|
|
<Box direction="Column" gap="700">
|
|
|
|
|
|
<Appearance />
|
2025-07-27 15:13:00 +03:00
|
|
|
|
<DateAndTime />
|
2025-02-10 16:49:47 +11:00
|
|
|
|
<Editor />
|
|
|
|
|
|
<Messages />
|
2026-05-28 19:31:36 -04:00
|
|
|
|
<Privacy />
|
2026-05-14 11:07:10 -04:00
|
|
|
|
<Calls />
|
2026-06-10 20:31:35 -04:00
|
|
|
|
<AppUpdates />
|
2025-02-10 16:49:47 +11:00
|
|
|
|
</Box>
|
|
|
|
|
|
</PageContent>
|
|
|
|
|
|
</Scroll>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Page>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|