fix(desktop): project sends into open thread cache

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-07-17 19:57:24 -04:00
parent 738d456367
commit d7b6d5de07
4 changed files with 77 additions and 2 deletions

View File

@@ -74,6 +74,8 @@ type MessageQueryContext = {
previousWindow: ChannelWindowStore | undefined;
channelId: string;
queryKey: ReturnType<typeof channelMessagesKey>;
threadQueryKey?: ReturnType<typeof threadRepliesKey>;
previousThreadReplies?: RelayEvent[];
};
const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
@@ -565,6 +567,17 @@ export function useSendMessageMutation(
mediaTags ?? [],
);
const threadRootId = getThreadReference(optimisticMessage.tags).rootId;
const threadQueryKey = threadRootId
? threadRepliesKey(effectiveChannel.id, threadRootId)
: undefined;
if (threadQueryKey) {
await queryClient.cancelQueries({ queryKey: threadQueryKey });
}
const previousThreadReplies = threadQueryKey
? (queryClient.getQueryData<RelayEvent[]>(threadQueryKey) ?? [])
: undefined;
const nextWindow = mergeLiveChannelWindowEvent(
previousWindow ?? emptyChannelWindowStore(),
optimisticMessage,
@@ -572,12 +585,21 @@ export function useSendMessageMutation(
queryClient.setQueryData(windowKey, nextWindow);
projectChannelWindowMessages(queryClient, effectiveChannel.id);
if (threadQueryKey) {
queryClient.setQueryData<RelayEvent[]>(
threadQueryKey,
mergeMessages(previousThreadReplies ?? [], optimisticMessage),
);
}
return {
optimisticId: optimisticMessage.id,
previousMessages,
previousWindow,
channelId: effectiveChannel.id,
queryKey,
threadQueryKey,
previousThreadReplies,
};
},
onError: (error, _variables, context) => {
@@ -594,6 +616,12 @@ export function useSendMessageMutation(
channelWindowKey(context.channelId),
context.previousWindow,
);
if (context.threadQueryKey) {
queryClient.setQueryData(
context.threadQueryKey,
context.previousThreadReplies,
);
}
},
onSuccess: (message, _variables, context) => {
// An accepted send proves the write-block is lifted; clear any recorded
@@ -619,6 +647,16 @@ export function useSendMessageMutation(
});
queryClient.setQueryData(windowKey, next);
projectChannelWindowMessages(queryClient, context.channelId);
if (context.threadQueryKey) {
queryClient.setQueryData<RelayEvent[]>(
context.threadQueryKey,
(current = []) =>
mergeMessages(current, {
...message,
localKey: context.optimisticId,
}),
);
}
},
});
}

View File

@@ -164,6 +164,8 @@ type E2eConfig = {
applyCommunityDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Persist sends without echoing them to live subscriptions. */
suppressLiveMessageEcho?: boolean;
/** Reject successive kind-9 sends with these messages, then resume. */
sendMessageErrors?: string[];
/** Reject successive managed-agent starts, then resume. */
@@ -7660,7 +7662,9 @@ async function handleSendChannelMessage(
...extraTags,
]);
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
if (!config?.mock?.suppressLiveMessageEcho) {
emitMockLiveEvent(args.channelId, event);
}
return {
event_id: event.id,
@@ -7723,7 +7727,9 @@ async function handleSendChannelMessage(
};
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
if (!config?.mock?.suppressLiveMessageEcho) {
emitMockLiveEvent(args.channelId, event);
}
return {
event_id: event.id,

View File

@@ -1158,6 +1158,35 @@ test("thread refetch preserves a live reply and reaction received in flight", as
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
});
test("thread reply appears without a live subscription echo", async ({
page,
}) => {
await installMockBridge(page, { suppressLiveMessageEcho: true });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const seed = `Thread no-echo seed ${Date.now()}`;
await page.getByTestId("message-input").fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
const reply = `Thread reply without echo ${Date.now()}`;
await threadPanel.getByTestId("message-input").fill(reply);
await threadPanel.getByTestId("send-message").click();
await expect(threadPanel).toContainText(reply);
await expect(threadPanel.getByTestId("message-input")).toBeEnabled();
});
test("thread composer keeps focus after sending a thread reply", async ({
page,
}) => {

View File

@@ -184,6 +184,8 @@ type MockBridgeOptions = {
applyCommunityDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Persist sends without echoing them to live subscriptions. */
suppressLiveMessageEcho?: boolean;
/** Reject successive kind-9 sends with these messages, then resume. */
sendMessageErrors?: string[];
/** Reject successive managed-agent starts, then resume. */