Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default defineConfig({
"**/human-edit-agent-content.spec.ts",
"**/reaction-order.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,10 @@ export function HomeView({
threadContext.events.map((event) => [event.id, event]),
);
const contextEventIds = new Set(eventById.keys());
const reactionEvents = (channelMessages ?? []).filter((event) => {
const reactionEvents = [
...(channelMessages ?? []),
...threadContext.reactionEvents,
].filter((event) => {
if (event.kind !== KIND_REACTION) {
return false;
}
Expand Down Expand Up @@ -394,6 +397,7 @@ export function HomeView({
selectedChannel,
selectedItem,
threadContext.events,
threadContext.reactionEvents,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
Expand Down Expand Up @@ -742,6 +746,7 @@ export function HomeView({
eventId: message.id,
remove,
});
await threadContext.refreshReactions();
await channelMessagesQuery.refetch();
onRefresh();
}
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/features/home/useInboxThreadContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers"
import { relayEventFromFeedItem } from "@/features/home/lib/inbox";
import { getThreadReference } from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
import { getEventById } from "@/shared/api/tauri";
import type { FeedItem, RelayEvent } from "@/shared/api/types";
import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds";

type InboxThreadContextResult = {
events: RelayEvent[];
isLoading: boolean;
/** kind:7 events referencing the context messages, fetched by `#e`. */
reactionEvents: RelayEvent[];
/** Re-fetch reaction events (e.g. after a toggle) without reloading context. */
refreshReactions: () => Promise<void>;
};

const THREAD_CONTEXT_LIMIT = 100;
Expand Down Expand Up @@ -176,8 +181,62 @@ export function useInboxThreadContext(
selectedThreadRootId,
]);

// Reactions carry only an `#e` reference, so the channel-window cache never
// has them for thread replies — fetch them for the rendered context messages.
const [reactionEvents, setReactionEvents] = React.useState<RelayEvent[]>([]);
const contextEventIdsKey = React.useMemo(
() =>
events
.map((event) => event.id)
.sort()
.join(","),
[events],
);

const fetchReactions = React.useCallback(async (): Promise<
RelayEvent[] | null
> => {
const eventIds = contextEventIdsKey ? contextEventIdsKey.split(",") : [];
if (!selectedChannelId || eventIds.length === 0) {
return [];
}

try {
return await relayClient.fetchAuxEventsByReference(
selectedChannelId,
eventIds,
buildChannelReactionAuxFilter,
);
} catch {
return null;
}
}, [contextEventIdsKey, selectedChannelId]);

React.useEffect(() => {
let isCancelled = false;

void fetchReactions().then((fetched) => {
if (!isCancelled && fetched !== null) {
setReactionEvents(fetched);
}
});

return () => {
isCancelled = true;
};
}, [fetchReactions]);

const refreshReactions = React.useCallback(async () => {
const fetched = await fetchReactions();
if (fetched !== null) {
setReactionEvents(fetched);
}
}, [fetchReactions]);

return {
events,
isLoading,
reactionEvents,
refreshReactions,
};
}
35 changes: 34 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ declare global {
mentionPubkeys?: string[];
extraTags?: string[][];
createdAt?: number;
/** 64-hex id required for the event to be a valid reaction target. */
id?: string;
}) => RelayEvent;
/** Prepend `count` synthetic older messages to a channel's mock store so
* an older-history fetch has something to paginate. Mirrors how the real
Expand Down Expand Up @@ -3313,6 +3315,7 @@ function emitMockChannelMessage(
mentionPubkeys?: string[],
extraTags?: string[][],
createdAt?: number,
id?: string,
) {
const eventKind = kind ?? 9;
if (!parentEventId) {
Expand All @@ -3322,7 +3325,14 @@ function emitMockChannelMessage(
pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
);
if (extraTags) tags.push(...extraTags);
const event = createMockEvent(eventKind, content, tags, pubkey, createdAt);
const event = createMockEvent(
eventKind,
content,
tags,
pubkey,
createdAt,
id,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
return event;
Expand Down Expand Up @@ -3353,6 +3363,7 @@ function emitMockChannelMessage(
tags,
authorPubkey,
createdAt,
id,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
Expand Down Expand Up @@ -7588,6 +7599,26 @@ function sendToMockSocket(args: {

const channelId = filter["#h"]?.[0];
if (!channelId) {
// Aux-backfill filters (reactions/deletions) are `#e`-keyed with no
// channel tag — serve them across all channel stores like the relay.
const referencedIds = filter["#e"];
if (referencedIds && referencedIds.length > 0) {
const targets = new Set(referencedIds);
for (const events of mockMessages.values()) {
for (const event of events) {
if (filter.kinds && !filter.kinds.includes(event.kind)) {
continue;
}
if (
event.tags.some(
(tag) => tag[0] === "e" && tag[1] && targets.has(tag[1]),
)
) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
}
}
}
sendWsText(socket.handler, ["EOSE", subId]);
return;
}
Expand Down Expand Up @@ -7751,6 +7782,7 @@ export function maybeInstallE2eTauriMocks() {
mentionPubkeys,
extraTags,
createdAt,
id,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
Expand All @@ -7768,6 +7800,7 @@ export function maybeInstallE2eTauriMocks() {
mentionPubkeys,
extraTags,
createdAt,
id,
);
};
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory;
Expand Down
131 changes: 131 additions & 0 deletions desktop/tests/e2e/inbox-reactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";

import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import type { RelayEvent } from "../../src/shared/api/types";

const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const SHOTS = "test-results/inbox-reactions";

type MockWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
/** 64-hex id — required for the message to be a valid reaction target. */
id?: string;
}) => RelayEvent;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
};

