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
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
64 changes: 64 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,67 @@ 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 (error) {
console.error(
"Failed to hydrate reactions for Inbox context messages",
selectedChannelId,
error,
);
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
152 changes: 152 additions & 0 deletions desktop/tests/e2e/inbox-reactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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.
// A second, unrelated mention is seeded so the test can genuinely switch
// selection away and back.
const { replyEvent, otherEvent } = 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",
});

const other = emitMessage({
channelName: "general",
content: "Unrelated mention for selection switching.",
pubkey: senderPubkey,
mentionPubkeys: [currentPubkey],
id: "c3".repeat(32),
});
pushFeedItem({
id: other.id,
kind: other.kind,
pubkey: other.pubkey,
content: other.content,
created_at: other.created_at,
channel_id: channelId,
channel_name: "general",
tags: other.tags,
category: "mention",
});
return { replyEvent: reply, otherEvent: other };
},
{
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. Select a genuinely
// different item first so the selection actually changes.
const otherItem = page.getByTestId(`home-inbox-item-${otherEvent.id}`);
await otherItem.click();
await expect(detail).toContainText("Unrelated mention");
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