mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
desktop: polish chat timeline, header, and channel layout
- Subtler day dividers and compact date headings; remove chat header border - Reposition scroll-to-latest above composer with muted outline style - Channel pane/screen layout and related sheet/sidebar tweaks Made-with: Cursor
This commit is contained in:
@@ -210,7 +210,7 @@ export function ChannelManagementSheet({
|
||||
return (
|
||||
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
|
||||
<SheetContent
|
||||
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
|
||||
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-xl"
|
||||
data-testid="channel-management-sheet"
|
||||
side="right"
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageTimeline
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
@@ -101,49 +101,51 @@ 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={
|
||||
!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="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={
|
||||
!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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -343,92 +343,95 @@ export function ChannelScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={onManageChannel}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading forum..." />}
|
||||
>
|
||||
<ForumView
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={onManageChannel}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
|
||||
<div className="relative z-0 -mt-8 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading forum..." />}
|
||||
>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading 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}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading 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}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel && searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -147,7 +147,7 @@ export function MembersSidebar({
|
||||
return (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetContent
|
||||
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
|
||||
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-md"
|
||||
data-testid="members-sidebar"
|
||||
side="right"
|
||||
>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
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 shrink-0 min-w-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
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - `formatFullDateTime` — verbose string for tooltips
|
||||
* ("Wednesday, April 2, 2026 at 2:34 PM").
|
||||
* - `formatDayHeading` — label for day dividers / sticky headers.
|
||||
* Returns "Today", "Yesterday", or a date like "Monday, March 31, 2026".
|
||||
* Returns "Today", "Yesterday", or a compact date like "Sat, Mar 21, 2026".
|
||||
* - `isSameDay` — compare two unix-second timestamps.
|
||||
*/
|
||||
|
||||
@@ -23,11 +23,12 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
/** Compact calendar line for dividers (not Today/Yesterday). */
|
||||
const DAY_HEADING_COMPACT = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/** Short clock time, e.g. "2:34 PM". */
|
||||
@@ -42,7 +43,7 @@ export function formatFullDateTime(unixSeconds: number): string {
|
||||
|
||||
/**
|
||||
* Human-friendly day label for dividers and sticky headers.
|
||||
* Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
|
||||
* Returns "Today", "Yesterday", or a short date like "Sat, Mar 21, 2026".
|
||||
*/
|
||||
export function formatDayHeading(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
@@ -58,7 +59,7 @@ export function formatDayHeading(unixSeconds: number): string {
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
return DAY_HEADING_FORMATTER.format(date);
|
||||
return DAY_HEADING_COMPACT.format(date);
|
||||
}
|
||||
|
||||
/** True when two unix-second timestamps fall on the same calendar day (local time). */
|
||||
|
||||
@@ -4,15 +4,15 @@ export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="flex items-center gap-3 py-2"
|
||||
className="flex items-center gap-2 py-1"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
<Separator className="flex-1 bg-border/50" />
|
||||
<p className="shrink-0 text-[11px] font-medium leading-none tracking-normal text-muted-foreground/70">
|
||||
{label}
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
<Separator className="flex-1 bg-border/50" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,10 +493,10 @@ export function MessageComposer({
|
||||
}, [media.handlePaperclip]);
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/80 bg-background p-4">
|
||||
<footer className="shrink-0 bg-transparent px-4 pb-4 pt-0">
|
||||
<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) => {
|
||||
|
||||
@@ -89,25 +89,25 @@ 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-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
|
||||
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" />
|
||||
@@ -166,17 +166,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"}
|
||||
|
||||
@@ -72,7 +72,7 @@ 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="mx-auto flex w-full max-w-4xl items-center">
|
||||
|
||||
@@ -144,7 +144,7 @@ export function SettingsView({
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
<aside
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0 lg:border-r",
|
||||
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0",
|
||||
isLoaded
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-2",
|
||||
|
||||
@@ -54,7 +54,7 @@ export function WorkflowDetailPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col border-l bg-background"
|
||||
className="flex h-full flex-col bg-background"
|
||||
data-testid="workflow-detail-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
|
||||
@@ -38,9 +38,9 @@ const sheetVariants = cva(
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
"inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user