// Regression test: a reaction added from the Inbox detail pane must survive
// the post-toggle refetch. Inbox items are typically thread replies, which the
// server-assembled channel window does NOT carry reactions for — the Inbox
// must hydrate them by `#e` reference or the optimistic pill vanishes.
test("inbox reaction on a thread-reply mention persists after refetch", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});

// Seed a thread: alice's top-level message, then alice's reply mentioning
// tyler (the current user). The reply is the inbox item — the shape Wes hit.
const replyEvent = await page.evaluate(
({ channelId, currentPubkey, senderPubkey }) => {
const win = window as MockWindow;
const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!emitMessage || !pushFeedItem) {
throw new Error("Mock bridge helpers are not installed.");
}

const root = emitMessage({
channelName: "general",
content: "Thread root about the launch.",
pubkey: senderPubkey,
id: "a1".repeat(32),
});
const reply = emitMessage({
channelName: "general",
content: "Reply mentioning you — please react to this.",
parentEventId: root.id,
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "b2".repeat(32),
});

pushFeedItem({
id: reply.id,
kind: reply.kind,
pubkey: reply.pubkey,
content: reply.content,
created_at: reply.created_at,
channel_id: channelId,
channel_name: "general",
tags: reply.tags,
category: "mention",
});
return reply;
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);

// Open the inbox item and react via the hover action bar's quick reaction.
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
await item.click();
const detail = page.getByTestId("home-inbox-detail");
await expect(detail).toContainText("please react to this");

const selectedMessage = page.getByTestId("home-inbox-selected-message");
await selectedMessage.hover();
await selectedMessage
.getByRole("button", { name: "React with :+1:" })
.click();

// The pill must appear AND persist: the post-toggle refetch replaces the
// optimistic state with fetched reaction events. Give the refetch time to
// land before asserting, then assert the pill is still there.
const reactions = selectedMessage.getByTestId("message-reactions");
await expect(reactions).toContainText("👍");
await page.waitForTimeout(1_500);
await expect(reactions).toContainText("👍");
await page.screenshot({ path: `${SHOTS}/01-pill-after-refetch.png` });

// Re-select the item (drops all optimistic state) — the reaction must
// come back purely from the fetched relay data.
await page.getByTestId("inbox-filter-trigger").click();
await page.keyboard.press("Escape");
const otherItem = page.getByTestId("home-inbox-list");
await otherItem.click();
await item.click();
await expect(detail).toContainText("please react to this");
await expect(
page
.getByTestId("home-inbox-selected-message")
.getByTestId("message-reactions"),
).toContainText("👍");
await page.screenshot({ path: `${SHOTS}/02-pill-after-reselect.png` });
});
Loading