Skip to content
Merged
15 changes: 8 additions & 7 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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}
Expand Down
128 changes: 128 additions & 0 deletions desktop/src/features/messages/lib/dateFormatters.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`,
);
});
81 changes: 75 additions & 6 deletions desktop/src/features/messages/lib/dateFormatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand All @@ -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". */
Expand All @@ -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);
Expand All @@ -58,18 +64,81 @@ 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). */
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() &&
a.getMonth() === b.getMonth() &&
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";
}
}
6 changes: 3 additions & 3 deletions desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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>
);
Expand Down
22 changes: 4 additions & 18 deletions desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}
</span>
<span
className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
Expand Down
Loading