feat(desktop): Tier A desktop features — web side (P5-46/36/44/43/49/47/55/57)

Web half of the desktop feature wave. A shared bridge (`hooks/useTauri.ts`:
invokeTauri/isTauri/useTauriEvent) backs per-feature hooks that no-op in the
browser and drive the native Tauri commands (compiled in cinny-desktop):

- P5-46 useTauriCallPower — hold system awake while a call is active.
- P5-36 useTauriJumpList — Windows jump list of recent rooms → matrix: deep links.
- P5-44 useTauriThumbbar — taskbar Mute/Deafen/End; events toggle mic/sound/hangup.
- P5-43 useTauriSmtc — SMTC call state + button events.
- P5-49 useTauriNetwork — react to native network-change → mx.retryImmediately().
- P5-47 window chrome — opt-in `customWindowChromeAtom` + TDS `TitleBar`; DesktopChrome
  wrapper in App.tsx (zero layout impact when off) + a desktop-only settings toggle.
- P5-55 composer toolbar drag-reorder (settings order[] + pragmatic-drag-and-drop).
- P5-57 DraftIndicator — subtle "draft saved" cue in the composer.

Client-scoped hooks mount via TauriDesktopFeatures in ClientNonUIFeatures; window
chrome mounts at App level. Gates: tsc/eslint/prettier clean, build OK, 556 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:07:03 -04:00
parent a0fcdf74da
commit aab7e5ae20
18 changed files with 1180 additions and 216 deletions
+77
View File
@@ -0,0 +1,77 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
const BAR_HEIGHT = toRem(32);
const CONTROL_WIDTH = toRem(46);
export const TitleBar = style([
DefaultReset,
{
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
height: BAR_HEIGHT,
width: '100%',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
// Sit above app content but never intercept scroll etc. below the bar.
userSelect: 'none',
},
]);
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
// the free space so most of the bar is grabbable.
export const DragRegion = style({
display: 'flex',
alignItems: 'center',
flexGrow: 1,
minWidth: 0,
gap: config.space.S200,
paddingInline: config.space.S300,
});
export const Brand = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
// Children shouldn't swallow the drag; the region itself owns the attribute.
pointerEvents: 'none',
});
export const Controls = style({
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
});
export const ControlButton = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: CONTROL_WIDTH,
height: '100%',
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
color: 'inherit',
transition: 'background-color 100ms ease',
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.ContainerLine,
},
},
},
]);
export const ControlButtonClose = style({
selectors: {
'&:hover': {
backgroundColor: color.Critical.Main,
color: color.Critical.OnMain,
},
},
});
+135
View File
@@ -0,0 +1,135 @@
import React, { MouseEvent, ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { Text } from 'folds';
import { customWindowChromeAtom } from '../../state/customWindowChrome';
import { invokeTauri, isTauri } from '../../hooks/useTauri';
import * as css from './TitleBar.css';
/**
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
* a coarse "is this a Mac" signal to decide which side the window controls sit
* on, so the UA/platform sniff is sufficient and stays cross-platform.
*/
const isMacOS = (): boolean => {
const platform =
(
navigator as unknown as {
userAgentData?: { platform?: string };
}
).userAgentData?.platform ??
navigator.platform ??
navigator.userAgent;
return /mac/i.test(platform);
};
const MIN_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
</svg>
);
const MAX_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
</svg>
);
const CLOSE_GLYPH = (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
</svg>
);
type ControlButtonProps = {
label: string;
glyph: ReactNode;
onClick: () => void;
close?: boolean;
};
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
>
{glyph}
</button>
);
}
/**
* P5-47 — TDS Custom Window Chrome titlebar.
*
* Renders `null` unless we're inside Tauri **and** the user opted into custom
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
* draggable region (`data-tauri-drag-region`) with the app brand, plus
* minimize / maximize / close controls that call the native window commands.
*
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
* the left (the native traffic-light position) since decorations — and thus the
* real traffic lights — are stripped while custom chrome is on.
*/
export function TitleBar() {
const enabled = useAtomValue(customWindowChromeAtom);
if (!isTauri() || !enabled) return null;
const mac = isMacOS();
const handleDoubleClick = (evt: MouseEvent<HTMLDivElement>): void => {
// Only the drag surface itself toggles maximize, not the brand/children.
if (evt.target !== evt.currentTarget) return;
invokeTauri('window_toggle_maximize');
};
const controls = (
<div className={css.Controls}>
<ControlButton
label="Minimize"
glyph={MIN_GLYPH}
onClick={() => invokeTauri('window_minimize')}
/>
<ControlButton
label="Maximize"
glyph={MAX_GLYPH}
onClick={() => invokeTauri('window_toggle_maximize')}
/>
<ControlButton
label="Close"
glyph={CLOSE_GLYPH}
onClick={() => invokeTauri('window_close')}
close
/>
</div>
);
const dragRegion = (
<div className={css.DragRegion} data-tauri-drag-region onDoubleClick={handleDoubleClick}>
<span className={css.Brand}>
<Text as="span" size="T200" truncate>
Lotus Chat
</Text>
</span>
</div>
);
return (
<header className={css.TitleBar}>
{mac ? (
<>
{controls}
{dragRegion}
</>
) : (
<>
{dragRegion}
{controls}
</>
)}
</header>
);
}
@@ -0,0 +1,31 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// A brief, gentle acknowledgement when a draft first becomes persisted.
// Guarded by `prefers-reduced-motion` so it only plays for users who opt in.
const savedPulse = keyframes({
'0%': { opacity: 0.4, transform: 'scale(0.7)' },
'45%': { opacity: 1, transform: 'scale(1.15)' },
'100%': { opacity: 1, transform: 'scale(1)' },
});
export const DraftIndicatorBase = style({
userSelect: 'none',
whiteSpace: 'nowrap',
});
export const DraftDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Success.Main,
flexShrink: 0,
});
export const DraftDotPulse = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${savedPulse} 600ms ease-out`,
},
},
});
+64
View File
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
type DraftIndicatorProps = {
roomId: string;
};
/**
* Subtle, non-distracting status shown near the composer when the current room
* has a persisted (unsent) message draft. It reacts to the shared draft atom
* (`roomIdToMsgDraftAtomFamily`) — the same source that backs the
* `draft-msg-${roomId}` localStorage persistence — so it never introduces a
* parallel persistence path.
*
* A short "Saved" pulse plays the moment a draft becomes persisted, then the
* indicator settles into a quiet, muted resting state. The pulse is gated behind
* `prefers-reduced-motion` in CSS, so motion-averse users only ever see the
* static label.
*/
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
// Real content, not just an empty paragraph.
const hasDraft = toPlainText(draft, false).trim().length > 0;
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);
useEffect(() => {
if (hasDraft && !hadDraft.current) {
hadDraft.current = true;
setPulse(true);
const timeout = setTimeout(() => setPulse(false), PULSE_DURATION);
return () => clearTimeout(timeout);
}
hadDraft.current = hasDraft;
return undefined;
}, [hasDraft]);
if (!hasDraft) return null;
return (
<Box
className={DraftIndicatorBase}
as="span"
shrink="No"
alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S100}` }}
aria-hidden
>
<span className={`${DraftDot}${pulse ? ` ${DraftDotPulse}` : ''}`} />
<Text as="span" size="T200" priority="300">
Draft saved
</Text>
</Box>
);
}
+255 -180
View File
@@ -1,9 +1,11 @@
import React, {
KeyboardEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -98,7 +100,11 @@ import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
import { settingsAtom } from '../../state/settings';
import {
ComposerToolbarButtonKey,
normalizeComposerToolbarOrder,
settingsAtom,
} from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
@@ -128,6 +134,7 @@ import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
const GifPicker = React.lazy(() =>
@@ -219,6 +226,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = useCallback(() => {
@@ -954,59 +965,33 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
{showFormat && (
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
)}
{(showEmoji || showSticker) && (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{showSticker && !hideStickerBtn && (
after={(() => {
const formatButton = showFormat ? (
<IconButton
key="showFormat"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
aria-pressed={toolbar}
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
) : null;
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
// emoji button, so they are rendered together as one unit. Their
// relative order still follows the saved order.
const emojiStickerBlock =
showEmoji || showSticker ? (
<UseStateProvider key="showEmojiSticker" initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
const stickerBtn =
showSticker && !hideStickerBtn ? (
<IconButton
key="showSticker"
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
aria-label="Insert sticker"
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
@@ -1020,36 +1005,76 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
filled={emojiBoardTab === EmojiBoardTab.Sticker}
/>
</IconButton>
)}
{showEmoji && (
<IconButton
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
) : null;
const emojiBtn = showEmoji ? (
<IconButton
key="showEmoji"
ref={emojiBtnRef}
aria-label="Insert emoji"
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon
src={Icons.Smile}
filled={
hideStickerBtn
? !!emojiBoardTab
: emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>
)}
</PopOut>
)}
/>
</IconButton>
) : null;
const emojiFirst =
composerButtonOrder.indexOf('showEmoji') <
composerButtonOrder.indexOf('showSticker');
return (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
}
content={
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
</PopOut>
);
}}
</UseStateProvider>
)}
{!!gifApiKey && showGif && (
<UseStateProvider initial={false}>
) : null;
const gifButton =
!!gifApiKey && showGif ? (
<UseStateProvider key="showGif" initial={false}>
{(gifOpen: boolean, setGifOpen) => (
<PopOut
offset={16}
@@ -1101,113 +1126,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
)}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
{showLocation && (
<IconButton
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
)}
{showPoll && (
<IconButton
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
)}
{showVoice && (
<VoiceMessageRecorder
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
)}
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
{showSchedule && (
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
)}
) : null;
const locationButton = showLocation ? (
<IconButton
onClick={submit}
key="showLocation"
onClick={handleShareLocation}
disabled={locating}
aria-label="Share location"
variant="SurfaceVariant"
size="300"
radii="300"
title="Share location"
style={touchTarget}
>
{locating ? (
<Spinner variant="Secondary" size="100" />
) : (
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
) : null;
const pollButton = showPoll ? (
<IconButton
key="showPoll"
onClick={() => setPollOpen(true)}
aria-label="Create poll"
variant="SurfaceVariant"
size="300"
radii="300"
title="Create poll"
style={touchTarget}
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
) : null;
const voiceButton = showVoice ? (
<VoiceMessageRecorder
key="showVoice"
onSend={handleVoiceSend}
onError={(err) => {
setLocationError(err);
setTimeout(() => setLocationError(null), 4000);
}}
/>
) : null;
const scheduleButton = showSchedule ? (
<IconButton
key="showSchedule"
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Send} />
<Icon src={Icons.Clock} size="100" />
</IconButton>
</>
}
) : null;
const orderedButtons: ReactNode[] = [];
let emojiStickerRendered = false;
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
switch (key) {
case 'showFormat':
if (formatButton) orderedButtons.push(formatButton);
break;
case 'showEmoji':
case 'showSticker':
// Rendered once as a combined unit at whichever of the two
// keys comes first in the order.
if (!emojiStickerRendered) {
emojiStickerRendered = true;
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
}
break;
case 'showGif':
if (gifButton) orderedButtons.push(gifButton);
break;
case 'showLocation':
if (locationButton) orderedButtons.push(locationButton);
break;
case 'showPoll':
if (pollButton) orderedButtons.push(pollButton);
break;
case 'showVoice':
if (voiceButton) orderedButtons.push(voiceButton);
break;
case 'showSchedule':
if (scheduleButton) orderedButtons.push(scheduleButton);
break;
default:
break;
}
});
return (
<>
{orderedButtons}
{gifError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
{locationError && (
<Text
size="T200"
style={{
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{locationError}
</Text>
)}
<DraftIndicator roomId={roomId} />
{charCount > 0 && (
<Text
size="T200"
priority="300"
style={{
padding: `0 ${config.space.S100}`,
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
}}
>
{charCount}
</Text>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
);
})()}
bottom={
toolbar && (
<div>
+230 -34
View File
@@ -5,6 +5,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -34,6 +35,19 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { reorder } from '@atlaskit/pragmatic-drag-and-drop/reorder';
import {
attachClosestEdge,
extractClosestEdge,
Edge,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { useAtom } from 'jotai';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -47,12 +61,14 @@ import { useSetting } from '../../../state/hooks/settings';
import {
CallAudioBitrate,
ChatBackground,
ComposerToolbarButtonKey,
ComposerToolbarSettings,
DateFormat,
DenoiseModelId,
MessageLayout,
MessageSpacing,
NoiseSuppressionMode,
normalizeComposerToolbarOrder,
RingtoneId,
ScreenshareBitrate,
ScreenshareFramerate,
@@ -86,12 +102,33 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { isTauri as isTauriEnv } from '../../../hooks/useTauri';
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
* browser. Backed by the standalone `customWindowChromeAtom`; `useTauriWindowChrome`
* (mounted in App.tsx) applies `set_decorations` when this flips.
*/
function DesktopChromeSetting() {
const [customChrome, setCustomChrome] = useAtom(customWindowChromeAtom);
if (!isTauriEnv()) return null;
return (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Window Chrome (Beta)"
description="Replace the system title bar with a Lotus-styled one. Desktop only — toggles instantly."
after={<Switch variant="Primary" value={customChrome} onChange={setCustomChrome} />}
/>
</SequenceCard>
);
}
type ThemeSelectorProps = {
themeNames: Record<string, string>;
themes: Theme[];
@@ -405,6 +442,8 @@ function Appearance() {
/>
</SequenceCard>
<DesktopChromeSetting />
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Twitter Emoji"
@@ -1025,6 +1064,165 @@ function DateAndTime() {
);
}
const COMPOSER_TOOLBAR_LABELS: Record<ComposerToolbarButtonKey, string> = {
showFormat: 'Format',
showEmoji: 'Emoji',
showSticker: 'Sticker',
showGif: 'GIF',
showLocation: 'Location',
showPoll: 'Poll',
showVoice: 'Voice',
showSchedule: 'Schedule',
};
const COMPOSER_TOOLBAR_DRAG_TYPE = 'composer-toolbar-button';
type ComposerToolbarButtonRowProps = {
buttonKey: ComposerToolbarButtonKey;
index: number;
active: boolean;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarButtonRow({
buttonKey,
index,
active,
onToggle,
}: ComposerToolbarButtonRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [dragging, setDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const element = rowRef.current;
const dragHandle = handleRef.current;
if (!element || !dragHandle) return undefined;
return combine(
draggable({
element,
dragHandle,
getInitialData: () => ({ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index }),
onDragStart: () => setDragging(true),
onDrop: () => setDragging(false),
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
getData: ({ input }) =>
attachClosestEdge(
{ type: COMPOSER_TOOLBAR_DRAG_TYPE, buttonKey, index },
{ element, input, allowedEdges: ['top', 'bottom'] },
),
getIsSticky: () => true,
onDrag: ({ self, source }) => {
if (source.data.buttonKey === buttonKey) {
setClosestEdge(null);
return;
}
setClosestEdge(extractClosestEdge(self.data));
},
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [buttonKey, index]);
let boxShadow: string | undefined;
if (closestEdge === 'top') boxShadow = `inset 0 2px 0 0 ${color.Primary.Main}`;
else if (closestEdge === 'bottom') boxShadow = `inset 0 -2px 0 0 ${color.Primary.Main}`;
return (
<Box
ref={rowRef}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S400}`,
opacity: dragging ? 0.5 : undefined,
boxShadow,
}}
>
<IconButton
ref={handleRef}
size="300"
radii="300"
variant="SurfaceVariant"
style={{ cursor: 'grab' }}
aria-label={`Reorder ${COMPOSER_TOOLBAR_LABELS[buttonKey]}`}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<Text style={{ flexGrow: 1 }} size="T300">
{COMPOSER_TOOLBAR_LABELS[buttonKey]}
</Text>
<Chip
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => onToggle(buttonKey)}
aria-pressed={active}
>
<Text size="T200">{active ? 'Shown' : 'Hidden'}</Text>
</Chip>
</Box>
);
}
type ComposerToolbarReorderProps = {
order: ComposerToolbarButtonKey[];
buttons: ComposerToolbarSettings;
onReorder: (startIndex: number, finishIndex: number) => void;
onToggle: (key: ComposerToolbarButtonKey) => void;
};
function ComposerToolbarReorder({
order,
buttons,
onReorder,
onToggle,
}: ComposerToolbarReorderProps) {
useEffect(
() =>
monitorForElements({
canMonitor: ({ source }) => source.data.type === COMPOSER_TOOLBAR_DRAG_TYPE,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const startIndex = source.data.index;
const indexOfTarget = target.data.index;
if (typeof startIndex !== 'number' || typeof indexOfTarget !== 'number') return;
const closestEdgeOfTarget = extractClosestEdge(target.data);
// Insert relative to the target row, then compensate for the source
// row being removed from its original position.
let finishIndex = closestEdgeOfTarget === 'bottom' ? indexOfTarget + 1 : indexOfTarget;
if (startIndex < finishIndex) finishIndex -= 1;
if (finishIndex === startIndex) return;
onReorder(startIndex, finishIndex);
},
}),
[onReorder],
);
return (
<Box direction="Column">
{order.map((key, index) => (
<ComposerToolbarButtonRow
key={key}
buttonKey={key}
index={index}
active={buttons[key]}
onToggle={onToggle}
/>
))}
</Box>
);
}
function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -1034,20 +1232,31 @@ function Editor() {
'composerToolbarButtons',
);
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
};
const composerToolbarOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
);
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' },
];
const toggleToolbarButton = useCallback(
(key: ComposerToolbarButtonKey) => {
setComposerToolbarButtons((current) => ({ ...current, [key]: !current[key] }));
},
[setComposerToolbarButtons],
);
const reorderToolbarButtons = useCallback(
(startIndex: number, finishIndex: number) => {
setComposerToolbarButtons((current) => ({
...current,
order: reorder({
list: normalizeComposerToolbarOrder(current.order),
startIndex,
finishIndex,
}),
}));
},
[setComposerToolbarButtons],
);
return (
<Box direction="Column" gap="100">
@@ -1082,28 +1291,15 @@ function Editor() {
>
<SettingTile
title="Composer Toolbar"
description="Tap a button to show or hide it in the message composer."
description="Drag to reorder buttons, and tap a button to show or hide it in the message composer."
/>
<Box
wrap="Wrap"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
{TOOLBAR_CHIPS.map(({ key, label }) => {
const active = composerToolbarButtons?.[key] ?? true;
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
<Box direction="Column" style={{ paddingBottom: config.space.S200 }}>
<ComposerToolbarReorder
order={composerToolbarOrder}
buttons={composerToolbarButtons}
onReorder={reorderToolbarButtons}
onToggle={toggleToolbarButton}
/>
</Box>
</SequenceCard>
</Box>