fix(desktop): fetch profiles for reaction actors and thread-reply authors (#1550)

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-07 08:19:49 -07:00
committed by GitHub
co-authored by npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent c258ffc4a8
commit 62def1459c
6 changed files with 165 additions and 28 deletions
+1
View File
@@ -72,6 +72,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",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
@@ -37,6 +37,7 @@ import {
import {
collectMessageAuthorPubkeys,
collectMessageMentionPubkeys,
collectReactionActorPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import {
@@ -51,6 +52,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";
@@ -78,8 +80,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,
@@ -180,11 +182,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;
@@ -195,8 +199,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;
@@ -204,14 +206,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);
@@ -259,14 +255,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],
@@ -307,8 +306,7 @@ export function ChannelScreen({
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
...messageAuthorPubkeys,
...messageMentionPubkeys,
...messageEventProfilePubkeys,
...activeDmParticipantPubkeys,
...knownAgentPubkeys,
...typingEntries.map((entry) => entry.pubkey),
@@ -317,8 +315,7 @@ export function ChannelScreen({
[
activeDmParticipantPubkeys,
knownAgentPubkeys,
messageAuthorPubkeys,
messageMentionPubkeys,
messageEventProfilePubkeys,
typingEntries,
],
);
@@ -443,6 +440,7 @@ export function ChannelScreen({
const threadPanelData = useIndependentThreadPanel({
activeChannel,
channelEvents: resolvedMessages,
threadReplyEvents,
rootId: effectiveOpenThreadHeadId,
replyTargetId: threadReplyTargetId,
expandedReplyIds: expandedThreadReplyIds,
@@ -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);
@@ -486,6 +486,40 @@ function extractSystemMessagePubkeys(event: RelayEvent): string[] {
}
}
export function collectReactionActorPubkeys(events: RelayEvent[]) {
const deletedEventIds = new Set<string>();
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<string>();
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<string>();
@@ -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<string>;
@@ -23,12 +23,11 @@ export function useIndependentThreadPanel(args: {
personaLookup: Map<string, string>;
respondToLookup: Map<string, RespondToMode>;
}) {
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],
);
}
+53
View File
@@ -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();
});