mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` });
|
||||
});
|
||||
Reference in New Issue
Block a user