diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md
index d16b720ab..aa239dfd7 100644
--- a/LOTUS_FEATURES.md
+++ b/LOTUS_FEATURES.md
@@ -686,6 +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`.
### Message Search Date Range
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index dd14ebd99..b00874f41 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -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 && (
+
+ )}
{dmPreview && (
diff --git a/src/app/features/room/DraftIndicator.tsx b/src/app/features/room/DraftIndicator.tsx
index 6d4802d33..fdfeb7baf 100644
--- a/src/app/features/room/DraftIndicator.tsx
+++ b/src/app/features/room/DraftIndicator.tsx
@@ -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);
diff --git a/src/app/hooks/useHydrateMsgDrafts.ts b/src/app/hooks/useHydrateMsgDrafts.ts
new file mode 100644
index 000000000..bf6fee265
--- /dev/null
+++ b/src/app/hooks/useHydrateMsgDrafts.ts
@@ -0,0 +1,43 @@
+import { useEffect } from 'react';
+import { useStore } from 'jotai';
+import { Descendant } from 'slate';
+import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
+import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
+
+/**
+ * On startup, pre-fill the per-room message-draft atoms from their localStorage
+ * persistence (`draft-msg-`). RoomInput only restores a draft into its
+ * atom when that room's composer mounts, so without this a background room's
+ * persisted draft wouldn't reflect in the room-nav draft indicator until the
+ * room is opened. Runs once; only touches the same atoms RoomInput restores from
+ * (identical content), so composer restore is unaffected.
+ *
+ * Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
+ */
+export function useHydrateMsgDrafts(): void {
+ const store = useStore();
+
+ useEffect(() => {
+ let keys: string[];
+ try {
+ keys = Object.keys(localStorage);
+ } catch {
+ return;
+ }
+ 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
+ try {
+ const stored = localStorage.getItem(key);
+ if (!stored) return;
+ const nodes = JSON.parse(stored) as Descendant[];
+ if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
+ store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
+ }
+ } catch {
+ // Ignore a malformed stored draft.
+ }
+ });
+ }, [store]);
+}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 8630fdf2b..6b0095069 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -25,11 +25,8 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
-
-// Grace period after the initial sync settles before invite notifications arm, so
-// the async invite-atom population lands first and isn't mistaken for new invites.
-const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
@@ -67,6 +64,10 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
+// Grace period after the initial sync settles before invite notifications arm, so
+// the async invite-atom population lands first and isn't mistaken for new invites.
+const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
+
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
const [sh, sm] = start.split(':').map(Number);
@@ -865,9 +866,15 @@ function KeyboardShortcutsFeature() {
return ;
}
+function MsgDraftHydrator(): null {
+ useHydrateMsgDrafts();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
+
diff --git a/src/app/utils/draft.test.ts b/src/app/utils/draft.test.ts
new file mode 100644
index 000000000..e1e5dc0e9
--- /dev/null
+++ b/src/app/utils/draft.test.ts
@@ -0,0 +1,27 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { Descendant } from 'slate';
+import { hasMsgDraft } from './draft';
+
+const paragraph = (text: string): Descendant =>
+ ({ type: 'paragraph', children: [{ text }] }) as unknown as Descendant;
+
+test('hasMsgDraft is false for an empty array', () => {
+ assert.equal(hasMsgDraft([]), false);
+});
+
+test('hasMsgDraft is false for an empty paragraph (empty editor)', () => {
+ assert.equal(hasMsgDraft([paragraph('')]), false);
+});
+
+test('hasMsgDraft is false for whitespace-only content', () => {
+ assert.equal(hasMsgDraft([paragraph(' \n ')]), false);
+});
+
+test('hasMsgDraft is true when there is real text', () => {
+ assert.equal(hasMsgDraft([paragraph('hello')]), true);
+});
+
+test('hasMsgDraft is true across multiple paragraphs with text', () => {
+ assert.equal(hasMsgDraft([paragraph(''), paragraph(' hi ')]), true);
+});
diff --git a/src/app/utils/draft.ts b/src/app/utils/draft.ts
new file mode 100644
index 000000000..b0a53bf94
--- /dev/null
+++ b/src/app/utils/draft.ts
@@ -0,0 +1,14 @@
+import { Descendant } from 'slate';
+import { toPlainText } from '../components/editor/output';
+
+/** localStorage key prefix for a persisted composer draft (`draft-msg-`). */
+export const DRAFT_MSG_KEY_PREFIX = 'draft-msg-';
+
+/**
+ * Whether a Slate composer draft holds real (non-whitespace) text, as opposed to
+ * an empty editor (which is still a non-empty array of empty paragraphs). Shared
+ * by the composer's DraftIndicator and the room-nav draft indicator.
+ */
+export function hasMsgDraft(draft: Descendant[]): boolean {
+ return toPlainText(draft, false).trim().length > 0;
+}