From eaf6853910c15ca66124d1e5adb3d636f26a37e9 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 11:48:35 -0400 Subject: [PATCH] fix(nav): harden draft indicator after review Address findings from 2 review agents on the room-nav draft indicator: - Icon semantics (a11y): Icons.Message read as message activity and collided with two existing bubble uses in the same row (call-chat toggle, mark-unread), especially next to the unread badge. Replace it with the composer's shared DraftDot (a small color.Success.Main dot), so both draft surfaces share one visual language. Rendered as a role="img" span with aria-label "Unsent draft" (reliably announced, unlike a bare aria-labelled svg). - Precise thread-key filter: hydration skipped any draftKey containing '::', which would also skip an IPv6-literal server name in a roomId. Match '::$' (thread root is an event id) so only real thread drafts are skipped. - Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft value can't throw during a nav render. - Clear the draft atom on send: the send / scheduled-send handlers reset the editor and localStorage but left the jotai draft atom set, so the composer DraftIndicator could show a stale dot after sending a restored draft. Add setMsgDraft([]) to both. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 2 +- src/app/features/room-nav/RoomNavItem.tsx | 8 ++------ src/app/features/room/RoomInput.tsx | 13 ++++++++++++- src/app/hooks/useHydrateMsgDrafts.ts | 5 ++++- src/app/utils/draft.ts | 7 ++++++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index aa239dfd7..1f528058c 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -686,7 +686,7 @@ Context menu → **Forward** allows forwarding a message to any room the user is - Composer drafts are stored in `localStorage` keyed by `roomId` - Draft is cleared on successful send - The Jotai atom is the primary source of truth; `localStorage` is only read on room mount -- **Room-nav draft indicator**: a subtle chat-bubble icon (`Icons.Message`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`. +- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`. ### Message Search Date Range diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index b00874f41..bab6a5dee 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -50,6 +50,7 @@ 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 { DraftDot } from '../room/DraftIndicator.css'; import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels'; import { markAsRead } from '../../utils/notifications'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -833,12 +834,7 @@ function RoomNavItem_({ /> )} {hasDraft && !selected && ( - + )} {dmPreview && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6460080fc..ca6b3d1de 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -602,6 +602,7 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); setCharCount(0); + setMsgDraft([]); localStorage.removeItem(`draft-msg-${draftKey}`); setReplyDraft(undefined); sendTypingStatus(false); @@ -614,6 +615,7 @@ export const RoomInput = forwardRef( replyDraft, sendTypingStatus, setReplyDraft, + setMsgDraft, isMarkdown, commands, setToast, @@ -687,11 +689,20 @@ export const RoomInput = forwardRef( }); resetEditor(editor); resetEditorHistory(editor); + setMsgDraft([]); localStorage.removeItem(`draft-msg-${draftKey}`); setReplyDraft(undefined); sendTypingStatus(false); }, - [setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus], + [ + setScheduledMessages, + roomId, + draftKey, + editor, + setReplyDraft, + setMsgDraft, + sendTypingStatus, + ], ); const handleKeyDown: KeyboardEventHandler = useCallback( diff --git a/src/app/hooks/useHydrateMsgDrafts.ts b/src/app/hooks/useHydrateMsgDrafts.ts index bf6fee265..a67b6cb65 100644 --- a/src/app/hooks/useHydrateMsgDrafts.ts +++ b/src/app/hooks/useHydrateMsgDrafts.ts @@ -27,7 +27,10 @@ export function useHydrateMsgDrafts(): void { keys.forEach((key) => { if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return; const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length); - if (!draftKey || draftKey.includes('::')) return; // room-level drafts only + // Skip thread drafts (`::<$threadRootEventId>`) — room-level only. + // Match `::$` specifically so an IPv6-literal server name (e.g. `[::1]`) + // in a roomId isn't mistaken for a thread key. + if (!draftKey || draftKey.includes('::$')) return; try { const stored = localStorage.getItem(key); if (!stored) return; diff --git a/src/app/utils/draft.ts b/src/app/utils/draft.ts index b0a53bf94..92d350024 100644 --- a/src/app/utils/draft.ts +++ b/src/app/utils/draft.ts @@ -10,5 +10,10 @@ export const DRAFT_MSG_KEY_PREFIX = 'draft-msg-'; * by the composer's DraftIndicator and the room-nav draft indicator. */ export function hasMsgDraft(draft: Descendant[]): boolean { - return toPlainText(draft, false).trim().length > 0; + try { + return toPlainText(draft, false).trim().length > 0; + } catch { + // A corrupted/foreign draft shape must never crash a render that calls this. + return false; + } }