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:
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user