Skip to content
Draft
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
39 changes: 39 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import {
getContextMessageDepth,
getInboxDefaultReplyParentEventId,
getReactionTargetId,
isInboxThreadContextEvent,
matchesInboxFilter,
Expand Down Expand Up @@ -184,6 +185,44 @@ test("getContextMessageDepth does not loop forever on a cycle", () => {
assert.equal(getContextMessageDepth(a, map), 1);
});

// --- getInboxDefaultReplyParentEventId ---

function feedItem(id, tags = []) {
return {
id,
pubkey: "x",
createdAt: 0,
kind: 9,
tags,
content: "",
category: "activity",
channelId: "channel-a",
channelName: "bugs",
};
}

test("getInboxDefaultReplyParentEventId uses the selected item's parent for reply rows", () => {
assert.equal(
getInboxDefaultReplyParentEventId(
feedItem("latest-reply", [
["h", "channel-a"],
["e", "thread-root", "", "root"],
["e", "parent-comment", "", "reply"],
]),
),
"parent-comment",
);
});

test("getInboxDefaultReplyParentEventId falls back to the selected item for roots", () => {
assert.equal(
getInboxDefaultReplyParentEventId(
feedItem("thread-root", [["h", "channel-a"]]),
),
"thread-root",
);
});

// --- isInboxThreadContextEvent ---

function channelEvent(id, tags = []) {
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,14 @@ export function getReactionTargetId(tags: string[][]) {

return null;
}

export function getInboxDefaultReplyParentEventId(item: FeedItem): string {
const thread = getThreadReference(item.tags);

// Inbox rows are grouped by thread and surface the latest activity. If the
// latest activity is itself a reply, sending from the bottom composer should
// continue the selected item's parent thread, not create a new child under
// that latest activity. Explicit per-message reply targets still override
// this default in InboxDetailPane.
return thread.parentId ?? thread.rootId ?? item.id;
}
4 changes: 3 additions & 1 deletion desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type InboxDisplayMessage,
InboxMessageRow,
} from "@/features/home/ui/InboxMessageRow";
import { getInboxDefaultReplyParentEventId } from "@/features/home/lib/inboxViewHelpers";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
Expand Down Expand Up @@ -210,7 +211,8 @@ export function InboxDetailPane({
];
const replyTarget =
displayMessages.find((message) => message.id === replyTargetId) ?? null;
const composerParentEventId = replyTarget?.id ?? item.id;
const composerParentEventId =
replyTarget?.id ?? getInboxDefaultReplyParentEventId(item.item);
const composerReplyTarget =
replyTarget && replyTarget.id !== item.id
? {
Expand Down
74 changes: 74 additions & 0 deletions desktop/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,80 @@ test("inbox feed shows channel and agent activity sections", async ({
);
});

test("inbox composer replies to the selected thread parent, not the latest comment", async ({
page,
}) => {
await page.goto("/");

const { firstReplyId, latestReplyId, latestReplyContent } =
await page.evaluate(() => {
const emitMessage = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock Buzz bridge helpers are unavailable.");
}

const root = emitMessage({
channelName: "general",
content: "Inbox parent target root",
});
const firstReply = emitMessage({
channelName: "general",
content: "Inbox parent target first reply",
parentEventId: root.id,
});
const latestReply = emitMessage({
channelName: "general",
content: "Inbox parent target latest reply",
parentEventId: firstReply.id,
});

pushFeedItem({
id: latestReply.id,
kind: latestReply.kind,
pubkey: latestReply.pubkey,
content: latestReply.content,
created_at: latestReply.created_at,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: latestReply.tags,
category: "activity",
});

return {
firstReplyId: firstReply.id,
latestReplyId: latestReply.id,
latestReplyContent: latestReply.content,
};
});

const reply = `Inbox parent-target reply ${Date.now()}`;
const inboxList = page.getByTestId("home-inbox-list");
await expect(inboxList).toContainText(latestReplyContent);
await inboxList.getByText(latestReplyContent).click();
await expect(page.getByTestId("home-inbox-detail")).toContainText(
latestReplyContent,
);

await page.getByTestId("message-input").fill(reply);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("home-inbox-detail")).toContainText(reply);

const sendPayload = await page.evaluate((content) => {
const entry = [...(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])]
.reverse()
.find(
(candidate) =>
candidate.command === "send_channel_message" &&
(candidate.payload as { content?: string }).content === content,
);
return entry?.payload as { parentEventId?: string | null } | undefined;
}, reply);

expect(sendPayload?.parentEventId).toBe(firstReplyId);
expect(sendPayload?.parentEventId).not.toBe(latestReplyId);
});

test("opens a mocked forum activity item from the inbox feed", async ({
page,
}) => {
Expand Down
Loading