mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Tune chat text sizing (#891)
This commit is contained in:
@@ -164,10 +164,16 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
}: ChannelPaneProps) {
|
}: ChannelPaneProps) {
|
||||||
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
|
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
const composerWrapperRef = 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(
|
useComposerHeightPadding(
|
||||||
timelineScrollRef,
|
timelineScrollRef,
|
||||||
composerWrapperRef,
|
composerWrapperRef,
|
||||||
isSinglePanelView,
|
`${isSinglePanelView}:${hasMainComposerOverlay}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scope the edit target to the correct composer: if the message being edited
|
// 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;
|
return true;
|
||||||
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
|
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
|
||||||
|
|
||||||
const isNonMemberView =
|
|
||||||
activeChannel !== null &&
|
|
||||||
!activeChannel.isMember &&
|
|
||||||
activeChannel.visibility === "open" &&
|
|
||||||
!activeChannel.archivedAt;
|
|
||||||
|
|
||||||
const isComposerDisabled =
|
const isComposerDisabled =
|
||||||
!activeChannel?.isMember ||
|
!activeChannel?.isMember ||
|
||||||
activeChannel.archivedAt !== null ||
|
activeChannel.archivedAt !== null ||
|
||||||
@@ -309,6 +309,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
fetchOlder={fetchOlder}
|
fetchOlder={fetchOlder}
|
||||||
followThreadById={followThreadById}
|
followThreadById={followThreadById}
|
||||||
|
hasComposerOverlay={hasMainComposerOverlay}
|
||||||
hasOlderMessages={hasOlderMessages}
|
hasOlderMessages={hasOlderMessages}
|
||||||
isFetchingOlder={isFetchingOlder}
|
isFetchingOlder={isFetchingOlder}
|
||||||
isFollowingThreadById={isFollowingThreadById}
|
isFollowingThreadById={isFollowingThreadById}
|
||||||
|
|||||||
128
desktop/src/features/messages/lib/dateFormatters.test.mjs
Normal file
128
desktop/src/features/messages/lib/dateFormatters.test.mjs
Normal file
@@ -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
|
* - `formatFullDateTime` — verbose string for tooltips
|
||||||
* ("Wednesday, April 2, 2026 at 2:34 PM").
|
* ("Wednesday, April 2, 2026 at 2:34 PM").
|
||||||
* - `formatDayHeading` — label for day dividers / sticky headers.
|
* - `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.
|
* - `isSameDay` — compare two unix-second timestamps.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -23,11 +23,16 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
year: "numeric",
|
});
|
||||||
|
|
||||||
|
const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
});
|
||||||
|
|
||||||
|
const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Short clock time, e.g. "2:34 PM". */
|
/** 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.
|
* 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 {
|
export function formatDayHeading(unixSeconds: number): string {
|
||||||
const date = new Date(unixSeconds * 1_000);
|
const date = new Date(unixSeconds * 1_000);
|
||||||
@@ -58,7 +64,13 @@ export function formatDayHeading(unixSeconds: number): string {
|
|||||||
return "Yesterday";
|
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). */
|
/** 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));
|
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 {
|
function isSameDayDate(a: Date, b: Date): boolean {
|
||||||
return (
|
return (
|
||||||
a.getFullYear() === b.getFullYear() &&
|
a.getFullYear() === b.getFullYear() &&
|
||||||
@@ -73,3 +111,34 @@ function isSameDayDate(a: Date, b: Date): boolean {
|
|||||||
a.getDate() === b.getDate()
|
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 (
|
return (
|
||||||
<Markdown
|
<Markdown
|
||||||
channelNames={channelNames}
|
channelNames={channelNames}
|
||||||
className="max-w-full"
|
className="max-w-full text-[15px] leading-6"
|
||||||
content={message.body}
|
content={message.body}
|
||||||
customEmoji={customEmoji}
|
customEmoji={customEmoji}
|
||||||
imetaByUrl={imetaByUrl}
|
imetaByUrl={imetaByUrl}
|
||||||
@@ -200,11 +200,11 @@ export const MessageRow = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const authorNode = message.pubkey ? (
|
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}
|
{message.author}
|
||||||
</span>
|
</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}
|
{message.author}
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,28 +3,13 @@ import type {
|
|||||||
TimelineThreadSummaryParticipant,
|
TimelineThreadSummaryParticipant,
|
||||||
} from "@/features/messages/lib/threadPanel";
|
} from "@/features/messages/lib/threadPanel";
|
||||||
import type { TimelineMessage } from "@/features/messages/types";
|
import type { TimelineMessage } from "@/features/messages/types";
|
||||||
|
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
|
|
||||||
const MESSAGE_TEXT_OFFSET_PX = 54;
|
const MESSAGE_TEXT_OFFSET_PX = 54;
|
||||||
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
|
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
|
||||||
const NESTED_REPLY_OFFSET_PX = 28;
|
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({
|
function ParticipantAvatar({
|
||||||
hasNextParticipant,
|
hasNextParticipant,
|
||||||
participant,
|
participant,
|
||||||
@@ -69,7 +54,7 @@ export function MessageThreadSummaryRow({
|
|||||||
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
|
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
|
||||||
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
|
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
|
||||||
const summaryAriaLabel = summary.lastReplyAt
|
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}`;
|
: `View thread with ${summary.replyCount} ${replyLabel}`;
|
||||||
const depthGuideOffsets =
|
const depthGuideOffsets =
|
||||||
visibleDepth === 0
|
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"
|
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"
|
data-testid="message-thread-summary-last-reply"
|
||||||
>
|
>
|
||||||
last reply {formatLastReplyTime(summary.lastReplyAt)}
|
last reply{" "}
|
||||||
|
{formatThreadSummaryLastReplyTime(summary.lastReplyAt)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
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 { TimelineMessage } from "@/features/messages/types";
|
||||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Separator } from "@/shared/ui/separator";
|
import { Separator } from "@/shared/ui/separator";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
@@ -24,6 +25,8 @@ type MessageTimelineProps = {
|
|||||||
/** Optional external ref to the scroll container — used by the parent to
|
/** Optional external ref to the scroll container — used by the parent to
|
||||||
* observe scroll position or adjust padding dynamically. */
|
* observe scroll position or adjust padding dynamically. */
|
||||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** True when the timeline has the composer overlay below it. */
|
||||||
|
hasComposerOverlay?: boolean;
|
||||||
isFetchingOlder?: boolean;
|
isFetchingOlder?: boolean;
|
||||||
messageFooters?: Record<string, React.ReactNode>;
|
messageFooters?: Record<string, React.ReactNode>;
|
||||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
/** 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.",
|
emptyDescription = "Send the first message to start the thread.",
|
||||||
currentPubkey,
|
currentPubkey,
|
||||||
fetchOlder,
|
fetchOlder,
|
||||||
|
hasComposerOverlay = true,
|
||||||
hasOlderMessages = true,
|
hasOlderMessages = true,
|
||||||
isFetchingOlder = false,
|
isFetchingOlder = false,
|
||||||
followThreadById,
|
followThreadById,
|
||||||
@@ -141,7 +145,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div
|
<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-scroll-restoration-id={scrollRestorationId}
|
||||||
data-testid="message-timeline"
|
data-testid="message-timeline"
|
||||||
onScroll={syncScrollState}
|
onScroll={syncScrollState}
|
||||||
@@ -216,7 +223,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAtBottom ? (
|
{!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
|
<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"
|
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"
|
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 { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
@@ -78,10 +82,10 @@ function ProfileName({
|
|||||||
const node = (
|
const node = (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xs transition-colors hover:text-foreground",
|
|
||||||
pubkey && "cursor-pointer",
|
pubkey && "cursor-pointer",
|
||||||
highlight &&
|
highlight
|
||||||
"rounded-md bg-primary/15 px-1 py-0.5 font-medium text-primary hover:bg-primary/25 hover:text-primary/90",
|
? cn(MENTION_CHIP_BASE_CLASSES, MENTION_CHIP_HOVER_CLASSES)
|
||||||
|
: "rounded-xs transition-colors hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{highlight ? "@" : null}
|
{highlight ? "@" : null}
|
||||||
@@ -222,12 +226,7 @@ function describeSystemEvent(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: actorName,
|
title: actorName,
|
||||||
action: (
|
action: <>added {targetName} to the channel</>,
|
||||||
<>
|
|
||||||
added <span className="font-medium">{targetName}</span> to the
|
|
||||||
channel
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "member_left":
|
case "member_left":
|
||||||
@@ -238,12 +237,7 @@ function describeSystemEvent(
|
|||||||
case "member_removed":
|
case "member_removed":
|
||||||
return {
|
return {
|
||||||
title: actorName,
|
title: actorName,
|
||||||
action: (
|
action: <>removed {targetName} from the channel</>,
|
||||||
<>
|
|
||||||
removed <span className="font-medium">{targetName}</span> from the
|
|
||||||
channel
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
case "topic_changed":
|
case "topic_changed":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -114,11 +114,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rich-text-composer .tiptap .mention-highlight {
|
.rich-text-composer .tiptap .mention-highlight {
|
||||||
border-radius: 0.375rem;
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
background: hsl(var(--primary) / 0.15);
|
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));
|
color: hsl(var(--primary));
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-activity-shimmer {
|
.agent-activity-shimmer {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import remarkCustomEmoji, {
|
|||||||
import remarkMentions from "@/shared/lib/remarkMentions";
|
import remarkMentions from "@/shared/lib/remarkMentions";
|
||||||
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
||||||
import { Button } from "@/shared/ui/button";
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
@@ -908,7 +912,11 @@ function createMarkdownComponents(
|
|||||||
const mentionNode = (
|
const mentionNode = (
|
||||||
<span
|
<span
|
||||||
data-mention=""
|
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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
5
desktop/src/shared/ui/mentionChip.ts
Normal file
5
desktop/src/shared/ui/mentionChip.ts
Normal 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";
|
||||||
Reference in New Issue
Block a user