desktop: Slack-style thread sidebar for channel replies

- Main timeline shows top-level messages only; replies load in a side panel
- Reuse GET /api/channels/:id/threads/:event_id via get_forum_thread
- Thread panel with composer; merge-friendly query key channelThreadKey

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-07 10:30:07 +02:00
parent dde459dc5d
commit 49d1ae4741
4 changed files with 442 additions and 78 deletions

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { ChannelThreadPanel } from "@/features/messages/ui/ChannelThreadPanel";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
@@ -23,6 +24,7 @@ type ChannelPaneProps = {
messages: TimelineMessage[];
onCancelEdit?: () => void;
onCancelReply: () => void;
onCloseThread?: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string) => Promise<void>;
@@ -42,6 +44,7 @@ type ChannelPaneProps = {
replyTargetId: string | null;
replyTargetMessage: TimelineMessage | null;
targetMessageId: string | null;
threadRootId: string | null;
typingPubkeys: string[];
};
@@ -57,6 +60,7 @@ export const ChannelPane = React.memo(function ChannelPane({
messages,
onCancelEdit,
onCancelReply,
onCloseThread,
onDelete,
onEdit,
onEditSave,
@@ -68,82 +72,114 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetId,
replyTargetMessage,
targetMessageId,
threadRootId,
typingPubkeys,
}: ChannelPaneProps) {
const composerDisabled =
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending;
const mainPlaceholder = activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel";
return (
<>
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending
}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
/>
</>
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
{!threadRootId ? (
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={composerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={mainPlaceholder}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
/>
) : null}
</div>
{threadRootId && activeChannel && onCloseThread ? (
<ChannelThreadPanel
channel={activeChannel}
currentPubkey={currentPubkey}
disabledComposer={composerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onClose={onCloseThread}
onEditSave={onEditSave}
onSend={onSend}
profiles={profiles}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
rootEventId={threadRootId}
/>
) : null}
</div>
);
});

View File

@@ -15,7 +15,11 @@ import {
useSendMessageMutation,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import type { TimelineMessage } from "@/features/messages/types";
import {
channelMessagesKey,
channelThreadKey,
} from "@/features/messages/lib/messageQueryKeys";
import {
collectMessageAuthorPubkeys,
formatTimelineMessages,
@@ -37,6 +41,7 @@ import type {
RelayEvent,
SearchHit,
} from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ChannelPane = React.lazy(async () => {
@@ -79,6 +84,7 @@ export function ChannelScreen({
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const [threadRootId, setThreadRootId] = React.useState<string | null>(null);
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
@@ -175,6 +181,19 @@ export function ChannelScreen({
resolvedMessages,
],
);
/** Main channel timeline: top-level messages only; thread replies live in the thread sidebar. */
const mainTimelineMessages = React.useMemo(
() =>
timelineMessages.filter((message) => {
if (message.kind === KIND_SYSTEM_MESSAGE) {
return true;
}
return message.depth === 0;
}),
[timelineMessages],
);
const replyTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
@@ -193,7 +212,6 @@ export function ChannelScreen({
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleToggleReaction,
} = useChannelPaneHandlers({
deleteMessageMutation,
@@ -206,6 +224,56 @@ export function ChannelScreen({
toggleReactionMutation,
});
const handleReplyOpenThread = React.useCallback(
(message: TimelineMessage) => {
setThreadRootId(message.rootId ?? message.id);
handleReply(message);
},
[handleReply],
);
const handleCloseThread = React.useCallback(() => {
setThreadRootId(null);
setReplyTargetId(null);
}, []);
const handleSend = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
const parentEventId = threadRootId
? (replyTargetId ?? threadRootId)
: replyTargetId;
const result = await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
parentEventId,
mediaTags,
});
if (threadRootId) {
setReplyTargetId(result.id);
if (activeChannelId) {
void queryClient.invalidateQueries({
queryKey: channelThreadKey(activeChannelId, threadRootId),
});
}
} else {
setReplyTargetId(null);
}
},
[
activeChannelId,
queryClient,
replyTargetId,
sendMessageMutation,
threadRootId,
],
);
const canReact = activeChannel !== null && activeChannel.archivedAt === null;
const effectiveToggleReaction = React.useMemo(
() => (canReact ? handleToggleReaction : undefined),
@@ -237,6 +305,7 @@ export function ChannelScreen({
(_channelId: string | null) => {
setReplyTargetId(null);
setEditTargetId(null);
setThreadRootId(null);
},
[],
);
@@ -400,19 +469,21 @@ export function ChannelScreen({
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
messages={mainTimelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onCloseThread={handleCloseThread}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
onReply={handleReplyOpenThread}
onSend={handleSend}
onTargetReached={onTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
threadRootId={threadRootId}
targetMessageId={
activeChannel && searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId

View File

@@ -6,6 +6,11 @@ export function channelMessagesKey(channelId: string) {
return ["channel-messages", channelId] as const;
}
/** React-query key for GET /api/channels/:id/threads/:root_event_id (stream or forum). */
export function channelThreadKey(channelId: string, rootEventId: string) {
return ["channel-thread", channelId, rootEventId] as const;
}
export function dedupeMessagesById(messages: RelayEvent[]) {
const seenIds = new Set<string>();
const deduped: RelayEvent[] = [];

View File

@@ -0,0 +1,252 @@
import { MessageSquare, X } from "lucide-react";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
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 {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { Skeleton } from "@/shared/ui/skeleton";
import { formatRelativeTime } from "@/features/forum/lib/time";
type ChannelThreadPanelProps = {
channel: Channel;
currentPubkey?: string;
profiles?: UserProfileLookup;
rootEventId: string;
onClose: () => void;
replyTarget: {
author: string;
body: string;
id: string;
} | null;
onCancelReply: () => void;
onSend: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
isSending: boolean;
editTarget?: {
author: string;
body: string;
id: string;
} | null;
onCancelEdit?: () => void;
onEditSave?: (content: string) => Promise<void>;
disabledComposer: boolean;
};
function ThreadReplyRow({
content,
createdAt,
pubkey,
tags,
currentPubkey,
profiles,
channelNames,
}: {
content: string;
createdAt: number;
pubkey: string;
tags: string[][];
currentPubkey?: string;
profiles?: UserProfileLookup;
channelNames: string[];
}) {
const label = resolveUserLabel({
pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
const avatarUrl = profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
const mentionNames = resolveMentionNames(tags, profiles);
return (
<div className="border-b border-border/40 px-3 py-2.5 last:border-b-0">
<div className="flex items-start gap-2">
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-7 w-7 shrink-0 rounded-md text-[10px]"
iconClassName="h-3 w-3"
label={label}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-sm font-medium">{label}</span>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(createdAt)}
</span>
</div>
<div className="mt-0.5 text-sm">
<Markdown
channelNames={channelNames}
compact
content={content}
mentionNames={mentionNames}
/>
</div>
</div>
</div>
</div>
);
}
export function ChannelThreadPanel({
channel,
currentPubkey,
profiles,
rootEventId,
onClose,
replyTarget,
onCancelReply,
onSend,
isSending,
editTarget = null,
onCancelEdit,
onEditSave,
disabledComposer,
}: ChannelThreadPanelProps) {
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
[channels],
);
const threadQuery = useQuery({
queryKey: channelThreadKey(channel.id, rootEventId),
queryFn: () => getForumThread(channel.id, rootEventId),
enabled: Boolean(channel.id && rootEventId),
});
const thread = threadQuery.data;
const isLoading = threadQuery.isPending;
return (
<aside
className="flex h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20"
data-testid="channel-thread-panel"
>
<header className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">Thread</p>
<p className="truncate text-[11px] text-muted-foreground">
#{channel.name}
</p>
</div>
<Button
aria-label="Close thread"
className="h-8 w-8 shrink-0 p-0"
onClick={onClose}
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{isLoading ? (
<div className="space-y-3 p-3">
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
) : thread ? (
<>
<div className="border-b border-border/50 bg-background/40 px-3 py-3">
<div className="flex items-start gap-2">
<ProfileAvatar
avatarUrl={
profiles?.[thread.post.pubkey.toLowerCase()]?.avatarUrl ??
null
}
className="h-8 w-8 shrink-0 rounded-lg text-[11px]"
label={resolveUserLabel({
pubkey: thread.post.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
})}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{resolveUserLabel({
pubkey: thread.post.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
})}
</p>
<div className="mt-1 text-sm">
<Markdown
channelNames={channelNames}
compact
content={thread.post.content}
mentionNames={resolveMentionNames(
thread.post.tags,
profiles,
)}
/>
</div>
</div>
</div>
</div>
<div className="pb-2">
{thread.replies.map((reply) => (
<ThreadReplyRow
channelNames={channelNames}
content={reply.content}
createdAt={reply.createdAt}
currentPubkey={currentPubkey}
key={reply.eventId}
profiles={profiles}
pubkey={reply.pubkey}
tags={reply.tags}
/>
))}
{thread.replies.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No replies yet. Send a message below.
</p>
) : null}
</div>
</>
) : (
<p className="p-4 text-sm text-muted-foreground">
Could not load this thread.
</p>
)}
</div>
<div className="shrink-0 border-t border-border/60 bg-background/80 p-2 backdrop-blur-sm">
<MessageComposer
channelId={channel.id}
channelName={channel.name}
disabled={disabledComposer}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread…`}
replyTarget={replyTarget}
/>
</div>
</aside>
);
}