From 49d1ae474178cb3dfb732dc2273b3cb113712dcb Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 7 Apr 2026 10:30:07 +0200 Subject: [PATCH] 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 --- .../src/features/channels/ui/ChannelPane.tsx | 184 ++++++++----- .../features/channels/ui/ChannelScreen.tsx | 79 +++++- .../features/messages/lib/messageQueryKeys.ts | 5 + .../messages/ui/ChannelThreadPanel.tsx | 252 ++++++++++++++++++ 4 files changed, 442 insertions(+), 78 deletions(-) create mode 100644 desktop/src/features/messages/ui/ChannelThreadPanel.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 62550c90ab..fb865278ff 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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; @@ -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 ( - <> - - - - +
+
+ + + {!threadRootId ? ( + + ) : null} +
+ + {threadRootId && activeChannel && onCloseThread ? ( + + ) : null} +
); }); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 678822c6df..3d10c0db28 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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(null); const [editTargetId, setEditTargetId] = React.useState(null); + const [threadRootId, setThreadRootId] = React.useState(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 diff --git a/desktop/src/features/messages/lib/messageQueryKeys.ts b/desktop/src/features/messages/lib/messageQueryKeys.ts index 316ca3a7a6..c470891a7e 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.ts +++ b/desktop/src/features/messages/lib/messageQueryKeys.ts @@ -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(); const deduped: RelayEvent[] = []; diff --git a/desktop/src/features/messages/ui/ChannelThreadPanel.tsx b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx new file mode 100644 index 0000000000..8d5d94e850 --- /dev/null +++ b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx @@ -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; + isSending: boolean; + editTarget?: { + author: string; + body: string; + id: string; + } | null; + onCancelEdit?: () => void; + onEditSave?: (content: string) => Promise; + 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 ( +
+
+ +
+
+ {label} + + {formatRelativeTime(createdAt)} + +
+
+ +
+
+
+
+ ); +} + +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 ( + + ); +}