mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
chore(desktop): polish reply design surfaces
Align the header, timeline, thread panel, composer, and sidebar shells so the reply design branch reads as one consistent visual system. Made-with: Cursor
This commit is contained in:
@@ -107,8 +107,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
isSending;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<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
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={openThreadHeadId}
|
||||
@@ -139,33 +139,36 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onToggleReaction={onToggleReaction}
|
||||
targetMessageId={targetMessageId}
|
||||
/>
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendMessage}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
<div className="relative z-10 -mt-10 shrink-0">
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendMessage}
|
||||
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"
|
||||
}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{threadHeadMessage ? (
|
||||
|
||||
@@ -364,91 +364,97 @@ export function ChannelScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={headerStatusBadge}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="absolute inset-x-0 top-0 z-20">
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() =>
|
||||
setIsMembersSidebarOpen((prev) => !prev)
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={headerStatusBadge}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onBackThread={handleBackThread}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onOpenNestedThread={handleOpenNestedThread}
|
||||
onOpenThread={handleOpenThread}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
canGoBackThread={threadHeadPath.length > 1}
|
||||
openThreadHeadId={openThreadHeadId}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
targetMessageId={targetMessageId}
|
||||
threadHeadMessage={openThreadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadTotalReplyCount={threadTotalReplyCount}
|
||||
threadReplyTargetId={threadReplyTargetId}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
typingPubkeys={mainTypingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onBackThread={handleBackThread}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onOpenNestedThread={handleOpenNestedThread}
|
||||
onOpenThread={handleOpenThread}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
canGoBackThread={threadHeadPath.length > 1}
|
||||
openThreadHeadId={openThreadHeadId}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
targetMessageId={targetMessageId}
|
||||
threadHeadMessage={openThreadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadTotalReplyCount={threadTotalReplyCount}
|
||||
threadReplyTargetId={threadReplyTargetId}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
typingPubkeys={mainTypingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a channel to view messages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a channel to view messages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MembersSidebar
|
||||
|
||||
@@ -71,9 +71,11 @@ export function ChatHeader({
|
||||
mode = "channel",
|
||||
statusBadge,
|
||||
}: ChatHeaderProps) {
|
||||
const trimmedDescription = description.trim();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
|
||||
className="relative z-20 flex min-w-0 shrink-0 items-center gap-3 bg-background/25 px-4 pb-2 pt-6 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:px-6"
|
||||
data-testid="chat-header"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
@@ -87,6 +89,7 @@ export function ChatHeader({
|
||||
<h1
|
||||
className="min-w-0 truncate text-lg font-semibold tracking-tight"
|
||||
data-testid="chat-title"
|
||||
title={trimmedDescription || undefined}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
@@ -96,12 +99,6 @@ export function ChatHeader({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
|
||||
@@ -508,13 +508,13 @@ export function MessageComposer({
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"bg-background p-4",
|
||||
showTopBorder ? "border-t border-border/80" : "",
|
||||
"shrink-0 bg-transparent px-4 pb-4 pt-0",
|
||||
showTopBorder ? "border-t border-border/40 pt-3" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<form
|
||||
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
|
||||
className="relative isolate rounded-2xl border border-border/50 bg-background/25 px-3 py-4 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] sm:px-4"
|
||||
data-testid="message-composer"
|
||||
onDragOver={media.handleDragOver}
|
||||
onDrop={(e) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
|
||||
import { ArrowUp, AtSign, Paperclip } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
|
||||
@@ -67,14 +67,22 @@ export const MessageComposerToolbar = React.memo(
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
aria-label={isSending ? "Sending" : "Send message"}
|
||||
className="rounded-full"
|
||||
data-testid="send-message"
|
||||
disabled={sendDisabled || isSending}
|
||||
title="Send (Enter)"
|
||||
size="icon"
|
||||
title={isSending ? "Sending..." : "Send (Enter)"}
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
{isSending ? "Sending" : "Send"}
|
||||
{isSending ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
||||
/>
|
||||
) : (
|
||||
<ArrowUp aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ export const MessageRow = React.memo(
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex items-start gap-2.5 rounded-2xl px-2 py-1.5 transition-colors",
|
||||
"group/message flex items-start gap-2.5 rounded-2xl px-2 py-1 transition-colors",
|
||||
highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
@@ -181,7 +181,7 @@ export const MessageRow = React.memo(
|
||||
<UserAvatar
|
||||
accent={message.accent}
|
||||
avatarUrl={message.avatarUrl ?? null}
|
||||
className="!h-[42px] !w-[42px]"
|
||||
className="!h-8 !w-8"
|
||||
displayName={message.author}
|
||||
testId="message-avatar"
|
||||
/>
|
||||
@@ -191,81 +191,79 @@ export const MessageRow = React.memo(
|
||||
<UserAvatar
|
||||
accent={message.accent}
|
||||
avatarUrl={message.avatarUrl ?? null}
|
||||
className="shrink-0 !h-[42px] !w-[42px]"
|
||||
className="shrink-0 !h-8 !w-8"
|
||||
displayName={message.author}
|
||||
testId="message-avatar"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
<div className="relative min-w-0 flex-1 space-y-0">
|
||||
<div className="flex min-w-0 flex-nowrap items-start gap-x-2 pr-[9.5rem] sm:pr-40">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-x-2">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="min-w-0 truncate rounded pt-px text-left text-sm font-semibold leading-tight tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="min-w-0 truncate pt-px text-sm font-semibold leading-tight tracking-tight">
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</h3>
|
||||
)}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : message.role ? (
|
||||
<p className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
{message.edited ? (
|
||||
<p
|
||||
className="text-muted-foreground/70"
|
||||
title="This message has been edited"
|
||||
>
|
||||
(edited)
|
||||
</p>
|
||||
) : null}
|
||||
<MessageTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
<div className="absolute right-0 top-0 z-10 flex items-start justify-end gap-2 pt-px text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="shrink-0 font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
{message.edited ? (
|
||||
<p
|
||||
className="shrink-0 text-muted-foreground/70"
|
||||
title="This message has been edited"
|
||||
>
|
||||
(edited)
|
||||
</p>
|
||||
) : null}
|
||||
<MessageTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-1">{renderBody()}</div>
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
reactions={reactions}
|
||||
|
||||
@@ -90,24 +90,26 @@ export function MessageThreadPanel({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="hidden h-full w-[380px] shrink-0 flex-col border-l border-border/80 bg-background lg:flex"
|
||||
className="relative z-10 hidden h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20 pt-0 lg:flex"
|
||||
data-testid="message-thread-panel"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
{canGoBack ? (
|
||||
<Button
|
||||
aria-label="Back"
|
||||
data-testid="message-thread-back"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
|
||||
<div className="relative z-20 flex shrink-0 items-center justify-between bg-background/25 px-2 py-1 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{canGoBack ? (
|
||||
<Button
|
||||
aria-label="Back"
|
||||
data-testid="message-thread-back"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
@@ -122,26 +124,28 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
data-testid="message-thread-body"
|
||||
>
|
||||
<div className="px-3 py-3" data-testid="message-thread-head">
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<div className="px-4 py-3" data-testid="message-thread-head">
|
||||
<div className="mx-auto w-full max-w-[23rem]">
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-3" data-testid="message-thread-replies">
|
||||
<div className="px-4 pb-2 pt-3" data-testid="message-thread-replies">
|
||||
{threadReplies.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto w-full max-w-[23rem] space-y-2">
|
||||
{threadReplies.map((entry) => (
|
||||
<div key={entry.message.id}>
|
||||
<MessageRow
|
||||
@@ -166,20 +170,11 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="relative z-10 -mt-10 shrink-0">
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MessageThreadSummaryRow({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="ml-8 flex w-fit max-w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
className="-mt-1 ml-8 flex w-fit max-w-full items-center gap-2 rounded-xl px-3 py-1 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
data-thread-head-id={message.id}
|
||||
data-testid="message-thread-summary"
|
||||
onClick={() => onOpenThread(message)}
|
||||
|
||||
@@ -92,25 +92,28 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{stickyDayLabel && !isAtBottom ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-4 pt-2 sm:px-6"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-4 pt-1 sm:px-6"
|
||||
data-testid="message-timeline-sticky-day"
|
||||
>
|
||||
<p className="rounded-full bg-muted/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground shadow-sm backdrop-blur-sm">
|
||||
<p className="rounded-md bg-muted/60 px-2 py-0.5 text-[11px] font-medium leading-none tracking-normal text-muted-foreground/75 shadow-sm backdrop-blur-sm">
|
||||
{stickyDayLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-1 [overflow-anchor:none] sm:px-6"
|
||||
data-scroll-restoration-id="message-timeline"
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3 px-4" ref={contentRef}>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2 pb-36 pt-10"
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{isFetchingOlder ? (
|
||||
@@ -168,17 +171,18 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-12 z-20 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto rounded-full shadow-lg"
|
||||
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-sm backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
|
||||
data-testid="message-scroll-to-latest"
|
||||
onClick={() => {
|
||||
scrollToBottom("smooth");
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<ArrowDown aria-hidden />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
|
||||
@@ -73,18 +73,18 @@ export function TypingIndicatorRow({
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="bg-background/95 px-4 py-2 sm:px-6"
|
||||
className="shrink-0 bg-transparent px-4 py-2 sm:px-6"
|
||||
data-testid="message-typing-indicator"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2">
|
||||
<div className="flex shrink-0 items-center">
|
||||
{typingPubkeys.map((pubkey, index) => {
|
||||
const profile = profiles?.[pubkey];
|
||||
const profile = profiles?.[pubkey.toLowerCase()];
|
||||
const label = labels[index] ?? pubkey.slice(0, 8);
|
||||
return (
|
||||
<div
|
||||
key={pubkey}
|
||||
className={`relative h-5 w-5 flex-shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
|
||||
className={`relative h-5 w-5 shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
|
||||
data-testid="message-typing-avatar"
|
||||
>
|
||||
<ProfileAvatar
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarSeparator,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -429,8 +428,6 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator className="mx-0 w-full" />
|
||||
|
||||
<SidebarContent>
|
||||
{isLoading ? (
|
||||
<SidebarGroup>
|
||||
@@ -513,8 +510,6 @@ export function AppSidebar({
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarSeparator className="mx-0 w-full" />
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -249,7 +249,7 @@ const Sidebar = React.forwardRef<
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -308,10 +308,10 @@ const SidebarRail = React.forwardRef<
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
@@ -698,7 +698,7 @@ const SidebarMenuSub = React.forwardRef<
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"mx-3.5 flex min-w-0 flex-col gap-1 px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
|
||||
"flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
|
||||
"data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
|
||||
Reference in New Issue
Block a user