import React, { ChangeEventHandler, FormEventHandler, KeyboardEventHandler, MouseEventHandler, useCallback, useEffect, useRef, useState, } from 'react'; import dayjs from 'dayjs'; import { as, Box, Button, Chip, color, config, Header, Icon, IconButton, Icons, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Spinner, Switch, Text, toRem, } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import FocusTrap from 'focus-trap-react'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { useSetting } from '../../../state/hooks/settings'; import { ChatBackground, ComposerToolbarSettings, DateFormat, MessageLayout, MessageSpacing, settingsAtom, } from '../../../state/settings'; import { SettingTile } from '../../../components/setting-tile'; import { KeySymbol } from '../../../utils/key-symbol'; import { isMacOS } from '../../../utils/user-agent'; import { DarkTheme, LightTheme, Theme, ThemeKind, useSystemThemeKind, useTheme, useThemeNames, useThemes, } from '../../../hooks/useTheme'; import { stopPropagation } from '../../../utils/keyboard'; import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground'; import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot'; import { useMessageLayoutItems } from '../../../hooks/useMessageLayout'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { SequenceCardStyle } from '../styles.css'; import { useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { playCallJoinSound } from '../../../utils/callSounds'; type ThemeSelectorProps = { themeNames: Record; themes: Theme[]; selected: Theme; onSelect: (theme: Theme) => void; }; const ThemeSelector = as<'div', ThemeSelectorProps>( ({ themeNames, themes, selected, onSelect, ...props }, ref) => ( {themes.map((theme) => ( onSelect(theme)} > {themeNames[theme.id] ?? theme.id} ))} ), ); function SelectTheme({ disabled }: { disabled?: boolean }) { const themes = useThemes(); const themeNames = useThemeNames(); const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); const [menuCords, setMenuCords] = useState(); const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; const handleThemeMenu: MouseEventHandler = (evt) => { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; const handleThemeSelect = (theme: Theme) => { setThemeId(theme.id); setMenuCords(undefined); }; return ( <> setMenuCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > } /> ); } type SettingsSelectOption = { value: T; label: string }; function SettingsSelect({ value, options, onChange, }: { value: T; options: SettingsSelectOption[]; onChange: (v: T) => void; }) { const [menuCords, setMenuCords] = useState(); const selectedLabel = options.find((o) => o.value === value)?.label ?? value; const handleMenu: MouseEventHandler = (evt) => { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; const handleSelect = (v: T) => { onChange(v); setMenuCords(undefined); }; return ( <> setMenuCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > {options.map((opt) => ( handleSelect(opt.value)} > {opt.label} ))} } /> ); } 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(); const [dtCords, setDTCords] = useState(); const handleLightThemeMenu: MouseEventHandler = (evt) => { setLTCords(evt.currentTarget.getBoundingClientRect()); }; const handleDarkThemeMenu: MouseEventHandler = (evt) => { setDTCords(evt.currentTarget.getBoundingClientRect()); }; const handleLightThemeSelect = (theme: Theme) => { setLightThemeId(theme.id); setLTCords(undefined); }; const handleDarkThemeSelect = (theme: Theme) => { setDarkThemeId(theme.id); setDTCords(undefined); }; return ( } onClick={handleLightThemeMenu} > {themeNames[selectedLightTheme.id] ?? selectedLightTheme.id} } /> setLTCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > } /> } onClick={handleDarkThemeMenu} > {themeNames[selectedDarkTheme.id] ?? selectedDarkTheme.id} } /> setDTCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > } /> ); } function PageZoomInput() { const [pageZoom, setPageZoom] = useSetting(settingsAtom, 'pageZoom'); const [currentZoom, setCurrentZoom] = useState(`${pageZoom}`); const handleZoomChange: ChangeEventHandler = (evt) => { setCurrentZoom(evt.target.value); }; const handleZoomEnter: KeyboardEventHandler = (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 ( %} outlined /> ); } function Appearance() { const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode'); const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); const [perMessageProfiles, setPerMessageProfiles] = useSetting( settingsAtom, 'perMessageProfiles', ); const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled'); const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity'); const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting( settingsAtom, 'glassmorphismSidebar', ); const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const [mentionHighlightColor, setMentionHighlightColor] = useSetting( settingsAtom, 'mentionHighlightColor', ); const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily'); const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting( settingsAtom, 'seasonalThemeOverride', ); return ( Appearance } /> {systemTheme && } } /> } /> } /> } /> } /> setSeasonalThemeOverride(v as typeof seasonalThemeOverride)} options={[ { value: 'auto', label: '🗓 Auto (date-based)' }, { value: 'off', label: 'Off' }, { value: 'newyear', label: '🎆 New Year' }, { value: 'lunar', label: '🏮 Lunar New Year' }, { value: 'valentines', label: '💖 Valentine\'s Day' }, { value: 'stpatricks', label: '🍀 St. Patrick\'s Day' }, { value: 'aprilfools', label: '🃏 April Fool\'s Day' }, { value: 'earthday', label: '🌱 Earth Day' }, { value: 'autumn', label: '🍂 Autumn' }, { value: 'halloween', label: '🎃 Halloween' }, { value: 'christmas', label: '❄️ Christmas' }, { value: 'arcade', label: '👾 Retro Arcade Day' }, { value: 'deepspace', label: '🚀 Deep Space Week' }, ]} /> } /> } /> } /> {nightLightEnabled && ( Intensity: {nightLightOpacity}% ) => setNightLightOpacity(parseInt(e.target.value, 10)) } style={{ width: '100%' }} /> )} } /> {lotusTerminal && ( )} } /> setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code') } options={[ { value: 'system', label: 'System Default' }, { value: 'inter', label: 'Inter (default)' }, { value: 'jetbrains-mono', label: 'JetBrains Mono' }, { value: 'fira-code', label: 'Fira Code' }, ]} /> } /> setMentionHighlightColor(e.target.value)} style={{ width: '36px', height: '28px', cursor: 'pointer', borderRadius: '4px', border: 'none', padding: '2px', }} /> {mentionHighlightColor && ( )} } /> ); } type DateHintProps = { hasChanges: boolean; handleReset: () => void; }; function DateHint({ hasChanges, handleReset }: DateHintProps) { const [anchor, setAnchor] = useState(); const categoryPadding = { padding: config.space.S200, paddingTop: 0 }; const handleOpenMenu: MouseEventHandler = (evt) => { setAnchor(evt.currentTarget.getBoundingClientRect()); }; return ( setAnchor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} >
Formatting
Year
YY {': '} Two-digit year {' '} YYYY {': '}Four-digit year
Month
M {': '}The month MM {': '}Two-digit month {' '} MMM {': '}Short month name MMMM {': '}Full month name
Day of the Month
D {': '}Day of the month DD {': '}Two-digit day of the month
Day of the Week
d {': '}Day of the week (Sunday = 0) dd {': '}Two-letter day name ddd {': '}Short day name dddd {': '}Full day name
} > {hasChanges ? ( ) : ( )}
); } 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 = (evt) => { const format = evt.currentTarget.value; setDateFormatCustom(format); }; const handleReset = () => { setDateFormatCustom(value); }; const handleSubmit: FormEventHandler = (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 ( } /> ); } type PresetDateFormatProps = { value: string; onChange: (format: string) => void; }; function PresetDateFormat({ value, onChange }: PresetDateFormatProps) { const [menuCords, setMenuCords] = useState(); const dateFormatItems = useDateFormatItems(); const getDisplayDate = (format: string): string => format !== '' ? dayjs().format(format) : 'Custom'; const handleMenu: MouseEventHandler = (evt) => { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; const handleSelect = (format: DateFormat) => { onChange(format); setMenuCords(undefined); }; return ( <> setMenuCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > {dateFormatItems.map((item) => ( handleSelect(item.format)} > {getDisplayDate(item.format)} ))} } /> ); } 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 ( <> } /> {customDateFormat && ( )} ); } function DateAndTime() { const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( Date & Time } /> ); } function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [composerToolbarButtons, setComposerToolbarButtons] = useSetting( settingsAtom, 'composerToolbarButtons', ); const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => { setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] }); }; const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [ { key: 'showFormat', label: 'Format' }, { key: 'showEmoji', label: 'Emoji' }, { key: 'showSticker', label: 'Sticker' }, { key: 'showGif', label: 'GIF' }, { key: 'showLocation', label: 'Location' }, { key: 'showPoll', label: 'Poll' }, { key: 'showVoice', label: 'Voice' }, { key: 'showSchedule', label: 'Schedule' }, ]; return ( Editor } /> } /> } /> {TOOLBAR_CHIPS.map(({ key, label }) => { const active = composerToolbarButtons?.[key] ?? true; return ( toggleToolbarButton(key)} aria-pressed={active} > {label} ); })} ); } function Privacy() { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence'); const [privateReadReceipts, setPrivateReadReceipts] = useSetting( settingsAtom, 'privateReadReceipts', ); const [warnOnUnverifiedDevices, setWarnOnUnverifiedDevices] = useSetting( settingsAtom, 'warnOnUnverifiedDevices', ); return ( Privacy } /> } /> } /> } /> ); } function useKeyBind(setter: (code: string) => void) { const [listening, setListening] = useState(false); const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null); useEffect( () => () => { if (listenerRef.current) window.removeEventListener('keydown', listenerRef.current, true); }, [], ); const startListening = useCallback(() => { if (listening) return; setListening(true); const onKey = (e: KeyboardEvent) => { e.preventDefault(); if (e.code !== 'Escape') setter(e.code); setListening(false); window.removeEventListener('keydown', onKey, true); listenerRef.current = null; }; listenerRef.current = onKey; window.addEventListener('keydown', onKey, true); }, [listening, setter]); return { listening, startListening }; } const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', ''); function Calls() { const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin'); const [callNoiseSuppression, setCallNoiseSuppression] = useSetting( settingsAtom, 'callNoiseSuppression', ); const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey'); const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey'); const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute'); const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes'); const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting( settingsAtom, 'callJoinLeaveSound', ); const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => { setCallJoinLeaveSound(value); if (value !== 'off') playCallJoinSound(value); }; const pttBind = useKeyBind(setPttKey); const deafenBind = useKeyBind(setDeafenKey); return ( Calls } /> } /> } /> {pttMode && ( {pttBind.listening ? 'Press a key…' : keyLabel(pttKey)} } /> )} {deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)} } /> } /> {afkAutoMute && ( 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' }, ]} /> } /> )} handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')} options={[ { value: 'off', label: 'Off' }, { value: 'chime', label: 'Chime' }, { value: 'soft', label: 'Soft' }, { value: 'retro', label: 'Retro' }, ]} /> } /> ); } function ChatBgGrid() { const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; return ( {BG_OPTIONS.map((opt) => ( setMenuCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > {messageLayoutItems.map((item) => ( handleSelect(item.layout)} > {item.name} ))} } /> ); } function SelectMessageSpacing() { const [menuCords, setMenuCords] = useState(); const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const messageSpacingItems = useMessageSpacingItems(); const handleMenu: MouseEventHandler = (evt) => { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; const handleSelect = (layout: MessageSpacing) => { setMessageSpacing(layout); setMenuCords(undefined); }; return ( <> setMenuCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', escapeDeactivates: stopPropagation, }} > {messageSpacingItems.map((item) => ( handleSelect(item.spacing)} > {item.name} ))} } /> ); } function Messages() { const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( settingsAtom, 'legacyUsernameColor', ); const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, 'hideMembershipEvents', ); const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting( settingsAtom, 'hideNickAvatarEvents', ); const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); return ( Messages } /> } /> } /> } /> } /> setMediaAutoLoad(!v)} /> } /> } /> 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. Privacy risk — enabled by default } after={} /> } /> ); } 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' ? ( ) : status.state === 'checking' || status.state === 'installing' ? ( ) : ( ); return ( App Updates ); } type GeneralProps = { requestClose: () => void; }; export function General({ requestClose }: GeneralProps) { return ( General ); }