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.