6c903fe3e5
Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard renders the media-forward click-to-play facade for all of them — landscape 16:9 (YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok), and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as before: nothing loads from the third party until the user presses play, gated by the 'Inline Media Players' setting. Twitch embeds pass the current page hostname as the required parent param. Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card. All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases). Desktop Tauri CSP frame-src updated for the new hosts (separate commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2460 lines
85 KiB
TypeScript
2460 lines
85 KiB
TypeScript
import React, {
|
||
ChangeEventHandler,
|
||
FormEventHandler,
|
||
KeyboardEventHandler,
|
||
MouseEventHandler,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
as,
|
||
Box,
|
||
Button,
|
||
Chip,
|
||
color,
|
||
config,
|
||
Header,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Input,
|
||
Menu,
|
||
MenuItem,
|
||
PopOut,
|
||
RectCords,
|
||
Scroll,
|
||
Spinner,
|
||
Switch,
|
||
Text,
|
||
toRem,
|
||
} from 'folds';
|
||
import { isKeyHotkey } from 'is-hotkey';
|
||
import { HexColorPicker } from 'react-colorful';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import {
|
||
draggable,
|
||
dropTargetForElements,
|
||
monitorForElements,
|
||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
|
||
import {
|
||
attachClosestEdge,
|
||
extractClosestEdge,
|
||
Edge,
|
||
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||
import { useAtom } from 'jotai';
|
||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
|
||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||
import { SequenceCard } from '../../../components/sequence-card';
|
||
import {
|
||
DENOISE_MODELS,
|
||
isMLDenoiseSupported,
|
||
ML_DENOISE_REQUIREMENTS,
|
||
} from '../../../utils/lotusDenoiseUtils';
|
||
import { useSetting } from '../../../state/hooks/settings';
|
||
import {
|
||
CallAudioBitrate,
|
||
ChatBackground,
|
||
ComposerToolbarButtonKey,
|
||
ComposerToolbarSettings,
|
||
DateFormat,
|
||
DenoiseModelId,
|
||
MessageLayout,
|
||
MessageSpacing,
|
||
NoiseSuppressionMode,
|
||
normalizeComposerToolbarOrder,
|
||
RingtoneId,
|
||
ScreenshareBitrate,
|
||
ScreenshareFramerate,
|
||
Settings,
|
||
settingsAtom,
|
||
} from '../../../state/settings';
|
||
import {
|
||
AUDIO_BITRATE_OPTIONS,
|
||
SCREENSHARE_BITRATE_OPTIONS,
|
||
SCREENSHARE_FRAMERATE_OPTIONS,
|
||
} from '../../../utils/callQuality';
|
||
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
||
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
|
||
import { SettingTile } from '../../../components/setting-tile';
|
||
import { KeySymbol } from '../../../utils/key-symbol';
|
||
import { isMacOS } from '../../../utils/user-agent';
|
||
import {
|
||
DarkTheme,
|
||
LightTheme,
|
||
Theme,
|
||
ThemeKind,
|
||
useSystemThemeKind,
|
||
useTheme,
|
||
useThemeNames,
|
||
useThemes,
|
||
} from '../../../hooks/useTheme';
|
||
import { stopPropagation } from '../../../utils/keyboard';
|
||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||
import { SequenceCardStyle } from '../styles.css';
|
||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||
import { DenoiseTester } from './DenoiseTester';
|
||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||
|
||
/**
|
||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
|
||
* (mounted in App.tsx) applies `set_decorations` when this flips.
|
||
*/
|
||
function DesktopChromeSetting() {
|
||
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
|
||
if (!isTauriEnv()) return null;
|
||
// Persist the flag, then reload so the window layout is rebuilt from scratch.
|
||
// Toggling live reflows the whole app while the room timeline is mounted, which
|
||
// resizes its virtualized scroll container and triggers runaway back-pagination
|
||
// (the "screen expands + auto-scrolls into the past" bug). A reload applies the
|
||
// chrome cleanly against a fresh, correct layout.
|
||
const handleToggle = (value: boolean) => {
|
||
setCustomChrome(value);
|
||
window.location.reload();
|
||
};
|
||
return (
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Custom Window Chrome (Beta)"
|
||
description="Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
|
||
after={<Switch variant="Primary" value={customChrome} onChange={handleToggle} />}
|
||
/>
|
||
</SequenceCard>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* P6-1 — "Launch on login" toggle (desktop only). Renders nothing in the
|
||
* browser. Reads the current state from the `autostart` plugin on mount and
|
||
* enables/disables it via the plugin commands when flipped. Not backed by an
|
||
* atom — the OS registration is the source of truth, mirrored into local state.
|
||
*/
|
||
function AutostartSetting() {
|
||
const [enabled, setEnabled] = useState(false);
|
||
|
||
useEffect(() => {
|
||
tauriInvoke()?.('plugin:autostart|is_enabled')
|
||
.then((value) => setEnabled(value === true))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
const handleChange = (value: boolean) => {
|
||
invokeTauri(value ? 'plugin:autostart|enable' : 'plugin:autostart|disable');
|
||
setEnabled(value);
|
||
};
|
||
|
||
if (!isTauriEnv()) return null;
|
||
return (
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Launch on login"
|
||
description="Start Lotus Chat automatically when you sign in to your computer."
|
||
after={<Switch variant="Primary" value={enabled} onChange={handleChange} />}
|
||
/>
|
||
</SequenceCard>
|
||
);
|
||
}
|
||
|
||
type ThemeSelectorProps = {
|
||
themeNames: Record<string, string>;
|
||
themes: Theme[];
|
||
selected: Theme;
|
||
onSelect: (theme: Theme) => void;
|
||
};
|
||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||
<Menu {...props} ref={ref}>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{themes.map((theme) => (
|
||
<MenuItem
|
||
key={theme.id}
|
||
size="300"
|
||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => onSelect(theme)}
|
||
>
|
||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
),
|
||
);
|
||
|
||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||
const themes = useThemes();
|
||
const themeNames = useThemeNames();
|
||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||
|
||
const handleThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleThemeSelect = (theme: Theme) => {
|
||
setThemeId(theme.id);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={disabled ? undefined : handleThemeMenu}
|
||
aria-disabled={disabled}
|
||
>
|
||
<Text size="T300">{themeNames[selectedTheme.id] ?? selectedTheme.id}</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={themes}
|
||
selected={selectedTheme}
|
||
onSelect={handleThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SystemThemePreferences() {
|
||
const themeKind = useSystemThemeKind();
|
||
const themeNames = useThemeNames();
|
||
const themes = useThemes();
|
||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||
|
||
const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light);
|
||
const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark);
|
||
|
||
const selectedLightTheme = lightThemes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
|
||
const selectedDarkTheme = darkThemes.find((theme) => theme.id === darkThemeId) ?? DarkTheme;
|
||
|
||
const [ltCords, setLTCords] = useState<RectCords>();
|
||
const [dtCords, setDTCords] = useState<RectCords>();
|
||
|
||
const handleLightThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setLTCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
const handleDarkThemeMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setDTCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleLightThemeSelect = (theme: Theme) => {
|
||
setLightThemeId(theme.id);
|
||
setLTCords(undefined);
|
||
};
|
||
|
||
const handleDarkThemeSelect = (theme: Theme) => {
|
||
setDarkThemeId(theme.id);
|
||
setDTCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<Box wrap="Wrap" gap="400">
|
||
<SettingTile
|
||
title="Light Theme:"
|
||
after={
|
||
<Chip
|
||
variant={themeKind === ThemeKind.Light ? 'Primary' : 'Secondary'}
|
||
outlined={themeKind === ThemeKind.Light}
|
||
radii="Pill"
|
||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||
onClick={handleLightThemeMenu}
|
||
>
|
||
<Text size="B300">{themeNames[selectedLightTheme.id] ?? selectedLightTheme.id}</Text>
|
||
</Chip>
|
||
}
|
||
/>
|
||
<PopOut
|
||
anchor={ltCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setLTCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={lightThemes}
|
||
selected={selectedLightTheme}
|
||
onSelect={handleLightThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
<SettingTile
|
||
title="Dark Theme:"
|
||
after={
|
||
<Chip
|
||
variant={themeKind === ThemeKind.Dark ? 'Primary' : 'Secondary'}
|
||
outlined={themeKind === ThemeKind.Dark}
|
||
radii="Pill"
|
||
after={<Icon size="200" src={Icons.ChevronBottom} />}
|
||
onClick={handleDarkThemeMenu}
|
||
>
|
||
<Text size="B300">{themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id}</Text>
|
||
</Chip>
|
||
}
|
||
/>
|
||
<PopOut
|
||
anchor={dtCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setDTCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<ThemeSelector
|
||
themeNames={themeNames}
|
||
themes={darkThemes}
|
||
selected={selectedDarkTheme}
|
||
onSelect={handleDarkThemeSelect}
|
||
/>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function PageZoomInput() {
|
||
const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom');
|
||
const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`);
|
||
|
||
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||
setCurrentZoom(evt.target.value);
|
||
};
|
||
|
||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||
if (isKeyHotkey('escape', evt)) {
|
||
evt.stopPropagation();
|
||
setCurrentZoom(pageZoom.toString());
|
||
}
|
||
if (
|
||
isKeyHotkey('enter', evt) &&
|
||
'value' in evt.target &&
|
||
typeof evt.target.value === 'string'
|
||
) {
|
||
const newZoom = parseInt(evt.target.value, 10);
|
||
if (Number.isNaN(newZoom)) return;
|
||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||
setPageZoom(safeZoom);
|
||
setCurrentZoom(safeZoom.toString());
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Input
|
||
style={{ width: toRem(100) }}
|
||
aria-label="Page zoom percentage"
|
||
variant={pageZoom === parseInt(currentZoom, 10) ? 'Secondary' : 'Success'}
|
||
size="300"
|
||
radii="300"
|
||
type="number"
|
||
min="75"
|
||
max="150"
|
||
value={currentZoom}
|
||
onChange={handleZoomChange}
|
||
onKeyDown={handleZoomEnter}
|
||
after={<Text size="T300">%</Text>}
|
||
outlined
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Appearance() {
|
||
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
|
||
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
|
||
settingsAtom,
|
||
'perMessageProfiles',
|
||
);
|
||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
||
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
||
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
|
||
settingsAtom,
|
||
'glassmorphismSidebar',
|
||
);
|
||
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
|
||
settingsAtom,
|
||
'mentionHighlightColor',
|
||
);
|
||
const [customAccentColor, setCustomAccentColor] = useSetting(settingsAtom, 'customAccentColor');
|
||
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
||
settingsAtom,
|
||
'seasonalThemeOverride',
|
||
);
|
||
const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Appearance</Text>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="System Theme"
|
||
description="Choose between light and dark theme based on system preference."
|
||
after={<Switch variant="Primary" value={systemTheme} onChange={setSystemTheme} />}
|
||
/>
|
||
{systemTheme && <SystemThemePreferences />}
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Theme"
|
||
description="Theme to use when system theme is not enabled."
|
||
after={<SelectTheme disabled={systemTheme} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Monochrome Mode"
|
||
after={<Switch variant="Primary" value={monochromeMode} onChange={setMonochromeMode} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<DesktopChromeSetting />
|
||
<AutostartSetting />
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Twitter Emoji"
|
||
after={<Switch variant="Primary" value={twitterEmoji} onChange={setTwitterEmoji} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||
</SequenceCard>
|
||
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Chat Background"
|
||
description="Pattern applied behind the message timeline."
|
||
/>
|
||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||
<ChatBgGrid />
|
||
</Box>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Pause Background Animations"
|
||
description="Stop background animation to reduce motion or improve performance."
|
||
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Seasonal Theme"
|
||
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
|
||
/>
|
||
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
|
||
<SeasonalBgGrid
|
||
value={seasonalThemeOverride ?? 'auto'}
|
||
onChange={(v) => {
|
||
setSeasonalThemeOverride(v);
|
||
if (v !== 'auto' && v !== 'off') setChatBackground('none');
|
||
}}
|
||
/>
|
||
</Box>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Show Profile on Every Message"
|
||
description="Display avatar and name on each message instead of grouping consecutive messages."
|
||
after={
|
||
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="200"
|
||
>
|
||
<SettingTile
|
||
title="Night Light"
|
||
description="Reduce blue light with a warm orange tint overlay."
|
||
after={
|
||
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
|
||
}
|
||
/>
|
||
{nightLightEnabled && (
|
||
<Box
|
||
direction="Column"
|
||
gap="100"
|
||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||
>
|
||
<Text size="T200" priority="300">
|
||
Intensity: {nightLightOpacity}%
|
||
</Text>
|
||
<input
|
||
aria-label="Night light intensity"
|
||
type="range"
|
||
min={5}
|
||
max={80}
|
||
value={nightLightOpacity}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setNightLightOpacity(parseInt(e.target.value, 10))
|
||
}
|
||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Glassmorphism Sidebar"
|
||
description="Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={glassmorphismSidebar}
|
||
onChange={setGlassmorphismSidebar}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Lotus Terminal Mode"
|
||
description="LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
|
||
after={
|
||
<Box direction="Row" gap="200" alignItems="Center">
|
||
{lotusTerminal && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetBootSequence();
|
||
runLotusBootSequence(true);
|
||
}}
|
||
title="Replay boot sequence"
|
||
style={{
|
||
background: 'transparent',
|
||
border: '1px solid var(--accent-orange-border)',
|
||
color: 'var(--accent-orange)',
|
||
fontSize: '0.65rem',
|
||
padding: '0.2rem 0.6rem',
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
letterSpacing: '0.1em',
|
||
textTransform: 'uppercase',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
▶ Boot
|
||
</button>
|
||
)}
|
||
<Switch variant="Primary" value={lotusTerminal} onChange={setLotusTerminal} />
|
||
</Box>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="UI Font"
|
||
description="Font used throughout the interface."
|
||
after={
|
||
<SettingsSelect
|
||
value={fontFamily ?? 'inter'}
|
||
onChange={(v) =>
|
||
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||
}
|
||
options={[
|
||
{ value: 'system', label: 'System Default' },
|
||
{ value: 'inter', label: 'Inter (default)' },
|
||
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||
{ value: 'fira-code', label: 'Fira Code' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="@Mention Highlight Color"
|
||
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||
after={
|
||
<HexColorPickerPopOut
|
||
picker={
|
||
<HexColorPicker
|
||
color={mentionHighlightColor || '#4caf50'}
|
||
onChange={setMentionHighlightColor}
|
||
/>
|
||
}
|
||
onRemove={mentionHighlightColor ? () => setMentionHighlightColor('') : undefined}
|
||
>
|
||
{(openPicker, opened) => (
|
||
<Button
|
||
type="button"
|
||
aria-pressed={opened}
|
||
onClick={openPicker}
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
before={
|
||
<span
|
||
style={{
|
||
width: toRem(16),
|
||
height: toRem(16),
|
||
borderRadius: config.radii.R300,
|
||
background: mentionHighlightColor || color.Primary.Main,
|
||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||
display: 'inline-block',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<Text size="B300">{mentionHighlightColor ? 'Change' : 'Pick'}</Text>
|
||
</Button>
|
||
)}
|
||
</HexColorPickerPopOut>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Custom Accent Color"
|
||
description={
|
||
lotusTerminal
|
||
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
|
||
: 'Recolors the app accent (buttons, active states, links, selected states). Leave empty to use the theme default.'
|
||
}
|
||
after={
|
||
<HexColorPickerPopOut
|
||
picker={
|
||
<HexColorPicker
|
||
color={customAccentColor || color.Primary.Main}
|
||
onChange={setCustomAccentColor}
|
||
/>
|
||
}
|
||
onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined}
|
||
>
|
||
{(openPicker, opened) => (
|
||
<Button
|
||
type="button"
|
||
aria-pressed={opened}
|
||
onClick={openPicker}
|
||
disabled={lotusTerminal}
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
before={
|
||
<span
|
||
style={{
|
||
width: toRem(16),
|
||
height: toRem(16),
|
||
borderRadius: config.radii.R300,
|
||
background: customAccentColor || color.Primary.Main,
|
||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||
display: 'inline-block',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<Text size="B300">{customAccentColor ? 'Change' : 'Pick'}</Text>
|
||
</Button>
|
||
)}
|
||
</HexColorPickerPopOut>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type DateHintProps = {
|
||
hasChanges: boolean;
|
||
handleReset: () => void;
|
||
};
|
||
function DateHint({ hasChanges, handleReset }: DateHintProps) {
|
||
const [anchor, setAnchor] = useState<RectCords>();
|
||
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
|
||
|
||
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||
setAnchor(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
return (
|
||
<PopOut
|
||
anchor={anchor}
|
||
position="Top"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setAnchor(undefined),
|
||
clickOutsideDeactivates: true,
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
|
||
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||
<Text size="L400">Formatting</Text>
|
||
</Header>
|
||
|
||
<Box direction="Column">
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Year</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
YY
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}
|
||
Two-digit year
|
||
</Text>{' '}
|
||
</Text>
|
||
<Text size="T300">
|
||
YYYY
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Four-digit year
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Month</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
M
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}The month
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
MM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-digit month
|
||
</Text>{' '}
|
||
</Text>
|
||
<Text size="T300">
|
||
MMM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Short month name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
MMMM
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Full month name
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Day of the Month</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
D
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Day of the month
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
DD
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-digit day of the month
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
<Box style={categoryPadding} direction="Column">
|
||
<Header size="300">
|
||
<Text size="L400">Day of the Week</Text>
|
||
</Header>
|
||
<Box direction="Column" tabIndex={0} gap="100">
|
||
<Text size="T300">
|
||
d
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Day of the week (Sunday = 0)
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
dd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Two-letter day name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
ddd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Short day name
|
||
</Text>
|
||
</Text>
|
||
<Text size="T300">
|
||
dddd
|
||
<Text as="span" size="Inherit" priority="300">
|
||
{': '}Full day name
|
||
</Text>
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
>
|
||
{hasChanges ? (
|
||
<IconButton
|
||
onClick={handleReset}
|
||
type="reset"
|
||
variant="Secondary"
|
||
size="300"
|
||
radii="300"
|
||
aria-label="Reset to default"
|
||
>
|
||
<Icon src={Icons.Cross} size="100" />
|
||
</IconButton>
|
||
) : (
|
||
<IconButton
|
||
onClick={handleOpenMenu}
|
||
type="button"
|
||
variant="Secondary"
|
||
size="300"
|
||
radii="300"
|
||
aria-label="Setting info"
|
||
aria-pressed={!!anchor}
|
||
>
|
||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||
</IconButton>
|
||
)}
|
||
</PopOut>
|
||
);
|
||
}
|
||
|
||
type CustomDateFormatProps = {
|
||
value: string;
|
||
onChange: (format: string) => void;
|
||
};
|
||
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
|
||
const [dateFormatCustom, setDateFormatCustom] = useState(value);
|
||
|
||
useEffect(() => {
|
||
setDateFormatCustom(value);
|
||
}, [value]);
|
||
|
||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||
const format = evt.currentTarget.value;
|
||
setDateFormatCustom(format);
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setDateFormatCustom(value);
|
||
};
|
||
|
||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||
evt.preventDefault();
|
||
|
||
const target = evt.target as HTMLFormElement | undefined;
|
||
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
|
||
const format = customDateFormatInput?.value;
|
||
if (!format) return;
|
||
|
||
onChange(format);
|
||
};
|
||
|
||
const hasChanges = dateFormatCustom !== value;
|
||
return (
|
||
<SettingTile>
|
||
<Box as="form" onSubmit={handleSubmit} gap="200">
|
||
<Box grow="Yes" direction="Column">
|
||
<Input
|
||
required
|
||
name="customDateFormatInput"
|
||
aria-label="Custom date format"
|
||
value={dateFormatCustom}
|
||
onChange={handleChange}
|
||
maxLength={16}
|
||
autoComplete="off"
|
||
variant="Secondary"
|
||
radii="300"
|
||
style={{ paddingRight: config.space.S200 }}
|
||
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
|
||
/>
|
||
</Box>
|
||
<Button
|
||
size="400"
|
||
variant={hasChanges ? 'Success' : 'Secondary'}
|
||
fill={hasChanges ? 'Solid' : 'Soft'}
|
||
outlined
|
||
radii="300"
|
||
disabled={!hasChanges}
|
||
type="submit"
|
||
>
|
||
<Text size="B400">Save</Text>
|
||
</Button>
|
||
</Box>
|
||
</SettingTile>
|
||
);
|
||
}
|
||
|
||
type PresetDateFormatProps = {
|
||
value: string;
|
||
onChange: (format: string) => void;
|
||
};
|
||
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const dateFormatItems = useDateFormatItems();
|
||
|
||
const getDisplayDate = (format: string): string =>
|
||
format !== '' ? dayjs().format(format) : 'Custom';
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (format: DateFormat) => {
|
||
onChange(format);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{dateFormatItems.map((item) => (
|
||
<MenuItem
|
||
key={item.format}
|
||
size="300"
|
||
variant={value === item.format ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.format)}
|
||
>
|
||
<Text size="T300">{getDisplayDate(item.format)}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SelectDateFormat() {
|
||
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
|
||
const customDateFormat = selectedDateFormat === '';
|
||
|
||
const handlePresetChange = (format: string) => {
|
||
setSelectedDateFormat(format);
|
||
if (format !== '') {
|
||
setDateFormatString(format);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<SettingTile
|
||
title="Date Format"
|
||
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||
/>
|
||
{customDateFormat && (
|
||
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DateAndTime() {
|
||
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Date & Time</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="24-Hour Time Format"
|
||
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SelectDateFormat />
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
|
||
showFormat: 'Format',
|
||
showEmoji: 'Emoji',
|
||
showSticker: 'Sticker',
|
||
showGif: 'GIF',
|
||
showLocation: 'Location',
|
||
showPoll: 'Poll',
|
||
showVoice: 'Voice',
|
||
showSchedule: 'Schedule',
|
||
};
|
||
|
||
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
|
||
|
||
type ComposerToolbarButtonRowProps = {
|
||
buttonKey: ComposerToolbarButtonKey;
|
||
index: number;
|
||
active: boolean;
|
||
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||
};
|
||
|
||
function ComposerToolbarButtonRow({
|
||
buttonKey,
|
||
index,
|
||
active,
|
||
onToggle,
|
||
}: ComposerToolbarButtonRowProps) {
|
||
const rowRef = useRef<HTMLDivElement>(null);
|
||
const handleRef = useRef<HTMLButtonElement>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||
|
||
useEffect(() => {
|
||
const element = rowRef.current;
|
||
const dragHandle = handleRef.current;
|
||
if (!element || !dragHandle) return undefined;
|
||
|
||
return combine(
|
||
draggable({
|
||
element,
|
||
dragHandle,
|
||
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
|
||
onDragStart: () => setDragging(true),
|
||
onDrop: () => setDragging(false),
|
||
}),
|
||
dropTargetForElements({
|
||
element,
|
||
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||
getData: ({ input }) =>
|
||
attachClosestEdge(
|
||
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
|
||
{ element, input, allowedEdges: ['top', 'bottom'] },
|
||
),
|
||
getIsSticky: () => true,
|
||
onDrag: ({ self, source }) => {
|
||
if (source.data.buttonKey === buttonKey) {
|
||
setClosestEdge(null);
|
||
return;
|
||
}
|
||
setClosestEdge(extractClosestEdge(self.data));
|
||
},
|
||
onDragLeave: () => setClosestEdge(null),
|
||
onDrop: () => setClosestEdge(null),
|
||
}),
|
||
);
|
||
}, [buttonKey, index]);
|
||
|
||
let boxShadow: string | undefined;
|
||
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
|
||
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
|
||
|
||
return (
|
||
<Box
|
||
ref={rowRef}
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{
|
||
padding: `${config.space.S200} ${config.space.S400}`,
|
||
opacity: dragging ? 0.5 : undefined,
|
||
boxShadow,
|
||
}}
|
||
>
|
||
<IconButton
|
||
ref={handleRef}
|
||
size="300"
|
||
radii="300"
|
||
variant="SurfaceVariant"
|
||
style={{ cursor: 'grab' }}
|
||
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
|
||
>
|
||
<Icon size="100" src={Icons.VerticalDots} />
|
||
</IconButton>
|
||
<Text style={{ flexGrow: 1 }} size="T300">
|
||
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
|
||
</Text>
|
||
<Chip
|
||
variant={active ? 'Primary' : 'Secondary'}
|
||
outlined={active}
|
||
radii="Pill"
|
||
onClick={() => onToggle(buttonKey)}
|
||
aria-pressed={active}
|
||
>
|
||
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
|
||
</Chip>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type ComposerToolbarReorderProps = {
|
||
order: ComposerToolbarButtonKey[];
|
||
buttons: ComposerToolbarSettings;
|
||
onReorder: (startIndex: number, finishIndex: number) => void;
|
||
onToggle: (key: ComposerToolbarButtonKey) => void;
|
||
};
|
||
|
||
function ComposerToolbarReorder({
|
||
order,
|
||
buttons,
|
||
onReorder,
|
||
onToggle,
|
||
}: ComposerToolbarReorderProps) {
|
||
useEffect(
|
||
() =>
|
||
monitorForElements({
|
||
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
|
||
onDrop: ({ location, source }) => {
|
||
const target = location.current.dropTargets[0];
|
||
if (!target) return;
|
||
const startIndex = source.data.index;
|
||
const indexOfTarget = target.data.index;
|
||
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
|
||
const closestEdgeOfTarget = extractClosestEdge(target.data);
|
||
|
||
// Insert relative to the target row, then compensate for the source
|
||
// row being removed from its original position.
|
||
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
|
||
if (startIndex < finishIndex) finishIndex -= 1;
|
||
|
||
if (finishIndex === startIndex) return;
|
||
onReorder(startIndex, finishIndex);
|
||
},
|
||
}),
|
||
[onReorder],
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column">
|
||
{order.map((key, index) => (
|
||
<ComposerToolbarButtonRow
|
||
key={key}
|
||
buttonKey={key}
|
||
index={index}
|
||
active={buttons[key]}
|
||
onToggle={onToggle}
|
||
/>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function Editor() {
|
||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||
settingsAtom,
|
||
'composerToolbarButtons',
|
||
);
|
||
|
||
const composerToolbarOrder = useMemo(
|
||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||
[composerToolbarButtons?.order],
|
||
);
|
||
|
||
const toggleToolbarButton = useCallback(
|
||
(key: ComposerToolbarButtonKey) => {
|
||
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
|
||
},
|
||
[setComposerToolbarButtons],
|
||
);
|
||
|
||
const reorderToolbarButtons = useCallback(
|
||
(startIndex: number, finishIndex: number) => {
|
||
setComposerToolbarButtons((current) => ({
|
||
...current,
|
||
order: reorder({
|
||
list: normalizeComposerToolbarOrder(current.order),
|
||
startIndex,
|
||
finishIndex,
|
||
}),
|
||
}));
|
||
},
|
||
[setComposerToolbarButtons],
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Editor</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="ENTER for Newline"
|
||
description={`Use ${
|
||
isMacOS() ? KeySymbol.Command : 'Ctrl'
|
||
} + ENTER to send message and ENTER for newline.`}
|
||
after={<Switch variant="Primary" value={enterForNewline} onChange={setEnterForNewline} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Markdown Formatting"
|
||
after={<Switch variant="Primary" value={isMarkdown} onChange={setIsMarkdown} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Formatting Toolbar"
|
||
description="Show bold, italic, code and other formatting buttons above the message input."
|
||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="300"
|
||
>
|
||
<SettingTile
|
||
title="Composer Toolbar"
|
||
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
|
||
/>
|
||
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
|
||
<ComposerToolbarReorder
|
||
order={composerToolbarOrder}
|
||
buttons={composerToolbarButtons}
|
||
onReorder={reorderToolbarButtons}
|
||
onToggle={toggleToolbarButton}
|
||
/>
|
||
</Box>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function Privacy() {
|
||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
|
||
settingsAtom,
|
||
'privateReadReceipts',
|
||
);
|
||
const [warnOnUnverifiedDevices, setWarnOnUnverifiedDevices] = useSetting(
|
||
settingsAtom,
|
||
'warnOnUnverifiedDevices',
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Privacy</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Typing & Read Receipts"
|
||
description="Turn off both typing status and read receipts to keep your activity private."
|
||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Private Read Receipts"
|
||
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={privateReadReceipts}
|
||
onChange={setPrivateReadReceipts}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Online Status"
|
||
description="Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
|
||
after={<Switch variant="Primary" value={hidePresence} onChange={setHidePresence} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Warn before sending to unverified devices"
|
||
description="Show a warning in the composer when sending to a room with unverified devices."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={warnOnUnverifiedDevices}
|
||
onChange={setWarnOnUnverifiedDevices}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function useKeyBind(setter: (code: string) => void) {
|
||
const [listening, setListening] = useState(false);
|
||
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const startListening = useCallback(() => {
|
||
if (listening) return;
|
||
setListening(true);
|
||
const onKey = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
if (e.code !== 'Escape') setter(e.code);
|
||
setListening(false);
|
||
window.removeEventListener('keydown', onKey, true);
|
||
listenerRef.current = null;
|
||
};
|
||
listenerRef.current = onKey;
|
||
window.addEventListener('keydown', onKey, true);
|
||
}, [listening, setter]);
|
||
|
||
return { listening, startListening };
|
||
}
|
||
|
||
const keyLabel = (code: string) =>
|
||
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
|
||
|
||
function Calls() {
|
||
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
|
||
settingsAtom,
|
||
'callNoiseSuppression',
|
||
);
|
||
const [callDenoiseModel, setCallDenoiseModel] = useSetting(settingsAtom, 'callDenoiseModel');
|
||
const [callDenoiseNativeNS, setCallDenoiseNativeNS] = useSetting(
|
||
settingsAtom,
|
||
'callDenoiseNativeNS',
|
||
);
|
||
const [callDenoiseGate, setCallDenoiseGate] = useSetting(settingsAtom, 'callDenoiseGate');
|
||
const [callDenoiseGateThreshold, setCallDenoiseGateThreshold] = useSetting(
|
||
settingsAtom,
|
||
'callDenoiseGateThreshold',
|
||
);
|
||
|
||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||
settingsAtom,
|
||
'callJoinLeaveSound',
|
||
);
|
||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||
|
||
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
|
||
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
|
||
settingsAtom,
|
||
'screenshareBitrate',
|
||
);
|
||
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
|
||
settingsAtom,
|
||
'screenshareFramerate',
|
||
);
|
||
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||
|
||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||
setCallJoinLeaveSound(value);
|
||
if (value !== 'off') playCallJoinSound(value);
|
||
};
|
||
|
||
const handleRingtoneChange = (value: RingtoneId) => {
|
||
setRingtoneId(value);
|
||
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||
};
|
||
|
||
const pttBind = useKeyBind(setPttKey);
|
||
const deafenBind = useKeyBind(setDeafenKey);
|
||
|
||
const mlSupported = isMLDenoiseSupported();
|
||
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Calls</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Join with Camera On"
|
||
description="Enable camera automatically when joining a voice or video call. Camera is off by default."
|
||
after={<Switch variant="Primary" value={cameraOnJoin} onChange={setCameraOnJoin} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Noise Suppression"
|
||
description={
|
||
<Box direction="Column" gap="200">
|
||
<Text>
|
||
Filter background noise from your mic during calls. Browser-native uses the built-in
|
||
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
||
</Text>
|
||
|
||
{!mlSupported && (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="T200" priority="400">
|
||
ML options are not supported in this browser.
|
||
</Text>
|
||
<Box as="ul" style={{ paddingLeft: '20px', margin: 0 }}>
|
||
{ML_DENOISE_REQUIREMENTS.map((req) => (
|
||
<Text as="li" key={req} size="T200">
|
||
{req}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
}
|
||
after={
|
||
<SettingsSelect<NoiseSuppressionMode>
|
||
value={callNoiseSuppression}
|
||
onChange={setCallNoiseSuppression}
|
||
options={[
|
||
{ value: 'off', label: 'Off' },
|
||
{ value: 'browser', label: 'Browser-native' },
|
||
{
|
||
value: 'ml',
|
||
label: 'ML (Advanced)',
|
||
disabled: !mlSupported,
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
{callNoiseSuppression === 'ml' && (
|
||
<Box
|
||
direction="Column"
|
||
gap="400"
|
||
style={{
|
||
padding: '16px',
|
||
marginTop: '8px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
background: color.Surface.Container,
|
||
}}
|
||
>
|
||
{/* ── Model selection ───────────────────────────────────────── */}
|
||
<Box direction="Column" gap="200">
|
||
<Text size="L400">Model</Text>
|
||
<SettingTile
|
||
title="ML Model"
|
||
description="Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
|
||
after={
|
||
<SettingsSelect<DenoiseModelId>
|
||
value={callDenoiseModel}
|
||
onChange={setCallDenoiseModel}
|
||
options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
|
||
/>
|
||
}
|
||
/>
|
||
{selectedDenoiseModel && (
|
||
<Box
|
||
direction="Column"
|
||
gap="200"
|
||
style={{
|
||
padding: '12px',
|
||
borderRadius: '8px',
|
||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
background: color.SurfaceVariant.Container,
|
||
}}
|
||
>
|
||
<Text size="T300">{selectedDenoiseModel.name}</Text>
|
||
<Text size="T200" priority="300">
|
||
{selectedDenoiseModel.description}
|
||
</Text>
|
||
<Box direction="Row" gap="400" wrap="Wrap">
|
||
{(
|
||
[
|
||
{ label: 'CPU', value: selectedDenoiseModel.cpuUsage },
|
||
{ label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
|
||
{ label: 'Transients', value: selectedDenoiseModel.transients },
|
||
{ label: 'Download', value: selectedDenoiseModel.binarySize },
|
||
] as const
|
||
).map((stat) => (
|
||
<Box key={stat.label} direction="Column" gap="100">
|
||
<Text size="T200" priority="300">
|
||
{stat.label}
|
||
</Text>
|
||
<Text size="T300">{stat.value}</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<details>
|
||
<summary style={{ cursor: 'pointer' }}>
|
||
<Text as="span" size="T200" priority="300">
|
||
Compare all models
|
||
</Text>
|
||
</summary>
|
||
<Box direction="Column" gap="100" style={{ overflowX: 'auto', marginTop: '8px' }}>
|
||
<Box
|
||
direction="Row"
|
||
gap="100"
|
||
style={{
|
||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
paddingBottom: '4px',
|
||
}}
|
||
>
|
||
<Box style={{ width: '160px' }}>
|
||
<Text size="T200" priority="300">
|
||
Model
|
||
</Text>
|
||
</Box>
|
||
<Box style={{ width: '80px' }}>
|
||
<Text size="T200" priority="300">
|
||
CPU
|
||
</Text>
|
||
</Box>
|
||
<Box style={{ width: '90px' }}>
|
||
<Text size="T200" priority="300">
|
||
Quality
|
||
</Text>
|
||
</Box>
|
||
<Box grow="Yes">
|
||
<Text size="T200" priority="300">
|
||
Transients
|
||
</Text>
|
||
</Box>
|
||
</Box>
|
||
{DENOISE_MODELS.map((model) => (
|
||
<Box key={model.id} direction="Row" gap="100">
|
||
<Box style={{ width: '160px' }}>
|
||
<Text size="T200">{model.name}</Text>
|
||
</Box>
|
||
<Box style={{ width: '80px' }}>
|
||
<Text size="T200">{model.cpuUsage}</Text>
|
||
</Box>
|
||
<Box style={{ width: '90px' }}>
|
||
<Text size="T200">{model.voiceQuality}</Text>
|
||
</Box>
|
||
<Box grow="Yes">
|
||
<Text size="T200">{model.transients}</Text>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</details>
|
||
|
||
<Text size="T200" priority="400">
|
||
Note: Applying changes requires rejoining the call.
|
||
</Text>
|
||
</Box>
|
||
|
||
{/* ── Enhancement toggles ───────────────────────────────────── */}
|
||
<Box
|
||
direction="Column"
|
||
gap="300"
|
||
style={{
|
||
paddingTop: '12px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Enhancements</Text>
|
||
<SettingTile
|
||
title="Series Suppression"
|
||
description="Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={callDenoiseNativeNS}
|
||
onChange={setCallDenoiseNativeNS}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<SettingTile
|
||
title="Noise Gate"
|
||
description="Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
|
||
after={
|
||
<Switch variant="Primary" value={callDenoiseGate} onChange={setCallDenoiseGate} />
|
||
}
|
||
/>
|
||
|
||
{callDenoiseGate && (
|
||
<Box direction="Column" gap="100">
|
||
<Box direction="Row" justifyContent="SpaceBetween">
|
||
<Text size="T200">Gate Threshold</Text>
|
||
<Text size="T200">{callDenoiseGateThreshold} dB</Text>
|
||
</Box>
|
||
<input
|
||
aria-label="Noise gate threshold"
|
||
type="range"
|
||
min="-100"
|
||
max="0"
|
||
step="1"
|
||
value={callDenoiseGateThreshold}
|
||
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
|
||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* ── Test & calibrate ──────────────────────────────────────── */}
|
||
<Box
|
||
direction="Column"
|
||
gap="200"
|
||
style={{
|
||
paddingTop: '12px',
|
||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Text size="L400">Test & calibrate</Text>
|
||
<Text size="T200" priority="300">
|
||
Audition the selected model and tune the gate without joining a call. Changes to the
|
||
settings above apply the next time you start a monitor or play a clip.
|
||
</Text>
|
||
<DenoiseTester
|
||
model={callDenoiseModel}
|
||
useGate={callDenoiseGate}
|
||
gateThreshold={callDenoiseGateThreshold}
|
||
nativeNS={callDenoiseNativeNS}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="Push to Talk"
|
||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||
after={<Switch variant="Primary" value={pttMode} onChange={setPttMode} />}
|
||
/>
|
||
{pttMode && (
|
||
<SettingTile
|
||
title="PTT Key"
|
||
description="Press a key to bind it as your push-to-talk key."
|
||
after={
|
||
<Button
|
||
size="300"
|
||
variant={pttBind.listening ? 'Warning' : 'Secondary'}
|
||
fill={pttBind.listening ? 'Solid' : 'Soft'}
|
||
radii="300"
|
||
outlined
|
||
onClick={pttBind.startListening}
|
||
style={{ minWidth: '90px' }}
|
||
>
|
||
<Text size="B300">{pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}</Text>
|
||
</Button>
|
||
}
|
||
/>
|
||
)}
|
||
<SettingTile
|
||
title="Push to Deafen"
|
||
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
|
||
after={
|
||
<Button
|
||
size="300"
|
||
variant={deafenBind.listening ? 'Warning' : 'Secondary'}
|
||
fill={deafenBind.listening ? 'Solid' : 'Soft'}
|
||
radii="300"
|
||
outlined
|
||
onClick={deafenBind.startListening}
|
||
style={{ minWidth: '90px' }}
|
||
>
|
||
<Text size="B300">{deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)}</Text>
|
||
</Button>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard
|
||
className={SequenceCardStyle}
|
||
variant="SurfaceVariant"
|
||
direction="Column"
|
||
gap="400"
|
||
>
|
||
<SettingTile
|
||
title="AFK Auto-Mute"
|
||
description="Automatically mute your microphone when you have been silent in a call."
|
||
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||
/>
|
||
{afkAutoMute && (
|
||
<SettingTile
|
||
title="Idle Timeout"
|
||
description="How long to wait before auto-muting."
|
||
after={
|
||
<SettingsSelect
|
||
value={String(afkTimeoutMinutes ?? 10)}
|
||
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
|
||
options={[
|
||
{ value: '1', label: '1 minute' },
|
||
{ value: '5', label: '5 minutes' },
|
||
{ value: '10', label: '10 minutes' },
|
||
{ value: '20', label: '20 minutes' },
|
||
{ value: '30', label: '30 minutes' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Ringtone"
|
||
description="Sound played for incoming calls. Selecting an option plays a preview."
|
||
after={
|
||
<SettingsSelect
|
||
value={ringtoneId}
|
||
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
|
||
options={RINGTONE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Ringtone Volume"
|
||
description="Volume of the incoming call ringtone."
|
||
after={
|
||
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="100"
|
||
step="5"
|
||
value={ringtoneVolume}
|
||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||
aria-label="Ringtone volume"
|
||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||
/>
|
||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||
{ringtoneVolume}%
|
||
</Text>
|
||
</Box>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Join & Leave Sounds"
|
||
description="Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
|
||
after={
|
||
<SettingsSelect
|
||
value={callJoinLeaveSound}
|
||
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
|
||
options={[
|
||
{ value: 'off', label: 'Off' },
|
||
{ value: 'chime', label: 'Chime' },
|
||
{ value: 'soft', label: 'Soft' },
|
||
{ value: 'retro', label: 'Retro' },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Microphone Bitrate"
|
||
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||
after={
|
||
<SettingsSelect<CallAudioBitrate>
|
||
value={callAudioBitrate}
|
||
onChange={setCallAudioBitrate}
|
||
options={AUDIO_BITRATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Screenshare Bitrate"
|
||
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||
after={
|
||
<SettingsSelect<ScreenshareBitrate>
|
||
value={screenshareBitrate}
|
||
onChange={setScreenshareBitrate}
|
||
options={SCREENSHARE_BITRATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Screenshare Framerate"
|
||
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||
after={
|
||
<SettingsSelect<ScreenshareFramerate>
|
||
value={screenshareFramerate}
|
||
onChange={setScreenshareFramerate}
|
||
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Soundboard"
|
||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||
after={
|
||
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
|
||
}
|
||
/>
|
||
{soundboardEnabled && (
|
||
<SettingTile
|
||
title="Soundboard Volume"
|
||
after={
|
||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={5}
|
||
value={soundboardVolume}
|
||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||
style={{ flexGrow: 1 }}
|
||
aria-label="Soundboard volume"
|
||
/>
|
||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||
{soundboardVolume}%
|
||
</Text>
|
||
</Box>
|
||
}
|
||
/>
|
||
)}
|
||
</SequenceCard>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const SEASONAL_OPTIONS: {
|
||
value: Settings['seasonalThemeOverride'];
|
||
label: string;
|
||
emoji: string;
|
||
}[] = [
|
||
{ value: 'auto', label: 'Auto', emoji: '🗓' },
|
||
{ value: 'off', label: 'Off', emoji: '×' },
|
||
{ value: 'newyear', label: 'New Year', emoji: '🎆' },
|
||
{ value: 'lunar', label: 'Lunar New Year', emoji: '🏮' },
|
||
{ value: 'valentines', label: "Valentine's", emoji: '💖' },
|
||
{ value: 'stpatricks', label: "St. Patrick's", emoji: '🍀' },
|
||
{ value: 'aprilfools', label: 'April Fools', emoji: '?' },
|
||
{ value: 'earthday', label: 'Earth Day', emoji: '🌱' },
|
||
{ value: 'autumn', label: 'Autumn', emoji: '🍂' },
|
||
{ value: 'halloween', label: 'Halloween', emoji: '🎃' },
|
||
{ value: 'christmas', label: 'Christmas', emoji: '❄️' },
|
||
{ value: 'arcade', label: 'Arcade Day', emoji: '👾' },
|
||
{ value: 'deepspace', label: 'Deep Space', emoji: '🚀' },
|
||
];
|
||
|
||
function SeasonalBgGrid({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: Settings['seasonalThemeOverride'];
|
||
onChange: (v: Settings['seasonalThemeOverride']) => void;
|
||
}) {
|
||
return (
|
||
<Box
|
||
grow="Yes"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||
gap: config.space.S200,
|
||
}}
|
||
>
|
||
{SEASONAL_OPTIONS.map((opt) => {
|
||
const selected = value === opt.value;
|
||
const isSpecial = opt.value === 'auto' || opt.value === 'off';
|
||
return (
|
||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label={opt.label}
|
||
aria-pressed={selected}
|
||
data-selected={selected}
|
||
className={BgSwatchStyle}
|
||
onClick={() => onChange(opt.value)}
|
||
style={{
|
||
position: 'relative',
|
||
width: toRem(76),
|
||
height: toRem(56),
|
||
backgroundColor: '#030508',
|
||
}}
|
||
>
|
||
{!isSpecial && <SeasonalPreview theme={opt.value as SeasonTheme} />}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundImage:
|
||
opt.value === 'auto'
|
||
? 'linear-gradient(135deg, rgba(255,100,0,0.2), rgba(255,200,0,0.2), rgba(0,200,100,0.2), rgba(0,100,255,0.2))'
|
||
: undefined,
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
{isSpecial && (
|
||
<span style={{ fontSize: '22px', opacity: opt.value === 'off' ? 0.4 : 1 }}>
|
||
{opt.emoji}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
<Text size="T200" style={selected ? { color: color.Primary.Main } : undefined}>
|
||
{opt.label}
|
||
</Text>
|
||
{(opt.value === 'auto' || !isSpecial) && (
|
||
<Text size="T200" style={{ opacity: 0.6, textAlign: 'center' }}>
|
||
{opt.value === 'auto'
|
||
? 'By calendar'
|
||
: SEASON_DATE_RANGES[opt.value as SeasonTheme]}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function ChatBgGrid() {
|
||
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||
const [, setSeasonalThemeOverride] = useSetting(settingsAtom, 'seasonalThemeOverride');
|
||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||
const theme = useTheme();
|
||
const isDark = theme.kind === ThemeKind.Dark;
|
||
const reduced = useReducedMotion();
|
||
|
||
return (
|
||
<Box
|
||
grow="Yes"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
|
||
gap: config.space.S200,
|
||
}}
|
||
>
|
||
{BG_OPTIONS.map((opt) => (
|
||
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label={opt.label}
|
||
aria-pressed={chatBackground === opt.value}
|
||
data-selected={chatBackground === opt.value}
|
||
className={BgSwatchStyle}
|
||
onClick={() => {
|
||
setChatBackground(opt.value as ChatBackground);
|
||
if (opt.value !== 'none') setSeasonalThemeOverride('off');
|
||
}}
|
||
style={{
|
||
width: toRem(76),
|
||
height: toRem(50),
|
||
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations || reduced),
|
||
}}
|
||
/>
|
||
<Text
|
||
size="T200"
|
||
style={chatBackground === opt.value ? { color: color.Primary.Main } : undefined}
|
||
>
|
||
{opt.label}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
function SelectMessageLayout() {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||
const messageLayoutItems = useMessageLayoutItems();
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (layout: MessageLayout) => {
|
||
setMessageLayout(layout);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{messageLayoutItems.map((item) => (
|
||
<MenuItem
|
||
key={item.layout}
|
||
size="300"
|
||
variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.layout)}
|
||
>
|
||
<Text size="T300">{item.name}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SelectMessageSpacing() {
|
||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||
const messageSpacingItems = useMessageSpacingItems();
|
||
|
||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||
};
|
||
|
||
const handleSelect = (layout: MessageSpacing) => {
|
||
setMessageSpacing(layout);
|
||
setMenuCords(undefined);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
outlined
|
||
fill="Soft"
|
||
radii="300"
|
||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||
onClick={handleMenu}
|
||
>
|
||
<Text size="T300">
|
||
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
|
||
</Text>
|
||
</Button>
|
||
<PopOut
|
||
anchor={menuCords}
|
||
offset={5}
|
||
position="Bottom"
|
||
align="End"
|
||
content={
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
onDeactivate: () => setMenuCords(undefined),
|
||
clickOutsideDeactivates: true,
|
||
isKeyForward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||
isKeyBackward: (evt: KeyboardEvent) =>
|
||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||
escapeDeactivates: stopPropagation,
|
||
}}
|
||
>
|
||
<Menu>
|
||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||
{messageSpacingItems.map((item) => (
|
||
<MenuItem
|
||
key={item.spacing}
|
||
size="300"
|
||
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
|
||
radii="300"
|
||
onClick={() => handleSelect(item.spacing)}
|
||
>
|
||
<Text size="T300">{item.name}</Text>
|
||
</MenuItem>
|
||
))}
|
||
</Box>
|
||
</Menu>
|
||
</FocusTrap>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Messages() {
|
||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||
settingsAtom,
|
||
'legacyUsernameColor',
|
||
);
|
||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||
settingsAtom,
|
||
'hideMembershipEvents',
|
||
);
|
||
const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(
|
||
settingsAtom,
|
||
'hideNickAvatarEvents',
|
||
);
|
||
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||
settingsAtom,
|
||
'enforceRetentionLocally',
|
||
);
|
||
|
||
return (
|
||
<Box direction="Column" gap="100">
|
||
<Text size="L400">Messages</Text>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Message Layout" after={<SelectMessageLayout />} />
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile title="Message Spacing" after={<SelectMessageSpacing />} />
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Legacy Username Color"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={legacyUsernameColor}
|
||
onChange={setLegacyUsernameColor}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Membership Change"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={hideMembershipEvents}
|
||
onChange={setHideMembershipEvents}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Hide Profile Change"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={hideNickAvatarEvents}
|
||
onChange={setHideNickAvatarEvents}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Disable Media Auto Load"
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={!mediaAutoLoad}
|
||
onChange={(v) => setMediaAutoLoad(!v)}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="URL Preview"
|
||
description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
|
||
after={<Switch variant="Primary" value={urlPreview} onChange={setUrlPreview} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="URL Preview in Encrypted Rooms"
|
||
description={
|
||
<Box direction="Column" gap="100">
|
||
<Text size="T200" priority="300">
|
||
Your homeserver fetches link previews on your behalf. It cannot decrypt your
|
||
messages, but will see every URL you preview in encrypted rooms, potentially
|
||
revealing conversation topics.
|
||
</Text>
|
||
<Chip
|
||
variant="Warning"
|
||
fill="Soft"
|
||
radii="300"
|
||
size="400"
|
||
style={{ alignSelf: 'flex-start' }}
|
||
>
|
||
<Text size="T200">Privacy risk — enabled by default</Text>
|
||
</Chip>
|
||
</Box>
|
||
}
|
||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Inline Media Players"
|
||
description="Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
|
||
after={
|
||
<Switch variant="Primary" value={inlineMediaEmbeds} onChange={setInlineMediaEmbeds} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Show Hidden Events"
|
||
after={
|
||
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||
<SettingTile
|
||
title="Enforce Message Retention"
|
||
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||
after={
|
||
<Switch
|
||
variant="Primary"
|
||
value={enforceRetentionLocally}
|
||
onChange={setEnforceRetentionLocally}
|
||
/>
|
||
}
|
||
/>
|
||
</SequenceCard>
|
||
</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>
|
||
);
|
||
}
|