e9a970a75b
Settings dropdowns (Bug #3): - Add reusable SettingsSelect component using Menu+PopOut+FocusTrap — exact same pattern as Message Layout, so all dropdowns look consistent - Replace raw <select> for Seasonal Theme, UI Font, AFK Timeout, and Join & Leave Sounds with SettingsSelect Animated chat backgrounds bleeding onto content (Bug #6 / #7): - Remove filter:brightness() and opacity animations from chatBackground.ts (animRainGlowKeyframe, animGridBrightnessKeyframe, animFirefliesGlowKeyframe, animFirefliesBlinkKeyframe). These were applied to the Page element which caused ALL descendants (messages, composer) to flash in sync. Also created a CSS stacking context on Page that pushed SeasonalEffect (position:fixed; z-index:9997) behind the animated background layer. - Only backgroundPosition / backgroundSize animations remain — safe, do not affect descendants, and do not create stacking contexts. - Remove now-unused animation keyframe imports from chatBackground.ts. Voice ringing in persistent rooms (Bug #5): - Narrow the ringing condition from (Invite|Knock|Restricted) to only Invite, matching exactly the rooms where the call button is visible. - Add room.isCallRoom() early-exit so m.join_rule:call rooms never ring. Avatar decoration images not loading (Bug #8): - Change loading="lazy" → loading="eager" in DecorationPreviewCell. Lazy loading does not reliably trigger for images inside nested overflow scroll containers (the settings panel scroll area), so images never loaded. Docs: LOTUS_BUGS.md updated with root cause and resolution for all 5 new bugs. Docs: LOTUS_TODO.md adds P5-35/P5-36 (deferred desktop notification/jump list). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1730 lines
57 KiB
TypeScript
1730 lines
57 KiB
TypeScript
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 FocusTrap from 'focus-trap-react';
|
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
|
import { SequenceCard } from '../../../components/sequence-card';
|
|
import { useSetting } from '../../../state/hooks/settings';
|
|
import {
|
|
ChatBackground,
|
|
ComposerToolbarSettings,
|
|
DateFormat,
|
|
MessageLayout,
|
|
MessageSpacing,
|
|
settingsAtom,
|
|
} from '../../../state/settings';
|
|
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 { useDateFormatItems } from '../../../hooks/useDateFormat';
|
|
import { SequenceCardStyle } from '../styles.css';
|
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
|
|
|
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="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>
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type SettingsSelectOption<T extends string> = { value: T; label: string };
|
|
|
|
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"
|
|
onClick={() => 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',
|
|
);
|
|
|
|
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">
|
|
<SettingTile
|
|
title="Seasonal Theme"
|
|
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
|
|
after={
|
|
<SettingsSelect
|
|
value={seasonalThemeOverride ?? 'auto'}
|
|
onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
|
|
options={[
|
|
{ value: 'auto', label: '🗓 Auto (date-based)' },
|
|
{ value: 'off', label: 'Off' },
|
|
{ value: 'newyear', label: '🎆 New Year' },
|
|
{ value: 'lunar', label: '🏮 Lunar New Year' },
|
|
{ value: 'valentines', label: '💖 Valentine\'s Day' },
|
|
{ value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
|
|
{ value: 'aprilfools', label: '🃏 April Fool\'s Day' },
|
|
{ value: 'earthday', label: '🌱 Earth Day' },
|
|
{ value: 'autumn', label: '🍂 Autumn' },
|
|
{ value: 'halloween', label: '🎃 Halloween' },
|
|
{ value: 'christmas', label: '❄️ Christmas' },
|
|
{ value: 'arcade', label: '👾 Retro Arcade Day' },
|
|
{ value: 'deepspace', label: '🚀 Deep Space Week' },
|
|
]}
|
|
/>
|
|
}
|
|
/>
|
|
</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" 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>
|
|
|
|
<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={
|
|
<Box alignItems="Center" gap="200">
|
|
<input
|
|
type="color"
|
|
value={mentionHighlightColor || '#4caf50'}
|
|
onChange={(e) => setMentionHighlightColor(e.target.value)}
|
|
style={{
|
|
width: '36px',
|
|
height: '28px',
|
|
cursor: 'pointer',
|
|
borderRadius: '4px',
|
|
border: 'none',
|
|
padding: '2px',
|
|
}}
|
|
/>
|
|
{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>
|
|
</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 [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 handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
|
setCallJoinLeaveSound(value);
|
|
if (value !== 'off') playCallJoinSound(value);
|
|
};
|
|
|
|
const pttBind = useKeyBind(setPttKey);
|
|
const deafenBind = useKeyBind(setDeafenKey);
|
|
|
|
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="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
|
|
after={
|
|
<Switch
|
|
variant="Primary"
|
|
value={callNoiseSuppression}
|
|
onChange={setCallNoiseSuppression}
|
|
/>
|
|
}
|
|
/>
|
|
</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="Join & Leave Sounds"
|
|
description="Play a sound when someone joins or leaves a call you are in."
|
|
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>
|
|
);
|
|
}
|
|
|
|
function ChatBgGrid() {
|
|
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
|
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}
|
|
onClick={() => setChatBackground(opt.value as ChatBackground)}
|
|
style={{
|
|
display: 'block',
|
|
width: toRem(76),
|
|
height: toRem(50),
|
|
borderRadius: toRem(8),
|
|
cursor: 'pointer',
|
|
border:
|
|
chatBackground === opt.value
|
|
? `2px solid ${color.Critical.Main}`
|
|
: '2px solid rgba(128,128,128,0.25)',
|
|
padding: 0,
|
|
overflow: 'hidden',
|
|
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
|
|
}}
|
|
/>
|
|
<Text
|
|
size="T200"
|
|
style={chatBackground === opt.value ? { color: color.Critical.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 & 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>
|
|
);
|
|
}
|