mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
252
desktop/src/features/messages/ui/ChannelThreadPanel.tsx
Normal file
252
desktop/src/features/messages/ui/ChannelThreadPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user