feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state (#1581)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-07 12:48:57 -04:00
committed by GitHub
parent b90e80443e
commit b394e95ef0
25 changed files with 1542 additions and 69 deletions

View File

@@ -227,7 +227,11 @@ const overrides = new Map([
// +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore
// path writes in the draft-key effect body, fixing the image-drop bug on
// top-level nav switch (StrictMode simulate-unmount race on remount).
["src/features/messages/ui/MessageComposer.tsx", 1021],
// +12 autoSubmitDraftKey/onAutoSubmitComplete props + onAutoSubmitCompleteRef
// + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog
// flow. Load-bearing feature growth; queued to split with the rest of this
// list.
["src/features/messages/ui/MessageComposer.tsx", 1033],
]);
await runFileSizeCheck({

View File

@@ -147,6 +147,12 @@ export function useAppNavigation() {
options?: {
/** Open the agent activity pane for this agent pubkey on arrival. */
agentSession?: string;
/**
* When set, the main composer auto-submits the draft with this key
* once on mount. Clears itself (via `?autoSend` search param) after
* firing. Used by the Drafts panel "Send message" confirm flow.
*/
autoSend?: string;
messageId?: string;
replace?: boolean;
threadRootId?: string | null;
@@ -168,6 +174,7 @@ export function useAppNavigation() {
...(options?.agentSession
? { agentSession: options.agentSession }
: {}),
...(options?.autoSend ? { autoSend: options.autoSend } : {}),
},
},
{

View File

@@ -15,6 +15,7 @@ import type { RelayEvent } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
autoSendDraftKey: string | null;
channelId: string;
selectedPostId: string | null;
targetMessageId: string | null;
@@ -94,6 +95,7 @@ async function fetchRouteTargetEvents(
}
export function ChannelRouteScreen({
autoSendDraftKey,
channelId,
selectedPostId,
targetMessageId,
@@ -195,6 +197,7 @@ export function ChannelRouteScreen({
return (
<ChannelScreen
activeChannel={activeChannel}
autoSendDraftKey={autoSendDraftKey}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {

View File

@@ -39,6 +39,7 @@ function ForumPostRouteComponent() {
fallback={<ViewLoadingFallback includeHeader kind="forum" />}
>
<ChannelRouteScreen
autoSendDraftKey={null}
channelId={channelId}
selectedPostId={postId}
targetMessageId={null}

View File

@@ -11,6 +11,12 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteSearch = {
agentSession?: string;
/**
* When set, the composer on mount will auto-submit its loaded draft once,
* then clear this param. Value is the draft key that was loaded so the
* composer can verify it has the right draft before firing.
*/
autoSend?: string;
messageId?: string;
profile?: string;
profileTab?: ProfilePanelTab;
@@ -28,6 +34,7 @@ function validateChannelSearch(
): ChannelRouteSearch {
return {
agentSession: nonEmptyString(search.agentSession),
autoSend: nonEmptyString(search.autoSend),
messageId: nonEmptyString(search.messageId),
profile: nonEmptyString(search.profile),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
@@ -56,6 +63,7 @@ function ChannelRouteComponent() {
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
>
<ChannelRouteScreen
autoSendDraftKey={search.autoSend ?? null}
channelId={channelId}
selectedPostId={null}
targetMessageId={search.messageId ?? null}

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
@@ -61,6 +62,8 @@ export const ChannelPane = React.memo(function ChannelPane({
agentPubkeysPending = false,
agentSessionAgents,
activityAgents = agentSessionAgents,
autoSendDraftKey = null,
onAutoSendComplete = null,
botTypingEntries,
channelFind,
channelManagementOpen = false,
@@ -148,6 +151,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const welcomeComposerHideTimerRef = React.useRef<number | null>(null);
const [welcomeComposerBannerState, setWelcomeComposerBannerState] =
React.useState<WelcomeComposerBannerState>("prompt");
const { goChannel } = useAppNavigation();
const mainComposerMedia = useMediaUpload();
const isNonMemberView =
activeChannel !== null &&
@@ -156,6 +160,20 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel.archivedAt;
const hasMainComposerOverlay = !isNonMemberView;
const activeChannelId = activeChannel?.id ?? null;
// Clear the ?autoSend search param once the auto-submit fires so
// back-navigation cannot re-trigger the send.
// When `onAutoSendComplete` is provided it does a surgical single-key clear
// that preserves `?thread` and all other panel search state (required for
// the thread-draft send path so the thread panel does not unmount before the
// deferred setTimeout(0) submit fires). The goChannel fallback is kept for
// callers that do not supply the prop (e.g. isolated tests / older wrappers).
const handleAutoSubmitComplete = React.useCallback(() => {
if (onAutoSendComplete) {
onAutoSendComplete();
} else if (activeChannelId) {
void goChannel(activeChannelId, { replace: true });
}
}, [activeChannelId, goChannel, onAutoSendComplete]);
const huddleMemberPubkeys = React.useMemo(
() => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey),
[activeChannel, agentPubkeys, currentPubkey],
@@ -668,6 +686,8 @@ export const ChannelPane = React.memo(function ChannelPane({
containerClassName="px-5"
disabled={isComposerDisabled}
editTarget={mainEditTarget}
autoSubmitDraftKey={autoSendDraftKey}
onAutoSubmitComplete={handleAutoSubmitComplete}
isSending={isSending}
mediaController={mainComposerMedia}
onCancelEdit={onCancelEdit}
@@ -760,6 +780,8 @@ export const ChannelPane = React.memo(function ChannelPane({
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
autoSendDraftKey={autoSendDraftKey}
onAutoSubmitComplete={handleAutoSubmitComplete}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}

View File

@@ -19,6 +19,20 @@ export type ChannelPaneProps = {
agentPubkeys?: ReadonlySet<string>;
agentPubkeysPending?: boolean;
agentSessionAgents: ChannelAgentSessionAgent[];
/**
* When non-null, the main composer fires `submitMessage` once after loading
* the draft identified by this key — i.e. the user clicked "Send message"
* in the Drafts panel and confirmed. Cleared by the composer after firing so
* back-navigation cannot re-trigger.
*/
autoSendDraftKey?: string | null;
/**
* Called after the auto-submit guard fires to surgically clear `?autoSend`
* from the URL while preserving `?thread` and all other panel search state.
* If omitted, ChannelPane falls back to a full goChannel() re-navigation
* (safe for the main-composer path, which carries no URL-backed thread).
*/
onAutoSendComplete?: (() => void) | null;
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
channelManagementOpen?: boolean;

View File

@@ -84,6 +84,7 @@ const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
export function ChannelScreen({
activeChannel,
autoSendDraftKey,
currentIdentity,
currentProfile,
onCloseForumPost,
@@ -112,6 +113,7 @@ export function ChannelScreen({
} = useAppShell();
const {
channelManagementOpen,
clearAutoSend,
clearMessageRouteTarget,
openAgentSessionChannelId,
openAgentSessionPubkey,
@@ -846,6 +848,8 @@ export function ChannelScreen({
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
autoSendDraftKey={autoSendDraftKey}
onAutoSendComplete={clearAutoSend}
botTypingEntries={botTypingEntries}
channelFind={channelFind}
channelManagementOpen={channelManagementOpen}

View File

@@ -7,6 +7,13 @@ import type {
export type ChannelScreenProps = {
activeChannel: Channel | null;
/**
* When non-null, the main channel composer auto-submits once on mount after
* loading the draft identified by this key. The route component clears the
* `?autoSend` search param after the submit fires so back-navigation does
* not re-trigger. Value must match the composer's `effectiveDraftKey`.
*/
autoSendDraftKey: string | null;
currentIdentity?: Identity;
currentProfile?: Profile;
onCloseForumPost: () => void;

View File

@@ -0,0 +1,37 @@
/**
* URL search-param keys managed by the channel-panel history state.
*
* Kept in a separate pure module (no React / router imports) so tests can
* import and assert the patch contracts without a browser environment.
*/
export const CHANNEL_SEARCH_KEYS = [
"agentSession",
"agentSessionChannel",
"autoSend",
"channelManagement",
"messageId",
"profile",
"profileTab",
"profileView",
"thread",
"threadRootId",
] as const;
export type ChannelSearchKey = (typeof CHANNEL_SEARCH_KEYS)[number];
/**
* Returns the search-param patch that clears only the `autoSend` trigger.
*
* Exported so tests can verify the patch is surgical — it must not include
* `thread` or any other panel key that would collapse open panels. This is
* the regression guard for the "auto-submit clear drops the thread route"
* defect: a `goChannel()` re-navigation drops every search key including
* `thread`; this patch removes only `autoSend` via `applyPatch`, preserving
* the thread panel across the deferred `setTimeout(0)` submit.
*/
export function buildAutoSendClearPatch(): Partial<
Record<ChannelSearchKey, string | null>
> {
return { autoSend: null };
}

View File

@@ -10,6 +10,11 @@ import {
type HistorySearchSetterOptions,
useHistorySearchState,
} from "@/shared/hooks/useHistorySearchState";
import {
buildAutoSendClearPatch,
CHANNEL_SEARCH_KEYS,
} from "./channelSearchKeys";
export type { ChannelSearchKey } from "./channelSearchKeys";
/**
* Auxiliary-panel state for the channel routes, backed by URL search params
@@ -21,7 +26,9 @@ import {
* tab), `agentSession` (agent session panel pubkey), `agentSessionChannel`
* (optional channel scope for the agent session panel), `channelManagement`
* (presence flag for the channel-management panel — open/closed only, so it
* carries a sentinel `"1"` rather than an id).
* carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit
* trigger — cleared surgically after the auto-submit fires so `thread` and
* all other panel state are preserved).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
@@ -31,18 +38,6 @@ export type PanelValueSetter = (
options?: PanelSetterOptions,
) => void;
const CHANNEL_SEARCH_KEYS = [
"agentSession",
"agentSessionChannel",
"channelManagement",
"messageId",
"profile",
"profileTab",
"profileView",
"thread",
"threadRootId",
] as const;
const CHANNEL_MANAGEMENT_OPEN_VALUE = "1";
export function useChannelPanelHistoryState() {
@@ -105,8 +100,19 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);
// Clears only the ?autoSend param, preserving `thread` and all other panel
// search state. Use this instead of a full goChannel() re-navigation so the
// thread panel does not unmount between the auto-submit trigger clear and the
// deferred setTimeout(0) send.
const clearAutoSend = React.useCallback(
(options?: PanelSetterOptions) =>
applyPatch(buildAutoSendClearPatch(), { replace: true, ...options }),
[applyPatch],
);
return {
channelManagementOpen: values.channelManagement != null,
clearAutoSend,
clearMessageRouteTarget,
openAgentSessionChannelId: values.agentSessionChannel,
openAgentSessionPubkey: values.agentSession,

View File

@@ -48,6 +48,7 @@ import {
import { formatTime } from "@/features/messages/lib/dateFormatters";
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useActiveDraftCount } from "@/features/messages/ui/DraftsPanel";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import {
@@ -116,6 +117,10 @@ export function HomeView({
const isMessagesMode = !isReminders && !isDrafts;
const remindersQuery = useRemindersQuery(currentPubkey);
const dueReminderCount = countDueReminders(remindersQuery.data ?? []);
// Active draft count for the badge. When the Drafts panel is closed,
// rootStatusMap is empty so orphaned drafts are counted optimistically.
// They drop from the count once the panel opens and relay confirms deletion.
const activeDraftCount = useActiveDraftCount(new Map());
// `?item=` is Messages-mode-only machinery: a reminder never enters the
// FeedItem selection model, so reload while in Reminders mode keeps a stale
// `?item=` unconsumed and does not snap back to a feed-item detail view.
@@ -547,6 +552,7 @@ export function HomeView({
<InboxListPane
activeReminderEventIds={activeReminderEventIds}
agentPubkeys={inboxAgentPubkeys}
activeDraftCount={activeDraftCount}
doneSet={effectiveDoneSet}
dueReminderCount={dueReminderCount}
filter={filter}

View File

@@ -101,6 +101,7 @@ function ActivityLabel({
type InboxListPaneProps = {
activeReminderEventIds?: ReadonlySet<string>;
agentPubkeys?: ReadonlySet<string>;
activeDraftCount: number;
doneSet: ReadonlySet<string>;
filter: InboxFilter;
items: InboxItem[];
@@ -121,6 +122,7 @@ type InboxListPaneProps = {
export function InboxListPane({
activeReminderEventIds,
agentPubkeys,
activeDraftCount,
doneSet,
filter,
items,
@@ -456,6 +458,13 @@ export function InboxListPane({
>
{dueReminderCount}
</span>
) : activeDraftCount > 0 ? (
<span
className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-background bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
data-testid="inbox-draft-badge"
>
{activeDraftCount}
</span>
) : null}
</button>
</DropdownMenuTrigger>
@@ -481,6 +490,14 @@ export function InboxListPane({
>
{dueReminderCount}
</span>
) : option.value === "drafts" &&
activeDraftCount > 0 ? (
<span
className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
data-testid="inbox-draft-badge-option"
>
{activeDraftCount}
</span>
) : null}
</span>
</DropdownMenuRadioItem>

View File

@@ -0,0 +1,163 @@
/**
* Unit tests for useDraftRootStatus helpers.
*
* Tests import the ACTUAL exported `classifyError` and `deriveActiveDraftCount`
* (via DraftsPanel) so any implementation change is caught immediately.
*
* Tests cover:
* - classifyError: "event not found" → deleted; other errors → error
* - deriveActiveDraftCount: exclusion by status, channel-root pass-through
*/
import assert from "node:assert/strict";
import test from "node:test";
// ── Browser-global shim for DraftsPanel imports ───────────────────────────────
function makeLocalStorage() {
const store = new Map();
return {
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
};
}
if (typeof globalThis.window === "undefined") {
globalThis.window = { localStorage: makeLocalStorage() };
} else {
globalThis.window.localStorage = makeLocalStorage();
}
Object.defineProperty(globalThis, "localStorage", {
get: () => globalThis.window.localStorage,
configurable: true,
});
// Import the real exported helpers.
import { classifyError } from "./useDraftRootStatus.ts";
import { deriveActiveDraftCount } from "../ui/DraftsPanel.tsx";
// ── classifyError: string errors ─────────────────────────────────────────────
test("classifyError_event_not_found_string_returns_deleted", () => {
assert.equal(classifyError("event not found"), "deleted");
});
test("classifyError_event_not_found_string_with_prefix_returns_deleted", () => {
// The tauri command error is exactly "event not found" (see messages.rs:419)
// but we test a slightly prefixed variant in case the format changes.
assert.equal(classifyError("get_event: event not found"), "deleted");
});
test("classifyError_transport_failure_string_returns_error", () => {
assert.equal(classifyError("transport error: connection refused"), "error");
});
test("classifyError_empty_string_returns_error", () => {
assert.equal(classifyError(""), "error");
});
test("classifyError_auth_error_string_returns_error", () => {
assert.equal(classifyError("unauthorized: token expired"), "error");
});
test("classifyError_serialize_error_string_returns_error", () => {
assert.equal(classifyError("serialize event: invalid json"), "error");
});
// ── classifyError: Error instances ───────────────────────────────────────────
test("classifyError_Error_instance_with_event_not_found_returns_deleted", () => {
assert.equal(classifyError(new Error("event not found")), "deleted");
});
test("classifyError_Error_instance_with_other_message_returns_error", () => {
assert.equal(classifyError(new Error("network failure")), "error");
});
// ── Only deleted excludes from count ─────────────────────────────────────────
test("only_deleted_status_excludes_draft_from_count", () => {
function wouldExclude(rootStatus) {
return rootStatus === "deleted";
}
assert.equal(wouldExclude("deleted"), true, "deleted must be excluded");
assert.equal(
wouldExclude("checking"),
false,
"checking must NOT be excluded",
);
assert.equal(
wouldExclude("available"),
false,
"available must NOT be excluded",
);
assert.equal(wouldExclude("error"), false, "error must NOT be excluded");
});
// ── deriveActiveDraftCount (imported from DraftsPanel) ───────────────────────
test("deriveActiveDraftCount_excludes_thread_draft_with_deleted_root", () => {
const drafts = [{ key: "thread:root-aaa", draft: {} }];
const statusMap = new Map([["root-aaa", "deleted"]]);
assert.equal(deriveActiveDraftCount(drafts, statusMap), 0);
});
test("deriveActiveDraftCount_includes_thread_draft_with_available_root", () => {
const drafts = [{ key: "thread:root-aaa", draft: {} }];
const statusMap = new Map([["root-aaa", "available"]]);
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
});
test("deriveActiveDraftCount_includes_thread_draft_with_checking_root", () => {
const drafts = [{ key: "thread:root-aaa", draft: {} }];
const statusMap = new Map([["root-aaa", "checking"]]);
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
});
test("deriveActiveDraftCount_includes_thread_draft_with_error_root", () => {
const drafts = [{ key: "thread:root-aaa", draft: {} }];
const statusMap = new Map([["root-aaa", "error"]]);
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
});
test("deriveActiveDraftCount_includes_channel_root_draft_regardless_of_status_map", () => {
// Channel-root drafts (key = channel id, not "thread:...") cannot be orphaned.
const drafts = [{ key: "chan-xyz", draft: {} }];
const statusMap = new Map(); // empty — no entry for this key
assert.equal(deriveActiveDraftCount(drafts, statusMap), 1);
});
test("deriveActiveDraftCount_empty_rootStatusMap_treats_thread_drafts_as_available", () => {
// When panel is closed, statusMap is empty — thread drafts count optimistically.
const drafts = [
{ key: "thread:root-111", draft: {} },
{ key: "thread:root-222", draft: {} },
{ key: "chan-direct", draft: {} },
];
const statusMap = new Map();
assert.equal(deriveActiveDraftCount(drafts, statusMap), 3);
});
test("deriveActiveDraftCount_mixed_statuses_counts_correctly", () => {
const drafts = [
{ key: "thread:root-del", draft: {} }, // deleted — excluded
{ key: "thread:root-ok", draft: {} }, // available — included
{ key: "thread:root-chk", draft: {} }, // checking — included
{ key: "thread:root-err", draft: {} }, // error — included
{ key: "chan-direct", draft: {} }, // channel-root — included
];
const statusMap = new Map([
["root-del", "deleted"],
["root-ok", "available"],
["root-chk", "checking"],
["root-err", "error"],
]);
assert.equal(deriveActiveDraftCount(drafts, statusMap), 4);
});

View File

@@ -0,0 +1,87 @@
import { useQueries } from "@tanstack/react-query";
import { getEventById } from "@/shared/api/tauri";
/**
* Root-existence status for a thread-draft's parent event.
*
* - `checking` — query in flight. Treated as available/counted (optimistic)
* so a slow relay does not blink drafts out of the badge.
* - `available` — `getEventById` resolved; root exists.
* - `deleted` — ONLY when `get_event` returned the definitive
* `"event not found"` string (relay query succeeded with zero
* rows). Excluded from the badge count; open+send disabled;
* "thread deleted" label shown.
* - `error` — any other rejection (transport/auth/serialize). Treated
* as available/counted; NOT labeled deleted. Re-checked on
* next panel open.
*/
export type RootStatus = "checking" | "available" | "deleted" | "error";
const EVENT_NOT_FOUND_MESSAGE = "event not found";
export function classifyError(err: unknown): RootStatus {
// Only the definitive relay-returned string maps to `deleted`.
// Every other failure (transport, auth, serialization) is `error`.
if (typeof err === "string" && err.includes(EVENT_NOT_FOUND_MESSAGE)) {
return "deleted";
}
if (err instanceof Error && err.message.includes(EVENT_NOT_FOUND_MESSAGE)) {
return "deleted";
}
return "error";
}
/**
* Resolves root-existence status for a set of thread-root event IDs.
*
* **Query semantics (locked — Thufir will verify):**
* - `staleTime: 0` — every query is always considered stale.
* - `refetchOnMount: "always"` — re-fetch every time this hook mounts (i.e.
* every time the Drafts panel opens), so the flag is genuinely per-panel-open
* and a restored root flips `deleted` → `available` on next open.
* - Query key = `["draftRootStatus", rootId]` — root id only, no timestamp.
* React Query dedupes concurrent lookups within one open; never serves an
* indefinitely-stale `deleted`.
* - `deleted` is NEVER written into any persisted cache — it is a pure
* read-through of the current lookup.
* - Enabled only when `isOpen` is true so we don't burn relay RTTs when the
* Drafts panel is closed.
*
* @param rootIds Deduplicated thread-root event IDs to check.
* @param isOpen Whether the Drafts panel is currently visible.
* @returns A `Map<rootId, RootStatus>` — one entry per input root id.
*/
export function useDraftRootStatus(
rootIds: string[],
isOpen: boolean,
): Map<string, RootStatus> {
const results = useQueries({
queries: rootIds.map((rootId) => ({
queryKey: ["draftRootStatus", rootId] as const,
queryFn: () => getEventById(rootId),
staleTime: 0,
refetchOnMount: "always" as const,
retry: false,
enabled: isOpen && rootId.length > 0,
})),
});
const statusMap = new Map<string, RootStatus>();
rootIds.forEach((rootId, index) => {
const result = results[index];
if (!result) {
statusMap.set(rootId, "checking");
return;
}
if (result.isPending || result.isFetching) {
statusMap.set(rootId, "checking");
} else if (result.isError) {
statusMap.set(rootId, classifyError(result.error));
} else {
statusMap.set(rootId, "available");
}
});
return statusMap;
}

View File

@@ -3,6 +3,42 @@ import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
// ── Store reactivity ─────────────────────────────────────────────────────────
// `useSyncExternalStore` requires a stable subscribe/getSnapshot pair.
// localStorage is not reactive, so we maintain a module-level subscriber set
// and version counter. Every write bumps the counter and notifies subscribers
// so any component consuming `useDraftsSnapshot()` re-renders immediately.
type Subscriber = () => void;
const _subscribers = new Set<Subscriber>();
let _version = 0;
/** Notify all active subscribers. Called by every write path. */
function notifySubscribers(): void {
_version += 1;
for (const sub of _subscribers) {
sub();
}
}
function subscribeToStore(callback: Subscriber): () => void {
_subscribers.add(callback);
return () => {
_subscribers.delete(callback);
};
}
function getStoreSnapshot(): number {
return _version;
}
/**
* Subscribe to draft store changes. Returns an unsubscribe function.
* Exported for unit-testing the subscriber notification contract.
* Use `useDraftsSnapshot()` in React components.
*/
export { subscribeToStore, getStoreSnapshot };
export type DraftState = {
content: string;
selectionStart: number;
@@ -181,6 +217,7 @@ export function saveDraftEntry(draftKey: string, draft: DraftState): void {
map.set(draftKey, draft);
evictOldest(map);
flushStore(map);
notifySubscribers();
}
export function loadDraftEntry(draftKey: string): DraftState | undefined {
@@ -192,6 +229,7 @@ export function clearDraftEntry(draftKey: string): void {
if (map.has(draftKey)) {
map.delete(draftKey);
flushStore(map);
notifySubscribers();
}
}
@@ -316,6 +354,22 @@ export function markDraftSentEntry(
map.delete(draftKey);
evictOldest(map);
flushStore(map);
notifySubscribers();
}
// ── Reactive hooks ────────────────────────────────────────────────────────────
/**
* Returns the current store version, re-rendering the component on every
* draft write (save / clear / persist / markSent). The version number itself
* is not meaningful — callers derive their actual data from the snapshot.
*
* Use this anywhere that needs to react to draft changes without polling:
* - `DraftsPanel` (replaces manual `refreshDrafts` + `useEffect`)
* - `useActiveDraftCount` (badge count)
*/
export function useDraftsSnapshot(): number {
return React.useSyncExternalStore(subscribeToStore, getStoreSnapshot);
}
export function useDrafts() {

View File

@@ -0,0 +1,232 @@
/**
* Unit tests for useDrafts store reactivity.
*
* Tests verify the actual subscriber-notification contract:
* - A write path (saveDraftEntry / clearDraftEntry / persistDraftEntry /
* markDraftSentEntry) fires each registered subscriber exactly once.
* - The snapshot version increments on each write.
* - Unsubscribed callbacks are NOT called.
* - clearDraftEntry on a nonexistent key is a no-op (no notification).
*
* Uses the exported `subscribeToStore` + `getStoreSnapshot` primitives so the
* contract is tested at the source — no React renderer needed.
*/
import assert from "node:assert/strict";
import test from "node:test";
// ── Browser-global shim ───────────────────────────────────────────────────────
function makeLocalStorage() {
const store = new Map();
return {
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
};
}
function installFreshLocalStorage() {
const ls = makeLocalStorage();
if (typeof globalThis.window === "undefined") {
globalThis.window = { localStorage: ls };
} else {
globalThis.window.localStorage = ls;
}
Object.defineProperty(globalThis, "localStorage", {
get: () => globalThis.window.localStorage,
configurable: true,
});
return ls;
}
installFreshLocalStorage();
import {
clearAllDrafts,
clearDraftEntry,
getStoreSnapshot,
initDraftStore,
markDraftSentEntry,
persistDraftEntry,
saveDraftEntry,
subscribeToStore,
} from "./useDrafts.ts";
// ── Helpers ───────────────────────────────────────────────────────────────────
function setup(pubkey = "pubkey-reactivity") {
installFreshLocalStorage();
clearAllDrafts();
initDraftStore(pubkey);
}
function makeDraft(overrides = {}) {
const now = new Date().toISOString();
return {
content: "hello",
selectionStart: 5,
selectionEnd: 5,
channelId: "chan-1",
createdAt: now,
updatedAt: now,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
...overrides,
};
}
/**
* Subscribe, run `action`, unsubscribe, and return {callCount, versionBefore, versionAfter}.
*/
function observeWrite(action) {
let callCount = 0;
const versionBefore = getStoreSnapshot();
const unsub = subscribeToStore(() => {
callCount += 1;
});
action();
unsub();
const versionAfter = getStoreSnapshot();
return { callCount, versionBefore, versionAfter };
}
// ── saveDraftEntry notifies subscriber and bumps version ──────────────────────
test("saveDraftEntry_notifies_subscriber_and_bumps_version", () => {
setup();
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
saveDraftEntry("chan-save", makeDraft());
});
assert.equal(callCount, 1, "subscriber must be called exactly once");
assert.equal(
versionAfter,
versionBefore + 1,
"version must increment by 1 on saveDraftEntry",
);
});
// ── clearDraftEntry notifies when key exists ──────────────────────────────────
test("clearDraftEntry_notifies_subscriber_when_key_exists", () => {
setup();
saveDraftEntry("chan-del", makeDraft({ content: "to delete" }));
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
clearDraftEntry("chan-del");
});
assert.equal(
callCount,
1,
"subscriber must be called exactly once on delete",
);
assert.equal(
versionAfter,
versionBefore + 1,
"version must increment on delete",
);
});
// ── clearDraftEntry on nonexistent key is a no-op ─────────────────────────────
test("clearDraftEntry_is_noop_for_nonexistent_key", () => {
setup();
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
clearDraftEntry("key-that-does-not-exist");
});
assert.equal(
callCount,
0,
"subscriber must NOT be called for nonexistent key",
);
assert.equal(
versionAfter,
versionBefore,
"version must NOT change for nonexistent key",
);
});
// ── persistDraftEntry notifies on save ───────────────────────────────────────
test("persistDraftEntry_non_empty_notifies_subscriber", () => {
setup();
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
persistDraftEntry("chan-p", "some content", "chan-p", [], []);
});
assert.equal(callCount, 1, "subscriber must be called on persist");
assert.equal(
versionAfter,
versionBefore + 1,
"version must increment on persist",
);
});
// ── persistDraftEntry clears on empty content ─────────────────────────────────
test("persistDraftEntry_empty_content_notifies_subscriber", () => {
setup();
// First save something.
persistDraftEntry("chan-p2", "will be cleared", "chan-p2", [], []);
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
// Whitespace-only triggers a clear.
persistDraftEntry("chan-p2", " ", "chan-p2", [], []);
});
assert.equal(
callCount,
1,
"subscriber must be called when content is cleared",
);
assert.equal(
versionAfter,
versionBefore + 1,
"version must increment on clear",
);
});
// ── markDraftSentEntry notifies subscriber ────────────────────────────────────
test("markDraftSentEntry_notifies_subscriber", () => {
setup();
persistDraftEntry("chan-sent", "content to send", "chan-sent", [], []);
const { callCount, versionBefore, versionAfter } = observeWrite(() => {
markDraftSentEntry("chan-sent", "content to send", "chan-sent", [], []);
});
assert.equal(callCount, 1, "subscriber must be called on markSent");
assert.equal(
versionAfter,
versionBefore + 1,
"version must increment on markSent",
);
});
// ── Unsubscribed callback is NOT called ───────────────────────────────────────
test("unsubscribed_callback_is_not_called", () => {
setup();
let callCount = 0;
const unsub = subscribeToStore(() => {
callCount += 1;
});
// Immediately unsubscribe before the write.
unsub();
saveDraftEntry("chan-unsub", makeDraft());
assert.equal(callCount, 0, "unsubscribed callback must NOT be called");
});
// ── Multiple writes each bump version independently ──────────────────────────
test("multiple_writes_each_bump_version_independently", () => {
setup();
const v0 = getStoreSnapshot();
saveDraftEntry("chan-a", makeDraft());
const v1 = getStoreSnapshot();
saveDraftEntry("chan-b", makeDraft());
const v2 = getStoreSnapshot();
assert.equal(v1, v0 + 1, "first write must bump version");
assert.equal(v2, v1 + 1, "second write must bump version again");
});

View File

@@ -1,4 +1,4 @@
import { FileText, Lock, Pencil, Trash2 } from "lucide-react";
import { FileText, Lock, Pencil, Send, Trash2 } from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -7,14 +7,27 @@ import {
clearDraftEntry,
getActiveDraftEntries,
getSentDraftEntries,
useDraftsSnapshot,
type DraftState,
} from "@/features/messages/lib/useDrafts";
import {
useDraftRootStatus,
type RootStatus,
} from "@/features/messages/lib/useDraftRootStatus";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -196,19 +209,96 @@ export function canOpenDraft(draft: DraftState, source: DraftSource): boolean {
);
}
/** True when a draft is sendable: same conditions as openable, plus root not deleted. */
export function canSendDraft(
draft: DraftState,
source: DraftSource,
rootStatus: RootStatus,
): boolean {
if (!canOpenDraft(draft, source)) return false;
if (rootStatus === "deleted") return false;
// Mirror the destination composer's stable disabled states so we never
// offer a Send that will silently no-op:
// - not a member: the composer rejects sends
// - archived: read-only
// - forum: forum posting is not wired
// `isSending` is transient runtime state not visible from the panel — omit.
const ch = source.channel;
if (ch === null) return false;
if (!ch.isMember) return false;
if (ch.archivedAt !== null) return false;
if (ch.channelType === "forum") return false;
return true;
}
// ── Send confirmation dialog ──────────────────────────────────────────────────
type SendConfirmDialogProps = {
channelLabel: string;
isDm: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
};
function SendConfirmDialog({
channelLabel,
isDm,
onCancel,
onConfirm,
open,
}: SendConfirmDialogProps) {
const destination = isDm ? channelLabel : `#${channelLabel}`;
return (
<AlertDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCancel();
}
}}
open={open}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send message</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to send this message to {destination}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button onClick={onCancel} size="sm" type="button" variant="outline">
Cancel
</Button>
<Button onClick={onConfirm} size="sm" type="button">
Send
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ── DraftRow ─────────────────────────────────────────────────────────────────
function DraftRow({
entry,
onDelete,
onOpen,
onSend,
rootStatus,
source,
}: {
entry: DraftListEntry;
onDelete: (draftKey: string) => void;
onOpen: (entry: DraftListEntry) => void;
onSend: (entry: DraftListEntry) => void;
rootStatus: RootStatus;
source: DraftSource;
}) {
const isSent = entry.draft.status === "sent";
const canOpen = canOpenDraft(entry.draft, source);
const isOrphaned = rootStatus === "deleted";
const canOpen = canOpenDraft(entry.draft, source) && !isOrphaned;
const canSend = canSendDraft(entry.draft, source, rootStatus);
const isPrivate = source.channel?.visibility === "private";
const isDm = source.channel?.channelType === "dm";
const channelLabel = source.channel
@@ -219,7 +309,10 @@ function DraftRow({
return (
<div
className="group/draft-row relative rounded-md border border-border/70 bg-background transition-colors hover:bg-muted/40 focus-within:bg-muted/40"
className={cn(
"group/draft-row relative rounded-md border border-border/70 bg-background transition-colors hover:bg-muted/40 focus-within:bg-muted/40",
isOrphaned && "opacity-50",
)}
data-testid={`home-draft-item-${entry.key}`}
>
<button
@@ -229,13 +322,14 @@ function DraftRow({
onClick={() => onOpen(entry)}
type="button"
>
<div className="min-w-0 pr-0 transition-[padding] group-hover/draft-row:pr-16 group-focus-within/draft-row:pr-16">
<div className="min-w-0 pr-0 transition-[padding] group-hover/draft-row:pr-20 group-focus-within/draft-row:pr-20">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{isPrivate ? <Lock className="h-3.5 w-3.5 shrink-0" /> : null}
<span
className={cn(
"truncate font-medium",
source.channel ? "text-foreground" : "text-muted-foreground",
isOrphaned && "text-muted-foreground",
)}
>
{channelLabel}
@@ -243,6 +337,14 @@ function DraftRow({
<span className="shrink-0 text-muted-foreground/70">
{formatDraftCreatedAt(entry.draft)}
</span>
{isOrphaned ? (
<span
className="shrink-0 rounded px-1 py-0.5 text-2xs font-medium text-destructive/70 ring-1 ring-destructive/30"
data-testid={`home-draft-orphaned-label-${entry.key}`}
>
thread deleted
</span>
) : null}
</div>
<div className="mt-1 max-h-10 overflow-hidden text-sm font-medium leading-5 text-foreground">
<Markdown
@@ -254,15 +356,37 @@ function DraftRow({
</div>
</button>
{/* Hover action buttons: edit / delete / send (order per spec) */}
<div className="pointer-events-none absolute right-2 top-2 flex items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 shadow-xs ring-1 ring-border/70 transition-opacity group-hover/draft-row:pointer-events-auto group-hover/draft-row:opacity-100 group-focus-within/draft-row:pointer-events-auto group-focus-within/draft-row:opacity-100">
{isSent ? null : (
<DraftRowActionButton
disabled={!canOpen}
label={canOpen ? "Open draft" : "No channel link"}
onClick={() => onOpen(entry)}
>
<Pencil className="h-4 w-4" />
</DraftRowActionButton>
<>
<DraftRowActionButton
disabled={!canOpen}
label={
canOpen
? "Open draft"
: isOrphaned
? "Thread deleted"
: "No channel link"
}
onClick={() => onOpen(entry)}
>
<Pencil className="h-4 w-4" />
</DraftRowActionButton>
<DraftRowActionButton
disabled={!canSend}
label={
canSend
? "Send message"
: isOrphaned
? "Thread deleted"
: "No channel link"
}
onClick={() => onSend(entry)}
>
<Send className="h-4 w-4" />
</DraftRowActionButton>
</>
)}
<DraftRowActionButton
label="Delete draft"
@@ -275,27 +399,96 @@ function DraftRow({
);
}
// ── Shared derivation: active draft count ────────────────────────────────────
// The badge (InboxListPane/HomeView) and the panel both call
// `deriveActiveDraftCount` with the same arguments so the derivation logic
// cannot diverge. Note: the badge call-site feeds an empty rootStatusMap
// (panel closed → queries disabled), so orphaned thread drafts count
// optimistically until the panel opens and the relay confirms deletion.
// This bounded eventual-consistency is the sanctioned design (Will, 2026-07-07).
/**
* Derives the active (non-orphaned) draft count from the draft store snapshot
* and root-status map. A draft is excluded from the count when its thread root
* is definitively deleted (`"deleted"` status).
*
* @param activeDrafts Active draft entries from `getActiveDraftEntries()`.
* @param rootStatusMap Root-status map from `useDraftRootStatus()`.
* @returns Count of active drafts whose root is NOT deleted.
*/
export function deriveActiveDraftCount(
activeDrafts: Array<{ key: string; draft: DraftState }>,
rootStatusMap: Map<string, RootStatus>,
): number {
return activeDrafts.filter((entry) => {
const threadRootId = getThreadRootId(entry.key);
if (threadRootId === null) {
// Channel-root draft — cannot be orphaned.
return true;
}
const status = rootStatusMap.get(threadRootId) ?? "checking";
// Exclude only on definitive `deleted`; `checking`/`error` are optimistic.
return status !== "deleted";
}).length;
}
/**
* Reactive hook for the active (non-orphaned) draft count.
*
* Used by `HomeView` to thread the count to `InboxListPane` for the badge.
* Uses the same `deriveActiveDraftCount` function as the panel so the
* derivation logic cannot diverge.
*
* When the panel is closed, `rootStatusMap` is empty (queries disabled), so
* thread-reply drafts are counted optimistically — orphaned roots only drop
* out of the count once the panel opens and the relay lookups complete.
* This bounded eventual-consistency is product-approved (Will, 2026-07-07).
*/
export function useActiveDraftCount(
rootStatusMap: Map<string, RootStatus>,
): number {
// Re-render on every draft write via useDraftsSnapshot.
useDraftsSnapshot();
const activeDrafts = getActiveDraftEntries().filter(isVisibleDraft);
return deriveActiveDraftCount(activeDrafts, rootStatusMap);
}
// ── DraftsPanel ──────────────────────────────────────────────────────────────
export function DraftsPanel() {
const { goChannel } = useAppNavigation();
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
const channelsQuery = useChannelsQuery();
const [sections, setSections] =
React.useState<DraftSection[]>(readDraftSections);
const refreshDrafts = React.useCallback(() => {
setSections(readDraftSections());
}, []);
React.useEffect(() => {
refreshDrafts();
}, [refreshDrafts]);
// Collapse the old `sections` state + `refreshDrafts` pattern onto a
// reactive snapshot: every draft write re-renders via useSyncExternalStore.
useDraftsSnapshot();
const sections = readDraftSections();
const drafts = React.useMemo(
() => sections.flatMap((section) => section.entries),
[sections],
);
// Collect unique thread-root IDs from active drafts only (sent drafts cannot
// be sent/orphaned). Deduplicated on root id — multiple drafts can share one.
const threadRootIds = React.useMemo(() => {
const ids = new Set<string>();
for (const entry of sections.flatMap((s) =>
s.status === "active" ? s.entries : [],
)) {
const rootId = getThreadRootId(entry.key);
if (rootId) {
ids.add(rootId);
}
}
return [...ids];
}, [sections]);
// Panel is always mounted when visible; `isOpen=true` enables root queries.
const rootStatusMap = useDraftRootStatus(threadRootIds, true);
const profilePubkeys = React.useMemo(
() => [
...new Set(
@@ -324,6 +517,11 @@ export function DraftsPanel() {
[channelsQuery.data, currentPubkey, drafts, profiles],
);
// Send confirmation dialog state.
const [sendTarget, setSendTarget] = React.useState<DraftListEntry | null>(
null,
);
const handleOpen = React.useCallback(
(entry: DraftListEntry) => {
if (!entry.draft.channelId) {
@@ -339,13 +537,41 @@ export function DraftsPanel() {
[goChannel],
);
const handleDelete = React.useCallback(
(draftKey: string) => {
clearDraftEntry(draftKey);
refreshDrafts();
},
[refreshDrafts],
);
const handleDelete = React.useCallback((draftKey: string) => {
clearDraftEntry(draftKey);
// No manual refresh needed — clearDraftEntry notifies subscribers and
// useDraftsSnapshot() causes DraftsPanel to re-render automatically.
}, []);
const handleSendRequest = React.useCallback((entry: DraftListEntry) => {
setSendTarget(entry);
}, []);
const handleSendCancel = React.useCallback(() => {
setSendTarget(null);
}, []);
const handleSendConfirm = React.useCallback(() => {
if (!sendTarget) return;
const entry = sendTarget;
setSendTarget(null);
if (!entry.draft.channelId) return;
const threadRootId = getThreadRootId(entry.key);
void goChannel(entry.draft.channelId, {
...(threadRootId ? { messageId: threadRootId, threadRootId } : {}),
autoSend: entry.key,
});
}, [sendTarget, goChannel]);
const sendDialogSource = sendTarget
? (sources.get(sendTarget.key) ?? UNKNOWN_DRAFT_SOURCE)
: UNKNOWN_DRAFT_SOURCE;
const sendDialogIsDm = sendDialogSource.channel?.channelType === "dm";
const sendDialogChannelLabel = sendDialogSource.channel
? sendDialogSource.label
: UNKNOWN_CHANNEL_LABEL;
if (sections.length === 0) {
return (
@@ -357,23 +583,44 @@ export function DraftsPanel() {
}
return (
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{sections.map((section) => (
<div className="space-y-2" key={section.status}>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{section.label}
</h3>
{section.entries.map((entry) => (
<DraftRow
entry={entry}
key={entry.key}
onDelete={handleDelete}
onOpen={handleOpen}
source={sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE}
/>
))}
</div>
))}
</div>
<>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{sections.map((section) => (
<div className="space-y-2" key={section.status}>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{section.label}
</h3>
{section.entries.map((entry) => {
const threadRootId = getThreadRootId(entry.key);
const rootStatus: RootStatus =
threadRootId !== null
? (rootStatusMap.get(threadRootId) ?? "checking")
: "available";
return (
<DraftRow
entry={entry}
key={entry.key}
onDelete={handleDelete}
onOpen={handleOpen}
onSend={handleSendRequest}
rootStatus={rootStatus}
source={sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE}
/>
);
})}
</div>
))}
</div>
{sendTarget ? (
<SendConfirmDialog
channelLabel={sendDialogChannelLabel}
isDm={sendDialogIsDm}
onCancel={handleSendCancel}
onConfirm={handleSendConfirm}
open={true}
/>
) : null}
</>
);
}

View File

@@ -1,28 +1,36 @@
/**
* Unit tests for the canOpenDraft openability predicate in DraftsPanel.
* Unit tests for the canOpenDraft and canSendDraft predicates in DraftsPanel.
*
* These tests import and exercise the ACTUAL exported `canOpenDraft` function —
* not a restatement of its logic — so any regression (e.g. reverting isSent or
* source.channel checks, or removing the export) breaks these tests immediately.
* These tests import and exercise the ACTUAL exported functions —
* not a restatement of logic — so any regression breaks these tests immediately.
*
* Three properties under test:
* canOpenDraft properties under test:
* (a) active draft + resolved channel → canOpen = true
* (b) sent draft + resolved channel → canOpen = false (Delete-only)
* (c) active draft + unresolved channel (null) → canOpen = false (false affordance guard)
* (d) active draft + empty channelId → canOpen = false (belt-and-suspenders)
*
* canSendDraft additional gates (mirrors ChannelPane.isComposerDisabled):
* (e) not a member → canSend = false
* (f) archived channel → canSend = false
* (g) forum channel → canSend = false
* (h) deleted root → canSend = false
* (i) all-clear active + member + non-archived + non-forum → canSend = true
*/
import assert from "node:assert/strict";
import test from "node:test";
// canOpenDraft is a pure function — no browser globals or React needed.
import { canOpenDraft } from "./DraftsPanel.tsx";
// canOpenDraft and canSendDraft are pure functions — no browser globals or React needed.
import { canOpenDraft, canSendDraft } from "./DraftsPanel.tsx";
// Minimal Channel stub — only the fields canOpenDraft reads (none; it checks null/non-null).
// Minimal Channel stub — all fields canOpenDraft and canSendDraft read.
const RESOLVED_CHANNEL = {
id: "chan-1",
visibility: "public",
channelType: "channel",
channelType: "stream",
isMember: true,
archivedAt: null,
};
function activeDraft(channelId = "chan-1") {
@@ -104,3 +112,102 @@ test("canOpenDraft_sent_null_channel_returns_false", () => {
"sent draft with unresolved channel should not be openable",
);
});
// ── canSendDraft: happy path ──────────────────────────────────────────────────
test("canSendDraft_active_member_stream_channel_returns_true", () => {
const draft = activeDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(canSendDraft(draft, source, "available"), true);
});
// ── canSendDraft: deleted root ────────────────────────────────────────────────
test("canSendDraft_deleted_root_returns_false", () => {
const draft = activeDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canSendDraft(draft, source, "deleted"),
false,
"draft with deleted thread root must not be sendable",
);
});
// ── canSendDraft: not a member ────────────────────────────────────────────────
test("canSendDraft_not_member_returns_false", () => {
const draft = activeDraft("chan-1");
const source = {
channel: { ...RESOLVED_CHANNEL, isMember: false },
label: "#general",
};
assert.equal(
canSendDraft(draft, source, "available"),
false,
"draft in a channel the user is not a member of must not be sendable",
);
});
// ── canSendDraft: archived channel ────────────────────────────────────────────
test("canSendDraft_archived_channel_returns_false", () => {
const draft = activeDraft("chan-1");
const source = {
channel: { ...RESOLVED_CHANNEL, archivedAt: "2026-01-01T00:00:00Z" },
label: "#archived",
};
assert.equal(
canSendDraft(draft, source, "available"),
false,
"draft in an archived channel must not be sendable",
);
});
// ── canSendDraft: forum channel ───────────────────────────────────────────────
test("canSendDraft_forum_channel_returns_false", () => {
const draft = activeDraft("chan-1");
const source = {
channel: { ...RESOLVED_CHANNEL, channelType: "forum" },
label: "#forum",
};
assert.equal(
canSendDraft(draft, source, "available"),
false,
"draft in a forum channel must not be sendable (forum posting not wired)",
);
});
// ── canSendDraft: checking/error roots are sendable ──────────────────────────
test("canSendDraft_checking_root_returns_true", () => {
const draft = activeDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canSendDraft(draft, source, "checking"),
true,
"checking root is optimistic — draft should still be sendable",
);
});
test("canSendDraft_error_root_returns_true", () => {
const draft = activeDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canSendDraft(draft, source, "error"),
true,
"transport error root is optimistic — draft should still be sendable",
);
});
// ── canSendDraft: sent draft ──────────────────────────────────────────────────
test("canSendDraft_sent_draft_returns_false", () => {
const draft = sentDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canSendDraft(draft, source, "available"),
false,
"sent draft is not sendable regardless of channel state",
);
});

View File

@@ -62,6 +62,20 @@ type MessageComposerProps = {
containerClassName?: string;
disabled?: boolean;
draftKey?: string;
/**
* When provided, the composer fires `submitMessage` once on mount after
* the draft matching this key has been loaded into the editor. This powers
* the "Send message" confirm-dialog flow in the Drafts panel. The callback
* `onAutoSubmitComplete` must clear the trigger (e.g. remove `?autoSend`
* from the URL) — it is called synchronously before `submitMessage` fires
* so the param is gone before any navigation the send might cause.
*
* Fires at most once per mount: a stable key value that persists across
* re-renders does NOT re-fire.
*/
autoSubmitDraftKey?: string | null;
/** Called when the auto-submit fires so the parent can clear the trigger. */
onAutoSubmitComplete?: () => void;
editTarget?: {
author: string;
body: string;
@@ -129,6 +143,8 @@ function MessageComposerImpl({
containerClassName,
disabled = false,
draftKey,
autoSubmitDraftKey = null,
onAutoSubmitComplete,
editTarget = null,
isSending = false,
onCancelEdit,
@@ -634,6 +650,43 @@ function MessageComposerImpl({
]);
submitMessageRef.current = submitMessage;
// ── Auto-submit on draft send ────────────────────────────────────────────
// When `autoSubmitDraftKey` is set (the user clicked "Send message" in the
// Drafts panel and confirmed), fire `submitMessage` once after mount so the
// draft is sent through the real send path (mention resolution, media, etc.).
//
// Guard: only fire when the effective draft key matches the trigger so a
// stale URL param on a different channel never fires a spurious send.
//
// Fires at most once per mount (empty dep array after the key check) — the
// `onAutoSubmitComplete` callback clears the trigger before `submitMessage`
// runs, preventing re-fire on re-render or back-navigation.
const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete);
onAutoSubmitCompleteRef.current = onAutoSubmitComplete;
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only
React.useEffect(() => {
if (
autoSubmitDraftKey === null ||
autoSubmitDraftKey !== effectiveDraftKey
) {
return;
}
// Clear the trigger BEFORE firing so any navigation from the send cannot
// loop back with the param still present.
onAutoSubmitCompleteRef.current?.();
// Defer by one macrotask so the draft-persist lifecycle effect (which runs
// synchronously after mount) has a chance to load the draft content into
// the Tiptap editor before we try to submit.
const timer = window.setTimeout(() => {
submitMessageRef.current();
}, 0);
return () => {
window.clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount-only
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

View File

@@ -0,0 +1,173 @@
/**
* Unit tests for the auto-submit guard in MessageComposer and the
* auto-send clear behavior in useChannelPanelHistoryState.
*
* Structure:
* 1. Key-match predicate — verifies which (autoSubmitDraftKey, effectiveDraftKey)
* pairs arm/skip the auto-submit effect in MessageComposer.
* 2. Completion-dispatch logic — verifies that ChannelPane.handleAutoSubmitComplete
* calls the surgical `onAutoSendComplete` callback when provided, and only
* falls back to `goChannel` when it is absent.
* 3. Clear-patch contract — imports and exercises `buildAutoSendClearPatch` from
* useChannelPanelHistoryState to verify the patch removes `autoSend` but
* leaves `thread` (and every other panel key) untouched. This is the
* regression guard for the "auto-submit clear drops the thread route" defect:
* if the implementation ever changes to include `thread: null` in the patch,
* this test fails before the bug ships.
*
* What is NOT tested here (and why):
* - Mounting MessageComposer: depends on Tiptap, Tauri, and React Query context
* not available in the node:test harness. The once-only mount guard is an
* empty-dep-array effect — verified by code review and the `goChannel`/
* `onAutoSendComplete` dispatch test below.
* - URL navigation: useHistorySearchState wraps @tanstack/react-router which
* requires a browser environment. The `buildAutoSendClearPatch` pure export
* covers the correctness of the patch without a router.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { buildAutoSendClearPatch } from "../../channels/ui/channelSearchKeys.ts";
// ── 1. Key-match predicate ────────────────────────────────────────────────────
// Mirror the guard from MessageComposerImpl. This predicate controls whether
// the auto-submit effect arms on mount.
function shouldAutoSubmit(autoSubmitDraftKey, effectiveDraftKey) {
if (autoSubmitDraftKey === null) return false;
if (autoSubmitDraftKey !== effectiveDraftKey) return false;
return true;
}
test("autoSend_main_composer_key_match_should_fire", () => {
const channelId = "chan-abc";
assert.equal(shouldAutoSubmit(channelId, channelId), true);
});
test("autoSend_main_composer_key_mismatch_should_not_fire", () => {
assert.equal(shouldAutoSubmit("chan-abc", "chan-xyz"), false);
});
test("autoSend_thread_composer_key_match_should_fire", () => {
const draftKey = "thread:root-111";
assert.equal(shouldAutoSubmit(draftKey, draftKey), true);
});
test("autoSend_thread_composer_key_mismatch_wrong_thread_should_not_fire", () => {
assert.equal(shouldAutoSubmit("thread:root-aaa", "thread:root-bbb"), false);
});
test("autoSend_thread_key_in_main_composer_should_not_fire", () => {
assert.equal(shouldAutoSubmit("thread:root-111", "chan-xyz"), false);
});
test("autoSend_channel_key_in_thread_composer_should_not_fire", () => {
assert.equal(shouldAutoSubmit("chan-abc", "thread:root-111"), false);
});
test("autoSend_null_trigger_should_not_fire", () => {
assert.equal(shouldAutoSubmit(null, "chan-abc"), false);
});
test("autoSend_null_trigger_thread_should_not_fire", () => {
assert.equal(shouldAutoSubmit(null, "thread:root-111"), false);
});
// ── 2. Completion-dispatch logic ─────────────────────────────────────────────
// ChannelPane.handleAutoSubmitComplete: when onAutoSendComplete is provided it
// must be called (surgical clear); when absent the goChannel fallback is taken.
function buildHandleAutoSubmitComplete({
activeChannelId,
goChannel,
onAutoSendComplete = null,
}) {
return function handleAutoSubmitComplete() {
if (onAutoSendComplete) {
onAutoSendComplete();
} else if (activeChannelId) {
goChannel(activeChannelId, { replace: true });
}
};
}
test("handleAutoSubmitComplete_calls_onAutoSendComplete_when_provided", () => {
let surgicalClearCalled = 0;
let goChannelCalled = 0;
const handler = buildHandleAutoSubmitComplete({
activeChannelId: "chan-abc",
goChannel: () => {
goChannelCalled++;
},
onAutoSendComplete: () => {
surgicalClearCalled++;
},
});
handler();
assert.equal(
surgicalClearCalled,
1,
"onAutoSendComplete should be called once",
);
assert.equal(
goChannelCalled,
0,
"goChannel must NOT be called when onAutoSendComplete is provided",
);
});
test("handleAutoSubmitComplete_falls_back_to_goChannel_when_onAutoSendComplete_absent", () => {
let goChannelCalled = 0;
let lastChannelId = null;
const handler = buildHandleAutoSubmitComplete({
activeChannelId: "chan-abc",
goChannel: (id) => {
goChannelCalled++;
lastChannelId = id;
},
onAutoSendComplete: null,
});
handler();
assert.equal(goChannelCalled, 1, "goChannel should be called as fallback");
assert.equal(lastChannelId, "chan-abc");
});
test("handleAutoSubmitComplete_no_op_when_both_absent", () => {
// Should not throw when neither callback nor channelId is available.
const handler = buildHandleAutoSubmitComplete({
activeChannelId: null,
goChannel: () => {
throw new Error("must not call goChannel");
},
onAutoSendComplete: null,
});
assert.doesNotThrow(() => handler());
});
// ── 3. Clear-patch contract ───────────────────────────────────────────────────
// buildAutoSendClearPatch() must remove ONLY autoSend — never thread or any
// other panel key. This is the regression guard for the thread-route-drop defect.
test("buildAutoSendClearPatch_removes_autoSend", () => {
const patch = buildAutoSendClearPatch();
assert.equal(patch.autoSend, null, "autoSend must be null (remove from URL)");
});
test("buildAutoSendClearPatch_does_not_clear_thread", () => {
const patch = buildAutoSendClearPatch();
assert.equal(
patch.thread,
undefined,
"thread must NOT be in the patch — clearing it would unmount the thread panel",
);
});
test("buildAutoSendClearPatch_only_touches_autoSend", () => {
const patch = buildAutoSendClearPatch();
const keys = Object.keys(patch);
assert.deepEqual(
keys,
["autoSend"],
`patch must contain exactly [autoSend], got [${keys.join(", ")}]`,
);
});

View File

@@ -99,6 +99,15 @@ type MessageThreadPanelProps = {
isMessageUnreadById?: (messageId: string) => boolean;
onFollowThread?: () => void;
onUnfollowThread?: () => void;
/**
* When set to `thread:<threadHead.id>`, the thread composer auto-submits
* once on mount (Send-from-drafts flow). Must be cleared by
* `onAutoSubmitComplete` before `submitMessage` fires so the param cannot
* re-trigger on back-navigation.
*/
autoSendDraftKey?: string | null;
/** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */
onAutoSubmitComplete?: () => void;
};
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
@@ -329,6 +338,8 @@ export function MessageThreadPanel({
toolbarExtraActions,
widthPx,
transparentChrome = false,
autoSendDraftKey = null,
onAutoSubmitComplete,
}: MessageThreadPanelProps) {
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadContentRef = React.useRef<HTMLDivElement>(null);
@@ -870,6 +881,8 @@ export function MessageThreadPanel({
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
autoSubmitDraftKey={autoSendDraftKey}
onAutoSubmitComplete={onAutoSubmitComplete}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}

View File

@@ -170,6 +170,9 @@ type E2eConfig = {
scope_value: string;
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
}>;
// Event IDs that `get_event` should report as definitively not found.
// Causes `useDraftRootStatus` to classify as `deleted`.
deletedEventIds?: string[];
};
relayHttpUrl?: string;
relayWsUrl?: string;
@@ -7317,6 +7320,10 @@ async function handleGetEvent(
) {
const identity = getIdentity(config);
if (!identity) {
// Allow test specs to mark specific event IDs as definitively deleted.
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
throw new Error("event not found");
}
const knownEvents: RelayEvent[] = [
...Array.from(mockMessages.values()).flat(),
{

View File

@@ -238,15 +238,20 @@ test.describe("drafts screenshots", () => {
// Hover to reveal action buttons
await textDraftRow.hover();
// Both action buttons should become visible on hover
// All three action buttons should become visible on hover
const openDraftBtn = textDraftRow.getByRole("button", {
name: "Open draft",
exact: true,
});
const sendMessageBtn = textDraftRow.getByRole("button", {
name: "Send message",
exact: true,
});
const deleteDraftBtn = textDraftRow.getByRole("button", {
name: "Delete draft",
});
await expect(openDraftBtn).toBeVisible({ timeout: 4_000 });
await expect(sendMessageBtn).toBeVisible({ timeout: 4_000 });
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
await page.waitForTimeout(200);
@@ -267,4 +272,194 @@ test.describe("drafts screenshots", () => {
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
test("05 — thread-draft send confirm dialog", async ({ page }) => {
// Regression test for IMPORTANT #1: thread-reply draft Send navigates to
// the correct channel and passes the autoSend key so the thread composer
// (not the main composer) arms the auto-submit.
await installMockBridge(page);
await patchWorkspacePubkey(page);
// A fixed fake root event ID — in the mock bridge get_event is unhandled
// so useDraftRootStatus will map it to `error` (not `deleted`), keeping
// the draft sendable.
const THREAD_ROOT_ID =
"aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444";
const THREAD_DRAFT_KEY = `thread:${THREAD_ROOT_ID}`;
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{
storeKey: DRAFT_STORE_KEY,
value: {
[THREAD_DRAFT_KEY]: {
content: "Thread reply draft content",
selectionStart: 26,
selectionEnd: 26,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
} satisfies StoredDrafts,
},
);
const panel = await openDraftsPanel(page);
// The thread draft row should appear.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" label must NOT appear — root status is `error` (optimistic).
await expect(panel.getByText("Thread deleted")).not.toBeVisible();
// Hover to reveal the three action buttons.
await draftRow.hover();
const sendBtn = draftRow.getByRole("button", {
name: "Send message",
exact: true,
});
await expect(sendBtn).toBeVisible({ timeout: 4_000 });
// Click "Send message" — confirm dialog should appear.
await sendBtn.click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 4_000 });
await expect(dialog.getByText("Send message")).toBeVisible();
// Confirm dialog body names the channel.
await expect(dialog.getByText(/general/i)).toBeVisible();
await page.waitForTimeout(200);
await panel.screenshot({
path: `${SHOTS}/05-thread-draft-send-dialog.png`,
});
// Click Send — dialog closes and we navigate to the channel with ?autoSend.
await dialog.getByRole("button", { name: "Send", exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 4_000 });
// URL must include all three params set by DraftsPanel.handleConfirmSend:
// ?messageId=<rootId> — scroll-targets the root message in the timeline
// ?threadRootId=<rootId> — opens the thread panel for this root
// ?autoSend=thread:<rootId> — arms the thread composer's once-only guard
//
// Integration boundary: the mock bridge does not support get_event or
// message send, so ChannelRouteScreen.fetchRouteTargetEvents fails silently,
// threadHeadMessage stays null, and the MessageThreadPanel (and its composer)
// never mount. The auto-submit effect and the actual send are NOT assertable
// in this E2E shard — they are covered by MessageComposerAutoSend.test.mjs
// (key-match guard) and MessageComposerDraftImagePersist.test.mjs (full
// composer mount path).
await expect(page).toHaveURL(new RegExp(`messageId=${THREAD_ROOT_ID}`), {
timeout: 6_000,
});
await expect(page).toHaveURL(new RegExp(`threadRootId=${THREAD_ROOT_ID}`));
await expect(page).toHaveURL(
new RegExp(`autoSend=${encodeURIComponent(THREAD_DRAFT_KEY)}`),
);
});
test("06 — active-draft badge on inbox trigger and filter option", async ({
page,
}) => {
// Captures both badge placements for the PR screenshot:
// 1. The numeric badge on the inbox filter trigger button.
// 2. The badge next to "Drafts" in the filter dropdown.
// Two active drafts are seeded so the count is 2.
await installMockBridge(page);
await patchWorkspacePubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({
timeout: 10_000,
});
// Badge should be visible on the filter trigger with count = 2.
const triggerBadge = page.getByTestId("inbox-draft-badge");
await expect(triggerBadge).toBeVisible({ timeout: 6_000 });
await expect(triggerBadge).toHaveText("2");
// Open the filter dropdown so the badge-option is visible too.
await page.getByTestId("inbox-filter-trigger").click();
const dropdownBadge = page.getByTestId("inbox-draft-badge-option");
await expect(dropdownBadge).toBeVisible({ timeout: 4_000 });
await expect(dropdownBadge).toHaveText("2");
await page.waitForTimeout(200);
// Capture the full inbox header area including the open dropdown.
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/06-draft-badge.png`,
});
// Dismiss the dropdown cleanly.
await page.keyboard.press("Escape");
});
test("07 — thread-deleted state (orphaned thread-reply draft)", async ({
page,
}) => {
// A thread-reply draft whose root event is definitively deleted:
// `useDraftRootStatus` maps it to `deleted`, showing the "Thread deleted"
// label, greying the row, and disabling Open/Send.
const DELETED_ROOT_ID =
"dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000";
const THREAD_DRAFT_KEY = `thread:${DELETED_ROOT_ID}`;
await installMockBridge(page, { deletedEventIds: [DELETED_ROOT_ID] });
await patchWorkspacePubkey(page);
await seedDraftStore(page, {
[THREAD_DRAFT_KEY]: {
content: "Planning to follow up on the discussion from last week.",
selectionStart: 52,
selectionEnd: 52,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
});
const panel = await openDraftsPanel(page);
// The orphaned draft row should render.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" badge must appear once the root-status query resolves.
const orphanLabel = panel.getByTestId(
`home-draft-orphaned-label-${THREAD_DRAFT_KEY}`,
);
await expect(orphanLabel).toBeVisible({ timeout: 8_000 });
// Hover the row to confirm Open and Send are disabled.
await draftRow.hover();
// Both the open and send buttons are labelled "Thread deleted" when orphaned.
const disabledBtns = draftRow.getByRole("button", {
name: "Thread deleted",
});
await expect(disabledBtns).toHaveCount(2, { timeout: 4_000 });
// Delete is still enabled.
await expect(
draftRow.getByRole("button", { name: "Delete draft" }),
).toBeVisible({ timeout: 4_000 });
await page.waitForTimeout(200);
await panel.screenshot({
path: `${SHOTS}/07-thread-deleted-state.png`,
});
});
});

View File

@@ -200,6 +200,12 @@ type MockBridgeOptions = {
scope_value: string;
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
}>;
/**
* Event IDs that `get_event` should report as definitively not found.
* Causes `useDraftRootStatus` to map the draft to `deleted` state so specs
* can exercise the "Thread deleted" label / disabled-send path.
*/
deletedEventIds?: string[];
};
type BridgeOptions = {