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
+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>