Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions desktop/src/features/channels/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Comment thread
klopez4212 marked this conversation as resolved.
},
});
}

/**
* 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<Channel[]>(channelsQueryKey, (current) =>
reconcileRefreshedCachedChannel(current, channel),
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export function useSendMessageMutation(
mediaTags?: string[][];
sentFromThreadRootId?: string | null;
sentFromThreadRootExcerpt?: string | null;
transport?: "auto" | "http";
},
MessageQueryContext | undefined
>({
Expand All @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/messages/ui/NewMessageScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
117 changes: 52 additions & 65 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]"),
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down Expand Up @@ -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("/");
Expand All @@ -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 ({
Expand Down
Loading