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