mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
Polish conversation view: composer overlay, blur, and spacing (#507)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -328,16 +328,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
searchQuery={channelFind.query}
|
||||
targetMessageId={targetMessageId}
|
||||
/>
|
||||
{hasTypingActivity ? (
|
||||
<div className="relative bg-background">
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isNonMemberView ? (
|
||||
<div
|
||||
data-testid="join-banner"
|
||||
@@ -365,37 +355,47 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 shrink-0">
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendMessage}
|
||||
profiles={profiles}
|
||||
toolbarExtraActions={
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="pointer-events-auto">
|
||||
{hasTypingActivity ? (
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={composerBotTypingPubkeys}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
? "Archived channels are read-only."
|
||||
: activeChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: activeChannel
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
) : null}
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendMessage}
|
||||
profiles={profiles}
|
||||
toolbarExtraActions={
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={composerBotTypingPubkeys}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
? "Archived channels are read-only."
|
||||
: activeChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: activeChannel
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="sticky top-0 z-30 flex justify-center py-1"
|
||||
className="sticky top-0 z-[1] flex justify-center py-1"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
|
||||
@@ -544,13 +544,17 @@ export function MessageComposer({
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"shrink-0 bg-transparent px-4 pb-4 pt-0",
|
||||
"relative shrink-0 bg-transparent px-4 pb-4 pt-0",
|
||||
showTopBorder ? "border-t border-border/40 pt-3" : "",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-5 bg-background"
|
||||
/>
|
||||
<div className="relative mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<form
|
||||
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"
|
||||
className="relative isolate rounded-2xl border border-border/50 bg-background/70 px-3 py-3 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] sm:px-4"
|
||||
data-testid="message-composer"
|
||||
onDragOver={media.handleDragOver}
|
||||
onDrop={(e) => {
|
||||
|
||||
@@ -52,8 +52,8 @@ export const MessageComposerToolbar = React.memo(
|
||||
sendDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 py-1 min-h-11">
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-h-10 min-w-0 flex-1 items-center gap-1 py-1">
|
||||
{/*
|
||||
* AnimatePresence with mode="popLayout" — exiting elements
|
||||
* are popped out of flow immediately so entering elements
|
||||
|
||||
@@ -127,7 +127,7 @@ export const MessageRow = React.memo(
|
||||
const guideBleedPx = isThreadReplyLayout ? 4 : 0;
|
||||
const avatarSizeClass = isThreadReplyLayout
|
||||
? "!h-5 !w-5 !rounded-md"
|
||||
: "!h-8 !w-8";
|
||||
: "!h-9 !w-9";
|
||||
const avatarButtonRadiusClass = isThreadReplyLayout
|
||||
? "rounded-md"
|
||||
: "rounded-xl";
|
||||
@@ -321,8 +321,8 @@ export const MessageRow = React.memo(
|
||||
) : (
|
||||
<div className="flex shrink-0 items-start">{avatarNode}</div>
|
||||
)}
|
||||
<div className="-mt-1 min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0.5">
|
||||
<div className="-mt-1 min-w-0 flex-1 space-y-0">
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
@@ -347,7 +347,7 @@ export const MessageRow = React.memo(
|
||||
) : null}
|
||||
{metadataNode}
|
||||
</div>
|
||||
{messageBodyNode}
|
||||
<div className="-mt-0.5">{messageBodyNode}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -187,12 +187,12 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto pb-6"
|
||||
className="min-h-0 flex-1 overflow-y-auto pb-24"
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div className="pb-8" ref={contentRef}>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
@@ -273,15 +273,16 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-32 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="thread-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"}
|
||||
@@ -289,30 +290,32 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={threadTypingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={`Reply in thread to ${threadHead.author}`}
|
||||
replyTarget={composerReplyTarget}
|
||||
toolbarExtraActions={toolbarExtraActions}
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="pointer-events-auto">
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={threadTypingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={`Reply in thread to ${threadHead.author}`}
|
||||
replyTarget={composerReplyTarget}
|
||||
toolbarExtraActions={toolbarExtraActions}
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -126,7 +126,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-12 pt-1 [overflow-anchor:none] sm:px-6"
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-24 pt-1 [overflow-anchor:none] sm:px-6"
|
||||
data-scroll-restoration-id="message-timeline"
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
@@ -197,7 +197,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-12 z-20 flex justify-center px-4">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-32 z-20 flex justify-center px-4">
|
||||
<Button
|
||||
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"
|
||||
|
||||
@@ -206,7 +206,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(avatarPubkey, profiles)}
|
||||
className="!h-8 !w-8 shrink-0 text-[10px]"
|
||||
className="!h-9 !w-9 shrink-0 text-[10px]"
|
||||
displayName={avatarLabel}
|
||||
testId="system-message-avatar"
|
||||
/>
|
||||
|
||||
@@ -328,7 +328,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
return body.scrollHeight - body.clientHeight;
|
||||
});
|
||||
})
|
||||
.toBeGreaterThan(160);
|
||||
.toBeGreaterThanOrEqual(160);
|
||||
|
||||
await expect(
|
||||
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
|
||||
@@ -393,7 +393,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
return rowRect.top - bodyRect.top;
|
||||
});
|
||||
})
|
||||
.toBeLessThan(160);
|
||||
.toBeLessThanOrEqual(160);
|
||||
|
||||
const firstReplyId = await firstReplyRow.getAttribute("data-message-id");
|
||||
if (!firstReplyId) {
|
||||
@@ -447,7 +447,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
return rowRect.top - bodyRect.top;
|
||||
});
|
||||
})
|
||||
.toBeLessThan(160);
|
||||
.toBeLessThanOrEqual(160);
|
||||
|
||||
await firstReplySummaryRow.click();
|
||||
await expect(
|
||||
|
||||
@@ -424,7 +424,7 @@ test("does not shift the timeline when the composer grows", async ({
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const after = await getTimelineMetrics(page);
|
||||
expect(after.clientHeight).toBeLessThan(before.clientHeight);
|
||||
expect(after.clientHeight).toBeLessThanOrEqual(before.clientHeight);
|
||||
expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThanOrEqual(2);
|
||||
expect(after.distanceFromBottom).toBeGreaterThan(160);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user