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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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`
|
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
||||||
- Draft is cleared on successful send
|
- Draft is cleared on successful send
|
||||||
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
|
- 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
|
### Message Search Date Range
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|||||||
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||||
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||||
import { hasMsgDraft } from '../../utils/draft';
|
import { hasMsgDraft } from '../../utils/draft';
|
||||||
|
import { DraftDot } from '../room/DraftIndicator.css';
|
||||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
@@ -833,12 +834,7 @@ function RoomNavItem_({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasDraft && !selected && (
|
{hasDraft && !selected && (
|
||||||
<Icon
|
<span className={DraftDot} role="img" aria-label="Unsent draft" />
|
||||||
size="50"
|
|
||||||
src={Icons.Message}
|
|
||||||
aria-label="Unsent draft"
|
|
||||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{dmPreview && (
|
{dmPreview && (
|
||||||
|
|||||||
@@ -602,6 +602,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setCharCount(0);
|
setCharCount(0);
|
||||||
|
setMsgDraft([]);
|
||||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
@@ -614,6 +615,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
replyDraft,
|
replyDraft,
|
||||||
sendTypingStatus,
|
sendTypingStatus,
|
||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
|
setMsgDraft,
|
||||||
isMarkdown,
|
isMarkdown,
|
||||||
commands,
|
commands,
|
||||||
setToast,
|
setToast,
|
||||||
@@ -687,11 +689,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
|
setMsgDraft([]);
|
||||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
},
|
},
|
||||||
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
[
|
||||||
|
setScheduledMessages,
|
||||||
|
roomId,
|
||||||
|
draftKey,
|
||||||
|
editor,
|
||||||
|
setReplyDraft,
|
||||||
|
setMsgDraft,
|
||||||
|
sendTypingStatus,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export function useHydrateMsgDrafts(): void {
|
|||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
|
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
|
||||||
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
|
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
|
||||||
if (!draftKey || draftKey.includes('::')) return; // room-level drafts only
|
// Skip thread drafts (`<roomId>::<$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 {
|
try {
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
if (!stored) return;
|
if (!stored) return;
|
||||||
|
|||||||
@@ -10,5 +10,10 @@ export const DRAFT_MSG_KEY_PREFIX = 'draft-msg-';
|
|||||||
* by the composer's DraftIndicator and the room-nav draft indicator.
|
* by the composer's DraftIndicator and the room-nav draft indicator.
|
||||||
*/
|
*/
|
||||||
export function hasMsgDraft(draft: Descendant[]): boolean {
|
export function hasMsgDraft(draft: Descendant[]): boolean {
|
||||||
|
try {
|
||||||
return toPlainText(draft, false).trim().length > 0;
|
return toPlainText(draft, false).trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
// A corrupted/foreign draft shape must never crash a render that calls this.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user