Tune chat text sizing (#891)

This commit is contained in:
klopez4212
2026-06-08 07:13:22 -07:00
committed by GitHub
parent 29f6ccf9e9
commit 45f3dfe5ba
10 changed files with 261 additions and 55 deletions
@@ -164,10 +164,16 @@ export const ChannelPane = React.memo(function ChannelPane({
}: ChannelPaneProps) {
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const hasMainComposerOverlay = !isNonMemberView;
useComposerHeightPadding(
timelineScrollRef,
composerWrapperRef,
isSinglePanelView,
`${isSinglePanelView}:${hasMainComposerOverlay}`,
);
// Scope the edit target to the correct composer: if the message being edited
@@ -225,12 +231,6 @@ export const ChannelPane = React.memo(function ChannelPane({
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||
@@ -309,6 +309,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatDayHeading,
formatShortMonthDayOrdinal,
formatThreadSummaryLastReplyTime,
} from "./dateFormatters.ts";
function localUnixSeconds(year, monthIndex, day) {
return new Date(year, monthIndex, day, 12).getTime() / 1_000;
}
function weekday(date) {
return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date);
}
function month(date) {
return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date);
}
test("formatShortMonthDayOrdinal formats month before ordinal day", () => {
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)),
"May 19th",
);
});
test("formatShortMonthDayOrdinal handles ordinal suffixes", () => {
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)),
"May 1st",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)),
"May 2nd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)),
"May 3rd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)),
"May 4th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)),
"May 11th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)),
"May 12th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)),
"May 13th",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)),
"May 21st",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)),
"May 22nd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)),
"May 23rd",
);
assert.equal(
formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)),
"May 31st",
);
});
test("formatThreadSummaryLastReplyTime expands relative units", () => {
const now = localUnixSeconds(2026, 4, 19);
assert.equal(formatThreadSummaryLastReplyTime(now - 30, now), "just now");
assert.equal(formatThreadSummaryLastReplyTime(now - 60, now), "1 minute ago");
assert.equal(
formatThreadSummaryLastReplyTime(now - 180, now),
"3 minutes ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 3_600, now),
"1 hour ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 10_800, now),
"3 hours ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 86_400, now),
"1 day ago",
);
assert.equal(
formatThreadSummaryLastReplyTime(now - 345_600, now),
"4 days ago",
);
});
test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => {
const now = localUnixSeconds(2026, 5, 15);
const replyAt = localUnixSeconds(2026, 4, 19);
assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th");
});
test("formatDayHeading omits the year for current-year dates", () => {
const now = new Date();
const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12);
assert.equal(
formatDayHeading(date.getTime() / 1_000),
`${weekday(date)}, ${month(date)} 19th`,
);
});
test("formatDayHeading includes the year for other years", () => {
const year = new Date().getFullYear() - 1;
const date = new Date(year, 4, 19, 12);
assert.equal(
formatDayHeading(date.getTime() / 1_000),
`${weekday(date)}, May 19th, ${year}`,
);
});
@@ -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 date like "Monday, March 31st".
* - `isSameDay` — compare two unix-second timestamps.
*/
@@ -23,11 +23,16 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
minute: "2-digit",
});
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", {
const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
});
const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
});
const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
});
/** Short clock time, e.g. "2:34 PM". */
@@ -42,7 +47,8 @@ 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", a current-year date like "Monday, March 31st",
* or a prior-year date like "Monday, March 31st, 2025".
*/
export function formatDayHeading(unixSeconds: number): string {
const date = new Date(unixSeconds * 1_000);
@@ -58,7 +64,13 @@ export function formatDayHeading(unixSeconds: number): string {
return "Yesterday";
}
return DAY_HEADING_FORMATTER.format(date);
const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal(
date,
LONG_MONTH_FORMATTER,
)}`;
return date.getFullYear() === now.getFullYear()
? dateLabel
: `${dateLabel}, ${date.getFullYear()}`;
}
/** True when two unix-second timestamps fall on the same calendar day (local time). */
@@ -66,6 +78,32 @@ export function isSameDay(a: number, b: number): boolean {
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
}
/** Short month + ordinal day, e.g. "May 19th". */
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
return formatMonthDayOrdinal(
new Date(unixSeconds * 1_000),
SHORT_MONTH_FORMATTER,
);
}
/**
* Relative thread-summary timestamp with expanded units, e.g. "3 hours ago",
* falling back to "on May 19th" for older replies.
*/
export function formatThreadSummaryLastReplyTime(
unixSeconds: number,
nowSeconds = Date.now() / 1_000,
): string {
const diff = Math.max(0, nowSeconds - unixSeconds);
if (diff < 60) return "just now";
if (diff < 3_600) return formatAgo(Math.floor(diff / 60), "minute");
if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour");
if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day");
return `on ${formatShortMonthDayOrdinal(unixSeconds)}`;
}
function isSameDayDate(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
@@ -73,3 +111,34 @@ function isSameDayDate(a: Date, b: Date): boolean {
a.getDate() === b.getDate()
);
}
function formatMonthDayOrdinal(
date: Date,
monthFormatter: Intl.DateTimeFormat,
): string {
return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix(
date.getDate(),
)}`;
}
function formatAgo(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? "" : "s"} ago`;
}
function ordinalSuffix(day: number): string {
const lastTwoDigits = day % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) {
return "th";
}
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
@@ -148,7 +148,7 @@ export const MessageRow = React.memo(
return (
<Markdown
channelNames={channelNames}
className="max-w-full"
className="max-w-full text-[15px] leading-6"
content={message.body}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
@@ -200,11 +200,11 @@ export const MessageRow = React.memo(
);
const authorNode = message.pubkey ? (
<span className="truncate text-sm font-semibold leading-none tracking-tight hover:underline">
<span className="truncate text-[15px] font-semibold leading-none tracking-tight hover:underline">
{message.author}
</span>
) : (
<h3 className="truncate text-sm font-semibold leading-none tracking-tight">
<h3 className="truncate text-[15px] font-semibold leading-none tracking-tight">
{message.author}
</h3>
);
@@ -3,28 +3,13 @@ import type {
TimelineThreadSummaryParticipant,
} from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MESSAGE_TEXT_OFFSET_PX = 54;
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
const NESTED_REPLY_OFFSET_PX = 28;
function formatLastReplyTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3_600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h ago`;
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d ago`;
const date = new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
return `on ${date}`;
}
function ParticipantAvatar({
hasNextParticipant,
participant,
@@ -69,7 +54,7 @@ export function MessageThreadSummaryRow({
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
const summaryAriaLabel = summary.lastReplyAt
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatLastReplyTime(summary.lastReplyAt)}`
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}`
: `View thread with ${summary.replyCount} ${replyLabel}`;
const depthGuideOffsets =
visibleDepth === 0
@@ -137,7 +122,8 @@ export function MessageThreadSummaryRow({
className="col-start-1 row-start-1 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
data-testid="message-thread-summary-last-reply"
>
last reply {formatLastReplyTime(summary.lastReplyAt)}
last reply{" "}
{formatThreadSummaryLastReplyTime(summary.lastReplyAt)}
</span>
<span
className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
@@ -3,6 +3,7 @@ import { ArrowDown } from "lucide-react";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Spinner } from "@/shared/ui/spinner";
@@ -24,6 +25,8 @@ type MessageTimelineProps = {
/** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
/** True when the timeline has the composer overlay below it. */
hasComposerOverlay?: boolean;
isFetchingOlder?: boolean;
messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */
@@ -59,6 +62,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
emptyDescription = "Send the first message to start the thread.",
currentPubkey,
fetchOlder,
hasComposerOverlay = true,
hasOlderMessages = true,
isFetchingOlder = false,
followThreadById,
@@ -141,7 +145,10 @@ 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-24 pt-1 [overflow-anchor:none] sm:px-6"
className={cn(
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pt-1 [overflow-anchor:none] sm:px-6",
hasComposerOverlay ? "pb-24" : "pb-4",
)}
data-scroll-restoration-id={scrollRestorationId}
data-testid="message-timeline"
onScroll={syncScrollState}
@@ -216,7 +223,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-20 flex justify-center px-4">
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4",
hasComposerOverlay ? "bottom-36" : "bottom-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-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
data-testid="message-scroll-to-latest"
@@ -10,6 +10,10 @@ import { resolveUserLabel } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -78,10 +82,10 @@ function ProfileName({
const node = (
<span
className={cn(
"rounded-xs transition-colors hover:text-foreground",
pubkey && "cursor-pointer",
highlight &&
"rounded-md bg-primary/15 px-1 py-0.5 font-medium text-primary hover:bg-primary/25 hover:text-primary/90",
highlight
? cn(MENTION_CHIP_BASE_CLASSES, MENTION_CHIP_HOVER_CLASSES)
: "rounded-xs transition-colors hover:text-foreground",
)}
>
{highlight ? "@" : null}
@@ -222,12 +226,7 @@ function describeSystemEvent(
}
return {
title: actorName,
action: (
<>
added <span className="font-medium">{targetName}</span> to the
channel
</>
),
action: <>added {targetName} to the channel</>,
};
}
case "member_left":
@@ -238,12 +237,7 @@ function describeSystemEvent(
case "member_removed":
return {
title: actorName,
action: (
<>
removed <span className="font-medium">{targetName}</span> from the
channel
</>
),
action: <>removed {targetName} from the channel</>,
};
case "topic_changed":
return {
+6 -3
View File
@@ -114,11 +114,14 @@
}
.rich-text-composer .tiptap .mention-highlight {
border-radius: 0.375rem;
display: inline-block;
border-radius: 4px;
background: hsl(var(--primary) / 0.15);
padding: 0.125rem 0.375rem;
padding: 3px 4px 2px;
font-size: 15px;
line-height: 1;
color: hsl(var(--primary));
font-weight: 600;
font-weight: 500;
}
.agent-activity-shimmer {
+9 -1
View File
@@ -40,6 +40,10 @@ import remarkCustomEmoji, {
import remarkMentions from "@/shared/lib/remarkMentions";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import { Button } from "@/shared/ui/button";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -908,7 +912,11 @@ function createMarkdownComponents(
const mentionNode = (
<span
data-mention=""
className="cursor-pointer rounded-md bg-primary/15 px-1 py-0.5 text-sm font-semibold text-primary transition-colors hover:bg-primary/25 hover:text-primary/90"
className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
)}
>
{children}
</span>
+5
View File
@@ -0,0 +1,5 @@
export const MENTION_CHIP_BASE_CLASSES =
"inline-block rounded-[4px] bg-primary/15 px-1 pt-[3px] pb-[2px] text-[15px] font-medium leading-none text-primary";
export const MENTION_CHIP_HOVER_CLASSES =
"transition-colors hover:bg-primary/25 hover:text-primary/90";