mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(drafts): stop clearing drafts on workspace switch and hide sent section (#1708)
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
177fc30bea
commit
e2d104f669
@@ -433,12 +433,12 @@ test("initDraftStore_resets_cache_on_pubkey_change_without_clearAllDrafts", () =
|
||||
);
|
||||
});
|
||||
|
||||
// ── 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.
|
||||
// ── markDraftSentEntry: clears active draft (no longer writes sent records) ──
|
||||
// markDraftSentEntry now delegates to clearDraftEntry — it clears the active
|
||||
// draft and writes nothing to the store. The sent-section UI was removed;
|
||||
// these tests verify the new contract.
|
||||
|
||||
test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key", () => {
|
||||
test("markDraftSent_clears_active_draft_and_writes_no_sent_record", () => {
|
||||
setup();
|
||||
persistDraftEntry("chan-1", "sent message content", "chan-1", [IMG_A], []);
|
||||
markDraftSentEntry("chan-1", "sent message content", "chan-1", [IMG_A], []);
|
||||
@@ -448,185 +448,67 @@ test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key
|
||||
undefined,
|
||||
"active key must be cleared after markDraftSent",
|
||||
);
|
||||
// Sent record must exist under a sent: key.
|
||||
// No sent records written.
|
||||
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",
|
||||
);
|
||||
assert.equal(sent.length, 0, "no sent entries written");
|
||||
// All-entries also empty.
|
||||
assert.equal(getAllDraftEntries().length, 0, "store is empty after send");
|
||||
});
|
||||
|
||||
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).
|
||||
test("markDraftSent_is_a_no_op_when_active_key_absent", () => {
|
||||
setup();
|
||||
// Call without any prior persistDraftEntry — simulates the race where the
|
||||
// active key was deleted by a composer cleanup before markDraftSent ran.
|
||||
// active key was already cleared 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");
|
||||
// Still no sent record written.
|
||||
assert.equal(getSentDraftEntries().length, 0, "no sent entries");
|
||||
assert.equal(getAllDraftEntries().length, 0, "store still empty");
|
||||
});
|
||||
|
||||
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.
|
||||
// getActiveDraftEntries() — markDraftSent must not affect it.
|
||||
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");
|
||||
// No sent records.
|
||||
assert.equal(getSentDraftEntries().length, 0, "no sent records");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
test("getActiveDraftEntries_excludes_cleared_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.length, 1, "only one active draft remains");
|
||||
assert.equal(active[0].key, "chan-active");
|
||||
assert.equal(active[0].draft.status, "active");
|
||||
});
|
||||
|
||||
test("getSentDraftEntries_returns_only_sent_drafts", () => {
|
||||
test("getSentDraftEntries_returns_empty_after_markDraftSent", () => {
|
||||
setup("pubkey-sent");
|
||||
persistDraftEntry("chan-active2", "still drafting", "chan-active2", [], []);
|
||||
persistDraftEntry("chan-sent2", "already sent", "chan-sent2", [], []);
|
||||
markDraftSentEntry("chan-sent2", "already sent", "chan-sent2", [], []);
|
||||
// markDraftSentEntry now just clears the active entry — no sent record.
|
||||
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();
|
||||
assert.equal(sent.length, 0, "no sent entries");
|
||||
// The remaining active draft is still there.
|
||||
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",
|
||||
);
|
||||
assert.equal(active.length, 1, "one active draft");
|
||||
assert.equal(active[0].key, "chan-active2");
|
||||
});
|
||||
|
||||
// ── status migration: pre-status entries read as "active" ────────────────────
|
||||
@@ -682,3 +564,31 @@ test("pre_status_entry_appears_in_getActiveDraftEntries_after_migration", () =>
|
||||
assert.equal(active[0].key, "chan-old");
|
||||
assert.equal(active[0].draft.status, "active");
|
||||
});
|
||||
|
||||
test("pre_status_sent_entry_is_dropped_on_read", () => {
|
||||
// Entries previously written with status "sent" (by the old markDraftSentEntry)
|
||||
// used a "sent:" key prefix. readStore now skips those keys entirely so legacy
|
||||
// sent records cannot resurface as ghost drafts.
|
||||
setup("pubkey-normalise");
|
||||
const oldSentEntry = {
|
||||
content: "a message I sent a while ago",
|
||||
selectionStart: 27,
|
||||
selectionEnd: 27,
|
||||
channelId: "chan-z",
|
||||
createdAt: "2025-09-01T00:00:00.000Z",
|
||||
updatedAt: "2025-09-01T00:00:00.000Z",
|
||||
pendingImeta: [],
|
||||
spoileredAttachmentUrls: [],
|
||||
status: "sent",
|
||||
};
|
||||
localStorage.setItem(
|
||||
"buzz-drafts.v1:pubkey-normalise",
|
||||
JSON.stringify({ "sent:chan-z:1725148800000-1": oldSentEntry }),
|
||||
);
|
||||
clearAllDrafts();
|
||||
initDraftStore("pubkey-normalise");
|
||||
// The sent: key is skipped — the entry must NOT appear as an active draft.
|
||||
const active = getActiveDraftEntries();
|
||||
assert.equal(active.length, 0, "old sent: entry is dropped, not promoted");
|
||||
assert.equal(getSentDraftEntries().length, 0, "no entries read as sent");
|
||||
});
|
||||
|
||||
@@ -59,11 +59,11 @@ export type DraftState = {
|
||||
/** 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.
|
||||
* Lifecycle status of this draft. Always `"active"` at runtime.
|
||||
* The `"sent"` value is not written by any production path; legacy `sent:`
|
||||
* keyed records from older builds are dropped on read by `readStore`.
|
||||
* Entries persisted before this field was added have no status field —
|
||||
* the read path treats absent status as "active" (see `isValidDraftState`).
|
||||
* the read path treats absent status as `"active"` (see `isValidDraftState`).
|
||||
*/
|
||||
status: "active" | "sent";
|
||||
};
|
||||
@@ -77,10 +77,6 @@ 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}`;
|
||||
}
|
||||
@@ -140,6 +136,12 @@ function readStore(): Map<string, DraftState> {
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
for (const [key, value] of Object.entries(parsed as StoredDrafts)) {
|
||||
// Drop legacy sent: records — they were written by the old
|
||||
// markDraftSentEntry and have no role now that the Sent section is
|
||||
// removed. Skipping here compacts them out on the next flush.
|
||||
if (key.startsWith("sent:")) {
|
||||
continue;
|
||||
}
|
||||
if (isValidDraftState(value)) {
|
||||
map.set(key, value);
|
||||
}
|
||||
@@ -169,11 +171,13 @@ function isValidDraftState(v: unknown): v is DraftState {
|
||||
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.
|
||||
// status field. Treat absent status as "active" to avoid data loss on the
|
||||
// first run after the upgrade.
|
||||
// Legacy sent: keys are skipped by readStore before reaching this function;
|
||||
// reject any remaining entry whose status is not "active".
|
||||
if (d.status === undefined || d.status === null) {
|
||||
(d as DraftState).status = "active";
|
||||
} else if (d.status !== "active" && d.status !== "sent") {
|
||||
} else if (d.status !== "active") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -291,7 +295,7 @@ export function getActiveDraftEntries(): Array<{
|
||||
|
||||
/**
|
||||
* Returns only sent drafts, sorted most-recently-updated first.
|
||||
* Used by the "Sent" subsection of the Drafts inbox panel.
|
||||
* Returns empty — sent records are dropped on read. Kept for test assertions.
|
||||
*/
|
||||
export function getSentDraftEntries(): Array<{
|
||||
key: string;
|
||||
@@ -301,60 +305,20 @@ export function getSentDraftEntries(): Array<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a draft as sent by writing its content to a durable sent-record key.
|
||||
* Clear the active draft entry for a sent draft.
|
||||
*
|
||||
* 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).
|
||||
* Kept as a named export so callers (`useMentionSendFlow`) don't need
|
||||
* updating. Previously wrote a visible sent-record to the store; the
|
||||
* sent section has been removed, so we now just clear the active draft.
|
||||
*/
|
||||
export function markDraftSentEntry(
|
||||
draftKey: string,
|
||||
content: string,
|
||||
channelId: string,
|
||||
pendingImeta: ImetaMedia[],
|
||||
spoileredAttachmentUrls: 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);
|
||||
notifySubscribers();
|
||||
clearDraftEntry(draftKey);
|
||||
}
|
||||
|
||||
// ── Reactive hooks ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
clearDraftEntry,
|
||||
getActiveDraftEntries,
|
||||
getSentDraftEntries,
|
||||
useDraftsSnapshot,
|
||||
type DraftState,
|
||||
} from "@/features/messages/lib/useDrafts";
|
||||
@@ -122,15 +121,11 @@ function getDraftPreview(draft: DraftState): string {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,7 @@ test("canOpenDraft_active_resolved_channel_returns_true", () => {
|
||||
});
|
||||
|
||||
// ── (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).
|
||||
// Composer restores only active/thread keys; sent: keys are dropped on read.
|
||||
|
||||
test("canOpenDraft_sent_resolved_channel_returns_false", () => {
|
||||
const draft = sentDraft("chan-1");
|
||||
|
||||
@@ -623,9 +623,9 @@ function MessageComposerImpl({
|
||||
pendingImeta: currentPendingImeta,
|
||||
// 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.
|
||||
// never-persisted send — it returns null so the active draft is not
|
||||
// cleared (nothing to clear). The function is exported and tested directly
|
||||
// in MessageComposerDraftPredicate.test.mjs.
|
||||
sentDraftKey: resolveSentDraftKey(
|
||||
effectiveDraftKeyRef.current,
|
||||
drafts.loadDraft,
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
* 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)
|
||||
* (a) never-persisted key → null (fast send: no active draft to clear)
|
||||
* (b) persisted key → key (key is captured at submit time)
|
||||
* (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
|
||||
* Integration scenarios (d)+(e)+(f) drive the full storage flow to confirm
|
||||
* that the predicate output correctly gates markDraftSentEntry.
|
||||
*/
|
||||
|
||||
@@ -125,7 +125,7 @@ test("resolveSentDraftKey_null_effectiveKey_returns_null", () => {
|
||||
assert.equal(result2, null, "undefined effectiveDraftKey → null");
|
||||
});
|
||||
|
||||
// ── (d) Integration: never-persisted send → no sent record ───────────────────
|
||||
// ── (d) Integration: never-persisted send → no active draft to clear ─────────
|
||||
// Simulates submitMessage calling resolveSentDraftKey before the send:
|
||||
// the resolver returns null → markDraftSentEntry is never called.
|
||||
|
||||
@@ -138,13 +138,17 @@ test("submit_predicate_never_persisted_send_produces_no_sent_record", () => {
|
||||
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");
|
||||
// useMentionSendFlow.ts:399 fires false). Active draft was never written.
|
||||
assert.equal(
|
||||
getSentDraftEntries().length,
|
||||
0,
|
||||
"no active draft for fast send",
|
||||
);
|
||||
});
|
||||
|
||||
// ── (e) Integration: persisted draft → sent record written ───────────────────
|
||||
// ── (e) Integration: persisted draft → active draft cleared on send ───────────
|
||||
|
||||
test("submit_predicate_persisted_draft_produces_sent_record", () => {
|
||||
test("submit_predicate_persisted_draft_clears_active_entry_on_send", () => {
|
||||
setup("pubkey-normal-send");
|
||||
const draftKey = "chan-normal-integration";
|
||||
|
||||
@@ -162,18 +166,21 @@ test("submit_predicate_persisted_draft_produces_sent_record", () => {
|
||||
// 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");
|
||||
// Active key is cleared; active draft removed.
|
||||
assert.equal(loadDraftEntry(draftKey), undefined, "active key cleared");
|
||||
assert.equal(
|
||||
getSentDraftEntries().length,
|
||||
0,
|
||||
"no active draft entries remain",
|
||||
);
|
||||
});
|
||||
|
||||
// ── (f) Integration: persisted draft + race → sent record still written ───────
|
||||
// ── (f) Integration: persisted draft + race → active key already gone, no-op ──
|
||||
// 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.
|
||||
// markDraftSentEntry now just calls clearDraftEntry — a no-op when key is gone.
|
||||
|
||||
test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written", () => {
|
||||
test("submit_predicate_persisted_then_race_clears_key_markDraftSent_is_noop", () => {
|
||||
setup("pubkey-race-send");
|
||||
const draftKey = "chan-race-pred";
|
||||
|
||||
@@ -193,23 +200,9 @@ test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written"
|
||||
);
|
||||
|
||||
// Step 4: send succeeds; markDraftSentEntry called with captured sentDraftKey.
|
||||
// Key is already gone — no-op.
|
||||
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");
|
||||
assert.equal(getSentDraftEntries().length, 0, "no entries in store");
|
||||
assert.equal(loadDraftEntry(draftKey), undefined, "active key still absent");
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 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).
|
||||
* send fired (so the active draft should be cleared on send), or null when no
|
||||
* entry exists (fast / never-persisted send — no active draft to clear).
|
||||
*
|
||||
* This is a pure function with no dependencies so it can be imported and
|
||||
* exercised directly in Node .mjs tests without a React renderer.
|
||||
|
||||
@@ -5,10 +5,7 @@ import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
|
||||
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
||||
import {
|
||||
clearAllDrafts,
|
||||
initDraftStore,
|
||||
} from "@/features/messages/lib/useDrafts";
|
||||
import { initDraftStore } from "@/features/messages/lib/useDrafts";
|
||||
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
|
||||
import {
|
||||
resetActiveAgentTurnsStore,
|
||||
@@ -41,7 +38,6 @@ function resetWorkspaceState(): void {
|
||||
resetVideoPlayerState();
|
||||
resetRenderScopedReactionHydration();
|
||||
clearSearchHitEventCache();
|
||||
clearAllDrafts();
|
||||
clearMarkdownNodeCache();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ 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;
|
||||
@@ -68,23 +66,6 @@ const ACTIVE_DRAFTS: StoredDrafts = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 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
|
||||
@@ -191,37 +172,6 @@ test.describe("drafts screenshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user