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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Message}
|
||||
aria-label="Unsent draft"
|
||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{dmPreview && (
|
||||
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-<roomId>`). 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]);
|
||||
}
|
||||
@@ -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 <KeyboardShortcutsDialog />;
|
||||
}
|
||||
|
||||
function MsgDraftHydrator(): null {
|
||||
useHydrateMsgDrafts();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
<MsgDraftHydrator />
|
||||
<SystemEmojiFeature />
|
||||
<PageZoomFeature />
|
||||
<FaviconUpdater />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Descendant } from 'slate';
|
||||
import { toPlainText } from '../components/editor/output';
|
||||
|
||||
/** localStorage key prefix for a persisted composer draft (`draft-msg-<draftKey>`). */
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user