diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9c1a91ea410..82ad3f0aff7 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -325,24 +325,31 @@ export function useOpenDmMutation() { ); }, onSettled: () => { - void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + // The relay-returned DM is already in the cache. Mark the list stale so + // the normal live/poll refresh can reconcile it later without putting a + // full get_channels round-trip on the critical path to the conversation. + void queryClient.invalidateQueries({ + queryKey: channelsQueryKey, + refetchType: "none", + }); }, }); } /** - * Waits for any active channel-list refresh to settle, then restores a - * relay-returned channel to the shared cache before a caller depends on it for - * navigation. + * Reasserts a relay-returned channel in the shared cache before a caller + * depends on it for navigation. The open-DM mutation already made the relay + * write authoritative, so cancel any older list read and stay local rather + * than blocking on a read-after-write channel-list refresh. */ export function useUpsertCachedChannel() { const queryClient = useQueryClient(); return React.useCallback( async (channel: Channel) => { - await queryClient.refetchQueries({ + await queryClient.cancelQueries({ queryKey: channelsQueryKey, - type: "active", + exact: true, }); queryClient.setQueryData(channelsQueryKey, (current) => reconcileRefreshedCachedChannel(current, channel), diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 9091121d0bf..f0b22ef8f12 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -427,6 +427,7 @@ export function useSendMessageMutation( mediaTags?: string[][]; sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; + transport?: "auto" | "http"; }, MessageQueryContext | undefined >({ @@ -439,6 +440,7 @@ export function useSendMessageMutation( mediaTags, sentFromThreadRootId, sentFromThreadRootExcerpt, + transport = "auto", }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -498,6 +500,7 @@ export function useSendMessageMutation( // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. if ( + transport === "http" || parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f94dd5d1e1f..f7192f6e45c 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -263,6 +263,11 @@ export function NewMessageScreen() { content, mentionPubkeys, mediaTags, + // A newly opened DM is not subscribed yet, so publish its first + // message through the acknowledged HTTP path. This avoids holding + // the entire navigation on a WebSocket OK frame that staging may + // never deliver. + transport: "http", }); } catch (error) { preparedDirectMessageRef.current = null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 46bda44ffb7..9128072568a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9043,6 +9043,16 @@ async function handleSendChannelMessage( ); } + // Mirror the WebSocket send path's failure injection so specs that route + // the first message through the acknowledged HTTP transport still exercise + // `sendMessageErrors`. The real command rejects on a relay `OK false`, which + // surfaces to callers as a thrown error carrying the relay reason. + const sendMessageError = + kind === 9 ? config?.mock?.sendMessageErrors?.shift() : null; + if (sendMessageError) { + throw new Error(sendMessageError); + } + // NIP-92 imeta attachments. The real relay echoes these back on the stored // event; mirror that here so attachment renderers (FileCard, images, video) // have the imeta tags they key on. `null`/empty → no extra tags. diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ab7eac41b06..303b69f3125 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -780,33 +780,18 @@ test("creates the DM before preparing a persona mention", async ({ page }) => { expect(expandedOpenIndex).toBeLessThan(startIndex); expect(sendCommands).not.toContain("add_channel_members"); - const sentMessageCommand = sendCommandPayloads.find((entry) => { - if (entry.command !== "plugin:websocket|send") { - return false; - } - const data = (entry.payload as { message?: { data?: string } } | undefined) - ?.message?.data; - if (!data) { - return false; - } - const frame = JSON.parse(data) as unknown[]; - return ( - frame[0] === "EVENT" && - (frame[1] as { content?: string } | undefined)?.content.includes( - "for a hand", - ) - ); - }); - const sentMessageData = ( - sentMessageCommand?.payload as { message?: { data?: string } } | undefined - )?.message?.data; - expect(sentMessageData).toBeTruthy(); - const sentMessageEvent = ( - JSON.parse(sentMessageData ?? "[]") as [string, { tags?: string[][] }] - )[1]; - const sentChannelId = sentMessageEvent.tags?.find( - (tag) => tag[0] === "h", - )?.[1]; + const sentMessageCommand = sendCommandPayloads.find( + (entry) => + entry.command === "send_channel_message" && + ( + entry.payload as { content?: string; channelId?: string } | undefined + )?.content?.includes("for a hand"), + ); + const sentChannelId = ( + sentMessageCommand?.payload as + | { content?: string; channelId?: string } + | undefined + )?.channelId; expect(sentChannelId).toBeTruthy(); await expect( page.locator("[data-active='true'][data-channel-id]"), @@ -1047,7 +1032,15 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { await expect(input).toContainText("Fizz"); const commandsAfterFailure = await readCommandPayloadLog(page); - const failedSendChannelId = await readOutgoingChannelId(page, "for a hand"); + const failedSendChannelId = ( + commandsAfterFailure.find( + (entry) => + entry.command === "send_channel_message" && + ( + entry.payload as { content?: string; channelId?: string } | undefined + )?.content?.includes("for a hand"), + )?.payload as { content?: string; channelId?: string } | undefined + )?.channelId; expect(failedSendChannelId).toBeTruthy(); expect(commandsAfterFailure.map((entry) => entry.command)).not.toContain( "add_channel_members", @@ -1074,29 +1067,15 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { ), ).toBe(baselineOpenDmCount + 1); const retryCommands = allCommands.slice(retryBaseline); - const retrySend = retryCommands.find((entry) => { - if (entry.command !== "plugin:websocket|send") { - return false; - } - const data = (entry.payload as { message?: { data?: string } } | undefined) - ?.message?.data; - if (!data) { - return false; - } - const frame = JSON.parse(data) as unknown[]; - return ( - frame[0] === "EVENT" && - (frame[1] as { content?: string } | undefined)?.content === retryMessage - ); - }); - const retrySendData = ( - retrySend?.payload as { message?: { data?: string } } | undefined - )?.message?.data; - expect(retrySendData).toBeTruthy(); - const retryEvent = ( - JSON.parse(retrySendData ?? "[]") as [string, { tags?: string[][] }] - )[1]; - const retryChannelId = retryEvent.tags?.find((tag) => tag[0] === "h")?.[1]; + const retrySend = retryCommands.find( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string; channelId?: string } | undefined) + ?.content === retryMessage, + ); + const retryChannelId = ( + retrySend?.payload as { content?: string; channelId?: string } | undefined + )?.channelId; expect(retryChannelId).toBeTruthy(); expect(retryChannelId).not.toBe(failedSendChannelId); await expect( @@ -1230,7 +1209,7 @@ test("does not reopen a direct message after leaving the composer", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); }); -test("does not reopen a sent direct message after leaving during cache reseed", async ({ +test("opens a sent direct message without waiting for a channel-list refresh", async ({ page, }) => { await page.goto("/"); @@ -1240,29 +1219,37 @@ test("does not reopen a sent direct message after leaving during cache reseed", await page .getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`) .click(); - const staleMessage = "Stay on the channel after cache reseed"; - await page.getByTestId("message-input").fill(staleMessage); + const message = "Open without a channel-list refresh"; + await page.getByTestId("message-input").fill(message); + const baselineChannelsReads = commandCount( + await readCommandLog(page), + "get_channels", + ); + const baselineHttpSends = commandCount( + await readCommandLog(page), + "send_channel_message", + ); await page.evaluate(() => { const testWindow = window as Window & { __BUZZ_E2E__?: { mock?: { channelsReadDelayMs?: number } }; }; testWindow.__BUZZ_E2E__ ??= {}; testWindow.__BUZZ_E2E__.mock ??= {}; - testWindow.__BUZZ_E2E__.mock.channelsReadDelayMs = 1_000; + testWindow.__BUZZ_E2E__.mock.channelsReadDelayMs = 3_000; }); await page.getByTestId("send-message").click(); - await expect - .poll(async () => hasOutgoingEventWithContent(page, staleMessage)) - .toBe(true); - - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await page.waitForTimeout(1_250); - await expect(page).toHaveURL( - new RegExp(`/channels/${GENERAL_CHANNEL_ID}(?:\\?|$)`), + await expect(page.getByTestId("chat-title")).toHaveText("charlie", { + timeout: 1_000, + }); + await expect(page.getByTestId("message-timeline")).toContainText(message); + expect(commandCount(await readCommandLog(page), "get_channels")).toBe( + baselineChannelsReads, ); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + expect(commandCount(await readCommandLog(page), "send_channel_message")).toBe( + baselineHttpSends + 1, + ); + await expect(page).toHaveURL(/\/channels\/[0-9a-f-]+(?:\?|$)/); }); test("shows capped participant stack in group direct message header", async ({