2025-02-10 16:49:47 +11:00
import React , {
ChangeEventHandler ,
2025-07-27 15:13:00 +03:00
FormEventHandler ,
2025-02-10 16:49:47 +11:00
KeyboardEventHandler ,
MouseEventHandler ,
2026-05-15 15:38:02 -04:00
useCallback ,
2025-07-27 15:13:00 +03:00
useEffect ,
2026-07-01 09:07:03 -04:00
useMemo ,
2026-05-15 15:38:02 -04:00
useRef ,
2025-02-10 16:49:47 +11:00
useState ,
} from 'react' ;
2025-07-27 15:13:00 +03:00
import dayjs from 'dayjs' ;
2025-02-10 16:49:47 +11:00
import {
as ,
Box ,
Button ,
Chip ,
2026-06-01 21:30:27 -04:00
color ,
2025-02-10 16:49:47 +11:00
config ,
2025-07-27 15:13:00 +03:00
Header ,
2025-02-10 16:49:47 +11:00
Icon ,
IconButton ,
Icons ,
Input ,
Menu ,
MenuItem ,
PopOut ,
RectCords ,
Scroll ,
2026-06-10 20:31:35 -04:00
Spinner ,
2025-02-10 16:49:47 +11:00
Switch ,
Text ,
toRem ,
} from 'folds' ;
import { isKeyHotkey } from 'is-hotkey' ;
2026-06-19 20:54:32 -04:00
import { HexColorPicker } from 'react-colorful' ;
2025-02-10 16:49:47 +11:00
import FocusTrap from 'focus-trap-react' ;
2026-07-01 09:07:03 -04:00
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' ;
2026-06-19 20:54:32 -04:00
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut' ;
2026-06-19 21:01:57 -04:00
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css' ;
2025-02-10 16:49:47 +11:00
import { Page , PageContent , PageHeader } from '../../../components/page' ;
import { SequenceCard } from '../../../components/sequence-card' ;
2026-06-16 01:04:23 -04:00
import {
DENOISE_MODELS ,
isMLDenoiseSupported ,
ML_DENOISE_REQUIREMENTS ,
} from '../../../utils/lotusDenoiseUtils' ;
2025-02-10 16:49:47 +11:00
import { useSetting } from '../../../state/hooks/settings' ;
2026-05-21 20:49:33 -04:00
import {
2026-06-30 22:34:17 -04:00
CallAudioBitrate ,
2026-05-21 20:49:33 -04:00
ChatBackground ,
2026-07-01 09:07:03 -04:00
ComposerToolbarButtonKey ,
2026-06-10 13:20:29 -04:00
ComposerToolbarSettings ,
2026-05-21 20:49:33 -04:00
DateFormat ,
2026-06-16 01:40:37 -04:00
DenoiseModelId ,
2026-05-21 20:49:33 -04:00
MessageLayout ,
MessageSpacing ,
2026-06-15 20:29:59 -04:00
NoiseSuppressionMode ,
2026-07-01 09:07:03 -04:00
normalizeComposerToolbarOrder ,
2026-06-24 17:25:32 -04:00
RingtoneId ,
2026-06-30 22:34:17 -04:00
ScreenshareBitrate ,
ScreenshareFramerate ,
2026-06-15 01:14:56 -04:00
Settings ,
2026-05-21 20:49:33 -04:00
settingsAtom ,
} from '../../../state/settings' ;
2026-06-30 22:34:17 -04:00
import {
AUDIO_BITRATE_OPTIONS ,
SCREENSHARE_BITRATE_OPTIONS ,
SCREENSHARE_FRAMERATE_OPTIONS ,
} from '../../../utils/callQuality' ;
2026-06-15 20:29:59 -04:00
import { SeasonalPreview , SeasonTheme } from '../../../components/seasonal/SeasonalEffect' ;
2026-06-30 19:28:28 -04:00
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule' ;
2025-02-10 16:49:47 +11:00
import { SettingTile } from '../../../components/setting-tile' ;
import { KeySymbol } from '../../../utils/key-symbol' ;
import { isMacOS } from '../../../utils/user-agent' ;
import {
DarkTheme ,
LightTheme ,
Theme ,
ThemeKind ,
useSystemThemeKind ,
2026-05-13 21:51:19 -04:00
useTheme ,
2025-02-10 16:49:47 +11:00
useThemeNames ,
useThemes ,
} from '../../../hooks/useTheme' ;
import { stopPropagation } from '../../../utils/keyboard' ;
2026-07-10 16:05:56 -04:00
import { pickerInputStyle } from '../../../utils/datetimeInput' ;
2026-05-13 21:51:19 -04:00
import { BG_OPTIONS , getChatBg } from '../../lotus/chatBackground' ;
2026-05-13 22:44:34 -04:00
import { resetBootSequence , runLotusBootSequence } from '../../../../lotus-boot' ;
2025-02-10 16:49:47 +11:00
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout' ;
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing' ;
2026-07-18 16:08:40 -04:00
import {
TRANSLATE_TARGET_LANGUAGES ,
normalizeLang ,
isSupportedTargetLang ,
} from '../../../utils/translation/langUtils' ;
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine' ;
2026-06-16 01:40:37 -04:00
import { SequenceCardStyle } from '../styles.css' ;
import { useTauriUpdater } from '../../../hooks/useTauriUpdater' ;
2026-07-02 13:31:09 -04:00
import { isTauri as isTauriEnv , invokeTauri , tauriInvoke } from '../../../hooks/useTauri' ;
2026-07-01 09:07:03 -04:00
import { customWindowChromeAtom } from '../../../state/customWindowChrome' ;
2026-06-16 01:40:37 -04:00
import { useDateFormatItems } from '../../../hooks/useDateFormat' ;
2026-07-02 21:40:07 -04:00
import { useReducedMotion } from '../../../hooks/useReducedMotion' ;
2026-06-16 01:40:37 -04:00
import { playCallJoinSound } from '../../../utils/callSounds' ;
2026-06-24 17:25:32 -04:00
import { previewRingtone , RINGTONE_OPTIONS } from '../../../utils/ringtones' ;
2026-06-16 17:53:57 -04:00
import { DenoiseTester } from './DenoiseTester' ;
2026-06-28 22:43:18 -04:00
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect' ;
2025-02-10 16:49:47 +11:00
2026-07-01 09:07:03 -04:00
/**
* 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 ;
2026-07-02 16:16:52 -04:00
// 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 ();
};
2026-07-01 09:07:03 -04:00
return (
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Custom Window Chrome (Beta)"
2026-07-02 16:16:52 -04:00
description = "Replace the system title bar with a Lotus-styled one. Desktop only — reloads to apply."
after = {< Switch variant = "Primary" value = { customChrome } onChange = { handleToggle } />}
2026-07-01 09:07:03 -04:00
/>
</ SequenceCard >
);
}
2026-07-02 13:31:09 -04:00
/**
* 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 >
);
}
2025-02-10 16:49:47 +11:00
type ThemeSelectorProps = {
themeNames : Record < string , string >;
themes : Theme [];
selected : Theme ;
onSelect : ( theme : Theme ) => void ;
};
const ThemeSelector = as < 'div' , ThemeSelectorProps > (
({ themeNames , themes , selected , onSelect , ... props }, ref ) => (
< Menu { ...props } ref = { ref }>
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = {{ padding : config.space.S100 }}>
2025-02-10 16:49:47 +11:00
{ themes . map (( theme ) => (
< MenuItem
key = { theme . id }
size = "300"
variant = { theme . id === selected . id ? 'Primary' : 'Surface' }
radii = "300"
onClick = {() => onSelect ( theme )}
>
< Text size = "T300" >{ themeNames [ theme . id ] ?? theme . id }</ Text >
</ MenuItem >
))}
</ Box >
</ Menu >
2026-05-21 23:30:50 -04:00
),
2025-02-10 16:49:47 +11:00
);
function SelectTheme ({ disabled } : { disabled? : boolean }) {
const themes = useThemes ();
const themeNames = useThemeNames ();
const [ themeId , setThemeId ] = useSetting ( settingsAtom , 'themeId' );
const [ menuCords , setMenuCords ] = useState < RectCords >();
const selectedTheme = themes . find (( theme ) => theme . id === themeId ) ?? LightTheme ;
const handleThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setMenuCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleThemeSelect = ( theme : Theme ) => {
setThemeId ( theme . id );
setMenuCords ( undefined );
};
return (
<>
< Button
size = "300"
2026-06-19 00:15:35 -04:00
variant = "Secondary"
2025-02-10 16:49:47 +11:00
outlined
fill = "Soft"
radii = "300"
after = {< Icon size = "300" src = { Icons . ChevronBottom } />}
onClick = { disabled ? undefined : handleThemeMenu }
aria-disabled = { disabled }
>
< Text size = "T300" >{ themeNames [ selectedTheme . id ] ?? selectedTheme . id }</ Text >
</ Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setMenuCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< ThemeSelector
themeNames = { themeNames }
themes = { themes }
selected = { selectedTheme }
onSelect = { handleThemeSelect }
/>
</ FocusTrap >
}
/>
2026-06-14 12:20:47 -04:00
</>
);
}
2025-02-10 16:49:47 +11:00
function SystemThemePreferences() {
const themeKind = useSystemThemeKind ();
const themeNames = useThemeNames ();
const themes = useThemes ();
const [ lightThemeId , setLightThemeId ] = useSetting ( settingsAtom , 'lightThemeId' );
const [ darkThemeId , setDarkThemeId ] = useSetting ( settingsAtom , 'darkThemeId' );
const lightThemes = themes . filter (( theme ) => theme . kind === ThemeKind . Light );
const darkThemes = themes . filter (( theme ) => theme . kind === ThemeKind . Dark );
const selectedLightTheme = lightThemes . find (( theme ) => theme . id === lightThemeId ) ?? LightTheme ;
const selectedDarkTheme = darkThemes . find (( theme ) => theme . id === darkThemeId ) ?? DarkTheme ;
const [ ltCords , setLTCords ] = useState < RectCords >();
const [ dtCords , setDTCords ] = useState < RectCords >();
const handleLightThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setLTCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleDarkThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setDTCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleLightThemeSelect = ( theme : Theme ) => {
setLightThemeId ( theme . id );
setLTCords ( undefined );
};
const handleDarkThemeSelect = ( theme : Theme ) => {
setDarkThemeId ( theme . id );
setDTCords ( undefined );
};
return (
< Box wrap = "Wrap" gap = "400" >
< SettingTile
title = "Light Theme:"
after = {
< Chip
variant = { themeKind === ThemeKind . Light ? 'Primary' : 'Secondary' }
outlined = { themeKind === ThemeKind . Light }
radii = "Pill"
after = {< Icon size = "200" src = { Icons . ChevronBottom } />}
onClick = { handleLightThemeMenu }
>
< Text size = "B300" >{ themeNames [ selectedLightTheme . id ] ?? selectedLightTheme . id }</ Text >
</ Chip >
}
/>
< PopOut
anchor = { ltCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setLTCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< ThemeSelector
themeNames = { themeNames }
themes = { lightThemes }
selected = { selectedLightTheme }
onSelect = { handleLightThemeSelect }
/>
</ FocusTrap >
}
/>
< SettingTile
title = "Dark Theme:"
after = {
< Chip
variant = { themeKind === ThemeKind . Dark ? 'Primary' : 'Secondary' }
outlined = { themeKind === ThemeKind . Dark }
radii = "Pill"
after = {< Icon size = "200" src = { Icons . ChevronBottom } />}
onClick = { handleDarkThemeMenu }
>
< Text size = "B300" >{ themeNames [ selectedDarkTheme . id ] ?? selectedDarkTheme . id }</ Text >
</ Chip >
}
/>
< PopOut
anchor = { dtCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setDTCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< ThemeSelector
themeNames = { themeNames }
themes = { darkThemes }
selected = { selectedDarkTheme }
onSelect = { handleDarkThemeSelect }
/>
</ FocusTrap >
}
/>
</ Box >
);
}
function PageZoomInput() {
const [ pageZoom , setPageZoom ] = useSetting ( settingsAtom , 'pageZoom' );
const [ currentZoom , setCurrentZoom ] = useState ( ` ${ pageZoom } ` );
const handleZoomChange : ChangeEventHandler < HTMLInputElement > = ( evt ) => {
setCurrentZoom ( evt . target . value );
};
const handleZoomEnter : KeyboardEventHandler < HTMLInputElement > = ( evt ) => {
if ( isKeyHotkey ( 'escape' , evt )) {
evt . stopPropagation ();
setCurrentZoom ( pageZoom . toString ());
}
if (
isKeyHotkey ( 'enter' , evt ) &&
'value' in evt . target &&
typeof evt . target . value === 'string'
) {
const newZoom = parseInt ( evt . target . value , 10 );
if ( Number . isNaN ( newZoom )) return ;
const safeZoom = Math . max ( Math . min ( newZoom , 150 ), 75 );
setPageZoom ( safeZoom );
setCurrentZoom ( safeZoom . toString ());
}
};
return (
< Input
style = {{ width : toRem ( 100 ) }}
2026-05-21 12:03:26 -04:00
aria-label = "Page zoom percentage"
2025-02-10 16:49:47 +11:00
variant = { pageZoom === parseInt ( currentZoom , 10 ) ? 'Secondary' : 'Success' }
size = "300"
radii = "300"
type = "number"
min = "75"
max = "150"
value = { currentZoom }
onChange = { handleZoomChange }
onKeyDown = { handleZoomEnter }
after = {< Text size = "T300" > % </ Text >}
outlined
/>
);
}
function Appearance() {
const [ systemTheme , setSystemTheme ] = useSetting ( settingsAtom , 'useSystemTheme' );
2025-08-25 18:49:14 +05:30
const [ monochromeMode , setMonochromeMode ] = useSetting ( settingsAtom , 'monochromeMode' );
2025-02-10 16:49:47 +11:00
const [ twitterEmoji , setTwitterEmoji ] = useSetting ( settingsAtom , 'twitterEmoji' );
2026-05-21 20:49:33 -04:00
const [ perMessageProfiles , setPerMessageProfiles ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'perMessageProfiles' ,
2026-05-21 20:49:33 -04:00
);
2026-05-13 22:22:06 -04:00
const [ lotusTerminal , setLotusTerminal ] = useSetting ( settingsAtom , 'lotusTerminal' );
2026-06-02 15:36:45 -04:00
const [ nightLightEnabled , setNightLightEnabled ] = useSetting ( settingsAtom , 'nightLightEnabled' );
const [ nightLightOpacity , setNightLightOpacity ] = useSetting ( settingsAtom , 'nightLightOpacity' );
2026-07-10 16:05:56 -04:00
const [ nightLightSchedule , setNightLightSchedule ] = useSetting (
settingsAtom ,
'nightLightSchedule' ,
);
const [ nightLightStart , setNightLightStart ] = useSetting ( settingsAtom , 'nightLightStart' );
const [ nightLightEnd , setNightLightEnd ] = useSetting ( settingsAtom , 'nightLightEnd' );
2026-06-04 22:02:18 -04:00
const [ glassmorphismSidebar , setGlassmorphismSidebar ] = useSetting (
settingsAtom ,
'glassmorphismSidebar' ,
);
2026-06-05 11:46:16 -04:00
const [ pauseAnimations , setPauseAnimations ] = useSetting ( settingsAtom , 'pauseAnimations' );
2026-06-10 15:39:35 -04:00
const [ mentionHighlightColor , setMentionHighlightColor ] = useSetting (
settingsAtom ,
'mentionHighlightColor' ,
);
2026-06-28 14:22:08 -04:00
const [ customAccentColor , setCustomAccentColor ] = useSetting ( settingsAtom , 'customAccentColor' );
2026-06-10 15:39:35 -04:00
const [ fontFamily , setFontFamily ] = useSetting ( settingsAtom , 'fontFamily' );
2026-06-14 00:33:04 -04:00
const [ seasonalThemeOverride , setSeasonalThemeOverride ] = useSetting (
settingsAtom ,
'seasonalThemeOverride' ,
);
2026-06-18 10:37:44 -04:00
const [, setChatBackground ] = useSetting ( settingsAtom , 'chatBackground' );
2025-02-10 16:49:47 +11:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Appearance </ Text >
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
< SettingTile
title = "System Theme"
description = "Choose between light and dark theme based on system preference."
after = {< Switch variant = "Primary" value = { systemTheme } onChange = { setSystemTheme } />}
/>
{ systemTheme && < SystemThemePreferences />}
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Theme"
description = "Theme to use when system theme is not enabled."
after = {< SelectTheme disabled = { systemTheme } />}
/>
</ SequenceCard >
2025-08-25 18:49:14 +05:30
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Monochrome Mode"
after = {< Switch variant = "Primary" value = { monochromeMode } onChange = { setMonochromeMode } />}
/>
</ SequenceCard >
2026-07-01 09:07:03 -04:00
< DesktopChromeSetting />
2026-07-02 13:31:09 -04:00
< AutostartSetting />
2026-07-01 09:07:03 -04:00
2025-02-10 16:49:47 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Twitter Emoji"
after = {< Switch variant = "Primary" value = { twitterEmoji } onChange = { setTwitterEmoji } />}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Page Zoom" after = {< PageZoomInput />} />
</ SequenceCard >
2026-05-13 21:17:59 -04:00
2026-05-21 20:49:33 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
2026-05-13 21:17:59 -04:00
< SettingTile
title = "Chat Background"
description = "Pattern applied behind the message timeline."
/>
2026-05-13 21:51:19 -04:00
< Box style = {{ padding : `0 ${ config . space . S400 } ${ config . space . S300 } ` }}>
< ChatBgGrid />
</ Box >
2026-05-13 21:17:59 -04:00
</ SequenceCard >
2026-06-05 11:46:16 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Pause Background Animations"
description = "Stop background animation to reduce motion or improve performance."
after = {< Switch variant = "Primary" value = { pauseAnimations } onChange = { setPauseAnimations } />}
/>
</ SequenceCard >
2026-06-15 01:14:56 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
2026-06-14 00:33:04 -04:00
< SettingTile
title = "Seasonal Theme"
2026-06-30 19:28:28 -04:00
description = "Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
2026-06-14 00:33:04 -04:00
/>
2026-06-15 01:14:56 -04:00
< Box style = {{ padding : `0 ${ config . space . S400 } ${ config . space . S300 } ` }}>
< SeasonalBgGrid
value = { seasonalThemeOverride ?? 'auto' }
2026-06-18 10:37:44 -04:00
onChange = {( v ) => {
setSeasonalThemeOverride ( v );
2026-07-24 17:34:51 -04:00
// Any active seasonal mode (incl. "auto") is mutually exclusive
// with a chat background — else picking it is a silent no-op, since
// SeasonalEffect suppresses the overlay while a background is set.
// Only "off" leaves the background alone.
if ( v !== 'off' ) setChatBackground ( 'none' );
2026-06-18 10:37:44 -04:00
}}
2026-06-15 01:14:56 -04:00
/>
</ Box >
2026-06-14 00:33:04 -04:00
</ SequenceCard >
2026-05-13 21:17:59 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Show Profile on Every Message"
description = "Display avatar and name on each message instead of grouping consecutive messages."
2026-05-21 20:49:33 -04:00
after = {
< Switch variant = "Primary" value = { perMessageProfiles } onChange = { setPerMessageProfiles } />
}
2026-05-13 21:17:59 -04:00
/>
</ SequenceCard >
2026-06-02 15:36:45 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
< SettingTile
title = "Night Light"
description = "Reduce blue light with a warm orange tint overlay."
after = {
< Switch variant = "Primary" value = { nightLightEnabled } onChange = { setNightLightEnabled } />
}
/>
{ nightLightEnabled && (
< Box
direction = "Column"
2026-07-10 16:05:56 -04:00
gap = "300"
2026-06-02 15:36:45 -04:00
style = {{ padding : `0 ${ config . space . S400 } ${ config . space . S300 } ` }}
>
2026-07-10 16:05:56 -04:00
< Box direction = "Column" gap = "100" >
< 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 >
< Box alignItems = "Center" justifyContent = "SpaceBetween" gap = "200" >
< Box direction = "Column" >
< Text size = "T300" > Schedule </ Text >
< Text size = "T200" priority = "300" >
Only tint during the hours below
</ Text >
</ Box >
< Switch
variant = "Primary"
value = { nightLightSchedule }
onChange = { setNightLightSchedule }
/>
</ Box >
{ nightLightSchedule && (
< Box gap = "200" >
< Box direction = "Column" gap = "100" style = {{ flex : 1 }}>
< Text as = "label" htmlFor = "night-light-start" size = "T200" priority = "400" >
From
</ Text >
< input
id = "night-light-start"
type = "time"
value = { nightLightStart }
onChange = {( e ) => setNightLightStart ( e . target . value )}
style = { pickerInputStyle ( color , config )}
/>
</ Box >
< Box direction = "Column" gap = "100" style = {{ flex : 1 }}>
< Text as = "label" htmlFor = "night-light-end" size = "T200" priority = "400" >
To
</ Text >
< input
id = "night-light-end"
type = "time"
value = { nightLightEnd }
onChange = {( e ) => setNightLightEnd ( e . target . value )}
style = { pickerInputStyle ( color , config )}
/>
</ Box >
</ Box >
)}
2026-07-10 16:09:33 -04:00
{ nightLightSchedule && nightLightStart === nightLightEnd && (
< Text size = "T200" style = {{ color : color.Critical.Main }}>
Start and end times are the same , so the tint never turns on . Set different times .
</ Text >
)}
2026-06-02 15:36:45 -04:00
</ Box >
)}
</ SequenceCard >
2026-06-04 22:02:18 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Glassmorphism Sidebar"
description = "Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
after = {
< Switch
variant = "Primary"
value = { glassmorphismSidebar }
onChange = { setGlassmorphismSidebar }
/>
}
/>
</ SequenceCard >
2026-05-13 22:22:06 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Lotus Terminal Mode"
2026-05-16 02:11:52 -04:00
description = "LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
2026-05-13 22:44:34 -04:00
after = {
2026-05-15 15:38:02 -04:00
< Box direction = "Row" gap = "200" alignItems = "Center" >
2026-05-13 22:44:34 -04:00
{ lotusTerminal && (
< button
type = "button"
2026-05-21 20:49:33 -04:00
onClick = {() => {
resetBootSequence ();
runLotusBootSequence ( true );
}}
2026-05-13 22:44:34 -04:00
title = "Replay boot sequence"
style = {{
background : 'transparent' ,
2026-06-01 21:30:27 -04:00
border : '1px solid var(--accent-orange-border)' ,
color : 'var(--accent-orange)' ,
2026-05-13 22:44:34 -04:00
fontSize : '0.65rem' ,
padding : '0.2rem 0.6rem' ,
cursor : 'pointer' ,
fontFamily : 'inherit' ,
letterSpacing : '0.1em' ,
textTransform : 'uppercase' ,
whiteSpace : 'nowrap' ,
}}
>
▶ Boot
</ button >
)}
< Switch variant = "Primary" value = { lotusTerminal } onChange = { setLotusTerminal } />
</ Box >
}
2026-05-13 22:22:06 -04:00
/>
</ SequenceCard >
2026-06-10 15:39:35 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "UI Font"
description = "Font used throughout the interface."
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
2026-06-10 15:39:35 -04:00
value = { fontFamily ?? 'inter' }
2026-06-14 12:20:47 -04:00
onChange = {( v ) =>
setFontFamily ( v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code' )
2026-06-10 15:39:35 -04:00
}
2026-06-14 12:20:47 -04:00
options = {[
{ value : 'system' , label : 'System Default' },
{ value : 'inter' , label : 'Inter (default)' },
{ value : 'jetbrains-mono' , label : 'JetBrains Mono' },
{ value : 'fira-code' , label : 'Fira Code' },
]}
/>
2026-06-10 15:39:35 -04:00
}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "@Mention Highlight Color"
description = "Color used to highlight messages that mention you. Leave empty to use the theme default."
after = {
2026-06-19 20:54:32 -04:00
< HexColorPickerPopOut
picker = {
< HexColorPicker
color = { mentionHighlightColor || '#4caf50' }
onChange = { setMentionHighlightColor }
/>
}
onRemove = { mentionHighlightColor ? () => setMentionHighlightColor ( '' ) : undefined }
>
{( openPicker , opened ) => (
2026-06-19 00:15:35 -04:00
< Button
2026-06-10 15:39:35 -04:00
type = "button"
2026-06-19 20:54:32 -04:00
aria-pressed = { opened }
onClick = { openPicker }
2026-06-19 00:15:35 -04:00
size = "300"
variant = "Secondary"
fill = "Soft"
radii = "300"
2026-06-19 20:54:32 -04:00
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 ,
}}
/>
}
2026-06-10 15:39:35 -04:00
>
2026-06-19 20:54:32 -04:00
< Text size = "B300" >{ mentionHighlightColor ? 'Change' : 'Pick' }</ Text >
2026-06-19 00:15:35 -04:00
</ Button >
2026-06-10 15:39:35 -04:00
)}
2026-06-19 20:54:32 -04:00
</ HexColorPickerPopOut >
2026-06-10 15:39:35 -04:00
}
/>
</ SequenceCard >
2026-06-28 14:22:08 -04:00
< 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 >
2025-02-10 16:49:47 +11:00
</ Box >
);
}
2025-07-27 15:13:00 +03:00
type DateHintProps = {
hasChanges : boolean ;
handleReset : () => void ;
};
function DateHint ({ hasChanges , handleReset } : DateHintProps ) {
const [ anchor , setAnchor ] = useState < RectCords >();
const categoryPadding = { padding : config.space.S200 , paddingTop : 0 };
const handleOpenMenu : MouseEventHandler < HTMLElement > = ( evt ) => {
setAnchor ( evt . currentTarget . getBoundingClientRect ());
};
return (
< PopOut
anchor = { anchor }
position = "Top"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setAnchor ( undefined ),
clickOutsideDeactivates : true ,
escapeDeactivates : stopPropagation ,
}}
>
< Menu style = {{ maxHeight : '85vh' , overflowY : 'auto' }}>
< Header size = "300" style = {{ padding : `0 ${ config . space . S200 } ` }}>
< Text size = "L400" > Formatting </ Text >
</ Header >
< Box direction = "Column" >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Year </ Text >
</ Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
YY
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' }
Two - digit year
</ Text >{ ' ' }
</ Text >
< Text size = "T300" >
YYYY
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Four - digit year
</ Text >
</ Text >
</ Box >
</ Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Month </ Text >
</ Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
M
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } The month
</ Text >
</ Text >
< Text size = "T300" >
MM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - digit month
</ Text >{ ' ' }
</ Text >
< Text size = "T300" >
MMM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Short month name
</ Text >
</ Text >
< Text size = "T300" >
MMMM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Full month name
</ Text >
</ Text >
</ Box >
</ Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Day of the Month </ Text >
</ Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
D
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Day of the month
</ Text >
</ Text >
< Text size = "T300" >
DD
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - digit day of the month
</ Text >
</ Text >
</ Box >
</ Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Day of the Week </ Text >
</ Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
d
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Day of the week ( Sunday = 0 )
</ Text >
</ Text >
< Text size = "T300" >
dd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - letter day name
</ Text >
</ Text >
< Text size = "T300" >
ddd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Short day name
</ Text >
</ Text >
< Text size = "T300" >
dddd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Full day name
</ Text >
</ Text >
</ Box >
</ Box >
</ Box >
</ Menu >
</ FocusTrap >
}
>
{ hasChanges ? (
< IconButton
onClick = { handleReset }
type = "reset"
variant = "Secondary"
size = "300"
radii = "300"
2026-05-20 21:47:20 -04:00
aria-label = "Reset to default"
2025-07-27 15:13:00 +03:00
>
< Icon src = { Icons . Cross } size = "100" />
</ IconButton >
) : (
< IconButton
onClick = { handleOpenMenu }
type = "button"
variant = "Secondary"
size = "300"
radii = "300"
2026-05-20 21:47:20 -04:00
aria-label = "Setting info"
2025-07-27 15:13:00 +03:00
aria-pressed = { !! anchor }
>
< Icon style = {{ opacity : config.opacity.P300 }} size = "100" src = { Icons . Info } />
</ IconButton >
)}
</ PopOut >
);
}
type CustomDateFormatProps = {
value : string ;
onChange : ( format : string ) => void ;
};
function CustomDateFormat ({ value , onChange } : CustomDateFormatProps ) {
const [ dateFormatCustom , setDateFormatCustom ] = useState ( value );
useEffect (() => {
setDateFormatCustom ( value );
}, [ value ]);
const handleChange : ChangeEventHandler < HTMLInputElement > = ( evt ) => {
const format = evt . currentTarget . value ;
setDateFormatCustom ( format );
};
const handleReset = () => {
setDateFormatCustom ( value );
};
const handleSubmit : FormEventHandler < HTMLFormElement > = ( evt ) => {
evt . preventDefault ();
const target = evt . target as HTMLFormElement | undefined ;
const customDateFormatInput = target ? . customDateFormatInput as HTMLInputElement | undefined ;
const format = customDateFormatInput ? . value ;
if ( ! format ) return ;
onChange ( format );
};
const hasChanges = dateFormatCustom !== value ;
return (
< SettingTile >
< Box as = "form" onSubmit = { handleSubmit } gap = "200" >
< Box grow = "Yes" direction = "Column" >
< Input
required
name = "customDateFormatInput"
2026-05-21 12:03:26 -04:00
aria-label = "Custom date format"
2025-07-27 15:13:00 +03:00
value = { dateFormatCustom }
onChange = { handleChange }
maxLength = { 16 }
autoComplete = "off"
variant = "Secondary"
radii = "300"
style = {{ paddingRight : config.space.S200 }}
after = {< DateHint hasChanges = { hasChanges } handleReset = { handleReset } />}
/>
</ Box >
< Button
size = "400"
variant = { hasChanges ? 'Success' : 'Secondary' }
fill = { hasChanges ? 'Solid' : 'Soft' }
outlined
radii = "300"
disabled = { ! hasChanges }
type = "submit"
>
< Text size = "B400" > Save </ Text >
</ Button >
</ Box >
</ SettingTile >
);
}
type PresetDateFormatProps = {
value : string ;
onChange : ( format : string ) => void ;
};
function PresetDateFormat ({ value , onChange } : PresetDateFormatProps ) {
const [ menuCords , setMenuCords ] = useState < RectCords >();
const dateFormatItems = useDateFormatItems ();
const getDisplayDate = ( format : string ) : string =>
format !== '' ? dayjs (). format ( format ) : 'Custom' ;
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setMenuCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleSelect = ( format : DateFormat ) => {
onChange ( format );
setMenuCords ( undefined );
};
return (
<>
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = {< Icon size = "300" src = { Icons . ChevronBottom } />}
onClick = { handleMenu }
>
< Text size = "T300" >
{ getDisplayDate ( dateFormatItems . find (( i ) => i . format === value ) ? . format ?? value )}
</ Text >
</ Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setMenuCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< Menu >
< Box direction = "Column" gap = "100" style = {{ padding : config.space.S100 }}>
{ dateFormatItems . map (( item ) => (
< MenuItem
key = { item . format }
size = "300"
variant = { value === item . format ? 'Primary' : 'Surface' }
radii = "300"
onClick = {() => handleSelect ( item . format )}
>
< Text size = "T300" >{ getDisplayDate ( item . format )}</ Text >
</ MenuItem >
))}
</ Box >
</ Menu >
</ FocusTrap >
}
/>
</>
);
}
function SelectDateFormat() {
const [ dateFormatString , setDateFormatString ] = useSetting ( settingsAtom , 'dateFormatString' );
const [ selectedDateFormat , setSelectedDateFormat ] = useState ( dateFormatString );
const customDateFormat = selectedDateFormat === '' ;
const handlePresetChange = ( format : string ) => {
setSelectedDateFormat ( format );
if ( format !== '' ) {
setDateFormatString ( format );
}
};
return (
<>
< SettingTile
title = "Date Format"
description = { customDateFormat ? dayjs (). format ( dateFormatString ) : '' }
after = {< PresetDateFormat value = { selectedDateFormat } onChange = { handlePresetChange } />}
/>
{ customDateFormat && (
< CustomDateFormat value = { dateFormatString } onChange = { setDateFormatString } />
)}
</>
);
}
function DateAndTime() {
const [ hour24Clock , setHour24Clock ] = useSetting ( settingsAtom , 'hour24Clock' );
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Date & Time </ Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "24-Hour Time Format"
after = {< Switch variant = "Primary" value = { hour24Clock } onChange = { setHour24Clock } />}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SelectDateFormat />
</ SequenceCard >
</ Box >
);
}
2026-07-01 09:07:03 -04:00
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 >
);
}
2025-02-10 16:49:47 +11:00
function Editor() {
const [ enterForNewline , setEnterForNewline ] = useSetting ( settingsAtom , 'enterForNewline' );
const [ isMarkdown , setIsMarkdown ] = useSetting ( settingsAtom , 'isMarkdown' );
2026-05-13 23:03:14 -04:00
const [ editorToolbar , setEditorToolbar ] = useSetting ( settingsAtom , 'editorToolbar' );
2026-06-10 13:20:29 -04:00
const [ composerToolbarButtons , setComposerToolbarButtons ] = useSetting (
settingsAtom ,
'composerToolbarButtons' ,
);
2026-07-01 09:07:03 -04:00
const composerToolbarOrder = useMemo (
() => normalizeComposerToolbarOrder ( composerToolbarButtons ? . order ),
[ composerToolbarButtons ? . order ],
);
const toggleToolbarButton = useCallback (
( key : ComposerToolbarButtonKey ) => {
setComposerToolbarButtons (( current ) => ({ ... current , [ key ] : ! current [ key ] }));
},
[ setComposerToolbarButtons ],
);
2025-02-10 16:49:47 +11:00
2026-07-01 09:07:03 -04:00
const reorderToolbarButtons = useCallback (
( startIndex : number , finishIndex : number ) => {
setComposerToolbarButtons (( current ) => ({
... current ,
order : reorder ({
list : normalizeComposerToolbarOrder ( current . order ),
startIndex ,
finishIndex ,
}),
}));
},
[ setComposerToolbarButtons ],
);
2026-06-10 17:15:34 -04:00
2025-02-10 16:49:47 +11:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Editor </ Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "ENTER for Newline"
description = { `Use ${
isMacOS () ? KeySymbol . Command : 'Ctrl'
} + ENTER to send message and ENTER for newline.` }
after = {< Switch variant = "Primary" value = { enterForNewline } onChange = { setEnterForNewline } />}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Markdown Formatting"
after = {< Switch variant = "Primary" value = { isMarkdown } onChange = { setIsMarkdown } />}
/>
</ SequenceCard >
2026-05-13 23:03:14 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Formatting Toolbar"
description = "Show bold, italic, code and other formatting buttons above the message input."
after = {< Switch variant = "Primary" value = { editorToolbar } onChange = { setEditorToolbar } />}
/>
</ SequenceCard >
2026-06-10 22:55:32 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "300"
>
2026-06-10 13:20:29 -04:00
< SettingTile
2026-06-10 17:15:34 -04:00
title = "Composer Toolbar"
2026-07-01 09:07:03 -04:00
description = "Drag to reorder buttons, and tap a button to show or hide it in the message composer."
2026-06-10 13:20:29 -04:00
/>
2026-07-01 09:07:03 -04:00
< Box direction = "Column" style = {{ paddingBottom : config.space.S200 }}>
< ComposerToolbarReorder
order = { composerToolbarOrder }
buttons = { composerToolbarButtons }
onReorder = { reorderToolbarButtons }
onToggle = { toggleToolbarButton }
/>
2026-06-10 17:15:34 -04:00
</ Box >
2026-06-10 13:20:29 -04:00
</ SequenceCard >
2026-05-28 19:31:36 -04:00
</ Box >
);
}
function Privacy() {
const [ hideActivity , setHideActivity ] = useSetting ( settingsAtom , 'hideActivity' );
const [ hidePresence , setHidePresence ] = useSetting ( settingsAtom , 'hidePresence' );
2026-06-02 19:31:30 -04:00
const [ privateReadReceipts , setPrivateReadReceipts ] = useSetting (
settingsAtom ,
'privateReadReceipts' ,
);
2026-06-03 22:13:22 -04:00
const [ warnOnUnverifiedDevices , setWarnOnUnverifiedDevices ] = useSetting (
settingsAtom ,
'warnOnUnverifiedDevices' ,
);
2026-05-28 19:31:36 -04:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Privacy </ Text >
2025-02-26 21:44:53 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Typing & Read Receipts"
description = "Turn off both typing status and read receipts to keep your activity private."
after = {< Switch variant = "Primary" value = { hideActivity } onChange = { setHideActivity } />}
/>
</ SequenceCard >
2026-06-02 19:31:30 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Private Read Receipts"
description = "Send read receipts only to you and the server — other users won't see when you've read messages."
after = {
< Switch
variant = "Primary"
value = { privateReadReceipts }
onChange = { setPrivateReadReceipts }
/>
}
/>
</ SequenceCard >
2026-05-28 19:28:52 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Online Status"
description = "Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
after = {< Switch variant = "Primary" value = { hidePresence } onChange = { setHidePresence } />}
/>
</ SequenceCard >
2026-06-03 22:13:22 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Warn before sending to unverified devices"
description = "Show a warning in the composer when sending to a room with unverified devices."
after = {
< Switch
variant = "Primary"
value = { warnOnUnverifiedDevices }
onChange = { setWarnOnUnverifiedDevices }
/>
}
/>
</ SequenceCard >
2025-02-10 16:49:47 +11:00
</ Box >
);
}
2026-06-03 00:45:43 -04:00
function useKeyBind ( setter : ( code : string ) => void ) {
const [ listening , setListening ] = useState ( false );
const listenerRef = useRef < (( e : KeyboardEvent ) => void ) | null > ( null );
2026-05-15 15:38:02 -04:00
useEffect (
() => () => {
2026-06-03 00:45:43 -04:00
if ( listenerRef . current ) window . removeEventListener ( 'keydown' , listenerRef . current , true );
2026-05-15 15:38:02 -04:00
},
2026-05-21 23:30:50 -04:00
[],
2026-05-15 15:38:02 -04:00
);
2026-05-14 11:07:10 -04:00
2026-06-03 00:45:43 -04:00
const startListening = useCallback (() => {
if ( listening ) return ;
setListening ( true );
2026-05-14 11:07:10 -04:00
const onKey = ( e : KeyboardEvent ) => {
e . preventDefault ();
2026-06-03 00:45:43 -04:00
if ( e . code !== 'Escape' ) setter ( e . code );
setListening ( false );
2026-05-14 11:07:10 -04:00
window . removeEventListener ( 'keydown' , onKey , true );
2026-06-03 00:45:43 -04:00
listenerRef . current = null ;
2026-05-14 11:07:10 -04:00
};
2026-06-03 00:45:43 -04:00
listenerRef . current = onKey ;
2026-05-14 11:07:10 -04:00
window . addEventListener ( 'keydown' , onKey , true );
2026-06-03 00:45:43 -04:00
}, [ listening , setter ]);
return { listening , startListening };
}
2026-05-14 11:07:10 -04:00
2026-06-03 00:45:43 -04:00
const keyLabel = ( code : string ) =>
code === 'Space' ? 'Space' : code . replace ( 'Key' , '' ). replace ( 'Digit' , '' );
function Calls() {
const [ cameraOnJoin , setCameraOnJoin ] = useSetting ( settingsAtom , 'cameraOnJoin' );
const [ callNoiseSuppression , setCallNoiseSuppression ] = useSetting (
settingsAtom ,
'callNoiseSuppression' ,
);
2026-06-16 00:50:12 -04:00
const [ callDenoiseModel , setCallDenoiseModel ] = useSetting ( settingsAtom , 'callDenoiseModel' );
const [ callDenoiseNativeNS , setCallDenoiseNativeNS ] = useSetting (
settingsAtom ,
'callDenoiseNativeNS' ,
);
const [ callDenoiseGate , setCallDenoiseGate ] = useSetting ( settingsAtom , 'callDenoiseGate' );
const [ callDenoiseGateThreshold , setCallDenoiseGateThreshold ] = useSetting (
settingsAtom ,
'callDenoiseGateThreshold' ,
);
2026-06-03 00:45:43 -04:00
const [ pttMode , setPttMode ] = useSetting ( settingsAtom , 'pttMode' );
const [ pttKey , setPttKey ] = useSetting ( settingsAtom , 'pttKey' );
const [ deafenKey , setDeafenKey ] = useSetting ( settingsAtom , 'deafenKey' );
2026-06-12 19:53:19 -04:00
const [ afkAutoMute , setAfkAutoMute ] = useSetting ( settingsAtom , 'afkAutoMute' );
const [ afkTimeoutMinutes , setAfkTimeoutMinutes ] = useSetting ( settingsAtom , 'afkTimeoutMinutes' );
2026-06-12 22:20:22 -04:00
const [ callJoinLeaveSound , setCallJoinLeaveSound ] = useSetting (
settingsAtom ,
'callJoinLeaveSound' ,
);
2026-06-18 18:56:04 -04:00
const [ ringtoneVolume , setRingtoneVolume ] = useSetting ( settingsAtom , 'ringtoneVolume' );
2026-06-24 17:25:32 -04:00
const [ ringtoneId , setRingtoneId ] = useSetting ( settingsAtom , 'ringtoneId' );
2026-06-12 22:20:22 -04:00
2026-06-30 22:34:17 -04:00
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' );
2026-06-12 22:20:22 -04:00
const handleJoinLeaveSoundChange = ( value : 'off' | 'chime' | 'soft' | 'retro' ) => {
setCallJoinLeaveSound ( value );
if ( value !== 'off' ) playCallJoinSound ( value );
};
2026-06-03 00:45:43 -04:00
2026-06-24 17:25:32 -04:00
const handleRingtoneChange = ( value : RingtoneId ) => {
setRingtoneId ( value );
previewRingtone ( value , Math . max ( 0 , Math . min ( 1 , ringtoneVolume / 100 )));
};
2026-06-03 00:45:43 -04:00
const pttBind = useKeyBind ( setPttKey );
const deafenBind = useKeyBind ( setDeafenKey );
2026-05-14 11:07:10 -04:00
2026-06-16 00:50:12 -04:00
const mlSupported = isMLDenoiseSupported ();
2026-06-17 20:02:16 -04:00
const selectedDenoiseModel = DENOISE_MODELS . find (( m ) => m . id === callDenoiseModel );
2026-06-16 00:50:12 -04:00
2026-05-14 11:07:10 -04:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Calls </ Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Join with Camera On"
description = "Enable camera automatically when joining a voice or video call. Camera is off by default."
after = {< Switch variant = "Primary" value = { cameraOnJoin } onChange = { setCameraOnJoin } />}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Noise Suppression"
2026-06-16 00:50:12 -04:00
description = {
< Box direction = "Column" gap = "200" >
< Text >
2026-06-16 01:40:37 -04:00
Filter background noise from your mic during calls . Browser - native uses the built - in
2026-06-17 20:02:16 -04:00
WebRTC suppressor ( Google NSNet2 ). ML runs a dedicated model for stronger removal .
2026-06-16 00:50:12 -04:00
</ Text >
{ ! mlSupported && (
< Box direction = "Column" gap = "100" >
2026-06-16 01:40:37 -04:00
< Text size = "T200" priority = "400" >
2026-06-16 00:50:12 -04:00
ML options are not supported in this browser .
</ Text >
< Box as = "ul" style = {{ paddingLeft : '20px' , margin : 0 }}>
{ ML_DENOISE_REQUIREMENTS . map (( req ) => (
2026-06-16 01:40:37 -04:00
< Text as = "li" key = { req } size = "T200" >
2026-06-16 00:50:12 -04:00
{ req }
</ Text >
))}
</ Box >
</ Box >
)}
</ Box >
}
2026-05-21 20:49:33 -04:00
after = {
2026-06-15 20:29:59 -04:00
< SettingsSelect < NoiseSuppressionMode >
2026-05-21 20:49:33 -04:00
value = { callNoiseSuppression }
onChange = { setCallNoiseSuppression }
2026-06-15 20:29:59 -04:00
options = {[
{ value : 'off' , label : 'Off' },
{ value : 'browser' , label : 'Browser-native' },
2026-06-16 00:50:12 -04:00
{
value : 'ml' ,
label : 'ML (Advanced)' ,
disabled : ! mlSupported ,
},
2026-06-15 20:29:59 -04:00
]}
2026-05-21 20:49:33 -04:00
/>
}
2026-05-14 11:07:10 -04:00
/>
2026-06-16 00:50:12 -04:00
{ callNoiseSuppression === 'ml' && (
< Box
direction = "Column"
2026-06-17 20:02:16 -04:00
gap = "400"
2026-06-16 00:50:12 -04:00
style = {{
padding : '16px' ,
marginTop : '8px' ,
2026-06-28 22:01:17 -04:00
borderTop : ` ${ config . borderWidth . B300 } solid ${ color . Surface . ContainerLine } ` ,
background : color.Surface.Container ,
2026-06-16 00:50:12 -04:00
}}
>
2026-06-17 20:02:16 -04:00
{ /* ── Model selection ───────────────────────────────────────── */ }
< Box direction = "Column" gap = "200" >
< Text size = "L400" > Model </ Text >
< SettingTile
title = "ML Model"
description = "Choose the machine learning model used for noise removal. Heavier models clean more aggressively at a higher CPU cost."
after = {
< SettingsSelect < DenoiseModelId >
value = { callDenoiseModel }
onChange = { setCallDenoiseModel }
options = { DENOISE_MODELS . map (( m ) => ({ value : m.id , label : m.name }))}
/>
}
/>
{ selectedDenoiseModel && (
< Box
direction = "Column"
gap = "200"
style = {{
padding : '12px' ,
borderRadius : '8px' ,
2026-06-28 22:01:17 -04:00
border : ` ${ config . borderWidth . B300 } solid ${ color . Surface . ContainerLine } ` ,
background : color.SurfaceVariant.Container ,
2026-06-17 20:02:16 -04:00
}}
>
< Text size = "T300" >{ selectedDenoiseModel . name }</ Text >
< Text size = "T200" priority = "300" >
{ selectedDenoiseModel . description }
</ Text >
< Box direction = "Row" gap = "400" wrap = "Wrap" >
{(
[
{ label : 'CPU' , value : selectedDenoiseModel.cpuUsage },
{ label : 'Voice quality' , value : selectedDenoiseModel.voiceQuality },
{ label : 'Transients' , value : selectedDenoiseModel.transients },
{ label : 'Download' , value : selectedDenoiseModel.binarySize },
] as const
). map (( stat ) => (
< Box key = { stat . label } direction = "Column" gap = "100" >
< Text size = "T200" priority = "300" >
{ stat . label }
</ Text >
< Text size = "T300" >{ stat . value }</ Text >
</ Box >
))}
</ Box >
</ Box >
)}
2026-06-16 00:50:12 -04:00
2026-06-17 20:02:16 -04:00
< details >
< summary style = {{ cursor : 'pointer' }}>
< Text as = "span" size = "T200" priority = "300" >
Compare all models
</ Text >
</ summary >
< Box direction = "Column" gap = "100" style = {{ overflowX : 'auto' , marginTop : '8px' }}>
< Box
direction = "Row"
gap = "100"
style = {{
2026-06-28 22:01:17 -04:00
borderBottom : ` ${ config . borderWidth . B300 } solid ${ color . Surface . ContainerLine } ` ,
2026-06-17 20:02:16 -04:00
paddingBottom : '4px' ,
}}
>
< Box style = {{ width : '160px' }}>
< Text size = "T200" priority = "300" >
Model
</ Text >
</ Box >
< Box style = {{ width : '80px' }}>
< Text size = "T200" priority = "300" >
CPU
</ Text >
</ Box >
< Box style = {{ width : '90px' }}>
< Text size = "T200" priority = "300" >
Quality
</ Text >
</ Box >
< Box grow = "Yes" >
< Text size = "T200" priority = "300" >
Transients
</ Text >
</ Box >
</ Box >
{ DENOISE_MODELS . map (( model ) => (
< Box key = { model . id } direction = "Row" gap = "100" >
< Box style = {{ width : '160px' }}>
< Text size = "T200" >{ model . name }</ Text >
</ Box >
< Box style = {{ width : '80px' }}>
< Text size = "T200" >{ model . cpuUsage }</ Text >
</ Box >
< Box style = {{ width : '90px' }}>
< Text size = "T200" >{ model . voiceQuality }</ Text >
</ Box >
< Box grow = "Yes" >
< Text size = "T200" >{ model . transients }</ Text >
</ Box >
</ Box >
))}
</ Box >
</ details >
2026-06-16 00:50:12 -04:00
2026-06-17 20:02:16 -04:00
< Text size = "T200" priority = "400" >
Note : Applying changes requires rejoining the call .
</ Text >
</ Box >
{ /* ── Enhancement toggles ───────────────────────────────────── */ }
< Box
direction = "Column"
gap = "300"
2026-06-28 22:01:17 -04:00
style = {{
paddingTop : '12px' ,
borderTop : ` ${ config . borderWidth . B300 } solid ${ color . Surface . ContainerLine } ` ,
}}
2026-06-17 20:02:16 -04:00
>
< Text size = "L400" > Enhancements </ Text >
< SettingTile
title = "Series Suppression"
description = "Run the browser's native stationary noise filter before the ML model. Recommended for eliminating fan hum."
after = {
< Switch
variant = "Primary"
value = { callDenoiseNativeNS }
onChange = { setCallDenoiseNativeNS }
/>
}
/>
< SettingTile
title = "Noise Gate"
description = "Hard-cut audio when you aren't speaking to ensure absolute silence between sentences."
after = {
< Switch variant = "Primary" value = { callDenoiseGate } onChange = { setCallDenoiseGate } />
}
/>
2026-06-16 00:50:12 -04:00
2026-06-17 20:02:16 -04:00
{ callDenoiseGate && (
< Box direction = "Column" gap = "100" >
< Box direction = "Row" justifyContent = "SpaceBetween" >
< Text size = "T200" > Gate Threshold </ Text >
< Text size = "T200" >{ callDenoiseGateThreshold } dB </ Text >
</ Box >
< input
2026-07-02 11:45:21 -04:00
aria-label = "Noise gate threshold"
2026-06-17 20:02:16 -04:00
type = "range"
min = "-100"
max = "0"
step = "1"
value = { callDenoiseGateThreshold }
onChange = {( e ) => setCallDenoiseGateThreshold ( parseInt ( e . target . value , 10 ))}
2026-06-28 22:01:17 -04:00
style = {{ width : '100%' , accentColor : color.Primary.Main }}
2026-06-17 20:02:16 -04:00
/>
2026-06-16 00:50:12 -04:00
</ Box >
2026-06-17 20:02:16 -04:00
)}
</ Box >
2026-06-16 17:53:57 -04:00
2026-06-17 20:02:16 -04:00
{ /* ── Test & calibrate ──────────────────────────────────────── */ }
2026-06-16 17:53:57 -04:00
< Box
direction = "Column"
gap = "200"
2026-06-28 22:01:17 -04:00
style = {{
paddingTop : '12px' ,
borderTop : ` ${ config . borderWidth . B300 } solid ${ color . Surface . ContainerLine } ` ,
}}
2026-06-16 17:53:57 -04:00
>
< Text size = "L400" > Test & amp ; calibrate </ Text >
< Text size = "T200" priority = "300" >
Audition the selected model and tune the gate without joining a call . Changes to the
settings above apply the next time you start a monitor or play a clip .
</ Text >
< DenoiseTester
model = { callDenoiseModel }
useGate = { callDenoiseGate }
gateThreshold = { callDenoiseGateThreshold }
nativeNS = { callDenoiseNativeNS }
/>
</ Box >
2026-06-16 00:50:12 -04:00
</ Box >
)}
2026-05-14 11:07:10 -04:00
</ SequenceCard >
2026-05-21 20:49:33 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
2026-05-14 11:07:10 -04:00
< SettingTile
title = "Push to Talk"
description = "Mute your microphone by default. Hold the PTT key to speak."
after = {< Switch variant = "Primary" value = { pttMode } onChange = { setPttMode } />}
/>
{ pttMode && (
< SettingTile
2026-06-03 00:45:43 -04:00
title = "PTT Key"
2026-05-14 11:07:10 -04:00
description = "Press a key to bind it as your push-to-talk key."
after = {
< Button
size = "300"
2026-06-03 00:45:43 -04:00
variant = { pttBind . listening ? 'Warning' : 'Secondary' }
fill = { pttBind . listening ? 'Solid' : 'Soft' }
2026-05-14 11:07:10 -04:00
radii = "300"
outlined
2026-06-03 00:45:43 -04:00
onClick = { pttBind . startListening }
2026-05-14 11:07:10 -04:00
style = {{ minWidth : '90px' }}
>
2026-06-03 00:45:43 -04:00
< Text size = "B300" >{ pttBind . listening ? 'Press a key…' : keyLabel ( pttKey )}</ Text >
2026-05-14 11:07:10 -04:00
</ Button >
}
/>
)}
2026-06-03 00:45:43 -04:00
< SettingTile
title = "Push to Deafen"
description = "Toggle speaker mute during a call. Press Escape to cancel rebind."
after = {
< Button
size = "300"
variant = { deafenBind . listening ? 'Warning' : 'Secondary' }
fill = { deafenBind . listening ? 'Solid' : 'Soft' }
radii = "300"
outlined
onClick = { deafenBind . startListening }
style = {{ minWidth : '90px' }}
>
< Text size = "B300" >{ deafenBind . listening ? 'Press a key…' : keyLabel ( deafenKey )}</ Text >
</ Button >
}
/>
2026-05-14 11:07:10 -04:00
</ SequenceCard >
2026-06-12 19:53:19 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
< SettingTile
title = "AFK Auto-Mute"
description = "Automatically mute your microphone when you have been silent in a call."
after = {< Switch variant = "Primary" value = { afkAutoMute } onChange = { setAfkAutoMute } />}
/>
{ afkAutoMute && (
< SettingTile
title = "Idle Timeout"
description = "How long to wait before auto-muting."
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
value = { String ( afkTimeoutMinutes ?? 10 )}
onChange = {( v ) => setAfkTimeoutMinutes ( Number ( v ))}
options = {[
{ value : '1' , label : '1 minute' },
{ value : '5' , label : '5 minutes' },
{ value : '10' , label : '10 minutes' },
{ value : '20' , label : '20 minutes' },
{ value : '30' , label : '30 minutes' },
]}
/>
2026-06-12 19:53:19 -04:00
}
/>
)}
</ SequenceCard >
2026-06-24 17:25:32 -04:00
< 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 >
2026-06-18 18:56:04 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
2026-07-18 22:38:01 -04:00
< SettingTile title = "Ringtone Volume" description = "Volume of the incoming call ringtone." />
< Box
direction = "Row"
alignItems = "Center"
gap = "200"
style = {{ padding : `0 ${ config . space . S400 } ${ config . space . S300 } ` }}
>
< 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 >
2026-06-18 18:56:04 -04:00
</ SequenceCard >
2026-06-12 22:20:22 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Join & Leave Sounds"
2026-06-19 20:37:55 -04:00
description = "Play a sound when someone joins or leaves a call you are in. Selecting an option plays a preview."
2026-06-12 22:20:22 -04:00
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
2026-06-12 22:20:22 -04:00
value = { callJoinLeaveSound }
2026-06-14 12:20:47 -04:00
onChange = {( v ) => handleJoinLeaveSoundChange ( v as 'off' | 'chime' | 'soft' | 'retro' )}
options = {[
{ value : 'off' , label : 'Off' },
{ value : 'chime' , label : 'Chime' },
{ value : 'soft' , label : 'Soft' },
{ value : 'retro' , label : 'Retro' },
]}
/>
2026-06-12 22:20:22 -04:00
}
/>
</ SequenceCard >
2026-06-30 22:34:17 -04:00
< 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 && (
2026-07-18 22:38:01 -04:00
<>
< SettingTile title = "Soundboard Volume" />
< Box
alignItems = "Center"
gap = "200"
style = {{ padding : `0 ${ config . space . S400 } ${ config . space . S300 } ` }}
>
< 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 >
</>
2026-06-30 22:34:17 -04:00
)}
</ SequenceCard >
2026-05-14 11:07:10 -04:00
</ Box >
);
}
2026-06-15 20:29:59 -04:00
const SEASONAL_OPTIONS : {
value : Settings [ 'seasonalThemeOverride' ];
label : string ;
emoji : string ;
}[] = [
{ value : 'auto' , label : 'Auto' , emoji : '🗓' },
{ value : 'off' , label : 'Off' , emoji : '× ' },
{ value : 'newyear' , label : 'New Year' , emoji : '🎆' },
{ value : 'lunar' , label : 'Lunar New Year' , emoji : '🏮' },
{ value : 'valentines' , label : "Valentine's" , emoji : '💖' },
{ value : 'stpatricks' , label : "St. Patrick's" , emoji : '🍀' },
{ value : 'aprilfools' , label : 'April Fools' , emoji : '?' },
{ value : 'earthday' , label : 'Earth Day' , emoji : '🌱' },
{ value : 'autumn' , label : 'Autumn' , emoji : '🍂' },
{ value : 'halloween' , label : 'Halloween' , emoji : '🎃' },
{ value : 'christmas' , label : 'Christmas' , emoji : '❄️' },
{ value : 'arcade' , label : 'Arcade Day' , emoji : '👾' },
{ value : 'deepspace' , label : 'Deep Space' , emoji : '🚀' },
];
2026-06-15 01:14:56 -04:00
function SeasonalBgGrid ({
value ,
onChange ,
} : {
value : Settings [ 'seasonalThemeOverride' ];
onChange : ( v : Settings [ 'seasonalThemeOverride' ]) => void ;
}) {
return (
2026-06-26 16:21:33 -04:00
< Box
2026-06-27 21:30:21 -04:00
grow = "Yes"
2026-06-26 16:21:33 -04:00
style = {{
display : 'grid' ,
gridTemplateColumns : `repeat(auto-fill, minmax( ${ toRem ( 76 ) } , 1fr))` ,
gap : config.space.S200 ,
}}
>
2026-06-15 01:14:56 -04:00
{ 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 }
2026-06-19 21:01:57 -04:00
data-selected = { selected }
className = { BgSwatchStyle }
2026-06-15 01:14:56 -04:00
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 >
2026-06-18 22:46:19 -04:00
< Text size = "T200" style = { selected ? { color : color.Primary.Main } : undefined }>
2026-06-15 01:14:56 -04:00
{ opt . label }
</ Text >
2026-06-30 19:28:28 -04:00
{( 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 >
)}
2026-06-15 01:14:56 -04:00
</ Box >
);
})}
</ Box >
);
}
2026-05-13 21:51:19 -04:00
function ChatBgGrid() {
2026-05-13 21:17:59 -04:00
const [ chatBackground , setChatBackground ] = useSetting ( settingsAtom , 'chatBackground' );
2026-06-18 10:37:44 -04:00
const [, setSeasonalThemeOverride ] = useSetting ( settingsAtom , 'seasonalThemeOverride' );
2026-06-05 11:46:16 -04:00
const [ pauseAnimations ] = useSetting ( settingsAtom , 'pauseAnimations' );
2026-05-13 21:51:19 -04:00
const theme = useTheme ();
const isDark = theme . kind === ThemeKind . Dark ;
2026-07-02 21:40:07 -04:00
const reduced = useReducedMotion ();
2026-05-13 21:17:59 -04:00
return (
2026-06-26 16:21:33 -04:00
< Box
2026-06-27 21:30:21 -04:00
grow = "Yes"
2026-06-26 16:21:33 -04:00
style = {{
display : 'grid' ,
gridTemplateColumns : `repeat(auto-fill, minmax( ${ toRem ( 76 ) } , 1fr))` ,
gap : config.space.S200 ,
}}
>
2026-05-13 21:51:19 -04:00
{ BG_OPTIONS . map (( opt ) => (
< Box key = { opt . value } direction = "Column" gap = "100" style = {{ alignItems : 'center' }}>
< button
type = "button"
aria-label = { opt . label }
aria-pressed = { chatBackground === opt . value }
2026-06-19 21:01:57 -04:00
data-selected = { chatBackground === opt . value }
className = { BgSwatchStyle }
2026-06-18 10:37:44 -04:00
onClick = {() => {
setChatBackground ( opt . value as ChatBackground );
if ( opt . value !== 'none' ) setSeasonalThemeOverride ( 'off' );
}}
2026-05-13 21:51:19 -04:00
style = {{
width : toRem ( 76 ),
height : toRem ( 50 ),
2026-07-02 21:40:07 -04:00
... getChatBg ( opt . value as ChatBackground , isDark , pauseAnimations || reduced ),
2026-05-13 21:17:59 -04:00
}}
2026-05-13 21:51:19 -04:00
/>
2026-06-01 21:38:35 -04:00
< Text
size = "T200"
2026-06-18 22:46:19 -04:00
style = { chatBackground === opt . value ? { color : color.Primary.Main } : undefined }
2026-06-01 21:38:35 -04:00
>
2026-05-13 21:51:19 -04:00
{ opt . label }
</ Text >
</ Box >
))}
</ Box >
2026-05-13 21:17:59 -04:00
);
}
2025-02-10 16:49:47 +11:00
function SelectMessageLayout() {
const [ menuCords , setMenuCords ] = useState < RectCords >();
const [ messageLayout , setMessageLayout ] = useSetting ( settingsAtom , 'messageLayout' );
const messageLayoutItems = useMessageLayoutItems ();
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setMenuCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleSelect = ( layout : MessageLayout ) => {
setMessageLayout ( layout );
setMenuCords ( undefined );
};
return (
<>
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = {< Icon size = "300" src = { Icons . ChevronBottom } />}
onClick = { handleMenu }
>
< Text size = "T300" >
{ messageLayoutItems . find (( i ) => i . layout === messageLayout ) ? . name ?? messageLayout }
</ Text >
</ Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setMenuCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< Menu >
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = {{ padding : config.space.S100 }}>
2025-02-10 16:49:47 +11:00
{ messageLayoutItems . map (( item ) => (
< MenuItem
key = { item . layout }
size = "300"
variant = { messageLayout === item . layout ? 'Primary' : 'Surface' }
radii = "300"
onClick = {() => handleSelect ( item . layout )}
>
< Text size = "T300" >{ item . name }</ Text >
</ MenuItem >
))}
</ Box >
</ Menu >
</ FocusTrap >
}
/>
</>
);
}
function SelectMessageSpacing() {
const [ menuCords , setMenuCords ] = useState < RectCords >();
const [ messageSpacing , setMessageSpacing ] = useSetting ( settingsAtom , 'messageSpacing' );
const messageSpacingItems = useMessageSpacingItems ();
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) => {
setMenuCords ( evt . currentTarget . getBoundingClientRect ());
};
const handleSelect = ( layout : MessageSpacing ) => {
setMessageSpacing ( layout );
setMenuCords ( undefined );
};
return (
<>
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = {< Icon size = "300" src = { Icons . ChevronBottom } />}
onClick = { handleMenu }
>
< Text size = "T300" >
{ messageSpacingItems . find (( i ) => i . spacing === messageSpacing ) ? . name ?? messageSpacing }
</ Text >
</ Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = {{
initialFocus : false ,
onDeactivate : () => setMenuCords ( undefined ),
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) =>
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
}}
>
< Menu >
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = {{ padding : config.space.S100 }}>
2025-02-10 16:49:47 +11:00
{ messageSpacingItems . map (( item ) => (
< MenuItem
key = { item . spacing }
size = "300"
variant = { messageSpacing === item . spacing ? 'Primary' : 'Surface' }
radii = "300"
onClick = {() => handleSelect ( item . spacing )}
>
< Text size = "T300" >{ item . name }</ Text >
</ MenuItem >
))}
</ Box >
</ Menu >
</ FocusTrap >
}
/>
</>
);
}
function Messages() {
2025-03-23 22:09:29 +11:00
const [ legacyUsernameColor , setLegacyUsernameColor ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'legacyUsernameColor' ,
2025-03-23 22:09:29 +11:00
);
2025-02-10 16:49:47 +11:00
const [ hideMembershipEvents , setHideMembershipEvents ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'hideMembershipEvents' ,
2025-02-10 16:49:47 +11:00
);
const [ hideNickAvatarEvents , setHideNickAvatarEvents ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'hideNickAvatarEvents' ,
2025-02-10 16:49:47 +11:00
);
const [ mediaAutoLoad , setMediaAutoLoad ] = useSetting ( settingsAtom , 'mediaAutoLoad' );
const [ urlPreview , setUrlPreview ] = useSetting ( settingsAtom , 'urlPreview' );
const [ encUrlPreview , setEncUrlPreview ] = useSetting ( settingsAtom , 'encUrlPreview' );
2026-07-06 19:48:12 -04:00
const [ inlineMediaEmbeds , setInlineMediaEmbeds ] = useSetting ( settingsAtom , 'inlineMediaEmbeds' );
2025-02-10 16:49:47 +11:00
const [ showHiddenEvents , setShowHiddenEvents ] = useSetting ( settingsAtom , 'showHiddenEvents' );
2026-07-03 11:23:14 -04:00
const [ enforceRetentionLocally , setEnforceRetentionLocally ] = useSetting (
settingsAtom ,
'enforceRetentionLocally' ,
);
2026-07-18 16:08:40 -04:00
const [ translateTargetLang , setTranslateTargetLang ] = useSetting (
settingsAtom ,
'translateTargetLang' ,
);
const [ autoTranslate , setAutoTranslate ] = useSetting ( settingsAtom , 'autoTranslate' );
const translationSupported = chromeTranslationEngine . isSupported ();
const selectedTargetLang = isSupportedTargetLang ( translateTargetLang )
? normalizeLang ( translateTargetLang )
: 'en' ;
2025-02-10 16:49:47 +11:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Messages </ Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Message Layout" after = {< SelectMessageLayout />} />
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Message Spacing" after = {< SelectMessageSpacing />} />
</ SequenceCard >
2025-03-23 22:09:29 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Legacy Username Color"
after = {
< Switch
variant = "Primary"
value = { legacyUsernameColor }
onChange = { setLegacyUsernameColor }
/>
}
/>
</ SequenceCard >
2025-02-10 16:49:47 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Membership Change"
after = {
< Switch
variant = "Primary"
value = { hideMembershipEvents }
onChange = { setHideMembershipEvents }
/>
}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Profile Change"
after = {
< Switch
variant = "Primary"
value = { hideNickAvatarEvents }
onChange = { setHideNickAvatarEvents }
/>
}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Disable Media Auto Load"
2025-02-26 21:44:53 +11:00
after = {
< Switch
variant = "Primary"
value = { ! mediaAutoLoad }
onChange = {( v ) => setMediaAutoLoad ( ! v )}
/>
}
2025-02-10 16:49:47 +11:00
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
2026-06-01 21:30:27 -04:00
title = "URL Preview"
description = "Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
2025-02-10 16:49:47 +11:00
after = {< Switch variant = "Primary" value = { urlPreview } onChange = { setUrlPreview } />}
/>
</ SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
2026-06-01 21:30:27 -04:00
title = "URL Preview in Encrypted Rooms"
description = {
< Box direction = "Column" gap = "100" >
< Text size = "T200" priority = "300" >
Your homeserver fetches link previews on your behalf . It cannot decrypt your
messages , but will see every URL you preview in encrypted rooms , potentially
revealing conversation topics .
</ Text >
< Chip
variant = "Warning"
fill = "Soft"
radii = "300"
size = "400"
style = {{ alignSelf : 'flex-start' }}
>
< Text size = "T200" > Privacy risk — enabled by default </ Text >
</ Chip >
</ Box >
}
2025-02-10 16:49:47 +11:00
after = {< Switch variant = "Primary" value = { encUrlPreview } onChange = { setEncUrlPreview } />}
/>
</ SequenceCard >
2026-07-06 19:48:12 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Inline Media Players"
2026-07-06 20:01:05 -04:00
description = "Play YouTube, Vimeo, TikTok, Twitch, Spotify and more right in the chat. Nothing loads from the third-party site until you press play."
2026-07-06 19:48:12 -04:00
after = {
< Switch variant = "Primary" value = { inlineMediaEmbeds } onChange = { setInlineMediaEmbeds } />
}
/>
</ SequenceCard >
2025-02-10 16:49:47 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Show Hidden Events"
after = {
< Switch variant = "Primary" value = { showHiddenEvents } onChange = { setShowHiddenEvents } />
}
/>
</ SequenceCard >
2026-07-03 11:23:14 -04:00
< 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 >
2026-07-18 16:08:40 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Translate Messages Into"
description = {
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
: 'On-device translation isn’ t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
}
after = {
2026-07-18 22:38:01 -04:00
< SettingsSelect
2026-07-18 16:08:40 -04:00
value = { selectedTargetLang }
2026-07-18 22:38:01 -04:00
onChange = {( v ) => setTranslateTargetLang ( v )}
disabled = { ! translationSupported }
aria-label = "Translate messages into"
options = { TRANSLATE_TARGET_LANGUAGES . map (( l ) => ({ value : l.code , label : l.name }))}
/>
2026-07-18 16:08:40 -04:00
}
/>
{ translationSupported && (
< SettingTile
title = "Auto-translate Incoming Messages"
description = "Automatically translate messages that aren’ t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
after = {< Switch variant = "Primary" value = { autoTranslate } onChange = { setAutoTranslate } />}
/>
)}
</ SequenceCard >
2025-02-10 16:49:47 +11:00
</ Box >
);
}
2026-06-10 20:31:35 -04:00
function AppUpdates() {
const { isTauri , status , check , install } = useTauriUpdater ();
if ( ! isTauri ) return null ;
const description =
status . state === 'checking'
? 'Checking for updates...'
: status . state === 'up-to-date'
? 'Lotus Chat is up to date.'
: status . state === 'available'
? `Update available: v ${ status . version } `
: status . state === 'installing'
? 'Installing update, the app will restart shortly...'
: status . state === 'error'
? `Update check failed: ${ status . message } `
: 'Check for a new version of Lotus Chat.' ;
const after =
status . state === 'available' ? (
< Button size = "300" radii = "300" onClick = { install }>
< Text size = "B300" > Install & amp ; Restart </ Text >
</ Button >
) : status . state === 'checking' || status . state === 'installing' ? (
< Spinner variant = "Secondary" size = "200" />
) : (
< Button size = "300" radii = "300" variant = "Secondary" onClick = { check }>
< Text size = "B300" > Check </ Text >
</ Button >
);
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > App Updates </ Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Check for Updates" description = { description } after = { after } />
</ SequenceCard >
</ Box >
);
}
2025-02-10 16:49:47 +11:00
type GeneralProps = {
requestClose : () => void ;
};
export function General ({ requestClose } : GeneralProps ) {
return (
< Page >
< PageHeader outlined = { false }>
< Box grow = "Yes" gap = "200" >
< Box grow = "Yes" alignItems = "Center" gap = "200" >
< Text size = "H3" truncate >
General
</ Text >
</ Box >
< Box shrink = "No" >
2026-05-20 21:54:33 -04:00
< IconButton onClick = { requestClose } variant = "Surface" aria-label = "Close" >
2025-02-10 16:49:47 +11:00
< Icon src = { Icons . Cross } />
</ IconButton >
</ Box >
</ Box >
</ PageHeader >
< Box grow = "Yes" >
< Scroll hideTrack visibility = "Hover" >
< PageContent >
< Box direction = "Column" gap = "700" >
< Appearance />
2025-07-27 15:13:00 +03:00
< DateAndTime />
2025-02-10 16:49:47 +11:00
< Editor />
< Messages />
2026-05-28 19:31:36 -04:00
< Privacy />
2026-05-14 11:07:10 -04:00
< Calls />
2026-06-10 20:31:35 -04:00
< AppUpdates />
2025-02-10 16:49:47 +11:00
</ Box >
</ PageContent >
</ Scroll >
</ Box >
</ Page >
);
}