feat(desktop): add Slack-like Drafts inbox with persistence and image fix (#1539)

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-06 17:40:57 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 3729a65156
commit 4c598dcef5
17 changed files with 2652 additions and 75 deletions
+1
View File
@@ -69,6 +69,7 @@ export default defineConfig({
"**/reaction-order.spec.ts",
"**/send-channel-binding.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+8
View File
@@ -171,6 +171,14 @@ const overrides = new Map([
// +3: provider tri-state applied in update_managed_agent handler
// (if let Some(provider_update) = input.provider { record.provider = provider_update; }).
["src-tauri/src/commands/agent_models.rs", 1071],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
// split MessageComposer into submit/edit/media sub-modules.
// +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],
]);
await runFileSizeCheck({
+2 -1
View File
@@ -23,7 +23,8 @@ export type InboxFilter =
| "needs_action"
| "activity"
| "agent_activity"
| "reminders";
| "reminders"
| "drafts";
export type InboxItem = {
avatarUrl: string | null;
+2 -1
View File
@@ -112,7 +112,8 @@ export function HomeView({
const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } =
useHistorySearchState(INBOX_SEARCH_KEYS);
const isReminders = filter === "reminders";
const isMessagesMode = !isReminders;
const isDrafts = filter === "drafts";
const isMessagesMode = !isReminders && !isDrafts;
const remindersQuery = useRemindersQuery(currentPubkey);
const dueReminderCount = countDueReminders(remindersQuery.data ?? []);
// `?item=` is Messages-mode-only machinery: a reminder never enters the
+12 -2
View File
@@ -13,6 +13,7 @@ import {
type InboxItem,
type InboxTypeLabel,
} from "@/features/home/lib/inbox";
import { DraftsPanel } from "@/features/messages/ui/DraftsPanel";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
@@ -52,6 +53,7 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
{ value: "activity", label: "Activity" },
{ value: "agent_activity", label: "Agents" },
{ value: "reminders", label: "Reminders" },
{ value: "drafts", label: "Drafts" },
];
const INBOX_HEADER_ICON_BUTTON_CLASS =
@@ -137,6 +139,7 @@ export function InboxListPane({
}: InboxListPaneProps) {
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
const isReminders = filter === "reminders";
const isDrafts = filter === "drafts";
const scrollRef = React.useRef<HTMLDivElement>(null);
const unreadVisibleItemCount = React.useMemo(
() =>
@@ -399,7 +402,7 @@ export function InboxListPane({
<div
className={cn(
"flex min-h-9 items-center justify-between gap-3 rounded-lg px-2 py-1.5",
isReminders && "opacity-50",
(isReminders || isDrafts) && "opacity-50",
)}
>
<label
@@ -412,7 +415,7 @@ export function InboxListPane({
checked={unreadOnly}
className="shadow-none [&>span]:shadow-none"
data-testid="inbox-unread-only-toggle"
disabled={isReminders}
disabled={isReminders || isDrafts}
id="inbox-unread-only-switch"
onCheckedChange={onUnreadOnlyChange}
/>
@@ -499,6 +502,13 @@ export function InboxListPane({
<RemindersPanel includeDone pubkey={reminderPubkey} />
) : null}
</div>
) : isDrafts ? (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
data-testid="home-inbox-drafts"
>
<DraftsPanel />
</div>
) : (
<div
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
@@ -0,0 +1,684 @@
/**
* Unit tests for the localStorage-backed draft store.
*
* Tests cover:
* - save/load round-trip including attachments (pendingImeta)
* - persist-and-restore across channel switch (image-drop fix)
* - corruption tolerance (bad JSON in localStorage)
* - identity scoping (drafts don't leak across pubkeys)
* - MAX_DRAFTS eviction (oldest-updated entry removed when over cap)
* - clearAllDrafts resets the store
* - getAllDraftEntries returns sorted most-recently-updated first
*/
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();
// ── Module import ─────────────────────────────────────────────────────────────
// We import the standalone storage functions (not the React hook) so tests
// run without a React renderer context.
import {
clearAllDrafts,
clearDraftEntry,
getActiveDraftEntries,
getAllDraftEntries,
getSentDraftEntries,
initDraftStore,
loadDraftEntry,
markDraftSentEntry,
persistDraftEntry,
saveDraftEntry,
} from "./useDrafts.ts";
// Minimal ImetaMedia fixtures.
const IMG_A = {
url: "https://cdn.example.com/a.jpg",
sha256: "aabbccdd",
size: 1024,
type: "image/jpeg",
uploaded: 0,
};
const IMG_B = {
url: "https://cdn.example.com/b.png",
sha256: "eeff0011",
size: 2048,
type: "image/png",
uploaded: 0,
};
function setup(pubkey = "pubkey-alice") {
installFreshLocalStorage();
clearAllDrafts();
initDraftStore(pubkey);
}
function makeDraft(overrides = {}) {
const now = new Date().toISOString();
return {
content: "Hello world",
selectionStart: 11,
selectionEnd: 11,
channelId: "chan-1",
createdAt: now,
updatedAt: now,
pendingImeta: [],
spoileredAttachmentUrls: [],
...overrides,
};
}
// ── save / load round-trip ────────────────────────────────────────────────────
test("save_load_round_trip_preserves_content_and_attachments", () => {
setup();
saveDraftEntry(
"chan-1",
makeDraft({
pendingImeta: [IMG_A],
spoileredAttachmentUrls: ["https://cdn.example.com/a.jpg"],
}),
);
const loaded = loadDraftEntry("chan-1");
assert.ok(loaded, "draft should exist");
assert.equal(loaded.content, "Hello world");
assert.equal(loaded.pendingImeta.length, 1);
assert.equal(loaded.pendingImeta[0].url, IMG_A.url);
assert.deepEqual(loaded.spoileredAttachmentUrls, [
"https://cdn.example.com/a.jpg",
]);
});
test("save_load_round_trip_survives_restart_via_localstorage", () => {
setup();
saveDraftEntry(
"chan-persist",
makeDraft({
channelId: "chan-persist",
content: "Persisted draft",
pendingImeta: [IMG_B],
}),
);
// Simulate restart: clear in-memory cache, same localStorage + pubkey.
clearAllDrafts();
initDraftStore("pubkey-alice");
const loaded = loadDraftEntry("chan-persist");
assert.ok(loaded, "draft should survive simulated restart");
assert.equal(loaded.content, "Persisted draft");
assert.equal(loaded.pendingImeta[0].url, IMG_B.url);
});
// ── persistDraftEntry (image-drop fix) ────────────────────────────────────────
test("persist_draft_saves_images_on_channel_switch_and_restores_them", () => {
setup();
persistDraftEntry("chan-A", "Draft with image", "chan-A", [IMG_A], []);
const saved = loadDraftEntry("chan-A");
assert.ok(saved, "draft for chan-A should exist");
assert.equal(saved.pendingImeta.length, 1, "image should be persisted");
assert.equal(saved.pendingImeta[0].url, IMG_A.url);
});
test("persist_draft_clears_draft_when_content_and_attachments_are_empty", () => {
setup();
saveDraftEntry("chan-1", makeDraft({ content: "Something" }));
// Persist empty — should remove the draft.
persistDraftEntry("chan-1", " ", "chan-1", [], []);
assert.equal(
loadDraftEntry("chan-1"),
undefined,
"empty persist should clear draft",
);
});
test("persist_draft_preserves_createdAt_on_update", () => {
setup();
persistDraftEntry("chan-1", "v1", "chan-1", [], []);
const first = loadDraftEntry("chan-1");
assert.ok(first);
const createdAt = first.createdAt;
persistDraftEntry("chan-1", "v2", "chan-1", [], []);
const second = loadDraftEntry("chan-1");
assert.ok(second);
assert.equal(
second.createdAt,
createdAt,
"createdAt must not change on update",
);
assert.equal(second.content, "v2");
});
// ── clearDraftEntry ───────────────────────────────────────────────────────────
test("clearDraft_removes_entry_from_store_and_localstorage", () => {
setup();
persistDraftEntry("chan-del", "to delete", "chan-del", [], []);
clearDraftEntry("chan-del");
assert.equal(loadDraftEntry("chan-del"), undefined);
});
// ── corruption tolerance ──────────────────────────────────────────────────────
test("corrupt_localstorage_json_is_silently_ignored", () => {
setup("pubkey-corrupt");
localStorage.setItem("buzz-drafts.v1:pubkey-corrupt", "{not-valid-json");
// Re-init to force a fresh read from the corrupted store.
clearAllDrafts();
initDraftStore("pubkey-corrupt");
// Should return undefined, not throw.
assert.equal(loadDraftEntry("any-key"), undefined);
});
test("invalid_draft_entries_in_localstorage_are_skipped", () => {
setup("pubkey-invalid");
const validDraft = {
content: "valid",
selectionStart: 0,
selectionEnd: 0,
channelId: "chan-v",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
pendingImeta: [],
spoileredAttachmentUrls: [],
};
const data = JSON.stringify({
"chan-v": validDraft,
"chan-bad": { content: 42, selectionStart: "no" },
"chan-missing": { content: "x" },
});
localStorage.setItem("buzz-drafts.v1:pubkey-invalid", data);
clearAllDrafts();
initDraftStore("pubkey-invalid");
assert.ok(loadDraftEntry("chan-v"), "valid draft should load");
assert.equal(
loadDraftEntry("chan-bad"),
undefined,
"invalid shape should be skipped",
);
assert.equal(
loadDraftEntry("chan-missing"),
undefined,
"incomplete draft should be skipped",
);
});
// ── identity scoping ──────────────────────────────────────────────────────────
test("drafts_are_scoped_per_pubkey_and_do_not_leak_across_identities", () => {
setup("pubkey-alice");
persistDraftEntry("chan-1", "alice draft", "chan-1", [], []);
clearAllDrafts();
initDraftStore("pubkey-bob");
assert.equal(
loadDraftEntry("chan-1"),
undefined,
"bob must not see alice's draft",
);
clearAllDrafts();
initDraftStore("pubkey-alice");
assert.ok(
loadDraftEntry("chan-1"),
"alice's draft must survive identity switch",
);
});
// ── eviction ─────────────────────────────────────────────────────────────────
test("evicts_oldest_updated_entry_when_over_cap", () => {
setup("pubkey-evict");
const MAX = 100;
for (let i = 0; i <= MAX; i++) {
const ts = new Date(1_000_000 + i * 1000).toISOString();
saveDraftEntry(
`chan-${i}`,
makeDraft({
channelId: `chan-${i}`,
content: `draft ${i}`,
createdAt: ts,
updatedAt: ts,
}),
);
}
// chan-0 had the oldest updatedAt — it must have been evicted.
assert.equal(
loadDraftEntry("chan-0"),
undefined,
"oldest entry should be evicted",
);
assert.ok(loadDraftEntry("chan-1"), "chan-1 should survive");
assert.ok(loadDraftEntry(`chan-${MAX}`), `chan-${MAX} should survive`);
});
// ── getAllDraftEntries ────────────────────────────────────────────────────────
test("getAllDraftEntries_returns_all_entries_sorted_most_recently_updated_first", () => {
setup("pubkey-list");
const old = "2025-01-01T00:00:00.000Z";
const newer = "2025-06-01T00:00:00.000Z";
const newest = "2025-12-01T00:00:00.000Z";
saveDraftEntry(
"chan-a",
makeDraft({
channelId: "chan-a",
content: "a",
createdAt: old,
updatedAt: old,
}),
);
saveDraftEntry(
"chan-b",
makeDraft({
channelId: "chan-b",
content: "b",
createdAt: newer,
updatedAt: newer,
}),
);
saveDraftEntry(
"chan-c",
makeDraft({
channelId: "chan-c",
content: "c",
createdAt: newest,
updatedAt: newest,
}),
);
const all = getAllDraftEntries();
assert.equal(all.length, 3);
assert.equal(all[0].key, "chan-c", "most recent first");
assert.equal(all[1].key, "chan-b");
assert.equal(all[2].key, "chan-a", "oldest last");
});
test("getAllDraftEntries_returns_empty_array_when_no_drafts", () => {
setup("pubkey-empty");
assert.deepEqual(getAllDraftEntries(), []);
});
// ── channelId correctness on key switch ──────────────────────────────────────
// Regression: composer effect body was re-persisting prevKey with the incoming
// channel's id, corrupting the outgoing draft's channelId metadata.
// The first test below demonstrates the bug path — calling persistDraftEntry
// for key-A with channelId-B DOES overwrite the metadata, proving that the
// redundant body-side persist was the corruption source and had to be removed.
// The second test asserts the correct post-fix behavior: a normal A→B switch
// leaves draft A's channelId untouched.
test("persist_draft_bug_path_overwrites_channelId_confirming_removal_was_right", () => {
setup();
// Simulate correct outgoing save (cleanup runs first in React, correct channel).
persistDraftEntry("chan-A", "draft text", "chan-A", [IMG_A], []);
const afterCorrectSave = loadDraftEntry("chan-A");
assert.ok(afterCorrectSave, "chan-A draft should exist after correct save");
assert.equal(
afterCorrectSave.channelId,
"chan-A",
"channelId must be chan-A after correct save",
);
// Simulate the BUG path: a second persist of the same key but with the
// incoming channel's id (chan-B). This must NOT be done in practice, but
// we assert here that IF it were called, it would corrupt the metadata —
// confirming that removing the redundant body-side persist was the right fix.
persistDraftEntry("chan-A", "draft text", "chan-B", [IMG_A], []);
const afterBuggyOverwrite = loadDraftEntry("chan-A");
assert.ok(afterBuggyOverwrite);
assert.equal(
afterBuggyOverwrite.channelId,
"chan-B",
"channelId IS overwritten when persist is called with wrong channel — confirms the redundant persist must be removed",
);
});
test("persist_draft_outgoing_key_retains_original_channelId_when_body_persist_removed", () => {
// The correct behavior after the fix: only the cleanup call persists
// the outgoing draft. We simulate: persist A with channelId=A (cleanup),
// do NOT call persist A again with channelId=B (body removed), then verify A
// still has channelId=A when navigating to B's composer.
setup();
persistDraftEntry("chan-A", "draft in A", "chan-A", [IMG_A], []);
// Simulate switch to channel B: persist B's draft (new channel).
persistDraftEntry("chan-B", "", "chan-B", [], []); // empty B draft, gets cleared
// A's draft must still have channelId=A.
const draftA = loadDraftEntry("chan-A");
assert.ok(draftA, "chan-A draft should survive channel switch to B");
assert.equal(
draftA.channelId,
"chan-A",
"chan-A channelId must not be corrupted by switch to chan-B",
);
assert.equal(
draftA.pendingImeta.length,
1,
"image must be preserved on chan-A draft",
);
});
// ── thread-key handling ───────────────────────────────────────────────────────
test("thread_draft_key_stores_explicit_channelId_not_the_thread_key", () => {
setup();
const threadKey = "thread:aaaa1234";
const channelId = "the-channel-id";
saveDraftEntry(
threadKey,
makeDraft({
channelId,
content: "thread reply draft",
pendingImeta: [IMG_A],
}),
);
const loaded = loadDraftEntry(threadKey);
assert.ok(loaded);
assert.equal(
loaded.channelId,
channelId,
"channelId must equal the explicit value",
);
assert.equal(loaded.pendingImeta.length, 1);
});
// ── initDraftStore cache-reset safety ────────────────────────────────────────
test("initDraftStore_resets_cache_on_pubkey_change_without_clearAllDrafts", () => {
// Alice saves a draft.
setup("pubkey-alice");
persistDraftEntry("chan-1", "alice draft", "chan-1", [], []);
// Switch directly to bob without calling clearAllDrafts first.
// initDraftStore must reset the in-memory cache so alice's draft
// is not served under bob's identity.
initDraftStore("pubkey-bob");
assert.equal(
loadDraftEntry("chan-1"),
undefined,
"bob must not see alice's cached draft after direct initDraftStore switch",
);
});
// ── status field: markDraftSent, getActiveDraftEntries, getSentDraftEntries ───
// markDraftSentEntry snapshots the draft into a distinct `sent:<key>:<ts>` key
// and removes the original active key so composer cleanup and new drafts are
// never affected by the sent record's lifecycle.
test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key", () => {
setup();
persistDraftEntry("chan-1", "sent message content", "chan-1", [IMG_A], []);
markDraftSentEntry("chan-1", "sent message content", "chan-1", [IMG_A], []);
// Active key must be gone.
assert.equal(
loadDraftEntry("chan-1"),
undefined,
"active key must be cleared after markDraftSent",
);
// Sent record must exist under a sent: key.
const sent = getSentDraftEntries();
assert.equal(sent.length, 1, "one sent entry must exist");
assert.equal(sent[0].draft.status, "sent", "status must be 'sent'");
assert.equal(
sent[0].draft.content,
"sent message content",
"content preserved",
);
assert.equal(sent[0].draft.pendingImeta.length, 1, "image preserved");
assert.equal(sent[0].draft.pendingImeta[0].url, IMG_A.url);
assert.ok(
sent[0].key.startsWith("sent:chan-1:"),
"sent key must have sent: prefix",
);
});
test("markDraftSent_writes_sent_record_even_when_active_key_already_cleared", () => {
// The never-persisted boundary is enforced at the call site (sentDraftKey
// is only set when a draft was actually persisted). This function writes
// unconditionally so a navigation-during-send race cannot cause data loss:
// if the active key was already cleared before send success, the snapshot
// content still produces a sent record (createdAt falls back to now).
setup();
// Call without any prior persistDraftEntry — simulates the race where the
// active key was deleted by a composer cleanup before markDraftSent ran.
markDraftSentEntry("no-such-key", "content", "chan-x", [], []);
assert.equal(
loadDraftEntry("no-such-key"),
undefined,
"active key still absent",
);
const sent = getSentDraftEntries();
assert.equal(
sent.length,
1,
"sent record is written even without a live active key",
);
assert.equal(sent[0].draft.content, "content");
assert.equal(sent[0].draft.status, "sent");
assert.ok(
sent[0].key.startsWith("sent:no-such-key:"),
"sent key has correct prefix",
);
});
test("markDraftSent_send_then_cleanup_preserves_sent_record", () => {
// Simulate the full composer lifecycle:
// 1. Draft exists on key A.
// 2. User sends -> markDraftSent(A) snapshots under sent:A:ts and clears A.
// 3. Composer cleanup calls persistDraft(A, "", ...) -> clearDraftEntry(A).
// The sent record under sent:A:ts must still exist after step 3.
setup();
persistDraftEntry("chan-A", "my draft", "chan-A", [IMG_A], []);
markDraftSentEntry("chan-A", "my draft", "chan-A", [IMG_A], []);
// Simulate composer cleanup: empty persist on the now-absent active key.
persistDraftEntry("chan-A", "", "chan-A", [], []);
const sent = getSentDraftEntries();
assert.equal(sent.length, 1, "sent record must survive composer cleanup");
assert.equal(sent[0].draft.content, "my draft");
});
test("markDraftSent_navigation_during_async_send_still_creates_sent_record", () => {
// Regression test for the async-send/navigation race (Thufir Pass-2 finding):
// 1. Persisted draft A exists at submit time.
// 2. Composer clears the editor (clearContent) then awaits onSend.
// 3. While onSend is in flight, user switches channel. MessageComposer
// cleanup runs persistDraftEntry(A, empty) -> clearDraftEntry(A) — active
// key is gone before send success.
// 4. Send succeeds; markDraftSentEntry(A, savedContent, ...) runs.
// The sent record MUST still be written from the passed-in snapshot.
setup();
persistDraftEntry("chan-race", "race draft", "chan-race", [IMG_A], []);
// Simulate step 3: active key cleared by navigation-during-send cleanup.
persistDraftEntry("chan-race", "", "chan-race", [], []);
assert.equal(
loadDraftEntry("chan-race"),
undefined,
"active key should be cleared (simulating race)",
);
// Simulate step 4: send succeeds, mark sent with full snapshot.
markDraftSentEntry("chan-race", "race draft", "chan-race", [IMG_A], []);
const sent = getSentDraftEntries();
assert.equal(
sent.length,
1,
"sent record must be written despite active key being gone",
);
assert.equal(
sent[0].draft.content,
"race draft",
"snapshot content preserved",
);
assert.equal(
sent[0].draft.pendingImeta.length,
1,
"snapshot image preserved",
);
assert.equal(sent[0].draft.status, "sent");
});
test("markDraftSent_new_active_draft_after_send_is_independent", () => {
// After sending, a new draft typed in the same channel must appear in
// getActiveDraftEntries() as active, and the sent record must remain in
// getSentDraftEntries() -- they coexist under distinct keys.
setup("pubkey-coexist");
persistDraftEntry("chan-X", "original draft", "chan-X", [], []);
markDraftSentEntry("chan-X", "original draft", "chan-X", [], []);
// New draft in the same channel.
persistDraftEntry("chan-X", "new draft after send", "chan-X", [IMG_B], []);
const active = getActiveDraftEntries();
const sent = getSentDraftEntries();
assert.equal(active.length, 1, "one active draft");
assert.equal(active[0].draft.content, "new draft after send");
assert.equal(active[0].draft.status, "active");
assert.equal(sent.length, 1, "one sent record");
assert.equal(sent[0].draft.content, "original draft");
assert.equal(sent[0].draft.status, "sent");
});
test("markDraftSent_double_send_in_same_channel_creates_two_distinct_sent_records", () => {
// Sending twice from the same channel must produce two independent sent
// records -- the timestamp suffix prevents key collision.
setup("pubkey-double-send");
persistDraftEntry("chan-Y", "first draft", "chan-Y", [], []);
markDraftSentEntry("chan-Y", "first draft", "chan-Y", [], []);
// Second draft in the same channel.
persistDraftEntry("chan-Y", "second draft", "chan-Y", [], []);
markDraftSentEntry("chan-Y", "second draft", "chan-Y", [], []);
const sent = getSentDraftEntries();
assert.equal(sent.length, 2, "two distinct sent records");
const contents = sent.map((e) => e.draft.content).sort();
assert.deepEqual(contents, ["first draft", "second draft"]);
const keys = sent.map((e) => e.key);
assert.notEqual(keys[0], keys[1], "sent keys must be distinct");
});
test("getActiveDraftEntries_returns_only_active_drafts", () => {
setup("pubkey-active");
persistDraftEntry("chan-active", "active draft", "chan-active", [], []);
persistDraftEntry("chan-sent", "sent draft", "chan-sent", [], []);
markDraftSentEntry("chan-sent", "sent draft", "chan-sent", [], []);
const active = getActiveDraftEntries();
assert.equal(active.length, 1, "only one active draft");
assert.equal(active[0].key, "chan-active");
assert.equal(active[0].draft.status, "active");
});
test("getSentDraftEntries_returns_only_sent_drafts", () => {
setup("pubkey-sent");
persistDraftEntry("chan-active2", "still drafting", "chan-active2", [], []);
persistDraftEntry("chan-sent2", "already sent", "chan-sent2", [], []);
markDraftSentEntry("chan-sent2", "already sent", "chan-sent2", [], []);
const sent = getSentDraftEntries();
assert.equal(sent.length, 1, "only one sent draft");
assert.ok(sent[0].key.startsWith("sent:chan-sent2:"), "sent key has prefix");
assert.equal(sent[0].draft.status, "sent");
});
test("getActiveDraftEntries_and_getSentDraftEntries_partition_all_entries", () => {
setup("pubkey-partition");
persistDraftEntry("ch-a", "draft a", "ch-a", [], []);
persistDraftEntry("ch-b", "draft b", "ch-b", [], []);
persistDraftEntry("ch-c", "draft c", "ch-c", [], []);
markDraftSentEntry("ch-b", "draft b", "ch-b", [], []);
const all = getAllDraftEntries();
const active = getActiveDraftEntries();
const sent = getSentDraftEntries();
// ch-a, ch-c still active; sent:ch-b:ts is the sent record.
assert.equal(all.length, 3);
assert.equal(active.length + sent.length, all.length, "active + sent = all");
assert.ok(
active.every((e) => e.draft.status === "active"),
"all active entries have status active",
);
assert.ok(
sent.every((e) => e.draft.status === "sent"),
"all sent entries have status sent",
);
});
// ── status migration: pre-status entries read as "active" ────────────────────
test("pre_status_entry_without_status_field_is_read_as_active", () => {
setup("pubkey-migrate");
// Write a raw entry without the status field, simulating data persisted
// before the status field was introduced.
const legacyEntry = {
content: "legacy draft",
selectionStart: 0,
selectionEnd: 12,
channelId: "chan-legacy",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
pendingImeta: [],
spoileredAttachmentUrls: [],
// NOTE: no 'status' field
};
localStorage.setItem(
"buzz-drafts.v1:pubkey-migrate",
JSON.stringify({ "chan-legacy": legacyEntry }),
);
// Force re-read from localStorage.
clearAllDrafts();
initDraftStore("pubkey-migrate");
const loaded = loadDraftEntry("chan-legacy");
assert.ok(loaded, "legacy entry must load without rejection");
assert.equal(loaded.status, "active", "missing status defaults to 'active'");
assert.equal(loaded.content, "legacy draft");
});
test("pre_status_entry_appears_in_getActiveDraftEntries_after_migration", () => {
setup("pubkey-migrate2");
const legacyEntry = {
content: "old draft",
selectionStart: 0,
selectionEnd: 9,
channelId: "chan-old",
createdAt: "2025-06-01T00:00:00.000Z",
updatedAt: "2025-06-01T00:00:00.000Z",
pendingImeta: [],
spoileredAttachmentUrls: [],
};
localStorage.setItem(
"buzz-drafts.v1:pubkey-migrate2",
JSON.stringify({ "chan-old": legacyEntry }),
);
clearAllDrafts();
initDraftStore("pubkey-migrate2");
const active = getActiveDraftEntries();
assert.equal(active.length, 1, "legacy entry appears in active list");
assert.equal(active[0].key, "chan-old");
assert.equal(active[0].draft.status, "active");
});
+364 -32
View File
@@ -1,59 +1,391 @@
import * as React from "react";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
export type DraftState = {
content: string;
selectionStart: number;
selectionEnd: number;
/**
* The channel (or thread-scoped) ID this draft belongs to.
* Stored explicitly — do NOT parse the draft key to recover it.
* Thread draft keys use the form `thread:${threadHead.id}`; the
* channelId is the containing channel.
*/
channelId: string;
/** ISO-8601 timestamp when this draft was first created. */
createdAt: string;
/** ISO-8601 timestamp when this draft was last updated. */
updatedAt: string;
/** Pasted/uploaded image attachments, preserved across channel-switch. */
pendingImeta: ImetaMedia[];
/** URLs of imeta attachments marked as spoilered. */
spoileredAttachmentUrls: string[];
/**
* Lifecycle status of this draft.
* - "active": draft is in progress (not yet sent).
* - "sent": draft was sent; kept for the Drafts inbox "Sent" subsection.
* Entries persisted before this field was added have no status field —
* the read path treats absent status as "active" (see `isValidDraftState`).
*/
status: "active" | "sent";
};
const sharedDrafts = new Map<string, DraftState>();
/** Serialised shape stored in localStorage (same as DraftState for round-trips). */
type StoredDrafts = Record<string, DraftState>;
const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1";
const MAX_DRAFTS = 100;
/** Module-level pubkey set by `initDraftStore`. Empty string = no identity. */
let currentPubkey = "";
/** Monotonically-incrementing counter used to guarantee unique sent-record keys
* even when two sends happen within the same millisecond (e.g. in tests). */
let _sentSeq = 0;
function storageKey(): string {
return `${DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
}
/**
* Initialise (or re-initialise) the draft store for a given identity.
* Called from `useWorkspaceInit` alongside the other singleton resets.
* Resets the in-memory cache whenever the pubkey changes so a direct
* identity switch (without a prior `clearAllDrafts`) never serves the
* wrong identity's drafts.
*/
export function initDraftStore(pubkey: string): void {
if (currentPubkey !== pubkey) {
_memCache = null;
}
currentPubkey = pubkey;
// Eagerly load to surface corruption errors in console at startup rather
// than on first draft interaction.
readStore();
}
/**
* Reset the in-memory draft store on workspace switch.
* Replaces the old `clearAllDrafts()`.
*/
export function clearAllDrafts(): void {
sharedDrafts.clear();
currentPubkey = "";
_memCache = null;
}
// ── In-memory write-back cache ────────────────────────────────────────────────
// We keep a parsed copy so reads are synchronous O(1) object lookups,
// and only flush to localStorage on writes.
let _memCache: Map<string, DraftState> | null = null;
function readStore(): Map<string, DraftState> {
if (_memCache !== null) return _memCache;
const map = new Map<string, DraftState>();
if (!currentPubkey) {
_memCache = map;
return map;
}
const raw = localStorage.getItem(storageKey());
if (!raw) {
_memCache = map;
return map;
}
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
) {
for (const [key, value] of Object.entries(parsed as StoredDrafts)) {
if (isValidDraftState(value)) {
map.set(key, value);
}
}
}
} catch (err) {
console.debug("[useDrafts] localStorage corrupt, starting fresh:", err);
}
_memCache = map;
return map;
}
function isValidDraftState(v: unknown): v is DraftState {
if (typeof v !== "object" || v === null) return false;
const d = v as Partial<DraftState>;
if (
typeof d.content !== "string" ||
typeof d.selectionStart !== "number" ||
typeof d.selectionEnd !== "number" ||
typeof d.channelId !== "string" ||
typeof d.createdAt !== "string" ||
typeof d.updatedAt !== "string" ||
!Array.isArray(d.pendingImeta) ||
!Array.isArray(d.spoileredAttachmentUrls)
) {
return false;
}
// Migration: entries written before the status field was introduced have no
// status. Treat absent/invalid status as "active" rather than rejecting the
// entry — this avoids data loss on first run after the upgrade.
if (d.status === undefined || d.status === null) {
(d as DraftState).status = "active";
} else if (d.status !== "active" && d.status !== "sent") {
return false;
}
return true;
}
function flushStore(map: Map<string, DraftState>): void {
if (!currentPubkey) return;
const obj: StoredDrafts = {};
for (const [k, v] of map) {
obj[k] = v;
}
setLocalStorageItemWithRecovery(storageKey(), JSON.stringify(obj));
}
/**
* Evict the least-recently-updated entry until the map is within `MAX_DRAFTS`.
*/
function evictOldest(map: Map<string, DraftState>): void {
if (map.size <= MAX_DRAFTS) return;
// Sort ascending by updatedAt; evict oldest until within cap.
const sorted = [...map.entries()].sort((a, b) =>
a[1].updatedAt.localeCompare(b[1].updatedAt),
);
const excess = map.size - MAX_DRAFTS;
for (let i = 0; i < excess; i++) {
map.delete(sorted[i][0]);
}
}
// ── Public API ────────────────────────────────────────────────────────────────
// The standalone functions below are the primary storage layer. `useDrafts()`
// wraps them in `React.useCallback` for component use; the functions are also
// exported directly so non-React callers (tests, future inbox features) can
// use them without a React context.
export function saveDraftEntry(draftKey: string, draft: DraftState): void {
if (draft.content.trim().length === 0 && draft.pendingImeta.length === 0) {
return;
}
const map = readStore();
map.set(draftKey, draft);
evictOldest(map);
flushStore(map);
}
export function loadDraftEntry(draftKey: string): DraftState | undefined {
return readStore().get(draftKey);
}
export function clearDraftEntry(draftKey: string): void {
const map = readStore();
if (map.has(draftKey)) {
map.delete(draftKey);
flushStore(map);
}
}
/**
* Convenience: save if content or attachments are non-empty, otherwise clear.
* Preserves existing createdAt on updates; sets it on first save.
*/
export function persistDraftEntry(
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
): void {
const hasContent = content.trim().length > 0 || pendingImeta.length > 0;
if (hasContent) {
const map = readStore();
const existing = map.get(draftKey);
const now = new Date().toISOString();
saveDraftEntry(draftKey, {
content,
selectionEnd: content.length,
selectionStart: content.length,
channelId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
pendingImeta,
spoileredAttachmentUrls,
status: "active",
});
} else {
clearDraftEntry(draftKey);
}
}
/**
* Returns all drafts sorted most-recently-updated first.
* Used by the Drafts inbox panel (Phase 2).
*/
export function getAllDraftEntries(): Array<{
key: string;
draft: DraftState;
}> {
return [...readStore().entries()]
.sort((a, b) => b[1].updatedAt.localeCompare(a[1].updatedAt))
.map(([key, draft]) => ({ key, draft }));
}
/**
* Returns only active (unsent) drafts, sorted most-recently-updated first.
* Used by the "Drafts" subsection of the Drafts inbox panel.
*/
export function getActiveDraftEntries(): Array<{
key: string;
draft: DraftState;
}> {
return getAllDraftEntries().filter((e) => e.draft.status === "active");
}
/**
* Returns only sent drafts, sorted most-recently-updated first.
* Used by the "Sent" subsection of the Drafts inbox panel.
*/
export function getSentDraftEntries(): Array<{
key: string;
draft: DraftState;
}> {
return getAllDraftEntries().filter((e) => e.draft.status === "sent");
}
/**
* Mark a draft as sent by writing its content to a durable sent-record key.
*
* The active draft key is simultaneously cleared so the composer can create
* a fresh draft in the same channel without inheriting the sent status, and so
* the composer's empty-content cleanup can never delete the sent record.
*
* The sent record is stored under `sent:<draftKey>:<timestamp>` — a key the
* composer never writes to — so active and sent records for the same channel
* can coexist in the store independently.
*
* The "never-persisted draft writes no sent record" boundary is enforced at
* the call site: callers only invoke this function when `sentDraftKey` is
* non-null, which only holds for drafts that were persisted before submit.
* This function writes unconditionally so the sent record is created even
* when the active key was already cleared by a composer cleanup that raced
* the async send (e.g. the user switched channels while send was in flight).
*/
export function markDraftSentEntry(
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
): void {
const map = readStore();
const existing = map.get(draftKey);
const now = new Date().toISOString();
// Use the live entry's createdAt when available; fall back to now when the
// active key was already cleared by a navigation-during-send race. Either
// way the sent record is written — the race cannot cause data loss.
const createdAt = existing?.createdAt ?? now;
// Write the sent record under a stable, distinct key so it can never be
// overwritten by the composer's active-draft persist path.
// The `Date.now()-seq` suffix guarantees uniqueness even if two sends in the
// same channel happen within the same millisecond.
const sentKey = `sent:${draftKey}:${Date.now()}-${++_sentSeq}`;
map.set(sentKey, {
content,
selectionStart: content.length,
selectionEnd: content.length,
channelId,
createdAt,
updatedAt: now,
pendingImeta,
spoileredAttachmentUrls,
status: "sent",
});
// Clear the active draft key (if still present) so the composer starts fresh
// and any subsequent empty-content persist doesn't encounter the sent record.
map.delete(draftKey);
evictOldest(map);
flushStore(map);
}
export function useDrafts() {
const saveDraft = React.useCallback(
(channelId: string, draft: DraftState) => {
if (draft.content.trim().length === 0) {
return;
}
sharedDrafts.set(channelId, draft);
trimMapToSize(sharedDrafts, 50);
},
(draftKey: string, draft: DraftState) => saveDraftEntry(draftKey, draft),
[],
);
const loadDraft = React.useCallback(
(channelId: string): DraftState | undefined => {
return sharedDrafts.get(channelId);
},
(draftKey: string): DraftState | undefined => loadDraftEntry(draftKey),
[],
);
const clearDraft = React.useCallback((channelId: string) => {
sharedDrafts.delete(channelId);
}, []);
/** Save draft if content is non-empty, otherwise clear it. */
const persistDraft = React.useCallback(
(channelId: string, content: string) => {
if (content.trim().length > 0) {
saveDraft(channelId, {
content,
selectionEnd: content.length,
selectionStart: content.length,
});
} else {
clearDraft(channelId);
}
},
[saveDraft, clearDraft],
const clearDraft = React.useCallback(
(draftKey: string) => clearDraftEntry(draftKey),
[],
);
return { saveDraft, loadDraft, clearDraft, persistDraft };
const persistDraft = React.useCallback(
(
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
) =>
persistDraftEntry(
draftKey,
content,
channelId,
pendingImeta,
spoileredAttachmentUrls,
),
[],
);
const getAllDrafts = React.useCallback(() => getAllDraftEntries(), []);
const getActiveDrafts = React.useCallback(() => getActiveDraftEntries(), []);
const getSentDrafts = React.useCallback(() => getSentDraftEntries(), []);
const markDraftSent = React.useCallback(
(
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
) =>
markDraftSentEntry(
draftKey,
content,
channelId,
pendingImeta,
spoileredAttachmentUrls,
),
[],
);
return {
saveDraft,
loadDraft,
clearDraft,
persistDraft,
getAllDrafts,
getActiveDrafts,
getSentDrafts,
markDraftSent,
};
}
export type UseDraftsResult = ReturnType<typeof useDrafts>;
@@ -0,0 +1,379 @@
import { FileText, Lock, Pencil, Trash2 } from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
clearDraftEntry,
getActiveDraftEntries,
getSentDraftEntries,
type DraftState,
} from "@/features/messages/lib/useDrafts";
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 { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const SENT_DRAFT_PREFIX = "sent:";
const THREAD_DRAFT_PREFIX = "thread:";
const UNKNOWN_CHANNEL_LABEL = "Unknown channel";
type DraftListEntry = {
draft: DraftState;
key: string;
};
type DraftSection = {
entries: DraftListEntry[];
label: string;
status: DraftState["status"];
};
type DraftSource = {
channel: Channel | null;
label: string;
};
const UNKNOWN_DRAFT_SOURCE: DraftSource = {
channel: null,
label: UNKNOWN_CHANNEL_LABEL,
};
const draftTimeFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
function parseDraftTime(value: string): number {
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function formatDraftCreatedAt(draft: DraftState): string {
const time = parseDraftTime(draft.createdAt);
return time === 0
? "Unknown time"
: draftTimeFormatter.format(new Date(time));
}
function getOriginalDraftKey(draftKey: string): string {
if (!draftKey.startsWith(SENT_DRAFT_PREFIX)) {
return draftKey;
}
const sentPayload = draftKey.slice(SENT_DRAFT_PREFIX.length);
const timestampSeparatorIndex = sentPayload.lastIndexOf(":");
return timestampSeparatorIndex > 0
? sentPayload.slice(0, timestampSeparatorIndex)
: sentPayload;
}
function getThreadRootId(draftKey: string): string | null {
const originalDraftKey = getOriginalDraftKey(draftKey);
if (!originalDraftKey.startsWith(THREAD_DRAFT_PREFIX)) {
return null;
}
const id = originalDraftKey.slice(THREAD_DRAFT_PREFIX.length).trim();
return id.length > 0 ? id : null;
}
function isVisibleDraft(entry: DraftListEntry): boolean {
const content = entry.draft.content.trim();
const attachmentCount = entry.draft.pendingImeta.length;
return content.length > 0 || attachmentCount > 0;
}
function getDraftPreview(draft: DraftState): string {
const content = draft.content.trim();
if (content.length > 0) {
return content;
}
const attachmentCount = draft.pendingImeta.length;
if (attachmentCount === 1) {
return "1 attachment";
}
if (attachmentCount > 1) {
return `${attachmentCount} attachments`;
}
return "Empty draft";
}
function readDraftSections(): DraftSection[] {
const active = getActiveDraftEntries().filter(isVisibleDraft);
const sent = getSentDraftEntries().filter(isVisibleDraft);
const sections: DraftSection[] = [];
if (active.length > 0) {
sections.push({ label: "Drafts", status: "active", entries: active });
}
if (sent.length > 0) {
sections.push({ label: "Sent", status: "sent", entries: sent });
}
return sections;
}
function resolveDraftSources({
channels,
currentPubkey,
drafts,
profiles,
}: {
channels: Channel[] | undefined;
currentPubkey: string | undefined;
drafts: DraftListEntry[];
profiles: UserProfileLookup | undefined;
}): Map<string, DraftSource> {
const channelsById = new Map(
(channels ?? []).map((channel) => [channel.id, channel]),
);
const sources = new Map<string, DraftSource>();
for (const entry of drafts) {
const channel = channelsById.get(entry.draft.channelId);
sources.set(entry.key, {
channel: channel ?? null,
label: channel
? resolveChannelDisplayLabel(channel, currentPubkey, profiles)
: UNKNOWN_CHANNEL_LABEL,
});
}
return sources;
}
function DraftRowActionButton({
children,
disabled = false,
label,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
className="h-7 w-7 rounded-full p-0 text-muted-foreground hover:text-foreground"
disabled={disabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (!disabled) {
onClick();
}
}}
size="icon"
type="button"
variant="ghost"
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function canOpenDraft(draft: DraftState, source: DraftSource): boolean {
return (
draft.status !== "sent" &&
source.channel !== null &&
draft.channelId.length > 0
);
}
function DraftRow({
entry,
onDelete,
onOpen,
source,
}: {
entry: DraftListEntry;
onDelete: (draftKey: string) => void;
onOpen: (entry: DraftListEntry) => void;
source: DraftSource;
}) {
const isSent = entry.draft.status === "sent";
const canOpen = canOpenDraft(entry.draft, source);
const isPrivate = source.channel?.visibility === "private";
const isDm = source.channel?.channelType === "dm";
const channelLabel = source.channel
? isDm
? source.label
: `#${source.label}`
: UNKNOWN_CHANNEL_LABEL;
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"
data-testid={`home-draft-item-${entry.key}`}
>
<button
aria-label={`Open draft in ${channelLabel}`}
className="block w-full min-w-0 px-3 py-3 text-left disabled:cursor-default"
disabled={!canOpen}
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="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",
)}
>
{channelLabel}
</span>
<span className="shrink-0 text-muted-foreground/70">
{formatDraftCreatedAt(entry.draft)}
</span>
</div>
<div className="mt-1 max-h-10 overflow-hidden text-sm font-medium leading-5 text-foreground">
<Markdown
className="inbox-preview-markdown text-inherit leading-5"
content={getDraftPreview(entry.draft)}
interactive={false}
/>
</div>
</div>
</button>
<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
label="Delete draft"
onClick={() => onDelete(entry.key)}
>
<Trash2 className="h-4 w-4" />
</DraftRowActionButton>
</div>
</div>
);
}
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]);
const drafts = React.useMemo(
() => sections.flatMap((section) => section.entries),
[sections],
);
const profilePubkeys = React.useMemo(
() => [
...new Set(
(channelsQuery.data ?? [])
.filter((channel) =>
drafts.some((entry) => entry.draft.channelId === channel.id),
)
.flatMap((channel) => channel.participantPubkeys),
),
],
[channelsQuery.data, drafts],
);
const usersBatchQuery = useUsersBatchQuery(profilePubkeys, {
enabled: profilePubkeys.length > 0,
});
const profiles = usersBatchQuery.data?.profiles;
const sources = React.useMemo(
() =>
resolveDraftSources({
channels: channelsQuery.data,
currentPubkey,
drafts,
profiles,
}),
[channelsQuery.data, currentPubkey, drafts, profiles],
);
const handleOpen = React.useCallback(
(entry: DraftListEntry) => {
if (!entry.draft.channelId) {
return;
}
const threadRootId = getThreadRootId(entry.key);
void goChannel(
entry.draft.channelId,
threadRootId ? { messageId: threadRootId, threadRootId } : undefined,
);
},
[goChannel],
);
const handleDelete = React.useCallback(
(draftKey: string) => {
clearDraftEntry(draftKey);
refreshDrafts();
},
[refreshDrafts],
);
if (sections.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<FileText className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No drafts</p>
</div>
);
}
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>
);
}
@@ -0,0 +1,106 @@
/**
* Unit tests for the canOpenDraft openability predicate 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.
*
* Three 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)
*/
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";
// Minimal Channel stub — only the fields canOpenDraft reads (none; it checks null/non-null).
const RESOLVED_CHANNEL = {
id: "chan-1",
visibility: "public",
channelType: "channel",
};
function activeDraft(channelId = "chan-1") {
return {
channelId,
content: "hello",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
pendingImeta: [],
status: "active",
};
}
function sentDraft(channelId = "chan-1") {
return { ...activeDraft(channelId), status: "sent" };
}
// ── (a) active + resolved channel → openable ──────────────────────────────────
test("canOpenDraft_active_resolved_channel_returns_true", () => {
const draft = activeDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canOpenDraft(draft, source),
true,
"active draft with resolved channel should be openable",
);
});
// ── (b) sent + resolved channel → NOT openable ───────────────────────────────
// Composer restores only active/thread keys; sent: keys cannot be restored.
// Sent subsection is Delete-only (Will-confirmed behavior).
test("canOpenDraft_sent_resolved_channel_returns_false", () => {
const draft = sentDraft("chan-1");
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canOpenDraft(draft, source),
false,
"sent draft should not be openable regardless of channel resolution",
);
});
// ── (c) active + null channel → NOT openable ─────────────────────────────────
// Channel left/archived/unknown: routing to an empty channel surface is a false affordance.
test("canOpenDraft_active_null_channel_returns_false", () => {
const draft = activeDraft("chan-gone");
const source = { channel: null, label: "Unknown channel" };
assert.equal(
canOpenDraft(draft, source),
false,
"active draft with unresolved channel (null) should not be openable",
);
});
// ── (d) active + empty channelId → NOT openable ──────────────────────────────
// Belt-and-suspenders: a draft with no channelId at all cannot be navigated to.
test("canOpenDraft_empty_channelId_returns_false", () => {
const draft = activeDraft("");
// channel stub present but channelId is empty — navigation would fail
const source = { channel: RESOLVED_CHANNEL, label: "#general" };
assert.equal(
canOpenDraft(draft, source),
false,
"draft with empty channelId should not be openable",
);
});
// ── (e) sent + null channel → NOT openable (doubly guarded) ──────────────────
test("canOpenDraft_sent_null_channel_returns_false", () => {
const draft = sentDraft("chan-gone");
const source = { channel: null, label: "Unknown channel" };
assert.equal(
canOpenDraft(draft, source),
false,
"sent draft with unresolved channel should not be openable",
);
});
@@ -5,6 +5,7 @@ import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useDrafts } from "@/features/messages/lib/useDrafts";
import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey";
import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
@@ -51,6 +52,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useMentionSendFlow } from "./useMentionSendFlow";
import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
type MessageComposerProps = {
channelId?: string | null;
@@ -156,6 +158,8 @@ function MessageComposerImpl({
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
Set<string>
>(() => new Set());
const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls);
spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls;
const handleFormattingToggle = React.useCallback((pressed: boolean) => {
if (pressed) setIsEmojiPickerOpen(false);
@@ -164,7 +168,6 @@ function MessageComposerImpl({
const drafts = useDrafts();
const effectiveDraftKey = draftKey ?? channelId;
const previousDraftKeyRef = React.useRef<string | null>(null);
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
effectiveDraftKeyRef.current = effectiveDraftKey;
// Snapshot composer state before edit mode so cancel can restore it.
@@ -191,6 +194,38 @@ function MessageComposerImpl({
const media = mediaController ?? internalMedia;
const ownsDropZone = mediaController === undefined;
// Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on
// key change, and persist the outgoing draft in the cleanup. The StrictMode
// fix lives inside this hook — see useDraftPersistSnapshot.ts.
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
}, [effectiveDraftKey]);
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
@@ -298,40 +333,6 @@ function MessageComposerImpl({
setSpoileredAttachmentUrls,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
const prevKey = previousDraftKeyRef.current;
if (prevKey) {
drafts.persistDraft(prevKey, syncComposerContentFromEditor());
}
previousDraftKeyRef.current = effectiveDraftKey;
const saved = effectiveDraftKey
? drafts.loadDraft(effectiveDraftKey)
: undefined;
if (saved) {
setComposerContent(saved.content);
richText.setContent(saved.content);
} else {
setComposerContent("");
richText.clearContent();
}
media.setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
return () => {
if (effectiveDraftKey) {
drafts.persistDraft(effectiveDraftKey, syncComposerContentFromEditor());
}
};
}, [effectiveDraftKey]);
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
React.useEffect(() => {
if (editTarget) {
@@ -600,7 +601,15 @@ function MessageComposerImpl({
capturedChannelId: channelId,
capturedThreadContext,
pendingImeta: currentPendingImeta,
sentDraftKey: effectiveDraftKeyRef.current,
// resolveSentDraftKey checks at submit time (synchronously, before any
// await) whether a draft was actually persisted. If not — fast/
// never-persisted send — it returns null so no sent record is written.
// The function is exported and tested directly in
// MessageComposerDraftPredicate.test.mjs.
sentDraftKey: resolveSentDraftKey(
effectiveDraftKeyRef.current,
drafts.loadDraft,
),
spoileredAttachmentUrls,
trimmed,
});
@@ -608,6 +617,7 @@ function MessageComposerImpl({
channelId,
channelLinks.clearChannels,
customEmoji,
drafts.loadDraft,
emojiAutocomplete.clearEmojis,
media.pendingImetaRef,
media.setPendingImeta,
@@ -0,0 +1,402 @@
/**
* Regression test: draft images survive the top-level nav switch under
* React StrictMode.
*
* Background
* Bug: navigate Channel A Inbox back to Channel A. Images in the draft
* were lost; text survived. Root cause: React StrictMode double-invokes effects
* on mount (body cleanup body). The restore effect body called
* `media.setPendingImeta([image])` (async state update) then returned.
* StrictMode's simulate-unmount fired the cleanup BEFORE React committed the
* state update. `media.pendingImetaRef.current` was still `[]` at that point,
* so the cleanup called `persistDraftEntry(key, text, channel, [])`
* overwriting the correctly-saved `[image]` with an empty list. The second
* effect body then loaded the now-corrupted draft.
*
* Fix
* `useDraftPersistLifecycle` (in useDraftPersistSnapshot.ts, extracted from
* `MessageComposer`) owns the full restore/persist lifecycle. Inside the
* effect body it writes `pendingImetaForPersistRef.current = saved.pendingImeta`
* SYNCHRONOUSLY before the async `setPendingImeta` call. Because the write
* is synchronous (same microtask as the effect body), the cleanup closure
* always sees the restored value even when StrictMode fires the
* simulate-unmount before React commits the state update.
*
* What this test does
* We import and mount `useDraftPersistLifecycle` the REAL production hook
* inside `<React.StrictMode>`. The harness component calls the hook directly
* and provides thin stub collaborators. The hook owns the effect body and
* cleanup; the harness does NOT replicate the restore/persist logic.
*
* **Hard requirement**: removing the synchronous
* `pendingImetaForPersistRef.current = saved.pendingImeta` write from the
* production hook's effect body causes test 1 to fail (imageCount 1 0),
* because the cleanup reads the stale `[]` and overwrites the saved draft.
* This was verified in isolation before commit.
*
* StrictMode requirement
* React strips StrictMode effect double-invocation in production builds.
* This bug was reproduced in a dev build (`just desktop-dev`) where StrictMode
* is active. This test MUST run under `<React.StrictMode>` to be meaningful;
* a plain mount would pass regardless of the fix.
*
* CI surface
* Runs under `pnpm test` (node:test with the React dev build). Not Playwright.
* A packaged-build E2E would not reproduce the bug.
*/
import assert from "node:assert/strict";
import test from "node:test";
// ── Minimal DOM shim ─────────────────────────────────────────────────────────
// react-dom/client requires a small subset of the DOM API. We provide exactly
// what createRoot + commit need, without pulling in jsdom (not a project dep).
function installDOMShim() {
class MinimalEventTarget {
constructor() {
this._listeners = {};
}
addEventListener(type, fn) {
if (!this._listeners[type]) {
this._listeners[type] = [];
}
this._listeners[type].push(fn);
}
removeEventListener(type, fn) {
if (this._listeners[type]) {
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
}
}
dispatchEvent(e) {
const listeners = this._listeners[e.type] ?? [];
for (const fn of listeners) {
fn(e);
}
return true;
}
}
class MinimalNode extends MinimalEventTarget {
constructor(tagName) {
super();
this.tagName = tagName;
this.children = [];
this.childNodes = [];
this.style = {};
this.nodeType = 1;
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children[this.children.length - 1] ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
this.childNodes = this.childNodes.filter((c) => c !== child);
return child;
}
insertBefore(newNode, refNode) {
if (!refNode) return this.appendChild(newNode);
const i = this.children.indexOf(refNode);
if (i < 0) return this.appendChild(newNode);
this.children.splice(i, 0, newNode);
this.childNodes.splice(i, 0, newNode);
newNode.parentNode = this;
return newNode;
}
contains(node) {
if (!node) return false;
return this === node || this.children.some((c) => c?.contains?.(node));
}
}
class MinimalDocument extends MinimalEventTarget {
constructor() {
super();
this.nodeType = 9;
}
createElement(tagName) {
return new MinimalNode(tagName);
}
createTextNode(value) {
const n = new MinimalNode("#text");
n.nodeValue = value;
n.nodeType = 3;
return n;
}
createComment(value) {
const n = new MinimalNode("#comment");
n.nodeValue = value;
n.nodeType = 8;
return n;
}
get body() {
if (!this._body) {
this._body = this.createElement("body");
}
return this._body;
}
get activeElement() {
return null;
}
contains(node) {
return node != null;
}
}
globalThis.document = new MinimalDocument();
// HTMLIFrameElement is referenced in react-dom's getActiveElementDeep; stub it.
globalThis.HTMLIFrameElement = MinimalNode;
globalThis.HTMLElement = MinimalNode;
// react uses IS_REACT_ACT_ENVIRONMENT to enable act() in non-browser envs.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
if (typeof globalThis.window === "undefined") {
Object.defineProperty(globalThis, "window", {
value: globalThis,
configurable: true,
});
}
if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) {
Object.defineProperty(globalThis, "navigator", {
value: { userAgent: "node" },
configurable: true,
});
}
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
};
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
}
installDOMShim();
// ── localStorage 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();
Object.defineProperty(globalThis, "localStorage", {
get: () => ls,
configurable: true,
});
return ls;
}
installFreshLocalStorage();
// ── Imports ───────────────────────────────────────────────────────────────────
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
// Production hook under test — owns the restore effect, cleanup, and the
// synchronous ref write that is the StrictMode fix.
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts";
// Real storage functions — the test uses them, not a replica.
import {
clearAllDrafts,
initDraftStore,
loadDraftEntry,
persistDraftEntry,
} from "../lib/useDrafts.ts";
// ── Helpers ───────────────────────────────────────────────────────────────────
const IMG_A = {
url: "https://cdn.example.com/img-a.jpg",
sha256: "aabbccdd",
size: 1024,
type: "image/jpeg",
uploaded: 0,
};
function setupStore(pubkey) {
installFreshLocalStorage();
clearAllDrafts();
initDraftStore(pubkey);
}
async function mountStrictMode(Comp) {
const container = document.createElement("div");
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(React.StrictMode, null, React.createElement(Comp)),
);
});
return {
unmount: async () => {
await act(async () => {
root.unmount();
});
},
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
/**
* Test 1: the FIXED path.
*
* Mounts a thin harness component that calls the REAL `useDraftPersistLifecycle`
* hook under `<React.StrictMode>`. The hook owns the effect body and cleanup
* the harness provides stub collaborators but does NOT replicate any of the
* restore/persist logic.
*
* The hook's own effect body writes `pendingImetaForPersistRef.current =
* saved.pendingImeta` synchronously before `setPendingImeta`. StrictMode's
* simulate-unmount cleanup fires before `setPendingImeta` commits, but reads
* the correct `[IMG_A]` from the ref.
*
* **Revert verification (confirmed before commit)**: removing the synchronous
* `pendingImetaForPersistRef.current = saved.pendingImeta` line from the
* production hook's effect body causes imageCount 1 0 and this test fails.
*/
test("strictmode_draft_restore_cleanup_preserves_images_via_production_hook", async () => {
const DRAFT_KEY = "chan-lifecycle-fixed";
setupStore("pubkey-lifecycle-fixed");
// Seed: saved draft has an image.
persistDraftEntry(DRAFT_KEY, "hello from A", DRAFT_KEY, [IMG_A], []);
assert.equal(
loadDraftEntry(DRAFT_KEY)?.pendingImeta.length,
1,
"precondition: store has the image",
);
// `asyncState` simulates media.pendingImeta: starts at [] on fresh mount
// (no committed state yet — the state update from setPendingImeta hasn't
// committed before StrictMode's simulate-unmount fires).
let asyncState = [];
const spoileredRef = { current: new Set() };
function HarnessComposer() {
// The hook owns all draft-persist lifecycle — no restore/cleanup logic here.
useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: (key) => loadDraftEntry(key),
persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => {
persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls);
},
livePendingImeta: asyncState,
setPendingImeta: (imeta) => {
asyncState = imeta; // async — won't commit before StrictMode cleanup
},
setContent: () => {},
clearContent: () => {},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => "hello from A",
});
return null;
}
const handle = await mountStrictMode(HarnessComposer);
// After StrictMode double-invoke, the store must still contain the image.
// If the synchronous ref write were removed from the production hook,
// the first cleanup would persist [] and this assertion fails (imageCount=0).
const afterMount = loadDraftEntry(DRAFT_KEY);
assert.ok(afterMount, "draft must still exist after StrictMode mount");
assert.equal(
afterMount.pendingImeta.length,
1,
"image must survive StrictMode simulate-unmount cleanup — requires the synchronous ref write in useDraftPersistLifecycle's effect body",
);
assert.equal(afterMount.pendingImeta[0].url, IMG_A.url);
await handle.unmount();
});
/**
* Test 2: no-draft path clears correctly under StrictMode.
*
* When there is no saved draft for the key, the hook takes the `else` branch
* and sets `pendingImetaForPersistRef.current = []`. The cleanup persists `[]`.
* Verifies the else-branch synchronous write is also correct under StrictMode.
*/
test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => {
const DRAFT_KEY = "chan-lifecycle-nodraft";
setupStore("pubkey-lifecycle-nodraft");
// No draft seeded — loadDraftEntry returns undefined.
const spoileredRef = { current: new Set() };
function HarnessComposer() {
useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: (key) => loadDraftEntry(key),
persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => {
persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls);
},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: () => {},
clearContent: () => {},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => "",
});
return null;
}
const handle = await mountStrictMode(HarnessComposer);
// No draft existed; the hook took the else branch. Cleanup writes an empty
// entry (or skips if key is falsy, but DRAFT_KEY is defined here).
const afterMount = loadDraftEntry(DRAFT_KEY);
const imageCount = afterMount?.pendingImeta?.length ?? 0;
assert.equal(
imageCount,
0,
"no-draft path: cleanup must persist empty imeta, not a stale value",
);
await handle.unmount();
});
@@ -0,0 +1,215 @@
/**
* Regression tests for the submit-time draft-persistence predicate used by
* MessageComposer's `submitMessage` handler.
*
* The predicate lives in `resolveSentDraftKey` (draftSubmitKey.ts), which
* MessageComposer calls directly. These tests import and exercise the ACTUAL
* exported function not a restatement of its logic so a regression at
* the call site (e.g. reverting to the inline expression or removing the call)
* is a conscious change, and a logic regression inside the function breaks
* these tests immediately.
*
* Three properties under test:
* (a) never-persisted key null (fast send must not produce a sent record)
* (b) persisted key key (normal send produces a sent record)
* (c) submit-time capture semantics: the value returned at submit time is
* stable even if the store entry is cleared before send success (proving
* the predicate is evaluated once at submit, not re-read at success).
*
* Integration scenarios (d)+(e) drive the full storage flow to confirm
* that the predicate output correctly gates markDraftSentEntry.
*/
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();
// ── Imports ───────────────────────────────────────────────────────────────────
import { resolveSentDraftKey } from "./draftSubmitKey.ts";
import {
clearAllDrafts,
getSentDraftEntries,
initDraftStore,
loadDraftEntry,
markDraftSentEntry,
persistDraftEntry,
} from "../lib/useDrafts.ts";
function setup(pubkey = "pubkey-predicate") {
installFreshLocalStorage();
clearAllDrafts();
initDraftStore(pubkey);
}
const IMG_A = {
url: "https://cdn.example.com/a.jpg",
sha256: "aabbccdd",
size: 1024,
type: "image/jpeg",
uploaded: 0,
};
// ── (a) resolveSentDraftKey: never-persisted key → null ───────────────────────
// If this test breaks, the function no longer gates fast/never-persisted sends.
test("resolveSentDraftKey_unpersisted_key_returns_null", () => {
setup("pubkey-resolver-fast");
const draftKey = "chan-fast";
// Store has no entry for this key.
const result = resolveSentDraftKey(draftKey, loadDraftEntry);
assert.equal(
result,
null,
"unpersisted key → resolveSentDraftKey returns null",
);
});
// ── (b) resolveSentDraftKey: persisted key → key ──────────────────────────────
test("resolveSentDraftKey_persisted_key_returns_key", () => {
setup("pubkey-resolver-normal");
const draftKey = "chan-normal";
persistDraftEntry(draftKey, "hello", draftKey, [], []);
const result = resolveSentDraftKey(draftKey, loadDraftEntry);
assert.equal(
result,
draftKey,
"persisted key → resolveSentDraftKey returns the key",
);
});
// ── (c) resolveSentDraftKey: null/undefined effectiveDraftKey → null ──────────
test("resolveSentDraftKey_null_effectiveKey_returns_null", () => {
setup("pubkey-resolver-null");
const result = resolveSentDraftKey(null, loadDraftEntry);
assert.equal(result, null, "null effectiveDraftKey → null");
const result2 = resolveSentDraftKey(undefined, loadDraftEntry);
assert.equal(result2, null, "undefined effectiveDraftKey → null");
});
// ── (d) Integration: never-persisted send → no sent record ───────────────────
// Simulates submitMessage calling resolveSentDraftKey before the send:
// the resolver returns null → markDraftSentEntry is never called.
test("submit_predicate_never_persisted_send_produces_no_sent_record", () => {
setup("pubkey-fast-send");
const draftKey = "chan-fast-integration";
// Composer calls resolveSentDraftKey at submit time — store has no entry.
const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry);
assert.equal(sentDraftKey, null, "resolver returns null for fast send");
// markDraftSentEntry is never called (sentDraftKey is null → gate in
// useMentionSendFlow.ts:399 fires false). No sent record.
assert.equal(getSentDraftEntries().length, 0, "no sent record for fast send");
});
// ── (e) Integration: persisted draft → sent record written ───────────────────
test("submit_predicate_persisted_draft_produces_sent_record", () => {
setup("pubkey-normal-send");
const draftKey = "chan-normal-integration";
// Debounce persists the draft before submit.
persistDraftEntry(draftKey, "my draft content", draftKey, [], []);
// Composer calls resolveSentDraftKey at submit time.
const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry);
assert.equal(
sentDraftKey,
draftKey,
"resolver returns key for persisted draft",
);
// Send succeeds — markDraftSentEntry called with the captured key.
markDraftSentEntry(draftKey, "my draft content", draftKey, [], []);
const sent = getSentDraftEntries();
assert.equal(sent.length, 1, "sent record created");
assert.equal(sent[0].draft.content, "my draft content");
assert.equal(sent[0].draft.status, "sent");
});
// ── (f) Integration: persisted draft + race → sent record still written ───────
// Simulates: persist → resolveSentDraftKey captures key at submit time →
// navigation race clears the active entry → send succeeds with captured key.
// markDraftSentEntry writes unconditionally → sent record exists with snapshot.
test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written", () => {
setup("pubkey-race-send");
const draftKey = "chan-race-pred";
// Step 1: debounce persists the draft.
persistDraftEntry(draftKey, "race content", draftKey, [IMG_A], []);
// Step 2: resolver captures the key at submit time.
const sentDraftKey = resolveSentDraftKey(draftKey, loadDraftEntry);
assert.equal(sentDraftKey, draftKey, "resolver captures key at submit time");
// Step 3: navigation-during-send race — active key cleared by composer cleanup.
persistDraftEntry(draftKey, "", draftKey, [], []); // empty persist → clearDraftEntry
assert.equal(
loadDraftEntry(draftKey),
undefined,
"active key cleared by race before send success",
);
// Step 4: send succeeds; markDraftSentEntry called with captured sentDraftKey.
markDraftSentEntry(sentDraftKey, "race content", draftKey, [IMG_A], []);
const sent = getSentDraftEntries();
assert.equal(
sent.length,
1,
"sent record written despite active key being cleared",
);
assert.equal(
sent[0].draft.content,
"race content",
"snapshot content preserved",
);
assert.equal(
sent[0].draft.pendingImeta.length,
1,
"snapshot image preserved",
);
assert.equal(sent[0].draft.status, "sent");
});
@@ -0,0 +1,22 @@
/**
* Resolves the sentDraftKey to pass to sendMessageWithMentionFlow at submit
* time.
*
* Returns the key when a draft was actually persisted in the store before the
* send fired (so a sent record should be written), or null when no entry
* exists (fast / never-persisted send no sent record should be written).
*
* This is a pure function with no dependencies so it can be imported and
* exercised directly in Node .mjs tests without a React renderer.
*
* @param effectiveDraftKey - the draft key captured synchronously at submit time
* @param loadDraft - synchronous O(1) store read; returns undefined when absent
*/
export function resolveSentDraftKey(
effectiveDraftKey: string | null | undefined,
loadDraft: (key: string) => unknown,
): string | null {
return effectiveDraftKey && loadDraft(effectiveDraftKey)
? effectiveDraftKey
: null;
}
@@ -0,0 +1,122 @@
import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { DraftState } from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
effectiveDraftKey: string | null | undefined;
channelId: string | null | undefined;
/** Load a saved draft from the store. */
loadDraft: (draftKey: string) => DraftState | undefined;
/** Persist the current draft to the store (called in effect cleanup). */
persistDraft: (
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
) => void;
/** Live `pendingImeta` from React state — used for render-time ref sync. */
livePendingImeta: ImetaMedia[];
/** Async setter for pendingImeta — called after the synchronous snapshot. */
setPendingImeta: (imeta: ImetaMedia[]) => void;
/** Set the rich-text editor content from a draft string. */
setContent: (content: string) => void;
/** Clear the rich-text editor content (no-draft path). */
clearContent: () => void;
/** Set the spoilered attachment URLs state. */
setSpoileredAttachmentUrls: (urls: Set<string>) => void;
/**
* Stable ref to the spoilered attachment URLs read in the cleanup closure
* so it always captures the latest value at cleanup time.
*/
spoileredAttachmentUrlsRef: React.MutableRefObject<Set<string>>;
/**
* Read the current editor content synchronously called in the cleanup
* closure to capture the latest text before the effect fires.
*/
syncComposerContentFromEditor: () => string;
};
/**
* Owns the draft-persist lifecycle for `MessageComposer`.
*
* This hook:
* - Holds `pendingImetaForPersistRef` the ref the cleanup reads when
* persisting `pendingImeta` to the draft store.
* - Updates that ref on every render (render-time passive path) so normal
* add/remove-image operations are always captured.
* - Runs a `useEffect` keyed on `effectiveDraftKey` that restores a saved
* draft into the composer (content + imeta + spoilered urls) or clears it,
* and whose cleanup persists the outgoing draft before the key changes.
*
* **The StrictMode fix lives here.**
* When the restore effect body calls `setPendingImeta(saved.pendingImeta)`,
* that state update is async it won't commit until React re-renders.
* React StrictMode (dev builds) simulates an unmount immediately after the
* effect body, before the re-render. Without the synchronous write the
* cleanup would read `[]` and overwrite the just-restored images.
*
* The effect body calls `snapshotPendingImeta` (the synchronous ref write)
* BEFORE `setPendingImeta`, so the cleanup always sees the correct value.
*
* Extracted from `MessageComposer` so the full lifecycle can be exercised
* directly in a StrictMode test without mounting the full composer.
*/
export function useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft,
persistDraft,
livePendingImeta,
setPendingImeta,
setContent,
clearContent,
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
}: UseDraftPersistLifecycleParams): void {
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
// Render-time update: keep the ref in sync with committed state so the
// cleanup always reads the latest value during normal mounted operation.
pendingImetaForPersistRef.current = livePendingImeta;
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
// The outgoing draft is persisted by the cleanup below, which runs before
// this body on key changes and has the correct outgoing channelId in its
// closure. Do NOT re-persist prevKey here: channelId in this render
// already reflects the incoming channel, which would corrupt the outgoing
// draft's channelId metadata.
const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined;
if (saved) {
setContent(saved.content);
// Set the persist-snapshot ref SYNCHRONOUSLY before calling the async
// state setter, so the cleanup closure (which may fire before the state
// update commits in React StrictMode's simulate-unmount pass) reads the
// correct value instead of the stale [].
pendingImetaForPersistRef.current = saved.pendingImeta;
setPendingImeta(saved.pendingImeta);
setSpoileredAttachmentUrls(new Set(saved.spoileredAttachmentUrls));
} else {
clearContent();
// Same synchronous snapshot on the empty path.
pendingImetaForPersistRef.current = [];
setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
}
return () => {
if (effectiveDraftKey) {
persistDraft(
effectiveDraftKey,
syncComposerContentFromEditor(),
channelId ?? effectiveDraftKey,
[...pendingImetaForPersistRef.current],
[...spoileredAttachmentUrlsRef.current],
);
}
};
}, [effectiveDraftKey]);
}
@@ -63,7 +63,7 @@ type UseMentionSendFlowOptions = {
channelType: ChannelType | null;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "clearDraft">;
drafts: Pick<UseDraftsResult, "markDraftSent">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentions: UseMentionsResult;
onSendRef: React.MutableRefObject<
@@ -397,7 +397,13 @@ export function useMentionSendFlow({
draft.capturedThreadContext,
);
if (draft.sentDraftKey) {
drafts.clearDraft(draft.sentDraftKey);
drafts.markDraftSent(
draft.sentDraftKey,
draft.savedContent,
draft.capturedChannelId ?? draft.sentDraftKey,
draft.savedImeta,
[...draft.savedSpoileredAttachmentUrls],
);
}
} catch {
// Only restore the composer content if the user is still on the
@@ -8,7 +8,10 @@ import {
} from "@/shared/api/tauri";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import {
clearAllDrafts,
initDraftStore,
} from "@/features/messages/lib/useDrafts";
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
@@ -156,6 +159,11 @@ export function useWorkspaceInit(
}
if (!cancelled) {
// Initialise the draft store for this identity so localStorage drafts
// are scoped to the correct pubkey before the app renders.
if (activeWorkspace.pubkey) {
initDraftStore(activeWorkspace.pubkey);
}
setResult({
isReady: true,
needsSetup: false,
@@ -0,0 +1,270 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/drafts";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
// Channel IDs from the mock bridge seed data
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
// Fixed timestamps for deterministic rendering
const CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
const CREATED_AT_3 = "2026-07-03T09:15:00.000Z";
const CREATED_AT_SENT = "2026-07-04T16:45:00.000Z";
type StoredDraftState = {
content: string;
selectionStart: number;
selectionEnd: number;
channelId: string;
createdAt: string;
updatedAt: string;
pendingImeta: unknown[];
spoileredAttachmentUrls: string[];
status: "active" | "sent";
};
type StoredDrafts = Record<string, StoredDraftState>;
/** Active drafts: text draft in #general, image-only draft in #agents, long text draft. */
const ACTIVE_DRAFTS: StoredDrafts = {
[`channel:${GENERAL_CHANNEL_ID}`]: {
content:
"Hey team — I've been working on the new onboarding flow. Check out the latest mockups when you get a chance!",
selectionStart: 107,
selectionEnd: 107,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
[`channel:${AGENTS_CHANNEL_ID}`]: {
// Image-only draft — exercises the "1 attachment" fallback in getDraftPreview
content: "",
selectionStart: 0,
selectionEnd: 0,
channelId: AGENTS_CHANNEL_ID,
createdAt: CREATED_AT_2,
updatedAt: CREATED_AT_2,
pendingImeta: [
{
url: "https://example.com/screenshot.png",
sha256: "abc123",
size: 204800,
type: "image/png",
dim: "1280x900",
},
],
spoileredAttachmentUrls: [],
status: "active",
},
};
/** Active drafts + one sent record for shots that need both subsections. */
const ACTIVE_AND_SENT_DRAFTS: StoredDrafts = {
...ACTIVE_DRAFTS,
[`sent:channel:${GENERAL_CHANNEL_ID}:1720115100000-1`]: {
content:
"Shipping the draft message improvements in PR #1539 — image persistence, sent records, and the new Drafts inbox section.",
selectionStart: 119,
selectionEnd: 119,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_3,
updatedAt: CREATED_AT_SENT,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "sent",
},
};
/**
* Patch the mock workspace to include the pubkey so initDraftStore gets the
* correct pubkey on app startup. The workspace is seeded by installMockBridge
* without a pubkey field; this addInitScript runs after that seed (init
* scripts execute in registration order) and adds it.
*/
async function patchWorkspacePubkey(page: import("@playwright/test").Page) {
await page.addInitScript(
({ pubkey }) => {
const raw = window.localStorage.getItem("buzz-workspaces");
const workspaces = raw
? (JSON.parse(raw) as Array<Record<string, unknown>>)
: [];
if (workspaces[0]) {
workspaces[0].pubkey = pubkey;
window.localStorage.setItem(
"buzz-workspaces",
JSON.stringify(workspaces),
);
}
},
{ pubkey: MOCK_PUBKEY },
);
}
/** Seed draft localStorage before page load via addInitScript. */
async function seedDraftStore(
page: import("@playwright/test").Page,
drafts: StoredDrafts,
) {
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{ storeKey: DRAFT_STORE_KEY, value: drafts },
);
}
/** Navigate to `/`, wait for inbox, then select the Drafts filter. */
async function openDraftsPanel(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
// Dismiss the dropdown so it doesn't obscure the panel assertions.
await page.keyboard.press("Escape");
const panel = page.getByTestId("home-inbox-drafts");
await expect(panel).toBeVisible({ timeout: 8_000 });
return panel;
}
test.describe("drafts screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — drafts section populated", async ({ page }) => {
await installMockBridge(page);
await patchWorkspacePubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Both active draft rows should be visible
const draftRows = panel.locator("[data-testid^='home-draft-item-']");
await expect(draftRows).toHaveCount(2, { timeout: 6_000 });
// The text draft row shows content
await expect(
panel.getByText(
"Hey team — I've been working on the new onboarding flow.",
),
).toBeVisible({ timeout: 5_000 });
// The image-only draft shows the attachment fallback
await expect(panel.getByText("1 attachment")).toBeVisible({
timeout: 5_000,
});
// Section heading should be "DRAFTS"
await expect(panel.getByText("Drafts", { exact: true })).toBeVisible();
// Small settle before screenshot
await page.waitForTimeout(200);
await panel.screenshot({
path: `${SHOTS}/01-drafts-section-populated.png`,
});
});
test("02 — sent subsection visible", async ({ page }) => {
await installMockBridge(page);
await patchWorkspacePubkey(page);
await seedDraftStore(page, ACTIVE_AND_SENT_DRAFTS);
const panel = await openDraftsPanel(page);
// Both subsection headings should render
await expect(panel.getByText("Drafts", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(panel.getByText("Sent", { exact: true })).toBeVisible({
timeout: 5_000,
});
// At least one row in each subsection
const draftRows = panel.locator("[data-testid^='home-draft-item-']");
await expect(draftRows).toHaveCount(3, { timeout: 6_000 });
// The sent draft content should appear
await expect(
panel.getByText("Shipping the draft message improvements", {
exact: false,
}),
).toBeVisible({ timeout: 5_000 });
await page.waitForTimeout(200);
await panel.screenshot({ path: `${SHOTS}/02-sent-subsection.png` });
});
test("03 — hover actions visible", async ({ page }) => {
await installMockBridge(page);
await patchWorkspacePubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Wait for the text draft row
const textDraftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await expect(textDraftRow).toBeVisible({ timeout: 6_000 });
// Hover to reveal action buttons
await textDraftRow.hover();
// Both action buttons should become visible on hover
const openDraftBtn = textDraftRow.getByRole("button", {
name: "Open draft",
exact: true,
});
const deleteDraftBtn = textDraftRow.getByRole("button", {
name: "Delete draft",
});
await expect(openDraftBtn).toBeVisible({ timeout: 4_000 });
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
await page.waitForTimeout(200);
await panel.screenshot({ path: `${SHOTS}/03-hover-actions.png` });
});
test("04 — empty state", async ({ page }) => {
await installMockBridge(page);
// No draft seed → empty state
const panel = await openDraftsPanel(page);
// Empty state: FileText icon + "No drafts" text
await expect(panel.getByText("No drafts")).toBeVisible({ timeout: 5_000 });
await page.waitForTimeout(200);
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
});