mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
3729a65156
commit
4c598dcef5
@@ -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"],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -23,7 +23,8 @@ export type InboxFilter =
|
||||
| "needs_action"
|
||||
| "activity"
|
||||
| "agent_activity"
|
||||
| "reminders";
|
||||
| "reminders"
|
||||
| "drafts";
|
||||
|
||||
export type InboxItem = {
|
||||
avatarUrl: string | null;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user