Files
buzz/desktop/src/features/messages/ui/MessageThreadSidebar.tsx
Thomas Petersen d4e71c6f37 feat(desktop): add scoped reply thread sidebars
Keep forum and channel thread panes anchored to the selected branch, scope typing indicators to the active composer, and surface clearer relay errors when staging auth returns HTML.

Made-with: Cursor
2026-04-13 07:46:44 -04:00

150 lines
4.6 KiB
TypeScript

import { X } from "lucide-react";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { TimelineMessageList } from "./TimelineMessageList";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
type MessageThreadSidebarProps = {
channel: Channel | null;
currentPubkey?: string;
headMessage: TimelineMessage;
isSending: boolean;
messages: TimelineMessage[];
onCancelReply: () => void;
onClose: () => void;
onDelete?: (message: TimelineMessage) => void;
onReply: (message: TimelineMessage) => void;
onSend: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
replyTargetMessage: TimelineMessage | null;
typingPubkeys: string[];
};
export function MessageThreadSidebar({
channel,
currentPubkey,
headMessage,
isSending,
messages,
onCancelReply,
onClose,
onDelete,
onReply,
onSend,
onToggleReaction,
personaLookup,
profiles,
replyTargetMessage,
typingPubkeys,
}: MessageThreadSidebarProps) {
const threadReplyTarget =
replyTargetMessage && replyTargetMessage.id !== headMessage.id
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null;
const canReplyInChannel =
!!channel &&
channel.isMember &&
channel.archivedAt === null &&
channel.channelType !== "forum";
return (
<aside className="flex w-[min(100%,24rem)] shrink-0 flex-col border-l border-border/60 bg-muted/20">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Thread</p>
<p className="truncate text-xs text-muted-foreground">
{messages.length} {messages.length === 1 ? "reply" : "replies"}
</p>
</div>
<Button
aria-label="Close thread panel"
className="h-8 w-8 shrink-0"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="border-b border-border/60 bg-background/70 px-3 py-3">
<p className="mb-2 text-xs text-muted-foreground">Original message</p>
<MessageRow
message={{ ...headMessage, depth: 0 }}
onDelete={
currentPubkey && headMessage.pubkey === currentPubkey
? onDelete
: undefined
}
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
<div className="px-3 py-2">
{messages.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-6 text-center text-sm text-muted-foreground">
No replies in this thread yet.
</div>
) : (
<TimelineMessageList
activeReplyTargetId={threadReplyTarget?.id ?? null}
currentPubkey={currentPubkey}
messages={messages}
onDelete={onDelete}
onReply={onReply}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
/>
)}
</div>
</div>
{typingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
<MessageComposer
channelId={channel?.id ?? null}
channelName={channel?.name ?? "channel"}
disabled={!canReplyInChannel || isSending}
draftKey={channel ? `${channel.id}:thread:${headMessage.id}` : null}
isSending={isSending}
onCancelReply={threadReplyTarget ? onCancelReply : undefined}
onSend={onSend}
placeholder="Reply in thread..."
replyTarget={threadReplyTarget}
typingReplyParentId={replyTargetMessage?.id ?? headMessage.id}
/>
</aside>
);
}