2025-02-10 16:49:47 +11:00
import React , {
ChangeEventHandler ,
2025-07-27 15:13:00 +03:00
FormEventHandler ,
2025-02-10 16:49:47 +11:00
KeyboardEventHandler ,
MouseEventHandler ,
2026-05-15 15:38:02 -04:00
useCallback ,
2025-07-27 15:13:00 +03:00
useEffect ,
2026-05-15 15:38:02 -04:00
useRef ,
2025-02-10 16:49:47 +11:00
useState ,
} from 'react' ;
2025-07-27 15:13:00 +03:00
import dayjs from 'dayjs' ;
2025-02-10 16:49:47 +11:00
import {
as ,
Box ,
Button ,
Chip ,
2026-06-01 21:30:27 -04:00
color ,
2025-02-10 16:49:47 +11:00
config ,
2025-07-27 15:13:00 +03:00
Header ,
2025-02-10 16:49:47 +11:00
Icon ,
IconButton ,
Icons ,
Input ,
Menu ,
MenuItem ,
PopOut ,
RectCords ,
Scroll ,
2026-06-10 20:31:35 -04:00
Spinner ,
2025-02-10 16:49:47 +11:00
Switch ,
Text ,
toRem ,
} from 'folds' ;
import { isKeyHotkey } from 'is-hotkey' ;
import FocusTrap from 'focus-trap-react' ;
import { Page , PageContent , PageHeader } from '../../../components/page' ;
import { SequenceCard } from '../../../components/sequence-card' ;
import { useSetting } from '../../../state/hooks/settings' ;
2026-05-21 20:49:33 -04:00
import {
ChatBackground ,
2026-06-10 13:20:29 -04:00
ComposerToolbarSettings ,
2026-05-21 20:49:33 -04:00
DateFormat ,
MessageLayout ,
MessageSpacing ,
2026-06-15 20:29:59 -04:00
NoiseSuppressionMode ,
2026-06-15 01:14:56 -04:00
Settings ,
2026-05-21 20:49:33 -04:00
settingsAtom ,
} from '../../../state/settings' ;
2026-06-15 20:29:59 -04:00
import { SeasonalPreview , SeasonTheme } from '../../../components/seasonal/SeasonalEffect' ;
2025-02-10 16:49:47 +11:00
import { SettingTile } from '../../../components/setting-tile' ;
import { KeySymbol } from '../../../utils/key-symbol' ;
import { isMacOS } from '../../../utils/user-agent' ;
import {
DarkTheme ,
LightTheme ,
Theme ,
ThemeKind ,
useSystemThemeKind ,
2026-05-13 21:51:19 -04:00
useTheme ,
2025-02-10 16:49:47 +11:00
useThemeNames ,
useThemes ,
} from '../../../hooks/useTheme' ;
import { stopPropagation } from '../../../utils/keyboard' ;
2026-05-13 21:51:19 -04:00
import { BG_OPTIONS , getChatBg } from '../../lotus/chatBackground' ;
2026-05-13 22:44:34 -04:00
import { resetBootSequence , runLotusBootSequence } from '../../../../lotus-boot' ;
2025-02-10 16:49:47 +11:00
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout' ;
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing' ;
2025-07-27 15:13:00 +03:00
import { useDateFormatItems } from '../../../hooks/useDateFormat' ;
2025-02-10 16:49:47 +11:00
import { SequenceCardStyle } from '../styles.css' ;
2026-06-10 20:31:35 -04:00
import { useTauriUpdater } from '../../../hooks/useTauriUpdater' ;
2026-06-12 22:20:22 -04:00
import { playCallJoinSound } from '../../../utils/callSounds' ;
2025-02-10 16:49:47 +11:00
type ThemeSelectorProps = {
themeNames : Record < string , string > ;
themes : Theme [ ] ;
selected : Theme ;
onSelect : ( theme : Theme ) = > void ;
} ;
const ThemeSelector = as < 'div' , ThemeSelectorProps > (
( { themeNames , themes , selected , onSelect , . . . props } , ref ) = > (
< Menu { ...props } ref = { ref } >
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = { { padding : config.space.S100 } } >
2025-02-10 16:49:47 +11:00
{ themes . map ( ( theme ) = > (
< MenuItem
key = { theme . id }
size = "300"
variant = { theme . id === selected . id ? 'Primary' : 'Surface' }
radii = "300"
onClick = { ( ) = > onSelect ( theme ) }
>
< Text size = "T300" > { themeNames [ theme . id ] ? ? theme . id } < / Text >
< / MenuItem >
) ) }
< / Box >
< / Menu >
2026-05-21 23:30:50 -04:00
) ,
2025-02-10 16:49:47 +11:00
) ;
function SelectTheme ( { disabled } : { disabled? : boolean } ) {
const themes = useThemes ( ) ;
const themeNames = useThemeNames ( ) ;
const [ themeId , setThemeId ] = useSetting ( settingsAtom , 'themeId' ) ;
const [ menuCords , setMenuCords ] = useState < RectCords > ( ) ;
const selectedTheme = themes . find ( ( theme ) = > theme . id === themeId ) ? ? LightTheme ;
const handleThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setMenuCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleThemeSelect = ( theme : Theme ) = > {
setThemeId ( theme . id ) ;
setMenuCords ( undefined ) ;
} ;
return (
< >
< Button
size = "300"
variant = "Primary"
outlined
fill = "Soft"
radii = "300"
after = { < Icon size = "300" src = { Icons . ChevronBottom } / > }
onClick = { disabled ? undefined : handleThemeMenu }
aria-disabled = { disabled }
>
< Text size = "T300" > { themeNames [ selectedTheme . id ] ? ? selectedTheme . id } < / Text >
< / Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setMenuCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< ThemeSelector
themeNames = { themeNames }
themes = { themes }
selected = { selectedTheme }
onSelect = { handleThemeSelect }
/ >
< / FocusTrap >
}
/ >
< / >
) ;
}
2026-06-14 12:20:47 -04:00
type SettingsSelectOption < T extends string > = { value : T ; label : string } ;
function SettingsSelect < T extends string > ( {
value ,
options ,
onChange ,
} : {
value : T ;
options : SettingsSelectOption < T > [ ] ;
onChange : ( v : T ) = > void ;
} ) {
const [ menuCords , setMenuCords ] = useState < RectCords > ( ) ;
const selectedLabel = options . find ( ( o ) = > o . value === value ) ? . label ? ? value ;
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setMenuCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleSelect = ( v : T ) = > {
onChange ( v ) ;
setMenuCords ( undefined ) ;
} ;
return (
< >
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = { < Icon size = "300" src = { Icons . ChevronBottom } / > }
onClick = { handleMenu }
>
< Text size = "T300" > { selectedLabel } < / Text >
< / Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setMenuCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< Menu >
< Box direction = "Column" gap = "100" style = { { padding : config.space.S100 } } >
{ options . map ( ( opt ) = > (
< MenuItem
key = { opt . value }
size = "300"
variant = { opt . value === value ? 'Primary' : 'Surface' }
radii = "300"
onClick = { ( ) = > handleSelect ( opt . value ) }
>
< Text size = "T300" > { opt . label } < / Text >
< / MenuItem >
) ) }
< / Box >
< / Menu >
< / FocusTrap >
}
/ >
< / >
) ;
}
2025-02-10 16:49:47 +11:00
function SystemThemePreferences() {
const themeKind = useSystemThemeKind ( ) ;
const themeNames = useThemeNames ( ) ;
const themes = useThemes ( ) ;
const [ lightThemeId , setLightThemeId ] = useSetting ( settingsAtom , 'lightThemeId' ) ;
const [ darkThemeId , setDarkThemeId ] = useSetting ( settingsAtom , 'darkThemeId' ) ;
const lightThemes = themes . filter ( ( theme ) = > theme . kind === ThemeKind . Light ) ;
const darkThemes = themes . filter ( ( theme ) = > theme . kind === ThemeKind . Dark ) ;
const selectedLightTheme = lightThemes . find ( ( theme ) = > theme . id === lightThemeId ) ? ? LightTheme ;
const selectedDarkTheme = darkThemes . find ( ( theme ) = > theme . id === darkThemeId ) ? ? DarkTheme ;
const [ ltCords , setLTCords ] = useState < RectCords > ( ) ;
const [ dtCords , setDTCords ] = useState < RectCords > ( ) ;
const handleLightThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setLTCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleDarkThemeMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setDTCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleLightThemeSelect = ( theme : Theme ) = > {
setLightThemeId ( theme . id ) ;
setLTCords ( undefined ) ;
} ;
const handleDarkThemeSelect = ( theme : Theme ) = > {
setDarkThemeId ( theme . id ) ;
setDTCords ( undefined ) ;
} ;
return (
< Box wrap = "Wrap" gap = "400" >
< SettingTile
title = "Light Theme:"
after = {
< Chip
variant = { themeKind === ThemeKind . Light ? 'Primary' : 'Secondary' }
outlined = { themeKind === ThemeKind . Light }
radii = "Pill"
after = { < Icon size = "200" src = { Icons . ChevronBottom } / > }
onClick = { handleLightThemeMenu }
>
< Text size = "B300" > { themeNames [ selectedLightTheme . id ] ? ? selectedLightTheme . id } < / Text >
< / Chip >
}
/ >
< PopOut
anchor = { ltCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setLTCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< ThemeSelector
themeNames = { themeNames }
themes = { lightThemes }
selected = { selectedLightTheme }
onSelect = { handleLightThemeSelect }
/ >
< / FocusTrap >
}
/ >
< SettingTile
title = "Dark Theme:"
after = {
< Chip
variant = { themeKind === ThemeKind . Dark ? 'Primary' : 'Secondary' }
outlined = { themeKind === ThemeKind . Dark }
radii = "Pill"
after = { < Icon size = "200" src = { Icons . ChevronBottom } / > }
onClick = { handleDarkThemeMenu }
>
< Text size = "B300" > { themeNames [ selectedDarkTheme . id ] ? ? selectedDarkTheme . id } < / Text >
< / Chip >
}
/ >
< PopOut
anchor = { dtCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setDTCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< ThemeSelector
themeNames = { themeNames }
themes = { darkThemes }
selected = { selectedDarkTheme }
onSelect = { handleDarkThemeSelect }
/ >
< / FocusTrap >
}
/ >
< / Box >
) ;
}
function PageZoomInput() {
const [ pageZoom , setPageZoom ] = useSetting ( settingsAtom , 'pageZoom' ) ;
const [ currentZoom , setCurrentZoom ] = useState ( ` ${ pageZoom } ` ) ;
const handleZoomChange : ChangeEventHandler < HTMLInputElement > = ( evt ) = > {
setCurrentZoom ( evt . target . value ) ;
} ;
const handleZoomEnter : KeyboardEventHandler < HTMLInputElement > = ( evt ) = > {
if ( isKeyHotkey ( 'escape' , evt ) ) {
evt . stopPropagation ( ) ;
setCurrentZoom ( pageZoom . toString ( ) ) ;
}
if (
isKeyHotkey ( 'enter' , evt ) &&
'value' in evt . target &&
typeof evt . target . value === 'string'
) {
const newZoom = parseInt ( evt . target . value , 10 ) ;
if ( Number . isNaN ( newZoom ) ) return ;
const safeZoom = Math . max ( Math . min ( newZoom , 150 ) , 75 ) ;
setPageZoom ( safeZoom ) ;
setCurrentZoom ( safeZoom . toString ( ) ) ;
}
} ;
return (
< Input
style = { { width : toRem ( 100 ) } }
2026-05-21 12:03:26 -04:00
aria-label = "Page zoom percentage"
2025-02-10 16:49:47 +11:00
variant = { pageZoom === parseInt ( currentZoom , 10 ) ? 'Secondary' : 'Success' }
size = "300"
radii = "300"
type = "number"
min = "75"
max = "150"
value = { currentZoom }
onChange = { handleZoomChange }
onKeyDown = { handleZoomEnter }
after = { < Text size = "T300" > % < / Text > }
outlined
/ >
) ;
}
function Appearance() {
const [ systemTheme , setSystemTheme ] = useSetting ( settingsAtom , 'useSystemTheme' ) ;
2025-08-25 18:49:14 +05:30
const [ monochromeMode , setMonochromeMode ] = useSetting ( settingsAtom , 'monochromeMode' ) ;
2025-02-10 16:49:47 +11:00
const [ twitterEmoji , setTwitterEmoji ] = useSetting ( settingsAtom , 'twitterEmoji' ) ;
2026-05-21 20:49:33 -04:00
const [ perMessageProfiles , setPerMessageProfiles ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'perMessageProfiles' ,
2026-05-21 20:49:33 -04:00
) ;
2026-05-13 22:22:06 -04:00
const [ lotusTerminal , setLotusTerminal ] = useSetting ( settingsAtom , 'lotusTerminal' ) ;
2026-06-02 15:36:45 -04:00
const [ nightLightEnabled , setNightLightEnabled ] = useSetting ( settingsAtom , 'nightLightEnabled' ) ;
const [ nightLightOpacity , setNightLightOpacity ] = useSetting ( settingsAtom , 'nightLightOpacity' ) ;
2026-06-04 22:02:18 -04:00
const [ glassmorphismSidebar , setGlassmorphismSidebar ] = useSetting (
settingsAtom ,
'glassmorphismSidebar' ,
) ;
2026-06-05 11:46:16 -04:00
const [ pauseAnimations , setPauseAnimations ] = useSetting ( settingsAtom , 'pauseAnimations' ) ;
2026-06-10 15:39:35 -04:00
const [ mentionHighlightColor , setMentionHighlightColor ] = useSetting (
settingsAtom ,
'mentionHighlightColor' ,
) ;
const [ fontFamily , setFontFamily ] = useSetting ( settingsAtom , 'fontFamily' ) ;
2026-06-14 00:33:04 -04:00
const [ seasonalThemeOverride , setSeasonalThemeOverride ] = useSetting (
settingsAtom ,
'seasonalThemeOverride' ,
) ;
2025-02-10 16:49:47 +11:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Appearance < / Text >
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
< SettingTile
title = "System Theme"
description = "Choose between light and dark theme based on system preference."
after = { < Switch variant = "Primary" value = { systemTheme } onChange = { setSystemTheme } / > }
/ >
{ systemTheme && < SystemThemePreferences / > }
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Theme"
description = "Theme to use when system theme is not enabled."
after = { < SelectTheme disabled = { systemTheme } / > }
/ >
< / SequenceCard >
2025-08-25 18:49:14 +05:30
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Monochrome Mode"
after = { < Switch variant = "Primary" value = { monochromeMode } onChange = { setMonochromeMode } / > }
/ >
< / SequenceCard >
2025-02-10 16:49:47 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Twitter Emoji"
after = { < Switch variant = "Primary" value = { twitterEmoji } onChange = { setTwitterEmoji } / > }
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Page Zoom" after = { < PageZoomInput / > } / >
< / SequenceCard >
2026-05-13 21:17:59 -04:00
2026-05-21 20:49:33 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
2026-05-13 21:17:59 -04:00
< SettingTile
title = "Chat Background"
description = "Pattern applied behind the message timeline."
/ >
2026-05-13 21:51:19 -04:00
< Box style = { { padding : ` 0 ${ config . space . S400 } ${ config . space . S300 } ` } } >
< ChatBgGrid / >
< / Box >
2026-05-13 21:17:59 -04:00
< / SequenceCard >
2026-06-05 11:46:16 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Pause Background Animations"
description = "Stop background animation to reduce motion or improve performance."
after = { < Switch variant = "Primary" value = { pauseAnimations } onChange = { setPauseAnimations } / > }
/ >
< / SequenceCard >
2026-06-15 01:14:56 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
2026-06-14 00:33:04 -04:00
< SettingTile
title = "Seasonal Theme"
2026-06-15 01:14:56 -04:00
description = "Decorative overlays for holidays and events. Preview below — click to select."
2026-06-14 00:33:04 -04:00
/ >
2026-06-15 01:14:56 -04:00
< Box style = { { padding : ` 0 ${ config . space . S400 } ${ config . space . S300 } ` } } >
< SeasonalBgGrid
value = { seasonalThemeOverride ? ? 'auto' }
onChange = { ( v ) = > setSeasonalThemeOverride ( v ) }
/ >
< / Box >
2026-06-14 00:33:04 -04:00
< / SequenceCard >
2026-05-13 21:17:59 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Show Profile on Every Message"
description = "Display avatar and name on each message instead of grouping consecutive messages."
2026-05-21 20:49:33 -04:00
after = {
< Switch variant = "Primary" value = { perMessageProfiles } onChange = { setPerMessageProfiles } / >
}
2026-05-13 21:17:59 -04:00
/ >
< / SequenceCard >
2026-06-02 15:36:45 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "200"
>
< SettingTile
title = "Night Light"
description = "Reduce blue light with a warm orange tint overlay."
after = {
< Switch variant = "Primary" value = { nightLightEnabled } onChange = { setNightLightEnabled } / >
}
/ >
{ nightLightEnabled && (
< Box
direction = "Column"
gap = "100"
style = { { padding : ` 0 ${ config . space . S400 } ${ config . space . S300 } ` } }
>
< Text size = "T200" style = { { opacity : 0.7 } } >
Intensity : { nightLightOpacity } %
< / Text >
< input
type = "range"
min = { 5 }
max = { 80 }
value = { nightLightOpacity }
onChange = { ( e : React.ChangeEvent < HTMLInputElement > ) = >
setNightLightOpacity ( parseInt ( e . target . value , 10 ) )
}
style = { { width : '100%' } }
/ >
< / Box >
) }
< / SequenceCard >
2026-06-04 22:02:18 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Glassmorphism Sidebar"
description = "Semi-transparent sidebar with frosted glass effect. Works best with custom chat backgrounds."
after = {
< Switch
variant = "Primary"
value = { glassmorphismSidebar }
onChange = { setGlassmorphismSidebar }
/ >
}
/ >
< / SequenceCard >
2026-05-13 22:22:06 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Lotus Terminal Mode"
2026-05-16 02:11:52 -04:00
description = "LotusGuild Terminal Design System: Anduril Orange + Ice Cyan + Matrix Green, dot-grid background, CRT scanlines, and boot sequence animation (press Escape to skip)."
2026-05-13 22:44:34 -04:00
after = {
2026-05-15 15:38:02 -04:00
< Box direction = "Row" gap = "200" alignItems = "Center" >
2026-05-13 22:44:34 -04:00
{ lotusTerminal && (
< button
type = "button"
2026-05-21 20:49:33 -04:00
onClick = { ( ) = > {
resetBootSequence ( ) ;
runLotusBootSequence ( true ) ;
} }
2026-05-13 22:44:34 -04:00
title = "Replay boot sequence"
style = { {
background : 'transparent' ,
2026-06-01 21:30:27 -04:00
border : '1px solid var(--accent-orange-border)' ,
color : 'var(--accent-orange)' ,
2026-05-13 22:44:34 -04:00
fontSize : '0.65rem' ,
padding : '0.2rem 0.6rem' ,
cursor : 'pointer' ,
fontFamily : 'inherit' ,
letterSpacing : '0.1em' ,
textTransform : 'uppercase' ,
whiteSpace : 'nowrap' ,
} }
>
▶ Boot
< / button >
) }
< Switch variant = "Primary" value = { lotusTerminal } onChange = { setLotusTerminal } / >
< / Box >
}
2026-05-13 22:22:06 -04:00
/ >
< / SequenceCard >
2026-06-10 15:39:35 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "UI Font"
description = "Font used throughout the interface."
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
2026-06-10 15:39:35 -04:00
value = { fontFamily ? ? 'inter' }
2026-06-14 12:20:47 -04:00
onChange = { ( v ) = >
setFontFamily ( v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code' )
2026-06-10 15:39:35 -04:00
}
2026-06-14 12:20:47 -04:00
options = { [
{ value : 'system' , label : 'System Default' } ,
{ value : 'inter' , label : 'Inter (default)' } ,
{ value : 'jetbrains-mono' , label : 'JetBrains Mono' } ,
{ value : 'fira-code' , label : 'Fira Code' } ,
] }
/ >
2026-06-10 15:39:35 -04:00
}
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "@Mention Highlight Color"
description = "Color used to highlight messages that mention you. Leave empty to use the theme default."
after = {
< Box alignItems = "Center" gap = "200" >
< input
type = "color"
value = { mentionHighlightColor || '#4caf50' }
onChange = { ( e ) = > setMentionHighlightColor ( e . target . value ) }
2026-06-10 22:55:32 -04:00
style = { {
width : '36px' ,
height : '28px' ,
cursor : 'pointer' ,
borderRadius : '4px' ,
border : 'none' ,
padding : '2px' ,
} }
2026-06-10 15:39:35 -04:00
/ >
{ mentionHighlightColor && (
< button
type = "button"
onClick = { ( ) = > setMentionHighlightColor ( '' ) }
style = { {
background : 'none' ,
border : '1px solid var(--border-interactive-normal)' ,
borderRadius : '6px' ,
padding : '2px 8px' ,
cursor : 'pointer' ,
color : 'inherit' ,
fontSize : '12px' ,
} }
>
Reset
< / button >
) }
< / Box >
}
/ >
< / SequenceCard >
2025-02-10 16:49:47 +11:00
< / Box >
) ;
}
2025-07-27 15:13:00 +03:00
type DateHintProps = {
hasChanges : boolean ;
handleReset : ( ) = > void ;
} ;
function DateHint ( { hasChanges , handleReset } : DateHintProps ) {
const [ anchor , setAnchor ] = useState < RectCords > ( ) ;
const categoryPadding = { padding : config.space.S200 , paddingTop : 0 } ;
const handleOpenMenu : MouseEventHandler < HTMLElement > = ( evt ) = > {
setAnchor ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
return (
< PopOut
anchor = { anchor }
position = "Top"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setAnchor ( undefined ) ,
clickOutsideDeactivates : true ,
escapeDeactivates : stopPropagation ,
} }
>
< Menu style = { { maxHeight : '85vh' , overflowY : 'auto' } } >
< Header size = "300" style = { { padding : ` 0 ${ config . space . S200 } ` } } >
< Text size = "L400" > Formatting < / Text >
< / Header >
< Box direction = "Column" >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Year < / Text >
< / Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
YY
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' }
Two - digit year
< / Text > { ' ' }
< / Text >
< Text size = "T300" >
YYYY
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Four - digit year
< / Text >
< / Text >
< / Box >
< / Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Month < / Text >
< / Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
M
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } The month
< / Text >
< / Text >
< Text size = "T300" >
MM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - digit month
< / Text > { ' ' }
< / Text >
< Text size = "T300" >
MMM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Short month name
< / Text >
< / Text >
< Text size = "T300" >
MMMM
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Full month name
< / Text >
< / Text >
< / Box >
< / Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Day of the Month < / Text >
< / Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
D
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Day of the month
< / Text >
< / Text >
< Text size = "T300" >
DD
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - digit day of the month
< / Text >
< / Text >
< / Box >
< / Box >
< Box style = { categoryPadding } direction = "Column" >
< Header size = "300" >
< Text size = "L400" > Day of the Week < / Text >
< / Header >
< Box direction = "Column" tabIndex = { 0 } gap = "100" >
< Text size = "T300" >
d
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Day of the week ( Sunday = 0 )
< / Text >
< / Text >
< Text size = "T300" >
dd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Two - letter day name
< / Text >
< / Text >
< Text size = "T300" >
ddd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Short day name
< / Text >
< / Text >
< Text size = "T300" >
dddd
< Text as = "span" size = "Inherit" priority = "300" >
{ ': ' } Full day name
< / Text >
< / Text >
< / Box >
< / Box >
< / Box >
< / Menu >
< / FocusTrap >
}
>
{ hasChanges ? (
< IconButton
onClick = { handleReset }
type = "reset"
variant = "Secondary"
size = "300"
radii = "300"
2026-05-20 21:47:20 -04:00
aria-label = "Reset to default"
2025-07-27 15:13:00 +03:00
>
< Icon src = { Icons . Cross } size = "100" / >
< / IconButton >
) : (
< IconButton
onClick = { handleOpenMenu }
type = "button"
variant = "Secondary"
size = "300"
radii = "300"
2026-05-20 21:47:20 -04:00
aria-label = "Setting info"
2025-07-27 15:13:00 +03:00
aria-pressed = { ! ! anchor }
>
< Icon style = { { opacity : config.opacity.P300 } } size = "100" src = { Icons . Info } / >
< / IconButton >
) }
< / PopOut >
) ;
}
type CustomDateFormatProps = {
value : string ;
onChange : ( format : string ) = > void ;
} ;
function CustomDateFormat ( { value , onChange } : CustomDateFormatProps ) {
const [ dateFormatCustom , setDateFormatCustom ] = useState ( value ) ;
useEffect ( ( ) = > {
setDateFormatCustom ( value ) ;
} , [ value ] ) ;
const handleChange : ChangeEventHandler < HTMLInputElement > = ( evt ) = > {
const format = evt . currentTarget . value ;
setDateFormatCustom ( format ) ;
} ;
const handleReset = ( ) = > {
setDateFormatCustom ( value ) ;
} ;
const handleSubmit : FormEventHandler < HTMLFormElement > = ( evt ) = > {
evt . preventDefault ( ) ;
const target = evt . target as HTMLFormElement | undefined ;
const customDateFormatInput = target ? . customDateFormatInput as HTMLInputElement | undefined ;
const format = customDateFormatInput ? . value ;
if ( ! format ) return ;
onChange ( format ) ;
} ;
const hasChanges = dateFormatCustom !== value ;
return (
< SettingTile >
< Box as = "form" onSubmit = { handleSubmit } gap = "200" >
< Box grow = "Yes" direction = "Column" >
< Input
required
name = "customDateFormatInput"
2026-05-21 12:03:26 -04:00
aria-label = "Custom date format"
2025-07-27 15:13:00 +03:00
value = { dateFormatCustom }
onChange = { handleChange }
maxLength = { 16 }
autoComplete = "off"
variant = "Secondary"
radii = "300"
style = { { paddingRight : config.space.S200 } }
after = { < DateHint hasChanges = { hasChanges } handleReset = { handleReset } / > }
/ >
< / Box >
< Button
size = "400"
variant = { hasChanges ? 'Success' : 'Secondary' }
fill = { hasChanges ? 'Solid' : 'Soft' }
outlined
radii = "300"
disabled = { ! hasChanges }
type = "submit"
>
< Text size = "B400" > Save < / Text >
< / Button >
< / Box >
< / SettingTile >
) ;
}
type PresetDateFormatProps = {
value : string ;
onChange : ( format : string ) = > void ;
} ;
function PresetDateFormat ( { value , onChange } : PresetDateFormatProps ) {
const [ menuCords , setMenuCords ] = useState < RectCords > ( ) ;
const dateFormatItems = useDateFormatItems ( ) ;
const getDisplayDate = ( format : string ) : string = >
format !== '' ? dayjs ( ) . format ( format ) : 'Custom' ;
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setMenuCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleSelect = ( format : DateFormat ) = > {
onChange ( format ) ;
setMenuCords ( undefined ) ;
} ;
return (
< >
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = { < Icon size = "300" src = { Icons . ChevronBottom } / > }
onClick = { handleMenu }
>
< Text size = "T300" >
{ getDisplayDate ( dateFormatItems . find ( ( i ) = > i . format === value ) ? . format ? ? value ) }
< / Text >
< / Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setMenuCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< Menu >
< Box direction = "Column" gap = "100" style = { { padding : config.space.S100 } } >
{ dateFormatItems . map ( ( item ) = > (
< MenuItem
key = { item . format }
size = "300"
variant = { value === item . format ? 'Primary' : 'Surface' }
radii = "300"
onClick = { ( ) = > handleSelect ( item . format ) }
>
< Text size = "T300" > { getDisplayDate ( item . format ) } < / Text >
< / MenuItem >
) ) }
< / Box >
< / Menu >
< / FocusTrap >
}
/ >
< / >
) ;
}
function SelectDateFormat() {
const [ dateFormatString , setDateFormatString ] = useSetting ( settingsAtom , 'dateFormatString' ) ;
const [ selectedDateFormat , setSelectedDateFormat ] = useState ( dateFormatString ) ;
const customDateFormat = selectedDateFormat === '' ;
const handlePresetChange = ( format : string ) = > {
setSelectedDateFormat ( format ) ;
if ( format !== '' ) {
setDateFormatString ( format ) ;
}
} ;
return (
< >
< SettingTile
title = "Date Format"
description = { customDateFormat ? dayjs ( ) . format ( dateFormatString ) : '' }
after = { < PresetDateFormat value = { selectedDateFormat } onChange = { handlePresetChange } / > }
/ >
{ customDateFormat && (
< CustomDateFormat value = { dateFormatString } onChange = { setDateFormatString } / >
) }
< / >
) ;
}
function DateAndTime() {
const [ hour24Clock , setHour24Clock ] = useSetting ( settingsAtom , 'hour24Clock' ) ;
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Date & Time < / Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "24-Hour Time Format"
after = { < Switch variant = "Primary" value = { hour24Clock } onChange = { setHour24Clock } / > }
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SelectDateFormat / >
< / SequenceCard >
< / Box >
) ;
}
2025-02-10 16:49:47 +11:00
function Editor() {
const [ enterForNewline , setEnterForNewline ] = useSetting ( settingsAtom , 'enterForNewline' ) ;
const [ isMarkdown , setIsMarkdown ] = useSetting ( settingsAtom , 'isMarkdown' ) ;
2026-05-13 23:03:14 -04:00
const [ editorToolbar , setEditorToolbar ] = useSetting ( settingsAtom , 'editorToolbar' ) ;
2026-06-10 13:20:29 -04:00
const [ composerToolbarButtons , setComposerToolbarButtons ] = useSetting (
settingsAtom ,
'composerToolbarButtons' ,
) ;
const toggleToolbarButton = ( key : keyof ComposerToolbarSettings ) = > {
setComposerToolbarButtons ( { . . . composerToolbarButtons , [ key ] : ! composerToolbarButtons [ key ] } ) ;
} ;
2025-02-10 16:49:47 +11:00
2026-06-10 17:15:34 -04:00
const TOOLBAR_CHIPS : Array < { key : keyof ComposerToolbarSettings ; label : string } > = [
{ key : 'showFormat' , label : 'Format' } ,
{ key : 'showEmoji' , label : 'Emoji' } ,
{ key : 'showSticker' , label : 'Sticker' } ,
{ key : 'showGif' , label : 'GIF' } ,
{ key : 'showLocation' , label : 'Location' } ,
{ key : 'showPoll' , label : 'Poll' } ,
{ key : 'showVoice' , label : 'Voice' } ,
{ key : 'showSchedule' , label : 'Schedule' } ,
] ;
2025-02-10 16:49:47 +11:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Editor < / Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "ENTER for Newline"
description = { ` Use ${
isMacOS ( ) ? KeySymbol . Command : 'Ctrl'
} + ENTER to send message and ENTER for newline. ` }
after = { < Switch variant = "Primary" value = { enterForNewline } onChange = { setEnterForNewline } / > }
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Markdown Formatting"
after = { < Switch variant = "Primary" value = { isMarkdown } onChange = { setIsMarkdown } / > }
/ >
< / SequenceCard >
2026-05-13 23:03:14 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Formatting Toolbar"
description = "Show bold, italic, code and other formatting buttons above the message input."
after = { < Switch variant = "Primary" value = { editorToolbar } onChange = { setEditorToolbar } / > }
/ >
< / SequenceCard >
2026-06-10 22:55:32 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "300"
>
2026-06-10 13:20:29 -04:00
< SettingTile
2026-06-10 17:15:34 -04:00
title = "Composer Toolbar"
description = "Tap a button to show or hide it in the message composer."
2026-06-10 13:20:29 -04:00
/ >
2026-06-10 17:15:34 -04:00
< Box
wrap = "Wrap"
gap = "200"
style = { { padding : ` 0 ${ config . space . S400 } ${ config . space . S300 } ` } }
>
{ TOOLBAR_CHIPS . map ( ( { key , label } ) = > {
const active = composerToolbarButtons ? . [ key ] ? ? true ;
return (
< Chip
key = { key }
variant = { active ? 'Primary' : 'Secondary' }
outlined = { active }
radii = "Pill"
onClick = { ( ) = > toggleToolbarButton ( key ) }
aria-pressed = { active }
>
< Text size = "T300" > { label } < / Text >
< / Chip >
) ;
} ) }
< / Box >
2026-06-10 13:20:29 -04:00
< / SequenceCard >
2026-05-28 19:31:36 -04:00
< / Box >
) ;
}
function Privacy() {
const [ hideActivity , setHideActivity ] = useSetting ( settingsAtom , 'hideActivity' ) ;
const [ hidePresence , setHidePresence ] = useSetting ( settingsAtom , 'hidePresence' ) ;
2026-06-02 19:31:30 -04:00
const [ privateReadReceipts , setPrivateReadReceipts ] = useSetting (
settingsAtom ,
'privateReadReceipts' ,
) ;
2026-06-03 22:13:22 -04:00
const [ warnOnUnverifiedDevices , setWarnOnUnverifiedDevices ] = useSetting (
settingsAtom ,
'warnOnUnverifiedDevices' ,
) ;
2026-05-28 19:31:36 -04:00
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Privacy < / Text >
2025-02-26 21:44:53 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Typing & Read Receipts"
description = "Turn off both typing status and read receipts to keep your activity private."
after = { < Switch variant = "Primary" value = { hideActivity } onChange = { setHideActivity } / > }
/ >
< / SequenceCard >
2026-06-02 19:31:30 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Private Read Receipts"
description = "Send read receipts only to you and the server — other users won't see when you've read messages."
after = {
< Switch
variant = "Primary"
value = { privateReadReceipts }
onChange = { setPrivateReadReceipts }
/ >
}
/ >
< / SequenceCard >
2026-05-28 19:28:52 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Online Status"
description = "Appear offline to everyone while still using the app. Disables online, away, and idle indicators."
after = { < Switch variant = "Primary" value = { hidePresence } onChange = { setHidePresence } / > }
/ >
< / SequenceCard >
2026-06-03 22:13:22 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Warn before sending to unverified devices"
description = "Show a warning in the composer when sending to a room with unverified devices."
after = {
< Switch
variant = "Primary"
value = { warnOnUnverifiedDevices }
onChange = { setWarnOnUnverifiedDevices }
/ >
}
/ >
< / SequenceCard >
2025-02-10 16:49:47 +11:00
< / Box >
) ;
}
2026-06-03 00:45:43 -04:00
function useKeyBind ( setter : ( code : string ) = > void ) {
const [ listening , setListening ] = useState ( false ) ;
const listenerRef = useRef < ( ( e : KeyboardEvent ) = > void ) | null > ( null ) ;
2026-05-15 15:38:02 -04:00
useEffect (
( ) = > ( ) = > {
2026-06-03 00:45:43 -04:00
if ( listenerRef . current ) window . removeEventListener ( 'keydown' , listenerRef . current , true ) ;
2026-05-15 15:38:02 -04:00
} ,
2026-05-21 23:30:50 -04:00
[ ] ,
2026-05-15 15:38:02 -04:00
) ;
2026-05-14 11:07:10 -04:00
2026-06-03 00:45:43 -04:00
const startListening = useCallback ( ( ) = > {
if ( listening ) return ;
setListening ( true ) ;
2026-05-14 11:07:10 -04:00
const onKey = ( e : KeyboardEvent ) = > {
e . preventDefault ( ) ;
2026-06-03 00:45:43 -04:00
if ( e . code !== 'Escape' ) setter ( e . code ) ;
setListening ( false ) ;
2026-05-14 11:07:10 -04:00
window . removeEventListener ( 'keydown' , onKey , true ) ;
2026-06-03 00:45:43 -04:00
listenerRef . current = null ;
2026-05-14 11:07:10 -04:00
} ;
2026-06-03 00:45:43 -04:00
listenerRef . current = onKey ;
2026-05-14 11:07:10 -04:00
window . addEventListener ( 'keydown' , onKey , true ) ;
2026-06-03 00:45:43 -04:00
} , [ listening , setter ] ) ;
return { listening , startListening } ;
}
2026-05-14 11:07:10 -04:00
2026-06-03 00:45:43 -04:00
const keyLabel = ( code : string ) = >
code === 'Space' ? 'Space' : code . replace ( 'Key' , '' ) . replace ( 'Digit' , '' ) ;
function Calls() {
const [ cameraOnJoin , setCameraOnJoin ] = useSetting ( settingsAtom , 'cameraOnJoin' ) ;
const [ callNoiseSuppression , setCallNoiseSuppression ] = useSetting (
settingsAtom ,
'callNoiseSuppression' ,
) ;
const [ pttMode , setPttMode ] = useSetting ( settingsAtom , 'pttMode' ) ;
const [ pttKey , setPttKey ] = useSetting ( settingsAtom , 'pttKey' ) ;
const [ deafenKey , setDeafenKey ] = useSetting ( settingsAtom , 'deafenKey' ) ;
2026-06-12 19:53:19 -04:00
const [ afkAutoMute , setAfkAutoMute ] = useSetting ( settingsAtom , 'afkAutoMute' ) ;
const [ afkTimeoutMinutes , setAfkTimeoutMinutes ] = useSetting ( settingsAtom , 'afkTimeoutMinutes' ) ;
2026-06-12 22:20:22 -04:00
const [ callJoinLeaveSound , setCallJoinLeaveSound ] = useSetting (
settingsAtom ,
'callJoinLeaveSound' ,
) ;
const handleJoinLeaveSoundChange = ( value : 'off' | 'chime' | 'soft' | 'retro' ) = > {
setCallJoinLeaveSound ( value ) ;
if ( value !== 'off' ) playCallJoinSound ( value ) ;
} ;
2026-06-03 00:45:43 -04:00
const pttBind = useKeyBind ( setPttKey ) ;
const deafenBind = useKeyBind ( setDeafenKey ) ;
2026-05-14 11:07:10 -04:00
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-15 20:29:59 -04:00
description = "Filter background noise from your mic during calls. Browser-native uses the built-in WebRTC suppressor; ML runs on-device RNNoise for stronger, Krisp-style removal (higher CPU)."
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' } ,
{ value : 'ml' , label : 'ML (beta)' } ,
] }
2026-05-21 20:49:33 -04:00
/ >
}
2026-05-14 11:07:10 -04:00
/ >
< / SequenceCard >
2026-05-21 20:49:33 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
2026-05-14 11:07:10 -04:00
< SettingTile
title = "Push to Talk"
description = "Mute your microphone by default. Hold the PTT key to speak."
after = { < Switch variant = "Primary" value = { pttMode } onChange = { setPttMode } / > }
/ >
{ pttMode && (
< SettingTile
2026-06-03 00:45:43 -04:00
title = "PTT Key"
2026-05-14 11:07:10 -04:00
description = "Press a key to bind it as your push-to-talk key."
after = {
< Button
size = "300"
2026-06-03 00:45:43 -04:00
variant = { pttBind . listening ? 'Warning' : 'Secondary' }
fill = { pttBind . listening ? 'Solid' : 'Soft' }
2026-05-14 11:07:10 -04:00
radii = "300"
outlined
2026-06-03 00:45:43 -04:00
onClick = { pttBind . startListening }
2026-05-14 11:07:10 -04:00
style = { { minWidth : '90px' } }
>
2026-06-03 00:45:43 -04:00
< Text size = "B300" > { pttBind . listening ? 'Press a key…' : keyLabel ( pttKey ) } < / Text >
2026-05-14 11:07:10 -04:00
< / Button >
}
/ >
) }
2026-06-03 00:45:43 -04:00
< SettingTile
title = "Push to Deafen"
description = "Toggle speaker mute during a call. Press Escape to cancel rebind."
after = {
< Button
size = "300"
variant = { deafenBind . listening ? 'Warning' : 'Secondary' }
fill = { deafenBind . listening ? 'Solid' : 'Soft' }
radii = "300"
outlined
onClick = { deafenBind . startListening }
style = { { minWidth : '90px' } }
>
< Text size = "B300" > { deafenBind . listening ? 'Press a key…' : keyLabel ( deafenKey ) } < / Text >
< / Button >
}
/ >
2026-05-14 11:07:10 -04:00
< / SequenceCard >
2026-06-12 19:53:19 -04:00
< SequenceCard
className = { SequenceCardStyle }
variant = "SurfaceVariant"
direction = "Column"
gap = "400"
>
< SettingTile
title = "AFK Auto-Mute"
description = "Automatically mute your microphone when you have been silent in a call."
after = { < Switch variant = "Primary" value = { afkAutoMute } onChange = { setAfkAutoMute } / > }
/ >
{ afkAutoMute && (
< SettingTile
title = "Idle Timeout"
description = "How long to wait before auto-muting."
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
value = { String ( afkTimeoutMinutes ? ? 10 ) }
onChange = { ( v ) = > setAfkTimeoutMinutes ( Number ( v ) ) }
options = { [
{ value : '1' , label : '1 minute' } ,
{ value : '5' , label : '5 minutes' } ,
{ value : '10' , label : '10 minutes' } ,
{ value : '20' , label : '20 minutes' } ,
{ value : '30' , label : '30 minutes' } ,
] }
/ >
2026-06-12 19:53:19 -04:00
}
/ >
) }
< / SequenceCard >
2026-06-12 22:20:22 -04:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Join & Leave Sounds"
description = "Play a sound when someone joins or leaves a call you are in."
after = {
2026-06-14 12:20:47 -04:00
< SettingsSelect
2026-06-12 22:20:22 -04:00
value = { callJoinLeaveSound }
2026-06-14 12:20:47 -04:00
onChange = { ( v ) = > handleJoinLeaveSoundChange ( v as 'off' | 'chime' | 'soft' | 'retro' ) }
options = { [
{ value : 'off' , label : 'Off' } ,
{ value : 'chime' , label : 'Chime' } ,
{ value : 'soft' , label : 'Soft' } ,
{ value : 'retro' , label : 'Retro' } ,
] }
/ >
2026-06-12 22:20:22 -04:00
}
/ >
< / SequenceCard >
2026-05-14 11:07:10 -04:00
< / Box >
) ;
}
2026-06-15 20:29:59 -04:00
const SEASONAL_OPTIONS : {
value : Settings [ 'seasonalThemeOverride' ] ;
label : string ;
emoji : string ;
} [ ] = [
{ value : 'auto' , label : 'Auto' , emoji : '🗓' } ,
{ value : 'off' , label : 'Off' , emoji : '× ' } ,
{ value : 'newyear' , label : 'New Year' , emoji : '🎆' } ,
{ value : 'lunar' , label : 'Lunar New Year' , emoji : '🏮' } ,
{ value : 'valentines' , label : "Valentine's" , emoji : '💖' } ,
{ value : 'stpatricks' , label : "St. Patrick's" , emoji : '🍀' } ,
{ value : 'aprilfools' , label : 'April Fools' , emoji : '?' } ,
{ value : 'earthday' , label : 'Earth Day' , emoji : '🌱' } ,
{ value : 'autumn' , label : 'Autumn' , emoji : '🍂' } ,
{ value : 'halloween' , label : 'Halloween' , emoji : '🎃' } ,
{ value : 'christmas' , label : 'Christmas' , emoji : '❄️' } ,
{ value : 'arcade' , label : 'Arcade Day' , emoji : '👾' } ,
{ value : 'deepspace' , label : 'Deep Space' , emoji : '🚀' } ,
] ;
2026-06-15 01:14:56 -04:00
function SeasonalBgGrid ( {
value ,
onChange ,
} : {
value : Settings [ 'seasonalThemeOverride' ] ;
onChange : ( v : Settings [ 'seasonalThemeOverride' ] ) = > void ;
} ) {
return (
< Box wrap = "Wrap" gap = "200" >
{ SEASONAL_OPTIONS . map ( ( opt ) = > {
const selected = value === opt . value ;
const isSpecial = opt . value === 'auto' || opt . value === 'off' ;
return (
< Box key = { opt . value } direction = "Column" gap = "100" style = { { alignItems : 'center' } } >
< button
type = "button"
aria-label = { opt . label }
aria-pressed = { selected }
onClick = { ( ) = > onChange ( opt . value ) }
style = { {
position : 'relative' ,
display : 'block' ,
width : toRem ( 76 ) ,
height : toRem ( 56 ) ,
borderRadius : toRem ( 8 ) ,
cursor : 'pointer' ,
border : selected
? ` 2px solid ${ color . Critical . Main } `
: '2px solid rgba(128,128,128,0.25)' ,
padding : 0 ,
overflow : 'hidden' ,
backgroundColor : '#030508' ,
} }
>
{ ! isSpecial && < SeasonalPreview theme = { opt . value as SeasonTheme } / > }
< div
style = { {
position : 'absolute' ,
inset : 0 ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'center' ,
backgroundImage :
opt.value === 'auto'
? 'linear-gradient(135deg, rgba(255,100,0,0.2), rgba(255,200,0,0.2), rgba(0,200,100,0.2), rgba(0,100,255,0.2))'
: undefined ,
pointerEvents : 'none' ,
} }
>
{ isSpecial && (
< span style = { { fontSize : '22px' , opacity : opt.value === 'off' ? 0.4 : 1 } } >
{ opt . emoji }
< / span >
) }
< / div >
< / button >
< Text size = "T200" style = { selected ? { color : color.Critical.Main } : undefined } >
{ opt . label }
< / Text >
< / Box >
) ;
} ) }
< / Box >
) ;
}
2026-05-13 21:51:19 -04:00
function ChatBgGrid() {
2026-05-13 21:17:59 -04:00
const [ chatBackground , setChatBackground ] = useSetting ( settingsAtom , 'chatBackground' ) ;
2026-06-05 11:46:16 -04:00
const [ pauseAnimations ] = useSetting ( settingsAtom , 'pauseAnimations' ) ;
2026-05-13 21:51:19 -04:00
const theme = useTheme ( ) ;
const isDark = theme . kind === ThemeKind . Dark ;
2026-05-13 21:17:59 -04:00
return (
2026-05-13 21:51:19 -04:00
< Box wrap = "Wrap" gap = "200" >
{ BG_OPTIONS . map ( ( opt ) = > (
< Box key = { opt . value } direction = "Column" gap = "100" style = { { alignItems : 'center' } } >
< button
type = "button"
aria-label = { opt . label }
aria-pressed = { chatBackground === opt . value }
onClick = { ( ) = > setChatBackground ( opt . value as ChatBackground ) }
style = { {
display : 'block' ,
width : toRem ( 76 ) ,
height : toRem ( 50 ) ,
borderRadius : toRem ( 8 ) ,
cursor : 'pointer' ,
2026-05-21 20:49:33 -04:00
border :
chatBackground === opt . value
2026-06-01 21:30:27 -04:00
? ` 2px solid ${ color . Critical . Main } `
2026-05-21 20:49:33 -04:00
: '2px solid rgba(128,128,128,0.25)' ,
2026-05-13 21:51:19 -04:00
padding : 0 ,
overflow : 'hidden' ,
2026-06-05 11:46:16 -04:00
. . . getChatBg ( opt . value as ChatBackground , isDark , pauseAnimations ) ,
2026-05-13 21:17:59 -04:00
} }
2026-05-13 21:51:19 -04:00
/ >
2026-06-01 21:38:35 -04:00
< Text
size = "T200"
style = { chatBackground === opt . value ? { color : color.Critical.Main } : undefined }
>
2026-05-13 21:51:19 -04:00
{ opt . label }
< / Text >
< / Box >
) ) }
< / Box >
2026-05-13 21:17:59 -04:00
) ;
}
2025-02-10 16:49:47 +11:00
function SelectMessageLayout() {
const [ menuCords , setMenuCords ] = useState < RectCords > ( ) ;
const [ messageLayout , setMessageLayout ] = useSetting ( settingsAtom , 'messageLayout' ) ;
const messageLayoutItems = useMessageLayoutItems ( ) ;
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setMenuCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleSelect = ( layout : MessageLayout ) = > {
setMessageLayout ( layout ) ;
setMenuCords ( undefined ) ;
} ;
return (
< >
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = { < Icon size = "300" src = { Icons . ChevronBottom } / > }
onClick = { handleMenu }
>
< Text size = "T300" >
{ messageLayoutItems . find ( ( i ) = > i . layout === messageLayout ) ? . name ? ? messageLayout }
< / Text >
< / Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setMenuCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< Menu >
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = { { padding : config.space.S100 } } >
2025-02-10 16:49:47 +11:00
{ messageLayoutItems . map ( ( item ) = > (
< MenuItem
key = { item . layout }
size = "300"
variant = { messageLayout === item . layout ? 'Primary' : 'Surface' }
radii = "300"
onClick = { ( ) = > handleSelect ( item . layout ) }
>
< Text size = "T300" > { item . name } < / Text >
< / MenuItem >
) ) }
< / Box >
< / Menu >
< / FocusTrap >
}
/ >
< / >
) ;
}
function SelectMessageSpacing() {
const [ menuCords , setMenuCords ] = useState < RectCords > ( ) ;
const [ messageSpacing , setMessageSpacing ] = useSetting ( settingsAtom , 'messageSpacing' ) ;
const messageSpacingItems = useMessageSpacingItems ( ) ;
const handleMenu : MouseEventHandler < HTMLButtonElement > = ( evt ) = > {
setMenuCords ( evt . currentTarget . getBoundingClientRect ( ) ) ;
} ;
const handleSelect = ( layout : MessageSpacing ) = > {
setMessageSpacing ( layout ) ;
setMenuCords ( undefined ) ;
} ;
return (
< >
< Button
size = "300"
variant = "Secondary"
outlined
fill = "Soft"
radii = "300"
after = { < Icon size = "300" src = { Icons . ChevronBottom } / > }
onClick = { handleMenu }
>
< Text size = "T300" >
{ messageSpacingItems . find ( ( i ) = > i . spacing === messageSpacing ) ? . name ? ? messageSpacing }
< / Text >
< / Button >
< PopOut
anchor = { menuCords }
offset = { 5 }
position = "Bottom"
align = "End"
content = {
< FocusTrap
focusTrapOptions = { {
initialFocus : false ,
onDeactivate : ( ) = > setMenuCords ( undefined ) ,
clickOutsideDeactivates : true ,
isKeyForward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowDown' || evt . key === 'ArrowRight' ,
isKeyBackward : ( evt : KeyboardEvent ) = >
evt . key === 'ArrowUp' || evt . key === 'ArrowLeft' ,
escapeDeactivates : stopPropagation ,
} }
>
< Menu >
2025-08-04 20:29:12 +05:30
< Box direction = "Column" gap = "100" style = { { padding : config.space.S100 } } >
2025-02-10 16:49:47 +11:00
{ messageSpacingItems . map ( ( item ) = > (
< MenuItem
key = { item . spacing }
size = "300"
variant = { messageSpacing === item . spacing ? 'Primary' : 'Surface' }
radii = "300"
onClick = { ( ) = > handleSelect ( item . spacing ) }
>
< Text size = "T300" > { item . name } < / Text >
< / MenuItem >
) ) }
< / Box >
< / Menu >
< / FocusTrap >
}
/ >
< / >
) ;
}
function Messages() {
2025-03-23 22:09:29 +11:00
const [ legacyUsernameColor , setLegacyUsernameColor ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'legacyUsernameColor' ,
2025-03-23 22:09:29 +11:00
) ;
2025-02-10 16:49:47 +11:00
const [ hideMembershipEvents , setHideMembershipEvents ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'hideMembershipEvents' ,
2025-02-10 16:49:47 +11:00
) ;
const [ hideNickAvatarEvents , setHideNickAvatarEvents ] = useSetting (
settingsAtom ,
2026-05-21 23:30:50 -04:00
'hideNickAvatarEvents' ,
2025-02-10 16:49:47 +11:00
) ;
const [ mediaAutoLoad , setMediaAutoLoad ] = useSetting ( settingsAtom , 'mediaAutoLoad' ) ;
const [ urlPreview , setUrlPreview ] = useSetting ( settingsAtom , 'urlPreview' ) ;
const [ encUrlPreview , setEncUrlPreview ] = useSetting ( settingsAtom , 'encUrlPreview' ) ;
const [ showHiddenEvents , setShowHiddenEvents ] = useSetting ( settingsAtom , 'showHiddenEvents' ) ;
return (
< Box direction = "Column" gap = "100" >
< Text size = "L400" > Messages < / Text >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Message Layout" after = { < SelectMessageLayout / > } / >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile title = "Message Spacing" after = { < SelectMessageSpacing / > } / >
< / SequenceCard >
2025-03-23 22:09:29 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Legacy Username Color"
after = {
< Switch
variant = "Primary"
value = { legacyUsernameColor }
onChange = { setLegacyUsernameColor }
/ >
}
/ >
< / SequenceCard >
2025-02-10 16:49:47 +11:00
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Membership Change"
after = {
< Switch
variant = "Primary"
value = { hideMembershipEvents }
onChange = { setHideMembershipEvents }
/ >
}
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Hide Profile Change"
after = {
< Switch
variant = "Primary"
value = { hideNickAvatarEvents }
onChange = { setHideNickAvatarEvents }
/ >
}
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Disable Media Auto Load"
2025-02-26 21:44:53 +11:00
after = {
< Switch
variant = "Primary"
value = { ! mediaAutoLoad }
onChange = { ( v ) = > setMediaAutoLoad ( ! v ) }
/ >
}
2025-02-10 16:49:47 +11:00
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
2026-06-01 21:30:27 -04:00
title = "URL Preview"
description = "Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
2025-02-10 16:49:47 +11:00
after = { < Switch variant = "Primary" value = { urlPreview } onChange = { setUrlPreview } / > }
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
2026-06-01 21:30:27 -04:00
title = "URL Preview in Encrypted Rooms"
description = {
< Box direction = "Column" gap = "100" >
< Text size = "T200" priority = "300" >
Your homeserver fetches link previews on your behalf . It cannot decrypt your
messages , but will see every URL you preview in encrypted rooms , potentially
revealing conversation topics .
< / Text >
< Chip
variant = "Warning"
fill = "Soft"
radii = "300"
size = "400"
style = { { alignSelf : 'flex-start' } }
>
< Text size = "T200" > Privacy risk — enabled by default < / Text >
< / Chip >
< / Box >
}
2025-02-10 16:49:47 +11:00
after = { < Switch variant = "Primary" value = { encUrlPreview } onChange = { setEncUrlPreview } / > }
/ >
< / SequenceCard >
< SequenceCard className = { SequenceCardStyle } variant = "SurfaceVariant" direction = "Column" >
< SettingTile
title = "Show Hidden Events"
after = {
< Switch variant = "Primary" value = { showHiddenEvents } onChange = { setShowHiddenEvents } / >
}
/ >
< / SequenceCard >
< / Box >
) ;
}
2026-06-10 20:31:35 -04:00
function AppUpdates() {
const { isTauri , status , check , install } = useTauriUpdater ( ) ;
if ( ! isTauri ) return null ;
const description =
status . state === 'checking'
? 'Checking for updates...'
: status . state === 'up-to-date'
? 'Lotus Chat is up to date.'
: status . state === 'available'
? ` Update available: v ${ status . version } `
: status . state === 'installing'
? 'Installing update, the app will restart shortly...'
: status . state === 'error'
? ` Update check failed: ${ status . message } `
: 'Check for a new version of Lotus Chat.' ;
const after =
status . state === 'available' ? (
< Button size = "300" radii = "300" onClick = { install } >
< Text size = "B300" > Install & 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 >
) ;
}