From a89eae5dce18bb3b604d354294cd9d5f4e2dfcd4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 31 Aug 2026 16:05:43 -0400 Subject: [PATCH] fix(desktop): share draft intent authority across composer visits Keep authored intent in the scoped draft store independently of value deletion. Supersede recovery, sent cleanup and lifecycle persistence across reentry and remount while preserving ordinary destination-bound sends and untouched optimistic recovery. Keep editability and automatic prefix restoration programmatic. Exercise the real store, React lifecycle transition matrix and retained browser editor. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson (cherry picked from commit ff140562c693147730d548582bc39a12d37ce210) Signed-off-by: Logan Johnson (cherry picked from commit 4b99bc705ebb8a6920c114620360433556bf88f9) Signed-off-by: Logan Johnson --- .../messages/lib/draftAuthority.test.mjs | 84 ++++ .../src/features/messages/lib/useDrafts.ts | 67 ++- .../messages/lib/useRichTextEditor.ts | 1 + .../messages/ui/useDraftPersistSnapshot.ts | 58 +-- .../ui/useMentionSendFlow.authority.test.mjs | 218 ++++++++++ .../useMentionSendFlow.cancellation.test.mjs | 367 +---------------- .../messages/ui/useMentionSendFlow.helpers.ts | 2 +- .../ui/useMentionSendFlow.test-support.mjs | 382 ++++++++++++++++++ .../messages/ui/useMentionSendFlow.ts | 26 +- .../messages/ui/useMentionSendFlow.types.ts | 2 +- .../tests/e2e/remote-owned-mentions.spec.ts | 131 ++++++ docs/remote-mention-routing.md | 19 +- 12 files changed, 955 insertions(+), 402 deletions(-) create mode 100644 desktop/src/features/messages/lib/draftAuthority.test.mjs create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs diff --git a/desktop/src/features/messages/lib/draftAuthority.test.mjs b/desktop/src/features/messages/lib/draftAuthority.test.mjs new file mode 100644 index 0000000000..e05a9d319d --- /dev/null +++ b/desktop/src/features/messages/lib/draftAuthority.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; +import { + claimDraftSend, + clearAllDrafts, + deleteDraftEntry, + getDraftAuthority, + initDraftStore, + loadDraftEntry, + persistDraftEntry, + recordDraftAuthoredContent, + saveDraftEntry, +} from "./useDrafts.ts"; + +beforeEach(() => { + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; + clearAllDrafts(); + initDraftStore("author", "wss://authority.example"); +}); + +test("shared authority remains readable when authored empty removes the record", () => { + const oldVisit = getDraftAuthority("A"); + persistDraftEntry("A", "original", "channel", [], []); + const revision = oldVisit.revision; + persistDraftEntry("A", "", "channel", [], []); // optimistic clear + assert.equal(oldVisit.revision, revision); + assert.equal(oldVisit.emptyContentIsAuthoritative, false); + assert.equal(loadDraftEntry("A"), undefined); + const newVisit = getDraftAuthority("A"); + assert.equal(oldVisit, newVisit); + recordDraftAuthoredContent("A", "new text"); + recordDraftAuthoredContent("A", ""); + assert.notEqual(oldVisit.revision, revision); + assert.equal(oldVisit.emptyContentIsAuthoritative, true); + assert.equal(loadDraftEntry("A"), undefined); + const afterClear = oldVisit.revision; + recordDraftAuthoredContent("B", "other draft"); + assert.equal(oldVisit.revision, afterClear); +}); + +test("explicit deletion supersedes an old attempt even if the value is absent", () => { + const authority = getDraftAuthority("A"); + claimDraftSend("A"); + const revision = authority.revision; + deleteDraftEntry("A"); + assert.equal(loadDraftEntry("A"), undefined); + assert.notEqual(authority.revision, revision); + assert.equal(authority.emptyContentIsAuthoritative, true); +}); + +test("new sends revoke recovery without manufacturing authored emptiness", () => { + const authority = getDraftAuthority("A"); + claimDraftSend("A"); + const first = authority.revision; + claimDraftSend("A"); + assert.notEqual(authority.revision, first); + assert.equal(authority.emptyContentIsAuthoritative, false); +}); + +test("scope reset invalidates retained handles, including a round trip", () => { + const old = getDraftAuthority("A"); + recordDraftAuthoredContent("A", ""); + const revision = old.revision; + initDraftStore("other", "wss://other.example"); + assert.notEqual(old.revision, revision); + assert.equal(getDraftAuthority("A").emptyContentIsAuthoritative, false); + initDraftStore("author", "wss://authority.example"); + assert.notEqual(getDraftAuthority("A"), old); + assert.notEqual(old.revision, revision); +}); + +test("explicit replacement revokes old authority even for an identical snapshot", () => { + persistDraftEntry("A", "same", "channel", [], []); + const value = loadDraftEntry("A"); + const authority = getDraftAuthority("A"); + const revision = authority.revision; + saveDraftEntry("A", value); + assert.notEqual(authority.revision, revision); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index a3e0fcf919..2a87d692ce 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -102,6 +102,59 @@ function canonicalizeRelayScope(relayUrl: string): string { } } +/** Semantic intent survives value deletion and composer visits within this store. */ +type DraftAuthority = { + revision: number; + authoredRevision: number; + emptyContentIsAuthoritative: boolean; +}; +const draftAuthorities = new Map(); + +/** Mutable state stays private to the scoped draft owner. */ +function mutableDraftAuthority(draftKey: string): DraftAuthority { + let authority = draftAuthorities.get(draftKey); + if (!authority) { + authority = { + revision: 0, + authoredRevision: 0, + emptyContentIsAuthoritative: false, + }; + draftAuthorities.set(draftKey, authority); + } + return authority; +} + +/** Capture the shared intent for this key; remains readable across visits/deletion. */ +export function getDraftAuthority(draftKey: string): Readonly { + return mutableDraftAuthority(draftKey); +} + +/** Record deliberate editor intent even when there is no persisted value. */ +export function recordDraftAuthoredContent( + draftKey: string, + content: string, +): void { + const authority = mutableDraftAuthority(draftKey); + authority.revision += 1; + authority.authoredRevision += 1; + authority.emptyContentIsAuthoritative = content.length === 0; +} + +/** A newer send owns recovery/cleanup, independently of publication authority. */ +export function claimDraftSend(draftKey: string | null | undefined): void { + if (!draftKey) return; + mutableDraftAuthority(draftKey).revision += 1; +} + +function resetDraftAuthorities(): void { + // Invalidate handles retained by old continuations before dropping the scope. + for (const authority of draftAuthorities.values()) { + authority.revision += 1; + authority.authoredRevision += 1; + } + draftAuthorities.clear(); +} + /** Module-level workspace identity set by `initDraftStore`. Empty = no workspace. */ let currentPubkey = ""; let currentRelayScope = ""; @@ -132,6 +185,7 @@ export function initDraftStore(pubkey: string, relayUrl = ""): void { const relayScope = canonicalizeRelayScope(relayUrl); if (currentPubkey !== pubkey || currentRelayScope !== relayScope) { _memCache = null; + resetDraftAuthorities(); } currentPubkey = pubkey; currentRelayScope = relayScope; @@ -145,6 +199,7 @@ export function initDraftStore(pubkey: string, relayUrl = ""): void { * Replaces the old `clearAllDrafts()`. */ export function clearAllDrafts(): void { + resetDraftAuthorities(); currentPubkey = ""; currentRelayScope = ""; _memCache = null; @@ -287,6 +342,11 @@ function evictOldest(map: Map): void { // use them without a React context. export function saveDraftEntry(draftKey: string, draft: DraftState): void { + recordDraftAuthoredContent(draftKey, draft.content); + writeDraftEntry(draftKey, draft); +} + +function writeDraftEntry(draftKey: string, draft: DraftState): void { if (draft.content.trim().length === 0 && draft.pendingImeta.length === 0) { return; } @@ -302,6 +362,7 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined { } export function deleteDraftEntry(draftKey: string): void { + recordDraftAuthoredContent(draftKey, ""); discardQueuedAttachmentsForDraft(draftKey); clearDraftEntry(draftKey); } @@ -411,6 +472,8 @@ export function renameDraftEntry( if (!draftStatesEqual(existing, destination)) { return "collision"; } + recordDraftAuthoredContent(oldKey, ""); + recordDraftAuthoredContent(newKey, existing.content); // Identical records: remove the legacy key, keep the destination entry. map.delete(oldKey); flushStore(map); @@ -418,6 +481,8 @@ export function renameDraftEntry( return "migrated"; } + recordDraftAuthoredContent(oldKey, ""); + recordDraftAuthoredContent(newKey, existing.content); // No destination conflict: move the record. Cardinality is unchanged // (one delete + one set), so evictOldest is not called. map.set(newKey, existing); @@ -444,7 +509,7 @@ export function persistDraftEntry( const map = readStore(); const existing = map.get(draftKey); const now = new Date().toISOString(); - saveDraftEntry(draftKey, { + writeDraftEntry(draftKey, { content, selectionEnd: content.length, selectionStart: content.length, diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 2f00531820..f0c5878ab0 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -624,6 +624,7 @@ export function useRichTextEditor({ // About to disable: remember whether we currently hold focus so we know // whether to restore it when re-enabled. hadFocusBeforeDisableRef.current = editor.isFocused; + // Editability is not an authored document update (not even a clear). editor.setEditable(false, false); } else { editor.setEditable(true, false); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 1f04d09c55..c3b795e3d8 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -5,7 +5,8 @@ import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripIm import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { - getDraftStoreScope, + getDraftAuthority, + recordDraftAuthoredContent, type DraftMentionRef, type DraftState, } from "@/features/messages/lib/useDrafts"; @@ -66,7 +67,7 @@ type UseDraftPersistLifecycleParams = { }; type UseDraftPersistLifecycleResult = { - /** Captured visit revision; remains readable after exit, excluding later visits. */ + /** Shared key intent; later visits can revoke a captured continuation. */ getComposerRevision: () => number; /** Optimistic send/recovery is not an authored edit or authoritative deletion. */ runComposerUpdate: (update: () => void) => void; @@ -78,12 +79,6 @@ type UseDraftPersistLifecycleResult = { trackAuthoredContent: (content: string) => void; }; -const authoritativelyClearedDraftKeys = new Set(); - -function scopedDraftKey(draftKey: string): string { - return `${getDraftStoreScope()}:${draftKey}`; -} - /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -139,13 +134,24 @@ export function useDraftPersistLifecycle({ [getImplicitAgentMentionPrefix], ); - // Pending sends retain this visit's record after navigation/unmount. A live - // editor ref would lose A's deletion authority as soon as B became visible. - const visit = React.useMemo( - () => ({ channelId, draftKey: effectiveDraftKey, revision: 0 }), - [channelId, effectiveDraftKey], + // Each visit keeps its own accessor identity (visible ownership), but reads + // the store's shared key authority, including later visits and absent values. + const authority = React.useMemo( + () => + effectiveDraftKey + ? getDraftAuthority(effectiveDraftKey) + : { + revision: 0, + authoredRevision: 0, + emptyContentIsAuthoritative: false, + }, + [effectiveDraftKey], ); - const getComposerRevision = React.useCallback(() => visit.revision, [visit]); + const getComposerRevision = React.useCallback( + () => authority.revision, + [authority], + ); + const lastAuthoredRevisionRef = React.useRef(authority.authoredRevision); const pendingImetaForPersistRef = React.useRef([]); const emptyContentIsAuthoritativeRef = React.useRef(false); const isRestoringContentRef = React.useRef(false); @@ -186,12 +192,8 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); - const authoritativeDraftKey = effectiveDraftKey - ? scopedDraftKey(effectiveDraftKey) - : null; - const wasAuthoritativelyCleared = authoritativeDraftKey - ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) - : false; + lastAuthoredRevisionRef.current = authority.authoredRevision; + const wasAuthoritativelyCleared = authority.emptyContentIsAuthoritative; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; isRestoringContentRef.current = true; @@ -219,7 +221,13 @@ export function useDraftPersistLifecycle({ isRestoringContentRef.current = false; return () => { - if (effectiveDraftKey) { + // Another composer or explicit inbox deletion may supersede this visit + // without changing its editor. A send claim alone does not block saving + // the outgoing editor: optimistic clear must still persist normally. + if ( + effectiveDraftKey && + lastAuthoredRevisionRef.current === authority.authoredRevision + ) { const queuedAttachments = getQueuedAttachments?.() ?? []; if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); @@ -242,15 +250,13 @@ export function useDraftPersistLifecycle({ const trackAuthoredContent = React.useCallback( (content: string) => { if (isRestoringContentRef.current) return; - visit.revision += 1; if (!effectiveDraftKey) return; - const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + recordDraftAuthoredContent(effectiveDraftKey, content); + lastAuthoredRevisionRef.current = authority.authoredRevision; if (content.length > 0) { - authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); emptyContentIsAuthoritativeRef.current = false; return; } - authoritativelyClearedDraftKeys.add(authoritativeDraftKey); emptyContentIsAuthoritativeRef.current = true; persistDraft( effectiveDraftKey, @@ -262,11 +268,11 @@ export function useDraftPersistLifecycle({ ); }, [ + authority, channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef, - visit, ], ); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs new file mode 100644 index 0000000000..9d73f7b7b5 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.authority.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + setup, + deferred, + KEY, + TEXT, +} from "./useMentionSendFlow.test-support.mjs"; + +async function ordinary(s, gate) { + s.options.mentions.memberPubkeys = new Set([KEY]); + s.control.publish = gate; + s.rerender(); + let promise; + await s.act(async () => { + promise = s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + recoveryDraftKey: "thread:a", + sentDraftKey: "thread:a", + }); + }); + return { promise }; +} + +// Bound the transition product, not just one reporter sequence. Same snapshot +// authorship must supersede cleanup too; equality of content/refs is not intent. +for (const outcome of ["failure", "success"]) { + for (const intent of [ + "untouched", + "clear", + "text", + "same-text", + "new-refs", + ]) { + for (const exit of ["B", "unmount"]) { + test(`ordinary cross-visit ${outcome}, newer ${intent}, exit ${exit}`, async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + const gate = deferred(); + const { promise } = await ordinary(s, gate); + assert.equal(s.events("publish").length, 1); + assert.equal(s.options.contentRef.current, ""); + s.navigate("thread:b"); + s.edit("B preserved", []); + s.navigate("thread:a"); + const refs = + intent === "new-refs" + ? [{ ...s.refs[0], pubkey: "c".repeat(64) }] + : s.refs; + if (intent !== "untouched") { + s.edit( + intent === "text" || intent === "clear" ? "new A intent" : TEXT, + refs, + ); + if (intent === "clear") s.edit("", []); + } + s.navigate("thread:b"); + const before = s.store.get("thread:a"); + if (exit === "unmount") s.unmount(); + await s.act(async () => { + if (outcome === "failure") + gate.reject(new Error("final validation failed")); + else gate.resolve(); + await promise; + }); + assert.equal(s.events("SEND").length, outcome === "success" ? 1 : 0); + assert.equal(s.options.contentRef.current, "B preserved"); + assert.equal(s.store.get("thread:b").content, "B preserved"); + assert.deepEqual(s.store.get("thread:b").mentionRefs, []); + if (intent === "untouched" && outcome === "failure") { + assert.equal(s.store.get("thread:a").content, TEXT); + assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); + } else { + assert.deepEqual( + s.store.get("thread:a"), + before, + "new authored value or absence wins", + ); + } + }); + } + } +} + +for (const first of ["old", "new"]) { + test(`new same-key send supersedes old recovery, ${first} completes first`, async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + const oldGate = deferred(); + const old = await ordinary(s, oldGate); + s.navigate("thread:b"); + s.edit("B preserved", []); + s.navigate("thread:a"); + s.remount(); // a new composer has its own send latch, sharing draft authority + // Retry the same captured text without an editor update: claiming a new + // send itself must revoke the older recovery, even for identical refs. + s.options.contentRef.current = TEXT; + s.control.currentRefs = s.refs; + const newGate = deferred(); + const next = await ordinary(s, newGate); + assert.equal(s.events("publish").length, 2); + s.navigate("thread:b"); + const failOld = () => + s.act(async () => { + oldGate.reject(new Error("old validation failed")); + await old.promise; + }); + const finishNew = () => + s.act(async () => { + newGate.resolve(); + await next.promise; + }); + if (first === "old") { + await failOld(); + await finishNew(); + } else { + await finishNew(); + await failOld(); + } + assert.equal(s.events("SEND").length, 1); + assert.equal(s.store.has("thread:a"), false); + assert.equal(s.options.contentRef.current, "B preserved"); + }); +} + +// Media error reaches the same recovery guard as final-validation failure. +test("cross-visit author clear prevents old media error restoring text, refs or files", async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + s.options.mentions.memberPubkeys = new Set([KEY]); + s.rerender(); + let promise; + await s.act(async () => { + promise = s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + recoveryDraftKey: "thread:a", + queuedAttachments: [{ id: "old-file" }], + }); + }); + assert.ok(s.control.uploadCallbacks); + s.navigate("thread:b"); + s.edit("B preserved", []); + s.navigate("thread:a"); + s.edit("new A", []); + s.edit("", []); + s.navigate("thread:b"); + const queues = s.events("save-queue").length; + await s.act(async () => { + s.control.uploadCallbacks.onError(new Error("upload failed")); + await promise; + }); + assert.equal(s.store.has("thread:a"), false); + assert.equal(s.events("save-queue").length, queues); + assert.equal(s.events("SEND").length, 0); + assert.equal(s.options.contentRef.current, "B preserved"); +}); + +for (const replacement of ["delete", "same-text", "new-text"]) { + test(`outgoing lifecycle cleanup cannot overwrite shared ${replacement} from another owner`, async () => { + const { deleteDraftEntry, saveDraftEntry } = await import( + "../lib/useDrafts.ts" + ); + const s = await setup({ lifecycle: true }); + s.dismiss(); + const before = s.store.get("thread:a"); + s.act(() => { + if (replacement === "delete") deleteDraftEntry("thread:a"); + else + saveDraftEntry("thread:a", { + ...before, + content: replacement === "same-text" ? TEXT : "new owner", + }); + }); + const expected = s.store.get("thread:a"); + s.unmount(); + assert.deepEqual(s.store.get("thread:a"), expected); + }); +} + +test("automatic addressed prefix is programmatic optimistic empty, not new authored intent", async () => { + const s = await setup({ lifecycle: true }); + s.dismiss(); + s.options.mentions.memberPubkeys = new Set([KEY]); + s.options.onAddressedAgentsComposerCleared = () => { + s.options.richText.setContent("@RemoteScout "); + return "@RemoteScout "; + }; + const gate = deferred(); + s.control.publish = gate; + s.rerender(); + const before = s.options.getComposerRevision(); + let promise; + await s.act(async () => { + promise = s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: "general", + pendingImeta: [], + trimmed: TEXT, + recoveryDraftKey: "thread:a", + addressedAgentPubkeys: [KEY], + }); + }); + assert.equal( + s.options.getComposerRevision(), + before + 1, + "only send claim advances intent", + ); + await s.act(async () => { + gate.reject(new Error("send failed")); + await promise; + }); + assert.equal(s.options.contentRef.current, TEXT); + assert.equal(s.store.get("thread:a").content, TEXT); + assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.cancellation.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.cancellation.test.mjs index c8804fa255..d97ab1dda6 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.cancellation.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.cancellation.test.mjs @@ -1,358 +1,11 @@ import assert from "node:assert/strict"; -import fs from "node:fs"; -import vm from "node:vm"; -import { after, afterEach, before, test } from "node:test"; -import { JSDOM } from "jsdom"; -import * as React from "react"; -import ts from "typescript"; -import * as helpers from "./useMentionSendFlow.helpers.ts"; - -// Execute the product hooks with real React effects/renders; only external -// query/mutation/media dependencies are mocked. Deferred promises isolate the -// user-intent boundary independently of successful authorization. -const dom = new JSDOM("", { - url: "http://localhost", -}); -before(() => - Object.assign(globalThis, { - document: dom.window.document, - HTMLElement: dom.window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }), -); -afterEach(async () => (await import("@testing-library/react")).cleanup()); -after(() => dom.window.close()); -const KEY = "b".repeat(64); -const TEXT = "@RemoteScout hello"; -const noop = () => {}; -function deferred() { - let resolve; - let reject; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -function load(name, stubs) { - const source = fs.readFileSync( - new URL(`./${name}.ts`, import.meta.url), - "utf8", - ); - const exports = {}; - vm.runInNewContext( - ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - }, - }).outputText, - { - exports, - AbortController, - Error, - Map, - Set, - require: (key) => { - assert.ok(key in stubs, `unmocked dependency: ${key}`); - return stubs[key]; - }, - }, - ); - return exports; -} -async function setup({ lifecycle = false } = {}) { - const { act, renderHook } = await import("@testing-library/react"); - const calls = []; - const control = { prepare: null, add: null, publish: null, inventory: null }; - const refs = [{ displayName: "RemoteScout", pubkey: KEY, isAgent: true }]; - const query = { - data: [], - refetch: async () => { - if (control.inventory) await control.inventory.promise; - return { data: [] }; - }, - }; - const mutation = { - isPending: false, - mutateAsync: async (input) => { - calls.push(["add", input]); - if (control.add) await control.add.promise; - return { added: [KEY], errors: [] }; - }, - }; - const stubs = { - react: React, - sonner: { toast: { error: (error) => calls.push(["error", error]) } }, - "@/features/agents/hooks": new Proxy( - {}, - { - get: (_, key) => { - if ( - key === "useCreateChannelManagedAgentMutation" || - key === "useProvisionChannelManagedAgentMutation" - ) - return () => ({ - isPending: false, - mutateAsync: async (input) => { - calls.push(["persona", input]); - if (control.persona) await control.persona.promise; - return { agent: { pubkey: KEY, name: "Fizz" } }; - }, - }); - return key.includes("Mutation") ? () => mutation : () => query; - }, - }, - ), - "@/features/agents/channelAgents": { - applyReusableAgentAccessPolicy: async (agent) => { - calls.push(["local-policy"]); - if (control.policy) await control.policy.promise; - return agent; - }, - }, - "@/features/agents/lib/resolvePersonaRuntime": { - resolvePersonaRuntime: () => ({ runtime: "test-runtime" }), - }, - "@/features/channels/hooks": { - useAddChannelMembersMutation: () => mutation, - }, - "@/features/channels/useCanAddChannelMembers": { - useCanAddChannelMembers: () => true, - }, - "@/features/channels/lib/channelMemberAdmission": {}, - "@/features/messages/lib/dmThreadAgentMentionError": { - dmThreadAgentMentionError: () => null, - }, - "@/features/messages/lib/backgroundMediaUploadStore": { - saveQueuedAttachmentsForDraft: (...args) => - calls.push(["save-queue", ...args]), - prepareBackgroundMediaUpload: () => ({ - start(callbacks) { - control.uploadCallbacks = callbacks; - return true; - }, - cancel: noop, - }), - }, - "@/features/messages/lib/imetaMediaMarkdown": { - buildOutgoingMessage: (text) => ({ content: text, mediaTags: [] }), - }, - "@/shared/api/tauri": { invokeTauri: async () => {} }, - "@/shared/lib/pubkey": { - normalizePubkey: (key) => key.toLowerCase(), - truncatePubkey: (key) => key, - }, - "@/shared/lib/customEmojiTags": { buildCustomEmojiTags: () => [] }, - "./useMentionSendFlow.helpers": helpers, - "@/features/messages/lib/agentAddressMention.mjs": { - buildAgentAddressMentionTags: () => [], - }, - "@/features/messages/lib/agentMentionRevalidation": { - AgentMentionAuthorizationError: class extends Error {}, - }, - }; - stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs); - stubs["./useActivePreparedLinkPreviews"] = load( - "useActivePreparedLinkPreviews", - stubs, - ); - const { useMentionSendFlow } = load("useMentionSendFlow", stubs); - const store = new Map(); - const persistDraft = ( - key, - content, - channelId, - pendingImeta, - spoileredAttachmentUrls, - mentionRefs, - ) => { - calls.push([ - "persist", - key, - content, - channelId, - pendingImeta, - spoileredAttachmentUrls, - mentionRefs, - ]); - if (content || pendingImeta.length) - store.set(key, { - content, - channelId, - pendingImeta, - spoileredAttachmentUrls, - mentionRefs, - }); - else store.delete(key); - }; - const initialKey = lifecycle ? "thread:a" : "general"; - if (lifecycle) - store.set(initialKey, { - content: TEXT, - channelId: "general", - pendingImeta: [], - spoileredAttachmentUrls: [], - mentionRefs: refs, - }); - const options = { - channelId: "general", - effectiveDraftKey: initialKey, - getComposerRevision: () => 0, - runComposerUpdate: (update) => update(), - channelType: "stream", - customEmoji: [], - mentions: { - memberPubkeys: new Set(), - hasResolvedMembers: true, - extractMentionPersonas: () => [], - extractMentionPubkeys: () => [KEY], - isAgentPubkey: (key) => key === KEY, - isManagedAgentPubkey: () => false, - getDraftMentionRefs: () => control.currentRefs ?? refs, - registerMentionPubkey: (displayName, pubkey, options) => { - const ref = { displayName, pubkey, isAgent: options.isAgent }; - control.currentRefs = [...(control.currentRefs ?? []), ref]; - calls.push(["register-ref", ref]); - }, - getMentionDisplayName: () => "RemoteScout", - clearMentions: () => { - if (lifecycle) control.currentRefs = []; - }, - restoreDraftMentionRefs: (value) => { - if (lifecycle) control.currentRefs = value; - calls.push(["restore-refs", value]); - }, - revalidateMentionPubkeys: async (keys, channel, opts) => { - calls.push([opts.phase, channel]); - if (control[opts.phase]) await control[opts.phase].promise; - return keys; - }, - }, - contentRef: { current: TEXT }, - channelLinks: { clearChannels: noop }, - emojiAutocomplete: { clearEmojis: noop }, - richText: { - clearContent: () => { - if (lifecycle) lifecycleApi.trackAuthoredContent(""); - }, - setContent: (text) => { - if (lifecycle) lifecycleApi.trackAuthoredContent(text); - }, - }, - drafts: { - loadDraft: (key) => (lifecycle ? store.get(key) : null), - persistDraft, - markDraftSent: noop, - }, - setContent: noop, - setPendingImeta: noop, - setIsEmojiPickerOpen: noop, - clearQueuedAttachments: noop, - restoreQueuedAttachments: noop, - hasUnsavedMedia: () => false, - onSendRef: { current: async (...args) => calls.push(["SEND", ...args]) }, - }; - stubs["@/features/messages/lib/stripImplicitAgentMentions"] = { - stripImplicitAgentMentionPrefix: (text) => text, - }; - stubs["@/features/messages/lib/useDrafts"] = { - getDraftStoreScope: () => "test", - }; - const { useDraftPersistLifecycle } = load("useDraftPersistSnapshot", stubs); - let lifecycleApi; - const hook = renderHook( - () => { - if (lifecycle) { - // biome-ignore lint/correctness/useHookAtTopLevel: lifecycle is immutable for this harness mount - lifecycleApi = useDraftPersistLifecycle({ - effectiveDraftKey: options.effectiveDraftKey, - channelId: options.channelId, - loadDraft: options.drafts.loadDraft, - persistDraft, - getMentionRefs: options.mentions.getDraftMentionRefs, - restoreMentionRefs: options.mentions.restoreDraftMentionRefs, - livePendingImeta: [], - setPendingImeta: noop, - setContent: (text) => { - options.contentRef.current = text; - }, - clearContent: () => { - options.contentRef.current = ""; - }, - setSpoileredAttachmentUrls: noop, - spoileredAttachmentUrlsRef: { current: new Set() }, - syncComposerContentFromEditor: () => options.contentRef.current, - }); - options.getComposerRevision = lifecycleApi.getComposerRevision; - options.runComposerUpdate = lifecycleApi.runComposerUpdate; - } - return useMentionSendFlow(options); - }, - { - wrapper: ({ children }) => - React.createElement(React.StrictMode, null, children), - }, - ); - const flush = async () => - act(async () => { - await new Promise((resolve) => setImmediate(resolve)); - }); - const prompt = async (text = TEXT) => - act(async () => { - options.contentRef.current = text; - await hook.result.current.sendMessageWithMentionFlow({ - capturedChannelId: options.channelId, - pendingImeta: [], - trimmed: text, - recoveryDraftKey: options.effectiveDraftKey, - capturedThreadContext: lifecycle - ? { - parentEventId: options.effectiveDraftKey, - threadHeadId: options.effectiveDraftKey, - } - : null, - queuedAttachments: control.attachments ?? [], - }); - }); - await prompt(); - const invite = async () => - act(async () => hook.result.current.nonMemberPromptProps.onInvite()); - const dismiss = () => - act(() => hook.result.current.nonMemberPromptProps.onDismiss()); - const finish = async (gate) => { - gate.resolve(); - await flush(); - }; - const events = (name) => calls.filter((call) => call[0] === name); - return { - ...hook, - act, - calls, - control, - options, - query, - refs, - prompt, - invite, - dismiss, - finish, - flush, - events, - store, - edit: (text, mentionRefs = refs) => - act(() => { - options.contentRef.current = text; - control.currentRefs = mentionRefs; - lifecycleApi.trackAuthoredContent(text); - }), - navigate: (key) => { - options.effectiveDraftKey = key; - hook.rerender(); - }, - }; -} +import { test } from "node:test"; +import { + setup, + deferred, + KEY, + TEXT, +} from "./useMentionSendFlow.test-support.mjs"; for (const stage of ["prepare", "inventory", "add"]) { test(`dismiss during delayed ${stage} cannot add further or send, retains draft`, async () => { @@ -561,12 +214,12 @@ test("cancelled media continuation preserves refs and cannot overwrite an unrela s.dismiss(); assert.equal(s.options.contentRef.current, TEXT); assert.deepEqual(s.control.currentRefs, s.refs); - assert.equal(s.store.get("thread:a"), other); + assert.equal(s.store.get("thread:a").content, other.content); await s.act(async () => s.control.uploadCallbacks.onComplete([], new AbortController().signal), ); assert.equal(s.events("SEND").length, 0); - assert.equal(s.store.get("thread:a"), other); + assert.equal(s.store.get("thread:a").content, other.content); }); for (const action of ["unchanged", "navigate", "edit"]) { @@ -756,7 +409,7 @@ for (const action of ["untouched", "deleted", "superseded"]) { assert.equal(s.events("SEND").length, 0); if (action === "deleted") assert.equal(s.store.has("thread:a"), false); else if (action === "superseded") - assert.equal(s.store.get("thread:a"), other); + assert.equal(s.store.get("thread:a").content, other.content); else { assert.equal(s.store.get("thread:a").content, TEXT); assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index da43b0f385..d70186bc44 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -16,7 +16,7 @@ export { MENTION_REFERENCE_TAG }; export type ComposerDraftOwner = { channelId: string | null; draftKey: string | null | undefined; - /** Read this source visit, never the currently visible editor visit. */ + /** Read shared source-key intent, never another visible draft key. */ getComposerRevision: () => number; }; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs new file mode 100644 index 0000000000..9702463f18 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -0,0 +1,382 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { after, afterEach, before } from "node:test"; +import { JSDOM } from "jsdom"; +import * as React from "react"; +import ts from "typescript"; +import * as helpers from "./useMentionSendFlow.helpers.ts"; +import * as draftStore from "../lib/useDrafts.ts"; + +// Execute the product hooks with real React effects/renders; only external +// query/mutation/media dependencies are mocked. Deferred promises isolate the +// user-intent boundary independently of successful authorization. +const dom = new JSDOM("", { + url: "http://localhost", +}); +before(() => + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + localStorage: dom.window.localStorage, + }), +); +afterEach(async () => (await import("@testing-library/react")).cleanup()); +after(() => dom.window.close()); +export const KEY = "b".repeat(64); +export const TEXT = "@RemoteScout hello"; +const noop = () => {}; +export function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function load(name, stubs) { + const source = fs.readFileSync( + new URL(`./${name}.ts`, import.meta.url), + "utf8", + ); + const exports = {}; + vm.runInNewContext( + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText, + { + exports, + AbortController, + Error, + Map, + Set, + require: (key) => { + assert.ok(key in stubs, `unmocked dependency: ${key}`); + return stubs[key]; + }, + }, + ); + return exports; +} +export async function setup({ lifecycle = false } = {}) { + const { act, renderHook } = await import("@testing-library/react"); + draftStore.clearAllDrafts(); + dom.window.localStorage.clear(); + draftStore.initDraftStore("test-author", "wss://test.example"); + const calls = []; + const control = { prepare: null, add: null, publish: null, inventory: null }; + const refs = [{ displayName: "RemoteScout", pubkey: KEY, isAgent: true }]; + const query = { + data: [], + refetch: async () => { + if (control.inventory) await control.inventory.promise; + return { data: [] }; + }, + }; + const mutation = { + isPending: false, + mutateAsync: async (input) => { + calls.push(["add", input]); + if (control.add) await control.add.promise; + return { added: [KEY], errors: [] }; + }, + }; + const stubs = { + react: React, + "@/features/messages/lib/useDrafts": draftStore, + sonner: { toast: { error: (error) => calls.push(["error", error]) } }, + "@/features/agents/hooks": new Proxy( + {}, + { + get: (_, key) => { + if ( + key === "useCreateChannelManagedAgentMutation" || + key === "useProvisionChannelManagedAgentMutation" + ) + return () => ({ + isPending: false, + mutateAsync: async (input) => { + calls.push(["persona", input]); + if (control.persona) await control.persona.promise; + return { agent: { pubkey: KEY, name: "Fizz" } }; + }, + }); + return key.includes("Mutation") ? () => mutation : () => query; + }, + }, + ), + "@/features/agents/channelAgents": { + applyReusableAgentAccessPolicy: async (agent) => { + calls.push(["local-policy"]); + if (control.policy) await control.policy.promise; + return agent; + }, + }, + "@/features/agents/lib/resolvePersonaRuntime": { + resolvePersonaRuntime: () => ({ runtime: "test-runtime" }), + }, + "@/features/channels/hooks": { + useAddChannelMembersMutation: () => mutation, + }, + "@/features/channels/useCanAddChannelMembers": { + useCanAddChannelMembers: () => true, + }, + "@/features/channels/lib/channelMemberAdmission": {}, + "@/features/messages/lib/dmThreadAgentMentionError": { + dmThreadAgentMentionError: () => null, + }, + "@/features/messages/lib/backgroundMediaUploadStore": { + saveQueuedAttachmentsForDraft: (...args) => + calls.push(["save-queue", ...args]), + prepareBackgroundMediaUpload: () => ({ + start(callbacks) { + control.uploadCallbacks = callbacks; + return true; + }, + cancel: noop, + }), + }, + "@/features/messages/lib/imetaMediaMarkdown": { + buildOutgoingMessage: (text) => ({ content: text, mediaTags: [] }), + }, + "@/shared/api/tauri": { invokeTauri: async () => {} }, + "@/shared/lib/pubkey": { + normalizePubkey: (key) => key.toLowerCase(), + truncatePubkey: (key) => key, + }, + "@/shared/lib/customEmojiTags": { buildCustomEmojiTags: () => [] }, + "./useMentionSendFlow.helpers": helpers, + "@/features/messages/lib/agentAddressMention.mjs": { + buildAgentAddressMentionTags: () => [], + }, + "@/features/messages/lib/agentMentionRevalidation": { + AgentMentionAuthorizationError: class extends Error {}, + }, + }; + stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs); + stubs["./useActivePreparedLinkPreviews"] = load( + "useActivePreparedLinkPreviews", + stubs, + ); + const { useMentionSendFlow } = load("useMentionSendFlow", stubs); + // Real draft adapter: empty persistence deletes the actual value, while + // shared semantic authority remains independently readable. + const store = { + get: draftStore.loadDraftEntry, + has: (key) => draftStore.loadDraftEntry(key) !== undefined, + set: (key, value) => + draftStore.persistDraftEntry( + key, + value.content, + value.channelId, + value.pendingImeta, + value.spoileredAttachmentUrls, + value.mentionRefs, + ), + delete: draftStore.clearDraftEntry, + }; + const persistDraft = ( + key, + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + mentionRefs, + ) => { + calls.push([ + "persist", + key, + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + mentionRefs, + ]); + if (content || pendingImeta.length) + store.set(key, { + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + mentionRefs, + }); + else store.delete(key); + }; + const initialKey = lifecycle ? "thread:a" : "general"; + if (lifecycle) + store.set(initialKey, { + content: TEXT, + channelId: "general", + pendingImeta: [], + spoileredAttachmentUrls: [], + mentionRefs: refs, + }); + const options = { + channelId: "general", + effectiveDraftKey: initialKey, + getComposerRevision: () => 0, + runComposerUpdate: (update) => update(), + channelType: "stream", + customEmoji: [], + mentions: { + memberPubkeys: new Set(), + hasResolvedMembers: true, + extractMentionPersonas: () => [], + extractMentionPubkeys: () => [KEY], + isAgentPubkey: (key) => key === KEY, + isManagedAgentPubkey: () => false, + getDraftMentionRefs: () => control.currentRefs ?? refs, + registerMentionPubkey: (displayName, pubkey, options) => { + const ref = { displayName, pubkey, isAgent: options.isAgent }; + control.currentRefs = [...(control.currentRefs ?? []), ref]; + calls.push(["register-ref", ref]); + }, + getMentionDisplayName: () => "RemoteScout", + clearMentions: () => { + if (lifecycle) control.currentRefs = []; + }, + restoreDraftMentionRefs: (value) => { + if (lifecycle) control.currentRefs = value; + calls.push(["restore-refs", value]); + }, + revalidateMentionPubkeys: async (keys, channel, opts) => { + calls.push([opts.phase, channel]); + if (control[opts.phase]) await control[opts.phase].promise; + return keys; + }, + }, + contentRef: { current: TEXT }, + channelLinks: { clearChannels: noop }, + emojiAutocomplete: { clearEmojis: noop }, + richText: { + clearContent: () => { + if (lifecycle) lifecycleApi.trackAuthoredContent(""); + }, + setContent: (text) => { + if (lifecycle) lifecycleApi.trackAuthoredContent(text); + }, + }, + drafts: { + loadDraft: (key) => (lifecycle ? store.get(key) : null), + persistDraft, + markDraftSent: draftStore.markDraftSentEntry, + }, + setContent: noop, + setPendingImeta: noop, + setIsEmojiPickerOpen: noop, + clearQueuedAttachments: noop, + restoreQueuedAttachments: noop, + hasUnsavedMedia: () => false, + onSendRef: { current: async (...args) => calls.push(["SEND", ...args]) }, + }; + stubs["@/features/messages/lib/stripImplicitAgentMentions"] = { + stripImplicitAgentMentionPrefix: (text) => text, + }; + const { useDraftPersistLifecycle } = load("useDraftPersistSnapshot", stubs); + let lifecycleApi; + const renderComposer = () => { + if (lifecycle) { + // biome-ignore lint/correctness/useHookAtTopLevel: lifecycle is immutable for this harness mount + lifecycleApi = useDraftPersistLifecycle({ + effectiveDraftKey: options.effectiveDraftKey, + channelId: options.channelId, + loadDraft: options.drafts.loadDraft, + persistDraft, + getMentionRefs: options.mentions.getDraftMentionRefs, + restoreMentionRefs: options.mentions.restoreDraftMentionRefs, + livePendingImeta: [], + setPendingImeta: noop, + setContent: (text) => { + options.contentRef.current = text; + }, + clearContent: () => { + options.contentRef.current = ""; + }, + setSpoileredAttachmentUrls: noop, + spoileredAttachmentUrlsRef: { current: new Set() }, + syncComposerContentFromEditor: () => options.contentRef.current, + }); + options.getComposerRevision = lifecycleApi.getComposerRevision; + options.runComposerUpdate = lifecycleApi.runComposerUpdate; + } + return useMentionSendFlow(options); + }; + const mount = () => + renderHook(renderComposer, { + wrapper: ({ children }) => + React.createElement(React.StrictMode, null, children), + }); + let hook = mount(); + const flush = async () => + act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + const prompt = async (text = TEXT) => + act(async () => { + options.contentRef.current = text; + await hook.result.current.sendMessageWithMentionFlow({ + capturedChannelId: options.channelId, + pendingImeta: [], + trimmed: text, + recoveryDraftKey: options.effectiveDraftKey, + capturedThreadContext: lifecycle + ? { + parentEventId: options.effectiveDraftKey, + threadHeadId: options.effectiveDraftKey, + } + : null, + queuedAttachments: control.attachments ?? [], + }); + }); + await prompt(); + const invite = async () => + act(async () => hook.result.current.nonMemberPromptProps.onInvite()); + const dismiss = () => + act(() => hook.result.current.nonMemberPromptProps.onDismiss()); + const finish = async (gate) => { + gate.resolve(); + await flush(); + }; + const events = (name) => calls.filter((call) => call[0] === name); + return { + ...hook, + get result() { + return hook.result; + }, + rerender: () => hook.rerender(), + unmount: () => hook.unmount(), + remount: () => { + hook.unmount(); + hook = mount(); + }, + act, + calls, + control, + options, + query, + refs, + prompt, + invite, + dismiss, + finish, + flush, + events, + store, + edit: (text, mentionRefs = refs) => + act(() => { + options.contentRef.current = text; + control.currentRefs = mentionRefs; + lifecycleApi.trackAuthoredContent(text); + }), + navigate: (key) => { + options.effectiveDraftKey = key; + hook.rerender(); + }, + }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 7680a8cb70..eda4a07e12 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { claimDraftSend } from "@/features/messages/lib/useDrafts"; import { toast } from "sonner"; import { type CreateChannelManagedAgentInput, @@ -410,7 +411,7 @@ export function useMentionSendFlow({ if (!composerCleared) return; composerCleared = false; // An authored edit (even edit -> clear) ends optimistic recovery's - // authority over this visit, including its persisted record and files. + // authority over this key, including its persisted record and files. if (draft.sourceOwner.getComposerRevision() !== clearedRevision) return; const persisted = persistRecoverableDraft(); const canAnimateCurrentComposer = @@ -448,13 +449,15 @@ export function useMentionSendFlow({ }); }; if (ownsComposer() && getComposerRevision() === draft.composerRevision) { - runComposerUpdate(clearComposer); - if (draft.addressedAgentPubkeys.length > 0) { - optimisticComposerContent = - onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? - ""; - contentRef.current = optimisticComposerContent; - } + runComposerUpdate(() => { + clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } + }); composerCleared = true; clearedRevision = getComposerRevision(); } @@ -624,7 +627,10 @@ export function useMentionSendFlow({ const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), ); - if (ownsComposer()) { + if ( + ownsComposer() && + getComposerRevision() === draft.composerRevision + ) { onAddressedAgentsSendSucceeded?.( [ ...new Set([ @@ -758,6 +764,7 @@ export function useMentionSendFlow({ setIsMentionSendPending(true); // Capture exact selections before any async preparation can navigate the // reused editor to another draft (possibly with identical display text). + claimDraftSend(effectiveDraftKey); const composerRevision = getComposerRevision(); const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice(); const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed); @@ -912,6 +919,7 @@ export function useMentionSendFlow({ }, [ completeSend, + effectiveDraftKey, sourceOwner, getComposerRevision, channelType, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts index ac82b74eca..3bd9cbcfa9 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -10,7 +10,7 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; export type UseMentionSendFlowOptions = { - /** Lifecycle accessor bound to this visit, still readable after it exits. */ + /** Visit-bound accessor reading shared source-key intent, even after exit. */ getComposerRevision: () => number; runComposerUpdate: (update: () => void) => void; channelId: string | null; diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts index c8c75cd725..4df65c7cbf 100644 --- a/desktop/tests/e2e/remote-owned-mentions.spec.ts +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -559,3 +559,134 @@ for (const incoming of ["unrelated thread B draft", "@RemoteScout hello"]) { await assertNoLocalLifecycle(page); }); } + +for (const incoming of ["B preserved"]) { + test(`ordinary failure after returning to A and deleting does not resurrect storage`, async ({ + page, + }) => { + await install(page); + await page.evaluate( + async ({ pubkey, channelId }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }, + { pubkey: REMOTE, channelId: GENERAL }, + ); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? false, + ), + ) + .toBe(true); + const roots = await page.evaluate(() => + ["Lifecycle thread A", "Lifecycle thread B"].map( + (content) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + })?.id, + ), + ); + expect(roots.every(Boolean)).toBe(true); + const navigate = async (id: string | undefined) => { + // Drive the real reply route handler, including while the modal has + // focus (the equivalent external history/navigation intent). + await page + .getByTestId(`reply-message-${id}`) + .first() + .evaluate((button) => (button as HTMLButtonElement).click()); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(page.getByTestId("message-thread-head")).toContainText( + id === roots[0] ? "Lifecycle thread A" : "Lifecycle thread B", + ); + }; + const input = page + .getByTestId("message-thread-panel") + .getByTestId("message-input"); + // Visit both first: the transition under test must reuse the available + // panel/composer, not get a fortuitous unmount via a loading skeleton. + await navigate(roots[1]); + await input.fill(incoming); + await navigate(roots[0]); + await expect(input).toHaveText(""); + await input.fill("@Remote"); + await page + .getByTestId(`mention-suggestion-${REMOTE}`) + .locator("button") + .first() + .click(); + await page.keyboard.type("hello"); + await holdInviteCommand(page, "revalidate_relay_agents", 1); + await page + .getByTestId("message-thread-panel") + .getByTestId("send-message") + .click(); + await waitForInviteGate(page); + await expect(input).toHaveText(""); + await input.evaluate((el) => + el.setAttribute("data-lifecycle-host", "retained"), + ); + await navigate(roots[1]); + await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); + await expect(input).toHaveText(incoming); + await navigate(roots[0]); + await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); + await input.fill("new authored text"); + await input.fill(""); + const sourceRecord = () => + page.evaluate(([root, otherRoot]) => { + const key = Object.keys(localStorage).find((key) => + key.startsWith("buzz-drafts.v2"), + ); + if (!key) throw new Error("draft storage scope missing"); + const drafts = JSON.parse(localStorage.getItem(key) ?? "{}"); + if (!drafts[`thread:${otherRoot}`]) + throw new Error("control B draft missing"); + return drafts[`thread:${root}`] ?? null; + }, roots); + expect(await sourceRecord()).toBeNull(); + // Expando proves the actual editor DOM host survived A -> B. + await input.evaluate((el) => + el.setAttribute("data-lifecycle-host", "retained"), + ); + await navigate(roots[1]); + await expect(input).toHaveAttribute("data-lifecycle-host", "retained"); + await expect(input).toHaveText(incoming); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + // Read actual persistence, not the editor (whose tombstone could mask a + // resurrected record until reload). Neither text nor exact refs may return. + expect(await sourceRecord()).toBeNull(); + await navigate(roots[0]); + await expect(input).toHaveText(""); + + await navigate(roots[1]); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, REMOTE); + await releaseInviteCommand(page); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await page.waitForTimeout(300); + expect(await sent(page)).toEqual([]); + await expect(input).toHaveText(incoming); + await navigate(roots[0]); + await expect(input).toHaveText(""); + expect(await sourceRecord()).toBeNull(); + await assertNoLocalLifecycle(page); + }); +} diff --git a/docs/remote-mention-routing.md b/docs/remote-mention-routing.md index 5d4ea14ce7..2c80aedb67 100644 --- a/docs/remote-mention-routing.md +++ b/docs/remote-mention-routing.md @@ -54,11 +54,12 @@ Invitation intent belongs to one visit of the effective persistence key and channel, not just the mounted composer or destination channel. Same-channel thread changes and A → B → A invalidate the original visit. The visible owner gates optimistic clear, editor recovery and pending state. Storage recovery instead -consults the captured source visit's authored revision even after that visit -exits; losing visible ownership never revokes an authored deletion. The lifecycle -retains a per-visit revision record through its captured accessor, so B's edits -cannot authorize or suppress A's recovery. Preflight recovery and sent-draft -cleanup use that same source authority. Invalidation makes recovery durable +consults shared authored intent for the source draft key, even after exit, +re-entry or unmount. The draft store retains a revision and authoritative-empty +marker independently of the stored value. Later same-key authored text/deletion +or a new send revokes older recovery and sent-draft cleanup; editing B does not +change A's authority. A visit-specific accessor reads that shared authority while +preserving the separate visible-owner identity. Invalidation makes recovery durable synchronously, before a later visit can load or edit the source draft; late async completion cannot revive that recovery or release a newer attempt's latch. @@ -71,8 +72,12 @@ refs, writing them into the editor only while the original visit/revision remain current. Normal persona creation/reuse and ordinary destination-bound background sends retain their existing behavior; accepted membership is never rolled back. -Recovery still compares stored content/media/refs rather than implementing a -versioned draft database. Final membership/policy reads are not atomic with send, +Recovery additionally compares stored content/media/exact refs before replacing +an existing record. Programmatic persistence does not itself change semantic +intent. Explicit inbox deletion and replacement do; scope reset invalidates old +handles. This is same-window authority for live continuations, not cross-window +synchronization or a versioned storage protocol. Reload destroys continuations; +authored deletion has already removed the durable value. Final membership/policy reads are not atomic with send, and cancellation cannot retract an already dispatched publication. Standalone forum transport-failure binding recovery and native compatibility remain separate review/follow-up boundaries.