fix(desktop): hydrate reactions for Inbox context messages (#1596)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-07 12:29:56 -07:00
committed by GitHub
co-authored by Brain
parent 7f1952d7de
commit cdf982bdb3
5 changed files with 257 additions and 2 deletions
+1
View File
@@ -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",
+6 -1
View File
@@ -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;
}
@@ -394,6 +397,7 @@ export function HomeView({
selectedChannel,
selectedItem,
threadContext.events,
threadContext.reactionEvents,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
@@ -742,6 +746,7 @@ export function HomeView({
eventId: message.id,
remove,
});
await threadContext.refreshReactions();
await channelMessagesQuery.refetch();
onRefresh();
}
@@ -4,6 +4,7 @@ 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";
@@ -11,6 +12,10 @@ 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;
@@ -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,
};
}
+34 -1
View File
@@ -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
@@ -3313,6 +3315,7 @@ function emitMockChannelMessage(
mentionPubkeys?: string[],
extraTags?: string[][],
createdAt?: number,
id?: string,
) {
const eventKind = kind ?? 9;
if (!parentEventId) {
@@ -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;
@@ -3353,6 +3363,7 @@ function emitMockChannelMessage(
tags,
authorPubkey,
createdAt,
id,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
@@ -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;
}
@@ -7751,6 +7782,7 @@ export function maybeInstallE2eTauriMocks() {
mentionPubkeys,
extraTags,
createdAt,
id,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
@@ -7768,6 +7800,7 @@ export function maybeInstallE2eTauriMocks() {
mentionPubkeys,
extraTags,
createdAt,
id,
);
};
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory;
+152
View File
@@ -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` });
});