Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
46 changes: 22 additions & 24 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import {
collectMessageAuthorPubkeys,
collectMessageMentionPubkeys,
collectReactionActorPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -196,23 +200,15 @@ 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;
React.useEffect(() => {
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);
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -308,8 +307,7 @@ export function ChannelScreen({
const messageProfilePubkeys = React.useMemo(
() => [
...new Set([
...messageAuthorPubkeys,
...messageMentionPubkeys,
...messageEventProfilePubkeys,
...activeDmParticipantPubkeys,
...knownAgentPubkeys,
...typingEntries.map((entry) => entry.pubkey),
Expand All @@ -318,8 +316,7 @@ export function ChannelScreen({
[
activeDmParticipantPubkeys,
knownAgentPubkeys,
messageAuthorPubkeys,
messageMentionPubkeys,
messageEventProfilePubkeys,
typingEntries,
],
);
Expand Down Expand Up @@ -464,6 +461,7 @@ export function ChannelScreen({
const threadPanelData = useIndependentThreadPanel({
activeChannel,
channelEvents: resolvedMessages,
threadReplyEvents,
rootId: effectiveOpenThreadHeadId,
replyTargetId: threadReplyTargetId,
expandedReplyIds: expandedThreadReplyIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
collectReactionActorPubkeys,
countTopLevelTimelineRows,
formatTimelineMessages,
isTimelineContentEvent,
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,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>();

Expand Down
7 changes: 3 additions & 4 deletions desktop/src/features/messages/useIndependentThreadPanel.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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>;
Expand All @@ -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,
Expand All @@ -40,6 +39,6 @@ export function useIndependentThreadPanel(args: {
args.personaLookup,
args.respondToLookup,
),
[args, replies.data],
[args],
);
}
53 changes: 53 additions & 0 deletions desktop/tests/e2e/reaction-names.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading