mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: add day info to chat message timeline (#214)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
75
desktop/src/features/messages/lib/dateFormatters.ts
Normal file
75
desktop/src/features/messages/lib/dateFormatters.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared date/time formatters for the message timeline.
|
||||
*
|
||||
* - `formatTime` — short clock time ("2:34 PM"), used in message rows.
|
||||
* - `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".
|
||||
* - `isSameDay` — compare two unix-second timestamps.
|
||||
*/
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
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));
|
||||
}
|
||||
|
||||
/** Full date + time for tooltips, e.g. "Wednesday, April 2, 2026 at 2:34 PM". */
|
||||
export function formatFullDateTime(unixSeconds: number): string {
|
||||
return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly day label for dividers and sticky headers.
|
||||
* Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
|
||||
*/
|
||||
export function formatDayHeading(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date();
|
||||
|
||||
if (isSameDayDate(date, now)) {
|
||||
return "Today";
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (isSameDayDate(date, yesterday)) {
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
return DAY_HEADING_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
function isSameDayDate(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
@@ -18,14 +18,10 @@ import {
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { resolveEventAuthorPubkey } from "@/shared/lib/authors";
|
||||
import { formatTime } from "@/features/messages/lib/dateFormatters";
|
||||
|
||||
const HEX_RE = /^[0-9a-f]+$/i;
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
function isTimelineContentEvent(event: RelayEvent) {
|
||||
return (
|
||||
event.kind === KIND_STREAM_MESSAGE ||
|
||||
@@ -299,7 +295,7 @@ export function formatTimelineMessages(
|
||||
currentUserAvatarUrl,
|
||||
profiles,
|
||||
}),
|
||||
time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)),
|
||||
time: formatTime(event.created_at),
|
||||
body: edit ? edit.content : event.content,
|
||||
parentId: thread.parentId,
|
||||
rootId: thread.rootId,
|
||||
|
||||
18
desktop/src/features/messages/ui/DayDivider.tsx
Normal file
18
desktop/src/features/messages/ui/DayDivider.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
|
||||
export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="flex items-center gap-3 py-2"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
@@ -264,7 +265,10 @@ export const MessageRow = React.memo(
|
||||
(edited)
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
<MessageTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useStickyDayHeader } from "./useStickyDayHeader";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageTimelineProps = {
|
||||
@@ -81,101 +83,104 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
const stickyDayLabel = useStickyDayHeader(scrollContainerRef);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{isFetchingOlder ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-3 py-2"
|
||||
data-testid="message-timeline-beginning"
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Beginning of conversation
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{stickyDayLabel && !isAtBottom ? (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
data-testid="message-timeline-day-divider"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-4 pt-2 sm:px-6"
|
||||
data-testid="message-timeline-sticky-day"
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Today
|
||||
<p className="rounded-full bg-muted/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground shadow-sm backdrop-blur-sm">
|
||||
{stickyDayLabel}
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{isLoading ? <TimelineSkeleton /> : null}
|
||||
|
||||
{!isLoading && messages.length === 0 ? (
|
||||
<div
|
||||
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && messages.length > 0 ? (
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
messages={messages}
|
||||
onEdit={onEdit}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto rounded-full shadow-lg"
|
||||
data-testid="message-scroll-to-latest"
|
||||
onClick={() => {
|
||||
scrollToBottom("smooth");
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
) : null}
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
|
||||
ref={contentRef}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
</Button>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{isFetchingOlder ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-3 py-2"
|
||||
data-testid="message-timeline-beginning"
|
||||
>
|
||||
<Separator className="flex-1" />
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Beginning of conversation
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? <TimelineSkeleton /> : null}
|
||||
|
||||
{!isLoading && messages.length === 0 ? (
|
||||
<div
|
||||
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && messages.length > 0 ? (
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
messages={messages}
|
||||
onEdit={onEdit}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto rounded-full shadow-lg"
|
||||
data-testid="message-scroll-to-latest"
|
||||
onClick={() => {
|
||||
scrollToBottom("smooth");
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
21
desktop/src/features/messages/ui/MessageTimestamp.tsx
Normal file
21
desktop/src/features/messages/ui/MessageTimestamp.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { formatFullDateTime } from "@/features/messages/lib/dateFormatters";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export function MessageTimestamp({
|
||||
createdAt,
|
||||
time,
|
||||
}: {
|
||||
createdAt: number;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="cursor-default whitespace-nowrap">{time}</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{formatFullDateTime(createdAt)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ArrowRightLeft } from "lucide-react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
type SystemMessagePayload = {
|
||||
type: string;
|
||||
@@ -57,11 +58,13 @@ function describeSystemEvent(
|
||||
|
||||
export function SystemMessageRow({
|
||||
body,
|
||||
createdAt,
|
||||
time,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
}: {
|
||||
body: string;
|
||||
createdAt: number;
|
||||
time: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
@@ -87,8 +90,8 @@ export function SystemMessageRow({
|
||||
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<span className="ml-auto whitespace-nowrap text-xs text-muted-foreground/60">
|
||||
{time}
|
||||
<span className="ml-auto text-xs text-muted-foreground/60">
|
||||
<MessageTimestamp createdAt={createdAt} time={time} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
formatDayHeading,
|
||||
isSameDay,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
@@ -31,30 +36,51 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
}: TimelineMessageListProps) {
|
||||
return messages.map((message) =>
|
||||
message.kind === KIND_SYSTEM_MESSAGE ? (
|
||||
<SystemMessageRow
|
||||
key={message.id}
|
||||
body={message.body}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
),
|
||||
);
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const prev = i > 0 ? messages[i - 1] : null;
|
||||
|
||||
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
|
||||
elements.push(
|
||||
<DayDivider
|
||||
key={`day-${message.createdAt}`}
|
||||
label={formatDayHeading(message.createdAt)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
elements.push(
|
||||
<SystemMessageRow
|
||||
key={message.id}
|
||||
body={message.body}
|
||||
createdAt={message.createdAt}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
elements.push(
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
});
|
||||
|
||||
60
desktop/src/features/messages/ui/useStickyDayHeader.ts
Normal file
60
desktop/src/features/messages/ui/useStickyDayHeader.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Tracks the day label of the topmost visible day-divider in the scroll
|
||||
* container so a floating header can mirror it.
|
||||
*
|
||||
* Returns `null` when the first divider is still fully visible (no header
|
||||
* needed) or when there are no dividers in the DOM.
|
||||
*/
|
||||
export function useStickyDayHeader(
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [label, setLabel] = React.useState<string | null>(null);
|
||||
|
||||
const update = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dividers = container.querySelectorAll<HTMLElement>(
|
||||
"[data-testid='message-timeline-day-divider']",
|
||||
);
|
||||
if (dividers.length === 0) {
|
||||
setLabel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
|
||||
// Walk dividers from the end — the last one whose top is at or above the
|
||||
// container's top edge is the "current" day.
|
||||
let current: string | null = null;
|
||||
for (let i = dividers.length - 1; i >= 0; i--) {
|
||||
const rect = dividers[i].getBoundingClientRect();
|
||||
if (rect.top <= containerTop + 4) {
|
||||
current = dividers[i].getAttribute("data-day-label");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setLabel(current);
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener("scroll", update, { passive: true });
|
||||
// Run once on mount so the header appears if already scrolled.
|
||||
update();
|
||||
return () => {
|
||||
container.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [scrollContainerRef, update]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -223,13 +223,13 @@ test("replaces the channel pane when switching channels", async ({ page }) => {
|
||||
"Welcome to #general",
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
||||
await expect(page.getByTestId("message-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sends a mocked channel message", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user