From c44ef8d7955c3cbb688bf1cd1b3d176796123ec6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 11:37:21 -0400 Subject: [PATCH] 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 --- LOTUS_FEATURES.md | 1 + src/app/features/room-nav/RoomNavItem.tsx | 29 ++++++++++++- src/app/features/room/DraftIndicator.tsx | 4 +- src/app/hooks/useHydrateMsgDrafts.ts | 43 ++++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 15 +++++-- src/app/utils/draft.test.ts | 27 ++++++++++++ src/app/utils/draft.ts | 14 +++++++ 7 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 src/app/hooks/useHydrateMsgDrafts.ts create mode 100644 src/app/utils/draft.test.ts create mode 100644 src/app/utils/draft.ts 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; +}