mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
c258ffc4a8
commit
62def1459c
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user