From 0bd12dfb0264435ec398c6152ce4a5fe93446ae6 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Mon, 6 Jul 2026 14:08:31 -0400 Subject: [PATCH] fix(desktop): fetch profiles for reaction actors Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- desktop/playwright.config.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 46 ++++++++-------- .../lib/formatTimelineMessages.test.mjs | 52 ++++++++++++++++++ .../messages/lib/formatTimelineMessages.ts | 34 ++++++++++++ .../messages/useIndependentThreadPanel.ts | 7 ++- desktop/tests/e2e/reaction-names.spec.ts | 53 +++++++++++++++++++ 6 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 desktop/tests/e2e/reaction-names.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac..2a4588f4c1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -67,6 +67,7 @@ export default defineConfig({ "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", + "**/reaction-names.spec.ts", "**/send-channel-binding.spec.ts", ], use: { diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 1e5cef9323..1a51cbc89d 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -38,6 +38,7 @@ import { import { collectMessageAuthorPubkeys, collectMessageMentionPubkeys, + collectReactionActorPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; import { @@ -52,6 +53,7 @@ import { } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; +import { useThreadReplies } from "@/features/messages/useThreadReplies"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -79,8 +81,8 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760; - +const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, + EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, currentIdentity, @@ -181,11 +183,13 @@ export function ChannelScreen({ }, [activeChannelId, openThreadHeadId]); const messagesQuery = useChannelMessagesQuery(activeChannel); const windowQuery = useChannelWindowQuery(activeChannel); + const threadRepliesQuery = useThreadReplies( + activeChannel, + effectiveOpenThreadHeadId, + ); useChannelSubscription(activeChannel); const { fetchOlder, hasOlderMessages, isFetchingOlder } = useFetchOlderMessages(activeChannel); - // Newest top-level message only: opening a channel should clear the timeline - // without clearing unread thread replies. const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; if (!messages) return null; @@ -196,8 +200,6 @@ export function ChannelScreen({ } return null; }, [messagesQuery.data]); - // No `lastMessageAt` fallback: it is reply-inclusive and would clear unread - // thread/sidebar state before a real top-level position is known. const activeReadAt = latestActiveMessage ? new Date(latestActiveMessage.created_at * 1_000).toISOString() : null; @@ -205,14 +207,8 @@ export function ChannelScreen({ if (!activeChannelId || activeChannel?.isMember === false) { return; } - // Passive channel-open (NIP-RS Option 1): advance the marker to the newest - // top-level message only, clearing the main timeline while thread badges - // and Home inbox thread activity stay intact until each thread is read. markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]); - // Install the NIP-RS parent resolver. Active `thread:`/`msg:` contexts fold - // to this channel (never another message), preserving ancestor/descendant - // isolation while channel reads cover top-level history. Clear on leave. React.useEffect(() => { if (!activeChannelId) { setContextParentResolver(null); @@ -260,14 +256,17 @@ export function ChannelScreen({ messagesQuery.data, targetMessageEvents, ]); - const messageAuthorPubkeys = React.useMemo( - () => collectMessageAuthorPubkeys(resolvedMessages), - [resolvedMessages], - ); - const messageMentionPubkeys = React.useMemo( - () => collectMessageMentionPubkeys(resolvedMessages), - [resolvedMessages], - ); + const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS; + const messageEventProfilePubkeys = React.useMemo(() => { + const events = [...resolvedMessages, ...threadReplyEvents]; + return [ + ...new Set([ + ...collectMessageAuthorPubkeys(events), + ...collectMessageMentionPubkeys(events), + ...collectReactionActorPubkeys(events), + ]), + ]; + }, [resolvedMessages, threadReplyEvents]); const latestMessageEvent = React.useMemo( () => resolvedMessages[resolvedMessages.length - 1] ?? null, [resolvedMessages], @@ -308,8 +307,7 @@ export function ChannelScreen({ const messageProfilePubkeys = React.useMemo( () => [ ...new Set([ - ...messageAuthorPubkeys, - ...messageMentionPubkeys, + ...messageEventProfilePubkeys, ...activeDmParticipantPubkeys, ...knownAgentPubkeys, ...typingEntries.map((entry) => entry.pubkey), @@ -318,8 +316,7 @@ export function ChannelScreen({ [ activeDmParticipantPubkeys, knownAgentPubkeys, - messageAuthorPubkeys, - messageMentionPubkeys, + messageEventProfilePubkeys, typingEntries, ], ); @@ -464,6 +461,7 @@ export function ChannelScreen({ const threadPanelData = useIndependentThreadPanel({ activeChannel, channelEvents: resolvedMessages, + threadReplyEvents, rootId: effectiveOpenThreadHeadId, replyTargetId: threadReplyTargetId, expandedReplyIds: expandedThreadReplyIds, diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index bedfb156e4..68804f16a9 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + collectReactionActorPubkeys, countTopLevelTimelineRows, formatTimelineMessages, isTimelineContentEvent, @@ -168,6 +169,57 @@ test("non-deletion event kinds do NOT hide the target message", () => { assert.equal(out.length, 1, "the kind:9 message should still be visible"); }); +test("collectReactionActorPubkeys returns active kind:7 actors only", () => { + const reactionId = `${"c".repeat(64)}`; + const deletedReactionId = `${"d".repeat(64)}`; + const actor = PUBKEY_B.toUpperCase(); + const events = [ + streamMessage(), + { + id: reactionId, + pubkey: actor, + kind: 7, + created_at: 1_700_000_001, + content: "+", + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ], + sig: "sig", + }, + { + id: `${"e".repeat(64)}`, + pubkey: PUBKEY_A, + kind: 7, + created_at: 1_700_000_002, + content: "🎉", + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["actor", actor], + ], + sig: "sig", + }, + { + id: deletedReactionId, + pubkey: PUBKEY_A, + kind: 7, + created_at: 1_700_000_003, + content: "👀", + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ], + sig: "sig", + }, + deletionEvent(5, deletedReactionId, { + id: `${"f".repeat(64)}`, + }), + ]; + + assert.deepEqual(collectReactionActorPubkeys(events), [PUBKEY_B]); +}); + test("huddle start renders as a timeline row", () => { const out = formatTimelineMessages([huddleStarted()], null, undefined, null); assert.equal(out.length, 1); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 1a2018c923..9dbe80f07a 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -484,6 +484,40 @@ function extractSystemMessagePubkeys(event: RelayEvent): string[] { } } +export function collectReactionActorPubkeys(events: RelayEvent[]) { + const deletedEventIds = new Set(); + for (const event of events) { + if ( + event.kind !== KIND_DELETION && + event.kind !== KIND_NIP29_DELETE_EVENT + ) { + continue; + } + for (const targetId of getDeletionTargets(event.tags)) { + deletedEventIds.add(targetId.toLowerCase()); + } + } + + const pubkeys = new Set(); + for (const event of events) { + if ( + event.kind !== KIND_REACTION || + deletedEventIds.has(event.id.toLowerCase()) + ) { + continue; + } + pubkeys.add( + resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }).toLowerCase(), + ); + } + return [...pubkeys]; +} + export function collectMessageAuthorPubkeys(events: RelayEvent[]) { const pubkeys = new Set(); diff --git a/desktop/src/features/messages/useIndependentThreadPanel.ts b/desktop/src/features/messages/useIndependentThreadPanel.ts index 65318ea516..d692335771 100644 --- a/desktop/src/features/messages/useIndependentThreadPanel.ts +++ b/desktop/src/features/messages/useIndependentThreadPanel.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { buildIndependentThreadPanel } from "@/features/messages/lib/independentThreadPanel"; -import { useThreadReplies } from "@/features/messages/useThreadReplies"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, @@ -13,6 +12,7 @@ import type { export function useIndependentThreadPanel(args: { activeChannel: Channel | null; channelEvents: RelayEvent[]; + threadReplyEvents: RelayEvent[]; rootId: string | null; replyTargetId: string | null; expandedReplyIds: ReadonlySet; @@ -23,12 +23,11 @@ export function useIndependentThreadPanel(args: { personaLookup: Map; respondToLookup: Map; }) { - const replies = useThreadReplies(args.activeChannel, args.rootId); return React.useMemo( () => buildIndependentThreadPanel( args.channelEvents, - replies.data ?? [], + args.threadReplyEvents, args.rootId, args.replyTargetId, args.expandedReplyIds, @@ -40,6 +39,6 @@ export function useIndependentThreadPanel(args: { args.personaLookup, args.respondToLookup, ), - [args, replies.data], + [args], ); } diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts new file mode 100644 index 0000000000..512b913dff --- /dev/null +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const REACTION_TARGET_CONTENT = "React to me with a custom emoji"; +const REACTION_TARGET_EVENT_ID = "d".repeat(64); +const BOB_PUBKEY = + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; + +function reactionTargetRow(page: import("@playwright/test").Page) { + return page + .getByTestId("message-row") + .filter({ hasText: REACTION_TARGET_CONTENT }) + .last(); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("reaction popover resolves a reactor with no authored message in the window", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + }) === true, + ); + + await page.evaluate( + ({ pubkey, targetId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "🎉", + extraTags: [["e", targetId]], + kind: 7, + pubkey, + }); + }, + { pubkey: BOB_PUBKEY, targetId: REACTION_TARGET_EVENT_ID }, + ); + + const row = reactionTargetRow(page); + const pill = row.getByRole("button", { name: "Toggle 🎉 reaction" }); + await expect(pill).toBeVisible(); + await pill.hover(); + await expect(page.getByText("bob reacted with")).toBeVisible(); +});