Skip to content

Commit 62def14

Browse files
tlongwell-blocknpub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
andauthored
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>
1 parent c258ffc commit 62def14

6 files changed

Lines changed: 165 additions & 28 deletions

File tree

desktop/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export default defineConfig({
7272
"**/timeline-no-shift.spec.ts",
7373
"**/human-edit-agent-content.spec.ts",
7474
"**/reaction-order.spec.ts",
75+
"**/reaction-names.spec.ts",
7576
"**/send-channel-binding.spec.ts",
7677
"**/persona-model-combobox-screenshots.spec.ts",
7778
"**/drafts-screenshots.spec.ts",

desktop/src/features/channels/ui/ChannelScreen.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import {
3838
collectMessageAuthorPubkeys,
3939
collectMessageMentionPubkeys,
40+
collectReactionActorPubkeys,
4041
formatTimelineMessages,
4142
} from "@/features/messages/lib/formatTimelineMessages";
4243
import {
@@ -51,6 +52,7 @@ import {
5152
} from "@/features/messages/lib/timelineLoadingState";
5253
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
5354
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
55+
import { useThreadReplies } from "@/features/messages/useThreadReplies";
5456
import { useChannelTyping } from "@/features/messages/useChannelTyping";
5557
import type { TimelineMessage } from "@/features/messages/types";
5658
import { useUsersBatchQuery } from "@/features/profile/hooks";
@@ -78,8 +80,8 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget";
7880
import { useChannelUnreadState } from "./useChannelUnreadState";
7981
import type { ChannelScreenProps } from "./ChannelScreen.types";
8082

81-
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
82-
83+
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
84+
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
8385
export function ChannelScreen({
8486
activeChannel,
8587
currentIdentity,
@@ -180,11 +182,13 @@ export function ChannelScreen({
180182
}, [activeChannelId, openThreadHeadId]);
181183
const messagesQuery = useChannelMessagesQuery(activeChannel);
182184
const windowQuery = useChannelWindowQuery(activeChannel);
185+
const threadRepliesQuery = useThreadReplies(
186+
activeChannel,
187+
effectiveOpenThreadHeadId,
188+
);
183189
useChannelSubscription(activeChannel);
184190
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
185191
useFetchOlderMessages(activeChannel);
186-
// Newest top-level message only: opening a channel should clear the timeline
187-
// without clearing unread thread replies.
188192
const latestActiveMessage = React.useMemo(() => {
189193
const messages = messagesQuery.data;
190194
if (!messages) return null;
@@ -195,23 +199,15 @@ export function ChannelScreen({
195199
}
196200
return null;
197201
}, [messagesQuery.data]);
198-
// No `lastMessageAt` fallback: it is reply-inclusive and would clear unread
199-
// thread/sidebar state before a real top-level position is known.
200202
const activeReadAt = latestActiveMessage
201203
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
202204
: null;
203205
React.useEffect(() => {
204206
if (!activeChannelId || activeChannel?.isMember === false) {
205207
return;
206208
}
207-
// Passive channel-open (NIP-RS Option 1): advance the marker to the newest
208-
// top-level message only, clearing the main timeline while thread badges
209-
// and Home inbox thread activity stay intact until each thread is read.
210209
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
211210
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
212-
// Install the NIP-RS parent resolver. Active `thread:`/`msg:` contexts fold
213-
// to this channel (never another message), preserving ancestor/descendant
214-
// isolation while channel reads cover top-level history. Clear on leave.
215211
React.useEffect(() => {
216212
if (!activeChannelId) {
217213
setContextParentResolver(null);
@@ -259,14 +255,17 @@ export function ChannelScreen({
259255
messagesQuery.data,
260256
targetMessageEvents,
261257
]);
262-
const messageAuthorPubkeys = React.useMemo(
263-
() => collectMessageAuthorPubkeys(resolvedMessages),
264-
[resolvedMessages],
265-
);
266-
const messageMentionPubkeys = React.useMemo(
267-
() => collectMessageMentionPubkeys(resolvedMessages),
268-
[resolvedMessages],
269-
);
258+
const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
259+
const messageEventProfilePubkeys = React.useMemo(() => {
260+
const events = [...resolvedMessages, ...threadReplyEvents];
261+
return [
262+
...new Set([
263+
...collectMessageAuthorPubkeys(events),
264+
...collectMessageMentionPubkeys(events),
265+
...collectReactionActorPubkeys(events),
266+
]),
267+
];
268+
}, [resolvedMessages, threadReplyEvents]);
270269
const latestMessageEvent = React.useMemo(
271270
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
272271
[resolvedMessages],
@@ -307,8 +306,7 @@ export function ChannelScreen({
307306
const messageProfilePubkeys = React.useMemo(
308307
() => [
309308
...new Set([
310-
...messageAuthorPubkeys,
311-
...messageMentionPubkeys,
309+
...messageEventProfilePubkeys,
312310
...activeDmParticipantPubkeys,
313311
...knownAgentPubkeys,
314312
...typingEntries.map((entry) => entry.pubkey),
@@ -317,8 +315,7 @@ export function ChannelScreen({
317315
[
318316
activeDmParticipantPubkeys,
319317
knownAgentPubkeys,
320-
messageAuthorPubkeys,
321-
messageMentionPubkeys,
318+
messageEventProfilePubkeys,
322319
typingEntries,
323320
],
324321
);
@@ -443,6 +440,7 @@ export function ChannelScreen({
443440
const threadPanelData = useIndependentThreadPanel({
444441
activeChannel,
445442
channelEvents: resolvedMessages,
443+
threadReplyEvents,
446444
rootId: effectiveOpenThreadHeadId,
447445
replyTargetId: threadReplyTargetId,
448446
expandedReplyIds: expandedThreadReplyIds,

desktop/src/features/messages/lib/formatTimelineMessages.test.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
22
import test from "node:test";
33

44
import {
5+
collectReactionActorPubkeys,
56
countTopLevelTimelineRows,
67
formatTimelineMessages,
78
isTimelineContentEvent,
@@ -168,6 +169,57 @@ test("non-deletion event kinds do NOT hide the target message", () => {
168169
assert.equal(out.length, 1, "the kind:9 message should still be visible");
169170
});
170171

172+
test("collectReactionActorPubkeys returns active kind:7 actors only", () => {
173+
const reactionId = `${"c".repeat(64)}`;
174+
const deletedReactionId = `${"d".repeat(64)}`;
175+
const actor = PUBKEY_B.toUpperCase();
176+
const events = [
177+
streamMessage(),
178+
{
179+
id: reactionId,
180+
pubkey: actor,
181+
kind: 7,
182+
created_at: 1_700_000_001,
183+
content: "+",
184+
tags: [
185+
["h", CHANNEL_ID],
186+
["e", HEX64_A],
187+
],
188+
sig: "sig",
189+
},
190+
{
191+
id: `${"e".repeat(64)}`,
192+
pubkey: PUBKEY_A,
193+
kind: 7,
194+
created_at: 1_700_000_002,
195+
content: "🎉",
196+
tags: [
197+
["h", CHANNEL_ID],
198+
["e", HEX64_A],
199+
["actor", actor],
200+
],
201+
sig: "sig",
202+
},
203+
{
204+
id: deletedReactionId,
205+
pubkey: PUBKEY_A,
206+
kind: 7,
207+
created_at: 1_700_000_003,
208+
content: "👀",
209+
tags: [
210+
["h", CHANNEL_ID],
211+
["e", HEX64_A],
212+
],
213+
sig: "sig",
214+
},
215+
deletionEvent(5, deletedReactionId, {
216+
id: `${"f".repeat(64)}`,
217+
}),
218+
];
219+
220+
assert.deepEqual(collectReactionActorPubkeys(events), [PUBKEY_B]);
221+
});
222+
171223
test("huddle start renders as a timeline row", () => {
172224
const out = formatTimelineMessages([huddleStarted()], null, undefined, null);
173225
assert.equal(out.length, 1);

desktop/src/features/messages/lib/formatTimelineMessages.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,40 @@ function extractSystemMessagePubkeys(event: RelayEvent): string[] {
486486
}
487487
}
488488

489+
export function collectReactionActorPubkeys(events: RelayEvent[]) {
490+
const deletedEventIds = new Set<string>();
491+
for (const event of events) {
492+
if (
493+
event.kind !== KIND_DELETION &&
494+
event.kind !== KIND_NIP29_DELETE_EVENT
495+
) {
496+
continue;
497+
}
498+
for (const targetId of getDeletionTargets(event.tags)) {
499+
deletedEventIds.add(targetId.toLowerCase());
500+
}
501+
}
502+
503+
const pubkeys = new Set<string>();
504+
for (const event of events) {
505+
if (
506+
event.kind !== KIND_REACTION ||
507+
deletedEventIds.has(event.id.toLowerCase())
508+
) {
509+
continue;
510+
}
511+
pubkeys.add(
512+
resolveEventAuthorPubkey({
513+
pubkey: event.pubkey,
514+
tags: event.tags,
515+
preferActorTag: true,
516+
requireChannelTagForPTags: true,
517+
}).toLowerCase(),
518+
);
519+
}
520+
return [...pubkeys];
521+
}
522+
489523
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
490524
const pubkeys = new Set<string>();
491525

desktop/src/features/messages/useIndependentThreadPanel.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from "react";
22

33
import { buildIndependentThreadPanel } from "@/features/messages/lib/independentThreadPanel";
4-
import { useThreadReplies } from "@/features/messages/useThreadReplies";
54
import type { UserProfileLookup } from "@/features/profile/lib/identity";
65
import type {
76
Channel,
@@ -13,6 +12,7 @@ import type {
1312
export function useIndependentThreadPanel(args: {
1413
activeChannel: Channel | null;
1514
channelEvents: RelayEvent[];
15+
threadReplyEvents: RelayEvent[];
1616
rootId: string | null;
1717
replyTargetId: string | null;
1818
expandedReplyIds: ReadonlySet<string>;
@@ -23,12 +23,11 @@ export function useIndependentThreadPanel(args: {
2323
personaLookup: Map<string, string>;
2424
respondToLookup: Map<string, RespondToMode>;
2525
}) {
26-
const replies = useThreadReplies(args.activeChannel, args.rootId);
2726
return React.useMemo(
2827
() =>
2928
buildIndependentThreadPanel(
3029
args.channelEvents,
31-
replies.data ?? [],
30+
args.threadReplyEvents,
3231
args.rootId,
3332
args.replyTargetId,
3433
args.expandedReplyIds,
@@ -40,6 +39,6 @@ export function useIndependentThreadPanel(args: {
4039
args.personaLookup,
4140
args.respondToLookup,
4241
),
43-
[args, replies.data],
42+
[args],
4443
);
4544
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
import { installMockBridge } from "../helpers/bridge";
4+
5+
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
6+
const REACTION_TARGET_EVENT_ID = "d".repeat(64);
7+
const BOB_PUBKEY =
8+
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
9+
10+
function reactionTargetRow(page: import("@playwright/test").Page) {
11+
return page
12+
.getByTestId("message-row")
13+
.filter({ hasText: REACTION_TARGET_CONTENT })
14+
.last();
15+
}
16+
17+
test.beforeEach(async ({ page }) => {
18+
await installMockBridge(page);
19+
});
20+
21+
test("reaction popover resolves a reactor with no authored message in the window", async ({
22+
page,
23+
}) => {
24+
await page.goto("/");
25+
await page.getByTestId("channel-general").click();
26+
await expect(page.getByTestId("chat-title")).toHaveText("general");
27+
await page.waitForFunction(
28+
() =>
29+
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
30+
channelName: "general",
31+
kind: 7,
32+
}) === true,
33+
);
34+
35+
await page.evaluate(
36+
({ pubkey, targetId }) => {
37+
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
38+
channelName: "general",
39+
content: "🎉",
40+
extraTags: [["e", targetId]],
41+
kind: 7,
42+
pubkey,
43+
});
44+
},
45+
{ pubkey: BOB_PUBKEY, targetId: REACTION_TARGET_EVENT_ID },
46+
);
47+
48+
const row = reactionTargetRow(page);
49+
const pill = row.getByRole("button", { name: "Toggle 🎉 reaction" });
50+
await expect(pill).toBeVisible();
51+
await pill.hover();
52+
await expect(page.getByText("bob reacted with")).toBeVisible();
53+
});

0 commit comments

Comments
 (0)