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>
This commit is contained in:
2026-07-10 11:37:21 -04:00
co-authored by Claude Opus 4.8
parent 90e6901a60
commit c44ef8d795
7 changed files with 126 additions and 7 deletions
+28 -1
View File
@@ -1,4 +1,11 @@
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
import React, {
MouseEventHandler,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import {
Avatar,
@@ -27,6 +34,7 @@ import {
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
@@ -40,6 +48,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { hasMsgDraft } from '../../utils/draft';
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -678,6 +688,15 @@ function RoomNavItem_({
const roomName = useLocalRoomName(room);
const hasLocalName = useHasLocalRoomName(room.roomId);
// Whether this room has an unsent message draft. selectAtom maps to a boolean
// so the row only re-renders when that flips (the draft atom itself is written
// on room-leave, not per keystroke).
const hasDraftAtom = useMemo(
() => selectAtom(roomIdToMsgDraftAtomFamily(room.roomId), hasMsgDraft),
[room.roomId],
);
const hasDraft = useAtomValue(hasDraftAtom);
const latestEvent = useRoomLatestRenderedEvent(room);
const dmPreview = (() => {
if (!direct || !latestEvent) return null;
@@ -813,6 +832,14 @@ function RoomNavItem_({
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
{hasDraft && !selected && (
<Icon
size="50"
src={Icons.Message}
aria-label="Unsent draft"
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
/>
)}
</Box>
{dmPreview && (
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
+2 -2
View File
@@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
import { Box, Text, config } from 'folds';
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { toPlainText } from '../../components/editor';
import { hasMsgDraft } from '../../utils/draft';
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
const PULSE_DURATION = 600;
@@ -27,7 +27,7 @@ type DraftIndicatorProps = {
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 hasDraft = hasMsgDraft(draft);
const [pulse, setPulse] = useState(false);
const hadDraft = useRef(false);