mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): improve thread readability
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -24,7 +24,7 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
|
||||
* with auto horizontal margins so the reading measure stays comfortable no
|
||||
* matter how wide the window gets.
|
||||
*/
|
||||
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
|
||||
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 760;
|
||||
|
||||
/**
|
||||
* Horizontal distance the focus drawer travels on enter/exit.
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
formatDayHeading,
|
||||
formatMessageTimestamp,
|
||||
formatShortMonthDayOrdinal,
|
||||
formatThreadSummaryLastReplyTime,
|
||||
formatTimeWithoutDayPeriod,
|
||||
@@ -81,6 +82,37 @@ test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => {
|
||||
assert.equal(formatTimeWithoutDayPeriod("16:20"), "16:20");
|
||||
});
|
||||
|
||||
test("formatMessageTimestamp uses compact relative units for recent messages", () => {
|
||||
const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000;
|
||||
|
||||
assert.equal(formatMessageTimestamp(now - 30, now), "Just now");
|
||||
assert.equal(formatMessageTimestamp(now - 60, now), "1m ago");
|
||||
assert.equal(formatMessageTimestamp(now - 59 * 60, now), "59m ago");
|
||||
assert.equal(formatMessageTimestamp(now - 60 * 60, now), "1h ago");
|
||||
assert.equal(formatMessageTimestamp(now - 23 * 60 * 60, now), "23h ago");
|
||||
});
|
||||
|
||||
test("formatMessageTimestamp uses weekday and time for the previous six calendar days", () => {
|
||||
const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000;
|
||||
const yesterday = new Date(2026, 7, 5, 20, 15).getTime() / 1_000;
|
||||
const sixDaysAgo = new Date(2026, 6, 31, 9, 30).getTime() / 1_000;
|
||||
|
||||
assert.equal(formatMessageTimestamp(yesterday, now), "Wednesday at 8:15 PM");
|
||||
assert.equal(formatMessageTimestamp(sixDaysAgo, now), "Friday at 9:30 AM");
|
||||
});
|
||||
|
||||
test("formatMessageTimestamp uses short dates after six calendar days", () => {
|
||||
const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000;
|
||||
const sevenDaysAgo = new Date(2026, 6, 30, 9, 30).getTime() / 1_000;
|
||||
const previousYear = new Date(2025, 11, 15, 14, 5).getTime() / 1_000;
|
||||
|
||||
assert.equal(formatMessageTimestamp(sevenDaysAgo, now), "Jul 30 at 9:30 AM");
|
||||
assert.equal(
|
||||
formatMessageTimestamp(previousYear, now),
|
||||
"Dec 15, 2025 at 2:05 PM",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatThreadSummaryLastReplyTime expands relative units", () => {
|
||||
const now = localUnixSeconds(2026, 4, 19);
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const SHORT_DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/** Short clock time, e.g. "2:34 PM". */
|
||||
export function formatTime(unixSeconds: number): string {
|
||||
return TIME_FORMATTER.format(new Date(unixSeconds * 1_000));
|
||||
@@ -52,6 +63,39 @@ export function formatFullDateTime(unixSeconds: number): string {
|
||||
return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, recency-aware message timestamp.
|
||||
*
|
||||
* Recent messages use terse relative units ("Just now", "8m", "3h"). From
|
||||
* the previous calendar day through six calendar days ago, use the weekday and
|
||||
* clock time ("Wednesday at 2:34 PM"). Older messages use a short date and
|
||||
* clock time, adding the year only when it differs from the current year.
|
||||
*/
|
||||
export function formatMessageTimestamp(
|
||||
unixSeconds: number,
|
||||
nowSeconds = Date.now() / 1_000,
|
||||
): string {
|
||||
const diffSeconds = Math.max(0, nowSeconds - unixSeconds);
|
||||
if (diffSeconds < 60) return "Just now";
|
||||
if (diffSeconds < 3_600) return `${Math.floor(diffSeconds / 60)}m ago`;
|
||||
if (diffSeconds < 86_400) return `${Math.floor(diffSeconds / 3_600)}h ago`;
|
||||
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date(nowSeconds * 1_000);
|
||||
const calendarDayDiff = differenceInLocalCalendarDays(date, now);
|
||||
const time = TIME_FORMATTER.format(date);
|
||||
|
||||
if (calendarDayDiff >= 1 && calendarDayDiff <= 6) {
|
||||
return `${WEEKDAY_FORMATTER.format(date)} at ${time}`;
|
||||
}
|
||||
|
||||
const dateLabel =
|
||||
date.getFullYear() === now.getFullYear()
|
||||
? SHORT_DATE_FORMATTER.format(date)
|
||||
: SHORT_DATE_WITH_YEAR_FORMATTER.format(date);
|
||||
return `${dateLabel} at ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly day label for dividers and sticky headers.
|
||||
* Returns "Today", "Yesterday", a current-year date like "Monday, March 31st",
|
||||
@@ -131,6 +175,20 @@ function isSameDayDate(a: Date, b: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function differenceInLocalCalendarDays(earlier: Date, later: Date): number {
|
||||
const earlierDay = new Date(
|
||||
earlier.getFullYear(),
|
||||
earlier.getMonth(),
|
||||
earlier.getDate(),
|
||||
);
|
||||
const laterDay = new Date(
|
||||
later.getFullYear(),
|
||||
later.getMonth(),
|
||||
later.getDate(),
|
||||
);
|
||||
return Math.round((laterDay.getTime() - earlierDay.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatMonthDayOrdinal(
|
||||
date: Date,
|
||||
monthFormatter: Intl.DateTimeFormat,
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
*/
|
||||
|
||||
/** Inline gutter around thread message rows. */
|
||||
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
|
||||
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-4";
|
||||
|
||||
/** Inline gutter around the thread composer and its activity row. */
|
||||
export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
|
||||
|
||||
/**
|
||||
* Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode
|
||||
* drawer). `px-10` (40px) is the inline gutter between the column and the drawer
|
||||
* edges; the max-width itself is applied inline since it is a caller-provided
|
||||
* pixel value.
|
||||
* drawer). The responsive gutter keeps compact focus drawers usable while
|
||||
* preserving generous whitespace at desktop widths. The max-width itself is
|
||||
* applied inline since it is a caller-provided value.
|
||||
*/
|
||||
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10";
|
||||
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-6 sm:px-10";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
|
||||
export function MessageAgentOwner({
|
||||
@@ -11,7 +9,7 @@ export function MessageAgentOwner({
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-xs leading-4 text-muted-foreground/65"
|
||||
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-2xs font-normal leading-4 text-muted-foreground"
|
||||
data-testid="message-agent-owner"
|
||||
>
|
||||
<span className="sr-only">
|
||||
@@ -19,30 +17,22 @@ export function MessageAgentOwner({
|
||||
</span>
|
||||
{ownerPubkey && ownerLabel ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
|
||||
>
|
||||
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
|
||||
<span>managed by</span>
|
||||
<span aria-hidden="true" className="shrink-0">
|
||||
managed by
|
||||
</span>
|
||||
<UserProfilePopover
|
||||
pubkey={ownerPubkey}
|
||||
triggerAriaLabel={ownerLabel}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="min-w-0 truncate rounded font-semibold text-foreground/85 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="min-w-0 truncate rounded font-medium text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{ownerLabel}
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex min-w-0 items-center gap-1"
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">owner unavailable</span>
|
||||
<span aria-hidden="true" className="truncate">
|
||||
owner unavailable
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function MessageAuthorText({
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"truncate text-sm font-bold leading-4 tracking-tight",
|
||||
"truncate text-sm font-semibold leading-4",
|
||||
hoverUnderline && "hover:underline",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -246,7 +246,7 @@ export const MessageRow = React.memo(
|
||||
message.body,
|
||||
message.tags,
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
const bodyOffsetClass = "mt-1";
|
||||
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
const openVideoReviewAt = useOpenVideoReviewAt();
|
||||
@@ -367,7 +367,7 @@ export const MessageRow = React.memo(
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
className={cn(
|
||||
"max-w-full text-sm",
|
||||
"max-w-full leading-relaxed [&>p+p]:mt-2 [&>ol]:space-y-1.5 [&>ul]:space-y-1.5",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
@@ -823,7 +823,7 @@ export const MessageRow = React.memo(
|
||||
className={cn(
|
||||
"group/message relative z-10 rounded-2xl transition-colors",
|
||||
playEntrance && "motion-enter-conversation",
|
||||
"py-1",
|
||||
isThreadReplyLayout ? "py-2" : "py-1.5",
|
||||
hoverBackground
|
||||
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
|
||||
: isThreadReplyLayout
|
||||
|
||||
@@ -574,7 +574,7 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
|
||||
data-testid="message-thread-head"
|
||||
>
|
||||
<div className="rounded-2xl">
|
||||
@@ -631,7 +631,7 @@ export function MessageThreadPanel({
|
||||
|
||||
{showThreadHeadDivider ? (
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-3")}
|
||||
data-testid="message-thread-head-divider"
|
||||
>
|
||||
<Separator className="bg-border/60" />
|
||||
@@ -639,7 +639,7 @@ export function MessageThreadPanel({
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-0")}
|
||||
data-testid="message-thread-replies"
|
||||
>
|
||||
{threadRepliesPending && !isHuddleTranscript ? (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
formatFullDateTime,
|
||||
formatMessageTimestamp,
|
||||
formatTimeWithoutDayPeriod,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMinuteNow } from "@/shared/lib/useMinuteNow";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -23,7 +25,10 @@ export function MessageTimestamp({
|
||||
hideDayPeriod?: boolean;
|
||||
time: string;
|
||||
}) {
|
||||
const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time;
|
||||
const now = useMinuteNow();
|
||||
const displayTime = hideDayPeriod
|
||||
? formatTimeWithoutDayPeriod(time)
|
||||
: formatMessageTimestamp(createdAt, now / 1_000);
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
@@ -34,7 +39,7 @@ export function MessageTimestamp({
|
||||
<TooltipTrigger asChild>
|
||||
<p
|
||||
className={cn(
|
||||
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
|
||||
"shrink-0 cursor-default whitespace-nowrap text-2xs font-normal leading-4 tabular-nums text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
data-testid="message-timestamp"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
let currentMinute = Date.now();
|
||||
let minuteTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (minuteTimer === null) {
|
||||
minuteTimer = setInterval(() => {
|
||||
currentMinute = Date.now();
|
||||
for (const notify of listeners) notify();
|
||||
}, MINUTE_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && minuteTimer !== null) {
|
||||
clearInterval(minuteTimer);
|
||||
minuteTimer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return currentMinute;
|
||||
}
|
||||
|
||||
/** A shared minute clock for recency labels rendered in large lists. */
|
||||
export function useMinuteNow(): number {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -121,11 +121,11 @@ test("agent owner label identifies the agent and owner", async ({ page }) => {
|
||||
.filter({ hasText: "Hey team — checking in." });
|
||||
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
|
||||
|
||||
await expect(ownerTreatment.locator("svg")).toBeVisible();
|
||||
await expect(ownerTreatment.locator("svg")).toHaveCount(0);
|
||||
await expect(
|
||||
ownerTreatment.getByText("managed by", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
|
||||
await expect(ownerTreatment.locator(".font-medium")).toHaveText("bob");
|
||||
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
|
||||
await expect(ownerTreatment.locator(".sr-only")).toHaveText(
|
||||
"Agent managed by",
|
||||
|
||||
Reference in New Issue
Block a user