diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 5b0724b32..79e63e0fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -164,10 +164,16 @@ export const ChannelPane = React.memo(function ChannelPane({ }: ChannelPaneProps) { const timelineScrollRef = React.useRef(null); const composerWrapperRef = React.useRef(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} diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs new file mode 100644 index 000000000..7d533dbe3 --- /dev/null +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -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}`, + ); +}); diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index ee14df78d..344be9bf5 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -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"; + } +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 2e22be412..21a65e75e 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -148,7 +148,7 @@ export const MessageRow = React.memo( return ( + {message.author} ) : ( -

+

{message.author}

); diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 9ee082f57..1240bda2e 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -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({ participant, index, @@ -67,7 +52,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 @@ -134,7 +119,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)} ; + /** True when the timeline has the composer overlay below it. */ + hasComposerOverlay?: boolean; isFetchingOlder?: boolean; messageFooters?: Record; /** 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({
{!isAtBottom ? ( -
+