mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
Polish message timeline affordances
Made-with: Cursor
This commit is contained in:
@@ -4,7 +4,7 @@ export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="flex items-center gap-2.5 py-1.5"
|
||||
className="sticky top-0 z-30 flex items-center gap-2.5 bg-background/80 py-1 backdrop-blur-sm"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
|
||||
@@ -244,28 +244,23 @@ export const MessageComposerToolbar = React.memo(
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={isSending ? "Sending" : "Send message"}
|
||||
className="rounded-full"
|
||||
data-testid="send-message"
|
||||
disabled={sendDisabled || isSending}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
{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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Send (Enter)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
aria-label={isSending ? "Sending" : "Send message"}
|
||||
className="rounded-full"
|
||||
data-testid="send-message"
|
||||
disabled={sendDisabled || isSending}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,14 +40,15 @@ export function MessageThreadSummaryRow({
|
||||
summary: TimelineThreadSummary;
|
||||
}) {
|
||||
const visibleDepth = Math.min(Math.max(depth, 0), 6);
|
||||
const marginLeftPx = visibleDepth * 28;
|
||||
const messageTextOffsetPx = 50;
|
||||
const marginLeftPx = visibleDepth * 28 + messageTextOffsetPx;
|
||||
const depthGuideOffsets = Array.from(
|
||||
{ length: visibleDepth },
|
||||
(_, index) => 14 + index * 28,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative pb-2">
|
||||
{depthGuideOffsets.length > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -68,7 +69,7 @@ export function MessageThreadSummaryRow({
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="-mt-0.5 flex w-fit max-w-full items-center gap-1 rounded-xl px-3 py-0.5 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
className="-mt-0.5 inline-flex w-fit max-w-full items-center gap-1 rounded-full border border-border/70 bg-muted/70 py-0.5 pl-0.5 pr-2 text-left text-xs font-medium text-foreground/90 transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
data-thread-head-id={message.id}
|
||||
data-testid="message-thread-summary"
|
||||
onClick={() => onOpenThread(message)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useStickyDayHeader } from "./useStickyDayHeader";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
@@ -121,21 +120,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
const stickyDayLabel = useStickyDayHeader(scrollContainerRef);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<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-16 z-10 flex justify-center px-4 pt-1 sm:px-6"
|
||||
data-testid="message-timeline-sticky-day"
|
||||
>
|
||||
<p className="rounded-md border border-border/35 bg-background/45 px-2 py-0.5 text-[10px] font-medium leading-none tracking-[0.02em] text-muted-foreground/65 backdrop-blur-sm">
|
||||
{stickyDayLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<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"
|
||||
data-scroll-restoration-id="message-timeline"
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const STICKY_DAY_TRIGGER_OFFSET_PX = 64;
|
||||
|
||||
/**
|
||||
* Tracks the day label of the topmost visible day-divider in the scroll
|
||||
* container so a floating header can mirror it.
|
||||
*
|
||||
* Returns `null` when the first divider is still fully visible (no header
|
||||
* needed) or when there are no dividers in the DOM.
|
||||
*/
|
||||
export function useStickyDayHeader(
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [label, setLabel] = React.useState<string | null>(null);
|
||||
|
||||
const update = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dividers = container.querySelectorAll<HTMLElement>(
|
||||
"[data-testid='message-timeline-day-divider']",
|
||||
);
|
||||
if (dividers.length === 0) {
|
||||
setLabel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const stickyTriggerTop = containerTop + STICKY_DAY_TRIGGER_OFFSET_PX;
|
||||
|
||||
// Walk dividers from the end — the last one whose top has reached the
|
||||
// floating label's visual zone is the "current" day.
|
||||
let current: string | null = null;
|
||||
for (let i = dividers.length - 1; i >= 0; i--) {
|
||||
const rect = dividers[i].getBoundingClientRect();
|
||||
if (rect.top <= stickyTriggerTop) {
|
||||
current = dividers[i].getAttribute("data-day-label");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setLabel(current);
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener("scroll", update, { passive: true });
|
||||
// Run once on mount so the header appears if already scrolled.
|
||||
update();
|
||||
return () => {
|
||||
container.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [scrollContainerRef, update]);
|
||||
|
||||
return label;
|
||||
}
|
||||
Reference in New Issue
Block a user