mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
desktop: thread UX, main-feed filtering, and channel header
- Anchor thread sidebar on the opened message; fetch by Nostr root; list messages after the focus chronologically. - Thread composer: parent chain uses focus id before root so nested replies do not post as depth-1 to the channel root; keep reply target while thread is open. - Main channel list: show only roots and direct replies to the thread root (parentId === rootId); nested replies stay out of main. - Thread hints: aggregate by main-timeline anchor (prefer agent direct reply); show hints on depth-1 rows; highlight focused thread row. - ChatHeader: optional description; channel view drops subtitle line. - E2E: adjust integration tests for header without channel description. - Mentions/timeline types: relay agents in mentions; displayDepth and related. Made-with: Cursor
This commit is contained in:
@@ -52,7 +52,9 @@ type ChannelPaneProps = {
|
||||
replyTargetId: string | null;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
targetMessageId: string | null;
|
||||
threadHintsByRootId: Map<string, ThreadConversationHint>;
|
||||
threadHintsByAnchorId: Map<string, ThreadConversationHint>;
|
||||
/** Message id to anchor the thread panel header on (the row that opened the thread). */
|
||||
threadFocusEventId: string | null;
|
||||
threadRootId: string | null;
|
||||
typingPubkeys: string[];
|
||||
};
|
||||
@@ -82,7 +84,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
targetMessageId,
|
||||
threadHintsByRootId,
|
||||
threadHintsByAnchorId,
|
||||
threadFocusEventId,
|
||||
threadRootId,
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
@@ -107,7 +110,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageTimeline
|
||||
activeThreadRootId={threadRootId}
|
||||
activeThreadRootId={threadFocusEventId ?? threadRootId}
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
@@ -116,7 +119,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
onOpenThread={onReply}
|
||||
profiles={profiles}
|
||||
threadHintsByRootId={threadHintsByRootId}
|
||||
threadHintsByAnchorId={threadHintsByAnchorId}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
@@ -175,6 +178,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
currentPubkey={currentPubkey}
|
||||
disabledComposer={composerDisabled}
|
||||
editTarget={editTarget}
|
||||
focusEventId={threadFocusEventId}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { threadHintAnchorIdForNestedMessage } from "@/features/messages/lib/threadHintAnchor";
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getThreadReference,
|
||||
@@ -91,6 +92,10 @@ export function ChannelScreen({
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||
const [threadRootId, setThreadRootId] = React.useState<string | null>(null);
|
||||
/** Event id of the message row used to open the thread (Reply / thread hint). */
|
||||
const [threadFocusEventId, setThreadFocusEventId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const currentPubkey = currentIdentity?.pubkey;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
|
||||
@@ -188,7 +193,11 @@ export function ChannelScreen({
|
||||
],
|
||||
);
|
||||
|
||||
/** Main channel timeline: top-level messages only; thread replies live in the thread sidebar. */
|
||||
/**
|
||||
* Main channel: top-level posts plus **direct** replies to the thread root only
|
||||
* (`parentId === rootId` in NIP-10 e-tags). Nested replies (`parentId !== rootId`)
|
||||
* stay sidebar-only — they must not appear in the main stream even if depth is wrong.
|
||||
*/
|
||||
const mainTimelineMessages = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.filter((message) => {
|
||||
@@ -196,13 +205,30 @@ export function ChannelScreen({
|
||||
return true;
|
||||
}
|
||||
|
||||
return message.depth === 0;
|
||||
const { parentId, rootId } = message;
|
||||
if (!parentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rootId && parentId === rootId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
[timelineMessages],
|
||||
);
|
||||
|
||||
/** Reply counts + participant labels for roots that have sidebar thread activity. */
|
||||
const threadHintsByRootId = React.useMemo(() => {
|
||||
/**
|
||||
* Reply counts keyed by **main-timeline anchor** (prefer the direct reply to
|
||||
* the thread root — e.g. agent message — over the user's root post).
|
||||
*/
|
||||
const threadHintsByAnchorId = React.useMemo(() => {
|
||||
const messageById = new Map<string, TimelineMessage>(
|
||||
timelineMessages.map((m) => [m.id, m]),
|
||||
);
|
||||
const mainIds = new Set(mainTimelineMessages.map((m) => m.id));
|
||||
|
||||
const aggregates = new Map<
|
||||
string,
|
||||
{ replyCount: number; pubkeys: string[] }
|
||||
@@ -212,15 +238,17 @@ export function ChannelScreen({
|
||||
if (m.kind === KIND_SYSTEM_MESSAGE) {
|
||||
continue;
|
||||
}
|
||||
if (m.depth === 0) {
|
||||
continue;
|
||||
}
|
||||
const rootId = m.rootId;
|
||||
if (!rootId) {
|
||||
const { parentId, rootId } = m;
|
||||
if (!parentId || !rootId || parentId === rootId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agg = aggregates.get(rootId) ?? { replyCount: 0, pubkeys: [] };
|
||||
const anchorId = threadHintAnchorIdForNestedMessage(
|
||||
m,
|
||||
messageById,
|
||||
mainIds,
|
||||
);
|
||||
const agg = aggregates.get(anchorId) ?? { replyCount: 0, pubkeys: [] };
|
||||
agg.replyCount += 1;
|
||||
if (m.pubkey) {
|
||||
const pk = m.pubkey.toLowerCase();
|
||||
@@ -228,11 +256,11 @@ export function ChannelScreen({
|
||||
agg.pubkeys.push(m.pubkey);
|
||||
}
|
||||
}
|
||||
aggregates.set(rootId, agg);
|
||||
aggregates.set(anchorId, agg);
|
||||
}
|
||||
|
||||
const out = new Map<string, ThreadConversationHint>();
|
||||
for (const [rootId, agg] of aggregates) {
|
||||
for (const [anchorId, agg] of aggregates) {
|
||||
const participantPubkeys = agg.pubkeys.slice(0, 5);
|
||||
const participantLabels = participantPubkeys.map((pk) =>
|
||||
resolveUserLabel({
|
||||
@@ -242,14 +270,14 @@ export function ChannelScreen({
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
);
|
||||
out.set(rootId, {
|
||||
out.set(anchorId, {
|
||||
replyCount: agg.replyCount,
|
||||
participantPubkeys,
|
||||
participantLabels,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [currentPubkey, messageProfiles, timelineMessages]);
|
||||
}, [currentPubkey, mainTimelineMessages, messageProfiles, timelineMessages]);
|
||||
const replyTargetMessage = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
|
||||
@@ -283,6 +311,7 @@ export function ChannelScreen({
|
||||
const handleReplyOpenThread = React.useCallback(
|
||||
(message: TimelineMessage) => {
|
||||
setThreadRootId(message.rootId ?? message.id);
|
||||
setThreadFocusEventId(message.id);
|
||||
handleReply(message);
|
||||
},
|
||||
[handleReply],
|
||||
@@ -290,6 +319,7 @@ export function ChannelScreen({
|
||||
|
||||
const handleCloseThread = React.useCallback(() => {
|
||||
setThreadRootId(null);
|
||||
setThreadFocusEventId(null);
|
||||
setReplyTargetId(null);
|
||||
}, []);
|
||||
|
||||
@@ -327,7 +357,10 @@ export function ChannelScreen({
|
||||
return;
|
||||
}
|
||||
|
||||
const parentEventId = replyTargetId ?? threadRootId;
|
||||
// Prefer explicit reply chain, then the message that opened the thread (e.g. agent).
|
||||
// Never fall back to thread root alone when focus exists — that would create a depth-1
|
||||
// reply to the root and duplicate the message in the main channel.
|
||||
const parentEventId = replyTargetId ?? threadFocusEventId ?? threadRootId;
|
||||
|
||||
const result = await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
@@ -348,6 +381,7 @@ export function ChannelScreen({
|
||||
queryClient,
|
||||
replyTargetId,
|
||||
sendMessageMutation,
|
||||
threadFocusEventId,
|
||||
threadRootId,
|
||||
],
|
||||
);
|
||||
@@ -358,20 +392,6 @@ export function ChannelScreen({
|
||||
[canReact, handleToggleReaction],
|
||||
);
|
||||
|
||||
const channelDescription = activeChannel
|
||||
? [
|
||||
activeChannel.archivedAt ? "Archived." : null,
|
||||
!activeChannel.isMember
|
||||
? "Read-only until you join this open channel."
|
||||
: null,
|
||||
activeChannel.topic,
|
||||
activeChannel.description,
|
||||
activeChannel.purpose,
|
||||
null,
|
||||
]
|
||||
.filter((value) => value && value.trim().length > 0)
|
||||
.join(" ") || "Channel details and activity."
|
||||
: "Connect to the relay to browse channels and read messages.";
|
||||
const shouldLoadTimeline =
|
||||
activeChannel !== null && activeChannel.channelType !== "forum";
|
||||
const isTimelineLoading =
|
||||
@@ -384,6 +404,7 @@ export function ChannelScreen({
|
||||
setReplyTargetId(null);
|
||||
setEditTargetId(null);
|
||||
setThreadRootId(null);
|
||||
setThreadFocusEventId(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -399,13 +420,22 @@ export function ChannelScreen({
|
||||
}, [activeChannelId, resetComposerTargets]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (replyTargetId && !replyTargetMessage) {
|
||||
// While the thread panel is open, keep replyTargetId even if the target is not in the
|
||||
// current timeline slice momentarily — clearing it would make thread sends fall back to
|
||||
// the thread root and surface replies in the main channel.
|
||||
if (replyTargetId && !replyTargetMessage && !threadRootId) {
|
||||
setReplyTargetId(null);
|
||||
}
|
||||
if (editTargetId && !editTargetMessage) {
|
||||
setEditTargetId(null);
|
||||
}
|
||||
}, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]);
|
||||
}, [
|
||||
editTargetId,
|
||||
editTargetMessage,
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
threadRootId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
resetRequestedAncestors(activeChannelId);
|
||||
@@ -504,7 +534,6 @@ export function ChannelScreen({
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
@@ -563,7 +592,8 @@ export function ChannelScreen({
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
threadHintsByRootId={threadHintsByRootId}
|
||||
threadFocusEventId={threadFocusEventId}
|
||||
threadHintsByAnchorId={threadHintsByAnchorId}
|
||||
threadRootId={threadRootId}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Subtitle under the title; omitted when empty. */
|
||||
description?: string;
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "agents" | "workflows";
|
||||
@@ -79,12 +80,14 @@ export function ChatHeader({
|
||||
</h1>
|
||||
{statusBadge ? <div className="shrink-0">{statusBadge}</div> : null}
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{description?.trim() ? (
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
|
||||
@@ -284,6 +284,14 @@ export function formatTimelineMessages(
|
||||
});
|
||||
const thread = getThreadReference(event.tags);
|
||||
const edit = editsByTargetId.get(event.id);
|
||||
const depth = getDepth(event);
|
||||
let displayDepth = depth;
|
||||
if (thread.parentId) {
|
||||
const parentEvent = eventsById.get(thread.parentId);
|
||||
if (parentEvent && depth === 1 && getDepth(parentEvent) === 0) {
|
||||
displayDepth = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
createdAt: event.created_at,
|
||||
@@ -299,7 +307,8 @@ export function formatTimelineMessages(
|
||||
body: edit ? edit.content : event.content,
|
||||
parentId: thread.parentId,
|
||||
rootId: thread.rootId,
|
||||
depth: getDepth(event),
|
||||
depth,
|
||||
displayDepth,
|
||||
accent: currentPubkey === authorPubkey,
|
||||
pending: event.pending,
|
||||
edited: edit !== undefined,
|
||||
|
||||
38
desktop/src/features/messages/lib/threadHintAnchor.ts
Normal file
38
desktop/src/features/messages/lib/threadHintAnchor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
/**
|
||||
* Where to show "N replies" for nested thread activity: walk up from the nested
|
||||
* message's parent until we hit a **main-timeline** message. Prefer a **direct
|
||||
* reply to the thread root** (e.g. the agent's first response) over the root
|
||||
* post itself, so hints attach to the Q&A anchor users reply under.
|
||||
*/
|
||||
export function threadHintAnchorIdForNestedMessage(
|
||||
nested: TimelineMessage,
|
||||
messageById: Map<string, TimelineMessage>,
|
||||
mainMessageIds: Set<string>,
|
||||
): string {
|
||||
let pid: string | null | undefined = nested.parentId;
|
||||
let rootCandidate: string | null = null;
|
||||
|
||||
while (pid) {
|
||||
const msg = messageById.get(pid);
|
||||
if (!msg) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (mainMessageIds.has(pid)) {
|
||||
const isDirectReplyToThreadRoot = Boolean(
|
||||
msg.parentId && msg.rootId && msg.parentId === msg.rootId,
|
||||
);
|
||||
if (isDirectReplyToThreadRoot) {
|
||||
return pid;
|
||||
}
|
||||
if (!msg.parentId) {
|
||||
rootCandidate = pid;
|
||||
}
|
||||
}
|
||||
pid = msg.parentId;
|
||||
}
|
||||
|
||||
return rootCandidate ?? nested.rootId ?? nested.id;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
@@ -17,6 +20,7 @@ export function useMentions(channelId: string | null) {
|
||||
const membersQuery = useChannelMembersQuery(channelId);
|
||||
const members = membersQuery.data;
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const managedAgentNamesByPubkey = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -27,6 +31,16 @@ export function useMentions(channelId: string | null) {
|
||||
),
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
const relayAgentNamesByPubkey = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(relayAgentsQuery.data ?? []).map((agent) => [
|
||||
agent.pubkey.toLowerCase(),
|
||||
agent.name,
|
||||
]),
|
||||
),
|
||||
[relayAgentsQuery.data],
|
||||
);
|
||||
|
||||
const knownNames = React.useMemo<string[]>(() => {
|
||||
if (!members) return [];
|
||||
@@ -34,13 +48,30 @@ export function useMentions(channelId: string | null) {
|
||||
for (const member of members) {
|
||||
const name =
|
||||
member.displayName ??
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
if (channelId) {
|
||||
for (const agent of relayAgentsQuery.data ?? []) {
|
||||
if (!agent.channelIds.includes(channelId)) {
|
||||
continue;
|
||||
}
|
||||
if (!names.includes(agent.name)) {
|
||||
names.push(agent.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [members, managedAgentNamesByPubkey]);
|
||||
}, [
|
||||
channelId,
|
||||
members,
|
||||
managedAgentNamesByPubkey,
|
||||
relayAgentNamesByPubkey,
|
||||
relayAgentsQuery.data,
|
||||
]);
|
||||
|
||||
/** Lower-cased version of knownNames, used for case-insensitive prefix matching. */
|
||||
const knownNamesLower = React.useMemo<string[]>(
|
||||
@@ -80,6 +111,7 @@ export function useMentions(channelId: string | null) {
|
||||
.map((member) => {
|
||||
const fallbackName =
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
member.pubkey.slice(0, 8);
|
||||
|
||||
return {
|
||||
@@ -98,7 +130,12 @@ export function useMentions(channelId: string | null) {
|
||||
displayName: label,
|
||||
role: member.role === "admin" ? "admin" : null,
|
||||
}));
|
||||
}, [managedAgentNamesByPubkey, members, mentionQuery]);
|
||||
}, [
|
||||
managedAgentNamesByPubkey,
|
||||
members,
|
||||
mentionQuery,
|
||||
relayAgentNamesByPubkey,
|
||||
]);
|
||||
|
||||
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
|
||||
|
||||
@@ -188,15 +225,32 @@ export function useMentions(channelId: string | null) {
|
||||
}
|
||||
const name =
|
||||
member.displayName ??
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
relayAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (name && hasMention(name)) {
|
||||
pubkeys.push(member.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of relayAgentsQuery.data ?? []) {
|
||||
if (
|
||||
pubkeys.some((pk) => pk.toLowerCase() === agent.pubkey.toLowerCase())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (hasMention(agent.name)) {
|
||||
pubkeys.push(agent.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(pubkeys)];
|
||||
},
|
||||
[members, managedAgentNamesByPubkey],
|
||||
[
|
||||
members,
|
||||
managedAgentNamesByPubkey,
|
||||
relayAgentNamesByPubkey,
|
||||
relayAgentsQuery.data,
|
||||
],
|
||||
);
|
||||
|
||||
const clearMentions = React.useCallback(() => {
|
||||
|
||||
@@ -26,6 +26,8 @@ export type TimelineMessage = {
|
||||
parentId?: string | null;
|
||||
rootId?: string | null;
|
||||
depth: number;
|
||||
/** Layout indent (0 = full-width row). First reply under a root is 0 so Q&A reads inline in main. */
|
||||
displayDepth: number;
|
||||
accent?: boolean;
|
||||
pending?: boolean;
|
||||
edited?: boolean;
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { channelThreadKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { getForumThread } from "@/shared/api/forum";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type {
|
||||
Channel,
|
||||
ForumPost,
|
||||
ForumThreadResponse,
|
||||
ThreadReply,
|
||||
} from "@/shared/api/types";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -23,11 +28,88 @@ import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import { formatRelativeTime } from "@/features/forum/lib/time";
|
||||
|
||||
type ThreadHeaderSlice =
|
||||
| { type: "post"; data: ForumPost }
|
||||
| { type: "reply"; data: ThreadReply };
|
||||
|
||||
type ThreadTailItem =
|
||||
| { type: "post"; data: ForumPost }
|
||||
| { type: "reply"; data: ThreadReply };
|
||||
|
||||
/** Anchor header on the opened message; list messages strictly after it in time. */
|
||||
function sliceThreadForFocus(
|
||||
thread: ForumThreadResponse,
|
||||
focusEventId: string | null,
|
||||
): { header: ThreadHeaderSlice; tail: ThreadTailItem[] } {
|
||||
const post = thread.post;
|
||||
const entries = [
|
||||
{
|
||||
kind: "post" as const,
|
||||
eventId: post.eventId,
|
||||
createdAt: post.createdAt,
|
||||
post,
|
||||
},
|
||||
...thread.replies.map((reply) => ({
|
||||
kind: "reply" as const,
|
||||
eventId: reply.eventId,
|
||||
createdAt: reply.createdAt,
|
||||
reply,
|
||||
})),
|
||||
].sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const focusId =
|
||||
focusEventId && focusEventId.length > 0 ? focusEventId : post.eventId;
|
||||
const focusIdx = entries.findIndex((e) => e.eventId === focusId);
|
||||
|
||||
if (focusIdx < 0) {
|
||||
return {
|
||||
header: { type: "post", data: post },
|
||||
tail: entries
|
||||
.filter((e) => e.eventId !== post.eventId)
|
||||
.map((e) =>
|
||||
e.kind === "post"
|
||||
? { type: "post" as const, data: e.post }
|
||||
: { type: "reply" as const, data: e.reply },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const focusEntry = entries[focusIdx];
|
||||
if (!focusEntry) {
|
||||
return {
|
||||
header: { type: "post", data: post },
|
||||
tail: entries
|
||||
.filter((e) => e.eventId !== post.eventId)
|
||||
.map((e) =>
|
||||
e.kind === "post"
|
||||
? { type: "post" as const, data: e.post }
|
||||
: { type: "reply" as const, data: e.reply },
|
||||
),
|
||||
};
|
||||
}
|
||||
const header: ThreadHeaderSlice =
|
||||
focusEntry.kind === "post"
|
||||
? { type: "post", data: focusEntry.post }
|
||||
: { type: "reply", data: focusEntry.reply };
|
||||
|
||||
const tail = entries
|
||||
.slice(focusIdx + 1)
|
||||
.map((e) =>
|
||||
e.kind === "post"
|
||||
? { type: "post" as const, data: e.post }
|
||||
: { type: "reply" as const, data: e.reply },
|
||||
);
|
||||
|
||||
return { header, tail };
|
||||
}
|
||||
|
||||
type ChannelThreadPanelProps = {
|
||||
channel: Channel;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
rootEventId: string;
|
||||
/** Message id of the timeline row that opened this thread (header anchor). */
|
||||
focusEventId: string | null;
|
||||
onClose: () => void;
|
||||
onCancelReply: () => void;
|
||||
onSend: (
|
||||
@@ -107,6 +189,7 @@ export function ChannelThreadPanel({
|
||||
currentPubkey,
|
||||
profiles,
|
||||
rootEventId,
|
||||
focusEventId,
|
||||
onClose,
|
||||
onCancelReply,
|
||||
onSend,
|
||||
@@ -134,13 +217,21 @@ export function ChannelThreadPanel({
|
||||
const thread = threadQuery.data;
|
||||
const isLoading = threadQuery.isPending;
|
||||
|
||||
const { headerSlice, tailItems } = React.useMemo(() => {
|
||||
if (!thread) {
|
||||
return { headerSlice: null as ThreadHeaderSlice | null, tailItems: [] };
|
||||
}
|
||||
const { header, tail } = sliceThreadForFocus(thread, focusEventId);
|
||||
return { headerSlice: header, tailItems: tail };
|
||||
}, [focusEventId, thread]);
|
||||
|
||||
const implicitThreadAgentMention = React.useMemo(() => {
|
||||
const rootPk = thread?.post.pubkey;
|
||||
if (!rootPk) {
|
||||
const headerPk = headerSlice?.data.pubkey ?? thread?.post.pubkey;
|
||||
if (!headerPk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = rootPk.toLowerCase();
|
||||
const lower = headerPk.toLowerCase();
|
||||
const managed = (managedAgentsQuery.data ?? []).find(
|
||||
(a) => a.pubkey.toLowerCase() === lower,
|
||||
);
|
||||
@@ -156,7 +247,12 @@ export function ChannelThreadPanel({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [managedAgentsQuery.data, relayAgentsQuery.data, thread?.post.pubkey]);
|
||||
}, [
|
||||
headerSlice,
|
||||
managedAgentsQuery.data,
|
||||
relayAgentsQuery.data,
|
||||
thread?.post.pubkey,
|
||||
]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -182,18 +278,18 @@ export function ChannelThreadPanel({
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
) : thread ? (
|
||||
) : thread && headerSlice ? (
|
||||
<>
|
||||
<div className="bg-background/40 px-3 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={
|
||||
profiles?.[thread.post.pubkey.toLowerCase()]?.avatarUrl ??
|
||||
null
|
||||
profiles?.[headerSlice.data.pubkey.toLowerCase()]
|
||||
?.avatarUrl ?? null
|
||||
}
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-[11px]"
|
||||
label={resolveUserLabel({
|
||||
pubkey: thread.post.pubkey,
|
||||
pubkey: headerSlice.data.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
@@ -202,7 +298,7 @@ export function ChannelThreadPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{resolveUserLabel({
|
||||
pubkey: thread.post.pubkey,
|
||||
pubkey: headerSlice.data.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
@@ -212,9 +308,9 @@ export function ChannelThreadPanel({
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
compact
|
||||
content={thread.post.content}
|
||||
content={headerSlice.data.content}
|
||||
mentionNames={resolveMentionNames(
|
||||
thread.post.tags,
|
||||
headerSlice.data.tags,
|
||||
profiles,
|
||||
)}
|
||||
/>
|
||||
@@ -224,19 +320,19 @@ export function ChannelThreadPanel({
|
||||
</div>
|
||||
|
||||
<div className="pb-2">
|
||||
{thread.replies.map((reply) => (
|
||||
{tailItems.map((item) => (
|
||||
<ThreadReplyRow
|
||||
channelNames={channelNames}
|
||||
content={reply.content}
|
||||
createdAt={reply.createdAt}
|
||||
content={item.data.content}
|
||||
createdAt={item.data.createdAt}
|
||||
currentPubkey={currentPubkey}
|
||||
key={reply.eventId}
|
||||
key={item.data.eventId}
|
||||
profiles={profiles}
|
||||
pubkey={reply.pubkey}
|
||||
tags={reply.tags}
|
||||
pubkey={item.data.pubkey}
|
||||
tags={item.data.tags}
|
||||
/>
|
||||
))}
|
||||
{thread.replies.length === 0 ? (
|
||||
{tailItems.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No replies yet. Send a message below.
|
||||
</p>
|
||||
|
||||
@@ -127,8 +127,8 @@ export const MessageRow = React.memo(
|
||||
[channels],
|
||||
);
|
||||
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const layoutDepth = Math.min(message.displayDepth ?? message.depth, 6);
|
||||
const indentPx = layoutDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
@@ -220,7 +220,7 @@ export const MessageRow = React.memo(
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
{layoutDepth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
|
||||
@@ -366,10 +366,7 @@ export const MessageRow = React.memo(
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{message.depth === 0 &&
|
||||
threadHint &&
|
||||
threadHint.replyCount > 0 &&
|
||||
onOpenThread ? (
|
||||
{threadHint && threadHint.replyCount > 0 && onOpenThread ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 inline-flex max-w-full flex-wrap items-center gap-2 rounded-md py-0.5 text-left text-[11px] transition-colors",
|
||||
@@ -459,6 +456,7 @@ export const MessageRow = React.memo(
|
||||
prev.message.accent === next.message.accent &&
|
||||
prev.message.time === next.message.time &&
|
||||
prev.message.depth === next.message.depth &&
|
||||
prev.message.displayDepth === next.message.displayDepth &&
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
prev.message.edited === next.message.edited &&
|
||||
|
||||
@@ -28,7 +28,7 @@ type MessageTimelineProps = {
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
threadHintsByRootId?: Map<string, ThreadConversationHint>;
|
||||
threadHintsByAnchorId?: Map<string, ThreadConversationHint>;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
@@ -55,7 +55,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
profiles,
|
||||
threadHintsByRootId,
|
||||
threadHintsByAnchorId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenThread,
|
||||
@@ -169,7 +169,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
threadHintsByRootId={threadHintsByRootId}
|
||||
threadHintsByAnchorId={threadHintsByAnchorId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type TimelineMessageListProps = {
|
||||
currentPubkey?: string;
|
||||
highlightedMessageId?: string | null;
|
||||
messages: TimelineMessage[];
|
||||
threadHintsByRootId?: Map<string, ThreadConversationHint>;
|
||||
threadHintsByAnchorId?: Map<string, ThreadConversationHint>;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
@@ -39,7 +39,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
currentPubkey,
|
||||
highlightedMessageId = null,
|
||||
messages,
|
||||
threadHintsByRootId,
|
||||
threadHintsByAnchorId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenThread,
|
||||
@@ -74,7 +74,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
const threadHint = threadHintsByRootId?.get(message.id);
|
||||
const threadHint = threadHintsByAnchorId?.get(message.id);
|
||||
elements.push(
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
|
||||
@@ -412,7 +412,7 @@ test("create channel with description", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await createStream(page, channelName, description);
|
||||
|
||||
await expect(page.getByTestId("chat-description")).toContainText(description);
|
||||
await expect(page.getByTestId("chat-title")).toContainText(channelName);
|
||||
});
|
||||
|
||||
test("multiple channels independent", async ({ page }) => {
|
||||
@@ -503,15 +503,6 @@ test("manage sheet updates channel details and context through the relay", async
|
||||
|
||||
await page.getByTestId(`channel-${renamedChannel}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedTopic,
|
||||
);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedDescription,
|
||||
);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(
|
||||
updatedPurpose,
|
||||
);
|
||||
|
||||
await openChannelManagement(page);
|
||||
await expect(page.getByTestId("channel-management-name")).toHaveValue(
|
||||
|
||||
Reference in New Issue
Block a user