Files
cinny/src/app/features/room/DraftIndicator.tsx
T
jaredandClaude Opus 4.8 c44ef8d795 feat(nav): draft indicator on room-nav items
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.

- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
  the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
  re-renders only when its draft flag flips (the draft atom is written on
  room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
  for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
  per-room draft atoms from draft-msg-* localStorage on startup, so
  indicators are correct after a page reload, not only after revisiting a
  room. Thread drafts (key contains ::) are skipped; room-level only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 11:37:21 -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 { hasMsgDraft } from '../../utils/draft';
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 = hasMsgDraft(draft);
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>
);
}