diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 72b3368433..7b0f45eb97 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -453,6 +453,8 @@ export function useSendMessageMutation( sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; + expectedRelayUrl?: string; + expectedSignerPubkey?: string; }, MessageQueryContext | undefined >({ @@ -467,6 +469,8 @@ export function useSendMessageMutation( sentFromThreadRootId, sentFromThreadRootExcerpt, transport = "auto", + expectedRelayUrl, + expectedSignerPubkey, }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -493,6 +497,11 @@ export function useSendMessageMutation( if (!identity) { throw new Error("No identity available for sending messages."); } + if (Boolean(expectedRelayUrl) !== Boolean(expectedSignerPubkey)) { + throw new Error( + "Scoped message sends require both a relay and signer identity.", + ); + } // `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30 // emoji). Split it so each kind goes to its own validated Tauri arg — @@ -531,7 +540,8 @@ export function useSendMessageMutation( parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || - linkPreviewTags.length > 0 + linkPreviewTags.length > 0 || + expectedRelayUrl ) { const cachedMessages = queryClient.getQueryData( @@ -559,8 +569,8 @@ export function useSendMessageMutation( mentionTags, linkPreviewTags, sentFromThreadTag, - undefined, - undefined, + expectedRelayUrl, + expectedSignerPubkey, suppliedRootEventId, ); diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index e4741baa8b..bcf36c67f6 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -55,10 +55,13 @@ import { getActiveDraftEntries, getAllDraftEntries, getSentDraftEntries, + getDraftStoreScope, initDraftStore, loadDraftEntry, + loadDraftEntryForScope, markDraftSentEntry, persistDraftEntry, + persistDraftEntryForScope, renameDraftEntry, saveDraftEntry, subscribeToStore, @@ -1045,6 +1048,35 @@ test("cross_relay_isolation_via_initDraftStore_without_clearAllDrafts", () => { assert.notEqual(v2a, v2b, "buckets must differ"); }); +test("captured scope recovers an in-flight draft after the active relay changes", () => { + installFreshLocalStorage(); + clearAllDrafts(); + const pk = "pubkey-in-flight"; + initDraftStore(pk, "wss://relay-a.example.com"); + const capturedScope = getDraftStoreScope(); + + initDraftStore(pk, "wss://relay-b.example.com"); + persistDraftEntryForScope( + capturedScope, + "bestie-share:event", + "Keep this in relay A", + "bestie-share:event", + [], + [], + ); + + assert.equal(loadDraftEntry("bestie-share:event"), undefined); + assert.equal( + loadDraftEntryForScope(capturedScope, "bestie-share:event")?.content, + "Keep this in relay A", + ); + initDraftStore(pk, "wss://relay-a.example.com"); + assert.equal( + loadDraftEntry("bestie-share:event")?.content, + "Keep this in relay A", + ); +}); + test("legacy_migration_v1_entries_readable_after_scoped_init_v1_key_removed", () => { const storage = installFreshLocalStorage(); clearAllDrafts(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index a3e0fcf919..ae1e10d3d1 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -118,6 +118,39 @@ export function getDraftStoreScope(): string { return storageKey(); } +function readExplicitStore(scope: string): Map { + if (scope === storageKey()) return readStore(); + const map = new Map(); + const raw = localStorage.getItem(scope); + if (!raw) return map; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) { + for (const [key, value] of Object.entries(parsed as StoredDrafts)) { + if (!key.startsWith("sent:") && isValidDraftState(value)) { + map.set(key, value); + } + } + } + } catch { + // Match readStore's fail-open corruption behavior without mutating an + // inactive workspace while its owner is away. + } + return map; +} + +function flushExplicitStore( + scope: string, + map: Map, +): boolean { + const value = Object.fromEntries(map); + return setLocalStorageItemWithRecovery(scope, JSON.stringify(value)); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } @@ -301,6 +334,14 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined { return readStore().get(draftKey); } +/** Read a draft from the workspace scope captured before an async send. */ +export function loadDraftEntryForScope( + scope: string, + draftKey: string, +): DraftState | undefined { + return readExplicitStore(scope).get(draftKey); +} + export function deleteDraftEntry(draftKey: string): void { discardQueuedAttachmentsForDraft(draftKey); clearDraftEntry(draftKey); @@ -461,6 +502,55 @@ export function persistDraftEntry( } } +/** + * Recover an async send into the exact workspace that owned it, even when the + * active relay/identity changed while publication was in flight. + */ +export function persistDraftEntryForScope( + scope: string, + draftKey: string, + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], + mentionRefs: DraftMentionRef[] = [], +): void { + if (scope === storageKey()) { + persistDraftEntry( + draftKey, + content, + channelId, + pendingImeta, + spoileredAttachmentUrls, + mentionRefs, + ); + return; + } + const map = readExplicitStore(scope); + const hasContent = content.trim().length > 0 || pendingImeta.length > 0; + if (!hasContent) { + map.delete(draftKey); + flushExplicitStore(scope, map); + return; + } + const existing = map.get(draftKey); + const now = new Date().toISOString(); + map.set(draftKey, { + content, + selectionEnd: content.length, + selectionStart: content.length, + channelId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + pendingImeta, + mentionRefs, + spoileredAttachmentUrls, + status: "active", + }); + evictOldest(map); + flushExplicitStore(scope, map); +} + /** * Returns all drafts sorted most-recently-updated first. * Used by the Drafts inbox panel (Phase 2). diff --git a/desktop/src/features/messages/ui/BestieMessagePopover.tsx b/desktop/src/features/messages/ui/BestieMessagePopover.tsx index dd030482ee..fd31f1be6d 100644 --- a/desktop/src/features/messages/ui/BestieMessagePopover.tsx +++ b/desktop/src/features/messages/ui/BestieMessagePopover.tsx @@ -50,6 +50,19 @@ export function BestieMessagePanel({ mentionPubkeys: string[], mediaTags?: string[][], ) => { + // The action can outlive its rendered community while opening the DM and + // preparing optimistic state. Capture both ownership dimensions before + // the first await and require the backend to reject publication if either + // one changes in flight. + const expectedRelayUrl = activeCommunity?.relayUrl; + const expectedSignerPubkey = identityQuery.data?.pubkey; + if (!expectedRelayUrl || !expectedSignerPubkey) { + const error = new Error( + "Bestie messages require an active community and signing identity.", + ); + toast.error(`Couldn't send to ${bestie.name}`); + throw error; + } const { rootId } = getThreadReference(message.tags ?? []); const link = buildMessageLink({ channelId, @@ -64,14 +77,17 @@ export function BestieMessagePanel({ try { const dm = await openDmMutation.mutateAsync({ pubkeys: [bestie.pubkey], - expectedRelayUrl: activeCommunity?.relayUrl, - expectedSignerPubkey: identityQuery.data?.pubkey, + expectedRelayUrl, + expectedSignerPubkey, }); await sendMessageMutation.mutateAsync({ content, + expectedRelayUrl, + expectedSignerPubkey, mediaTags, mentionPubkeys, targetChannel: dm, + transport: "http", }); onClose(); toast.success(`Sent to ${bestie.name}`); diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index e5f08b6ebd..4c65da2422 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -232,12 +232,16 @@ import { act } from "react"; // Production hook under test — owns the restore effect, cleanup, and the // synchronous ref write that is the StrictMode fix. -import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; +import { + markRecoveredDraftRestorable, + useDraftPersistLifecycle, +} from "./useDraftPersistSnapshot.ts"; // Real storage functions — the test uses them, not a replica. import { clearAllDrafts, deleteDraftEntry, + getDraftStoreScope, initDraftStore, loadDraftEntry, persistDraftEntry, @@ -639,6 +643,53 @@ test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async ( await remounted.unmount(); }); +test("draft_lifecycle_restores_an_async_send_recovery_after_optimistic_clear", async () => { + const DRAFT_KEY = "chan-send-recovery"; + const ORIGINAL = "recover this failed send"; + setupStore("pubkey-send-recovery"); + persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ORIGINAL); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []); + markRecoveredDraftRestorable(getDraftStoreScope(), DRAFT_KEY); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ORIGINAL); + await remounted.unmount(); +}); + test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { const DRAFT_KEY = "chan-clear-caption-image"; setupStore("pubkey-clear-caption-image"); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 694bf6a2a5..eb0d0d5d42 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -76,6 +76,17 @@ function scopedDraftKey(draftKey: string): string { return `${getDraftStoreScope()}:${draftKey}`; } +/** + * Make an asynchronously recovered draft visible again after the optimistic + * send path marked its previous empty editor state as authoritative. + */ +export function markRecoveredDraftRestorable( + scope: string, + draftKey: string, +): void { + authoritativelyClearedDraftKeys.delete(`${scope}:${draftKey}`); +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index ab7d8c2f4d..243e069b9a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -32,6 +32,7 @@ export type PendingNonMemberMentionSend = { savedSpoileredAttachmentUrls: Set; sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; + recoveryDraftStoreScope: string; savedMentionRefs: DraftMentionRef[]; }; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 484b451207..d67475b384 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -24,7 +24,13 @@ import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + getDraftStoreScope, + loadDraftEntryForScope, + persistDraftEntryForScope, +} from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; +import { markRecoveredDraftRestorable } from "./useDraftPersistSnapshot"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; @@ -335,8 +341,9 @@ export function useMentionSendFlow({ ? prepareBackgroundMediaUpload(draft.queuedAttachments) : null; const persistPreflightDraft = () => { - if (isSendCancelled() || !draft.recoveryDraftKey) return; - drafts.persistDraft( + if (!draft.recoveryDraftKey) return; + persistDraftEntryForScope( + draft.recoveryDraftStoreScope, draft.recoveryDraftKey, draft.savedContent, draft.capturedChannelId ?? draft.recoveryDraftKey, @@ -344,14 +351,21 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, ); + markRecoveredDraftRestorable( + draft.recoveryDraftStoreScope, + draft.recoveryDraftKey, + ); saveQueuedAttachmentsForDraft( draft.recoveryDraftKey, draft.queuedAttachments, ); }; const persistCanceledDraft = () => { - if (isSendCancelled() || !draft.recoveryDraftKey) return; - const existing = drafts.loadDraft(draft.recoveryDraftKey); + if (!draft.recoveryDraftKey) return; + const existing = loadDraftEntryForScope( + draft.recoveryDraftStoreScope, + draft.recoveryDraftKey, + ); if ( existing && (existing.content !== draft.savedContent || @@ -364,7 +378,8 @@ export function useMentionSendFlow({ ) { return; } - drafts.persistDraft( + persistDraftEntryForScope( + draft.recoveryDraftStoreScope, draft.recoveryDraftKey, draft.savedContent, draft.capturedChannelId ?? draft.recoveryDraftKey, @@ -372,6 +387,10 @@ export function useMentionSendFlow({ [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, ); + markRecoveredDraftRestorable( + draft.recoveryDraftStoreScope, + draft.recoveryDraftKey, + ); }; let composerCleared = false; let optimisticComposerContent = ""; @@ -681,6 +700,7 @@ export function useMentionSendFlow({ if (isMentionSendPendingRef.current) { return; } + const recoveryDraftStoreScope = getDraftStoreScope(); isMentionSendPendingRef.current = true; setIsMentionSendPending(true); const isSendCancelled = () => @@ -798,6 +818,7 @@ export function useMentionSendFlow({ savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, recoveryDraftKey, + recoveryDraftStoreScope, savedMentionRefs, }; if (promptNonMemberPubkeys.length > 0) { diff --git a/desktop/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts index cbef0328d7..b2577e1cb2 100644 --- a/desktop/tests/e2e/bestie-sidebar.spec.ts +++ b/desktop/tests/e2e/bestie-sidebar.spec.ts @@ -28,6 +28,10 @@ const bestie = { relayUrl: RELAY_A, status: "running" as const, }; +const bestieInCommunityB = { + ...bestie, + relayUrl: COMMUNITY_B.relayUrl, +}; async function seedCommunities( page: import("@playwright/test").Page, @@ -444,3 +448,115 @@ test("a message can be handed to Bestie with an optional note", async ({ ); await expect(sentMessage).toContainText("Open original message"); }); + +test("a Bestie handoff fails closed across a community switch and preserves its draft", async ({ + page, +}) => { + await installMockBridge( + page, + { + managedAgents: [bestie, bestieInCommunityB], + sendMessageDelayMs: 1_000, + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const messageId = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + return emit({ + channelName: "general", + content: "Keep this handoff in its original community", + id: "e".repeat(64), + }).id; + }); + + const draft = "This note must stay in Alpha."; + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await row.getByTestId(`send-to-bestie-${messageId}`).click(); + const popover = page.getByTestId(`bestie-popover-${messageId}`); + const editor = popover.locator('[contenteditable="true"]'); + await editor.fill(draft); + await popover.getByRole("button", { name: "Send" }).click(); + + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => entry.command === "send_channel_message", + )?.payload, + ), + ) + .toMatchObject({ + content: expect.stringContaining(draft), + expectedRelayUrl: RELAY_A, + expectedSignerPubkey: OWNER_PUBKEY, + }); + + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(COMMUNITY_B.id); + await page.waitForTimeout(1_150); + + // The two fixtures intentionally resolve to the same participant-derived + // DM id. An unscoped send would therefore appear in both tenant views. + await page + .getByTestId("sidebar-channel-content") + .getByRole("button", { name: /Bestie/ }) + .first() + .click(); + await expect(page.getByTestId("chat-title")).toHaveText("Bestie"); + await expect(page.getByTestId("message-timeline")).not.toContainText(draft); + + await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(COMMUNITY_A.id); + await page + .getByTestId("sidebar-channel-content") + .getByRole("button", { name: /Bestie/ }) + .first() + .click(); + await expect(page.getByTestId("chat-title")).toHaveText("Bestie"); + await expect(page.getByTestId("message-timeline")).not.toContainText(draft); + + await page.getByTestId("channel-general").click(); + await page.evaluate((id) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + emit({ + channelName: "general", + content: "Keep this handoff in its original community", + id, + }); + }, messageId); + const restoredRow = page.locator(`[data-message-id="${messageId}"]`); + await restoredRow.hover(); + await restoredRow.getByTestId(`send-to-bestie-${messageId}`).click(); + const restoredPopover = page.getByTestId(`bestie-popover-${messageId}`); + await expect(restoredPopover.locator('[contenteditable="true"]')).toHaveText( + draft, + ); + await page.evaluate(() => { + if (window.__BUZZ_E2E__?.mock) { + window.__BUZZ_E2E__.mock.sendMessageDelayMs = 0; + } + }); + await restoredPopover.getByRole("button", { name: "Send" }).click(); + await expect(restoredPopover).toBeHidden(); + await page.getByTestId("open-bestie-dm").click(); + await expect(page.getByTestId("message-timeline")).toContainText(draft); +});