Files
cinny/src/app/features/room/DraftIndicator.tsx
T
jared aab7e5ae20 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>
2026-07-01 09:07:03 -04:00

65 lines
2.0 KiB
TypeScript

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>
);
}