mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
Polish thread summaries and reactions (#881)
This commit is contained in:
@@ -1,10 +1,20 @@
|
|||||||
|
import { SmilePlus } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||||
import type { TimelineReaction } from "@/features/messages/types";
|
import type { TimelineReaction } from "@/features/messages/types";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
|
const REACTION_PILL_BASE_CLASSES =
|
||||||
|
"inline-flex h-8 items-center rounded-full border text-xs font-medium leading-none transition-colors";
|
||||||
|
const REACTION_GLYPH_CLASSES = "-translate-y-px h-3.5 w-3.5 text-sm";
|
||||||
|
const REACTION_PILL_HOVER_CLASSES =
|
||||||
|
"hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
|
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
|
||||||
@@ -37,7 +47,10 @@ function EmojiGlyph({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("inline-block leading-none", className)}
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center leading-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
title={displayName}
|
title={displayName}
|
||||||
>
|
>
|
||||||
{reaction.emoji}
|
{reaction.emoji}
|
||||||
@@ -101,9 +114,10 @@ export function MessageReactions({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1.5 flex flex-wrap items-center gap-1.5 pt-1",
|
"group/reactions mt-1.5 flex flex-wrap items-center gap-1.5 pt-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-testid="message-reactions"
|
||||||
>
|
>
|
||||||
{reactions.map((reaction) => (
|
{reactions.map((reaction) => (
|
||||||
<ReactionPill
|
<ReactionPill
|
||||||
@@ -114,10 +128,79 @@ export function MessageReactions({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{canToggle ? (
|
||||||
|
<InlineReactionPicker
|
||||||
|
messageId={messageId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
pending={pending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InlineReactionPicker({
|
||||||
|
messageId,
|
||||||
|
onSelect,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
messageId: string;
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setOpen} open={open}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label="Add reaction"
|
||||||
|
className={cn(
|
||||||
|
REACTION_PILL_BASE_CLASSES,
|
||||||
|
"pointer-events-none w-10 min-w-10 justify-center p-0 text-muted-foreground opacity-0",
|
||||||
|
"group-hover/message:pointer-events-auto group-hover/message:opacity-100",
|
||||||
|
"group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100",
|
||||||
|
"group-hover/reactions:pointer-events-auto group-hover/reactions:opacity-100",
|
||||||
|
"group-focus-within/reactions:pointer-events-auto group-focus-within/reactions:opacity-100",
|
||||||
|
open
|
||||||
|
? "pointer-events-auto border-border/80 bg-background text-foreground opacity-100 shadow-xs"
|
||||||
|
: "border-border/70 bg-muted/70",
|
||||||
|
REACTION_PILL_HOVER_CLASSES,
|
||||||
|
)}
|
||||||
|
data-testid={`add-reaction-${messageId}`}
|
||||||
|
disabled={pending}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<SmilePlus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>React</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
|
||||||
|
side="top"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<EmojiPicker
|
||||||
|
autoFocus
|
||||||
|
onSelect={(value) => {
|
||||||
|
onSelect(value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReactionPill({
|
function ReactionPill({
|
||||||
reaction,
|
reaction,
|
||||||
canToggle,
|
canToggle,
|
||||||
@@ -166,12 +249,15 @@ function ReactionPill({
|
|||||||
}, [clearTimers]);
|
}, [clearTimers]);
|
||||||
|
|
||||||
const pillClasses = cn(
|
const pillClasses = cn(
|
||||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
|
REACTION_PILL_BASE_CLASSES,
|
||||||
|
"min-w-12 justify-center gap-1.5 px-2",
|
||||||
reaction.reactedByCurrentUser
|
reaction.reactedByCurrentUser
|
||||||
? "border-primary/40 bg-primary/10 text-primary"
|
? "border-primary/40 bg-primary/10 text-primary"
|
||||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||||
canToggle
|
canToggle
|
||||||
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
? reaction.reactedByCurrentUser
|
||||||
|
? "hover:bg-primary/10 hover:text-primary focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
: REACTION_PILL_HOVER_CLASSES
|
||||||
: "cursor-default",
|
: "cursor-default",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,7 +279,7 @@ function ReactionPill({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<EmojiGlyph reaction={reaction} className="h-[1.1em] w-[1.1em]" />
|
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
||||||
<span className="text-muted-foreground">{reaction.count}</span>
|
<span className="text-muted-foreground">{reaction.count}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -219,7 +305,10 @@ function ReactionPill({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<EmojiGlyph reaction={reaction} className="h-[1.1em] w-[1.1em]" />
|
<EmojiGlyph
|
||||||
|
reaction={reaction}
|
||||||
|
className={REACTION_GLYPH_CLASSES}
|
||||||
|
/>
|
||||||
<span className="text-muted-foreground">{reaction.count}</span>
|
<span className="text-muted-foreground">{reaction.count}</span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,21 +6,23 @@ import type { TimelineMessage } from "@/features/messages/types";
|
|||||||
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 NESTED_REPLY_OFFSET_PX = 28;
|
const NESTED_REPLY_OFFSET_PX = 28;
|
||||||
|
|
||||||
function formatRelativeTime(unixSeconds: number): string {
|
function formatLastReplyTime(unixSeconds: number): string {
|
||||||
const now = Date.now() / 1_000;
|
const now = Date.now() / 1_000;
|
||||||
const diff = now - unixSeconds;
|
const diff = now - unixSeconds;
|
||||||
|
|
||||||
if (diff < 60) return "just now";
|
if (diff < 60) return "just now";
|
||||||
if (diff < 3_600) return `${Math.floor(diff / 60)}m`;
|
if (diff < 3_600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h`;
|
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h ago`;
|
||||||
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d`;
|
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d ago`;
|
||||||
|
|
||||||
return new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
|
const date = new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
return `on ${date}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantAvatar({
|
function ParticipantAvatar({
|
||||||
@@ -38,9 +40,9 @@ function ParticipantAvatar({
|
|||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatarUrl={participant.avatarUrl}
|
avatarUrl={participant.avatarUrl}
|
||||||
className="rounded-full border-2 border-background"
|
className="h-8 w-8 rounded-full border-2 border-background text-[10px]"
|
||||||
displayName={participant.author}
|
displayName={participant.author}
|
||||||
size="xs"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,7 +64,11 @@ export function MessageThreadSummaryRow({
|
|||||||
visibleDepth > 0
|
visibleDepth > 0
|
||||||
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
|
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
|
||||||
: 0;
|
: 0;
|
||||||
const marginLeftPx = indentPx + MESSAGE_TEXT_OFFSET_PX;
|
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}`;
|
||||||
const depthGuideOffsets =
|
const depthGuideOffsets =
|
||||||
visibleDepth === 0
|
visibleDepth === 0
|
||||||
? []
|
? []
|
||||||
@@ -75,7 +81,7 @@ export function MessageThreadSummaryRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative pb-1 pt-1">
|
<div className="relative pb-1 pt-0.5">
|
||||||
{depthGuideOffsets.length > 0 ? (
|
{depthGuideOffsets.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -96,7 +102,8 @@ export function MessageThreadSummaryRow({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="group relative inline-flex w-fit max-w-full cursor-pointer items-center gap-1 rounded-full text-left text-xs font-medium text-muted-foreground transition-[color,opacity] before:pointer-events-none before:absolute before:-inset-y-0.5 before:-left-0.5 before:-right-1.5 before:rounded-full before:content-[''] before:transition-shadow hover:text-foreground hover:opacity-90 hover:before:ring-1 hover:before:ring-border/70 focus-visible:outline-hidden focus-visible:before:ring-1 focus-visible:before:ring-ring"
|
aria-label={summaryAriaLabel}
|
||||||
|
className="group relative isolate inline-flex h-8 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full text-left text-xs font-medium text-muted-foreground transition-[color,opacity] before:pointer-events-none before:absolute before:inset-y-0 before:left-0 before:-right-2 before:-z-10 before:rounded-full before:content-[''] before:transition-[background-color,box-shadow] hover:text-foreground hover:opacity-90 hover:before:bg-background/95 hover:before:ring-1 hover:before:ring-border/70 focus-visible:outline-hidden focus-visible:before:bg-background/95 focus-visible:before:ring-1 focus-visible:before:ring-ring"
|
||||||
data-thread-head-id={message.id}
|
data-thread-head-id={message.id}
|
||||||
data-testid="message-thread-summary"
|
data-testid="message-thread-summary"
|
||||||
onClick={() => onOpenThread(message)}
|
onClick={() => onOpenThread(message)}
|
||||||
@@ -113,15 +120,30 @@ export function MessageThreadSummaryRow({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium">
|
<div>
|
||||||
<span className="transition-colors group-hover:text-foreground">
|
<span className="font-medium transition-colors group-hover:text-foreground">
|
||||||
{summary.replyCount}{" "}
|
{summary.replyCount} {replyLabel}
|
||||||
{summary.replyCount === 1 ? "reply" : "replies"}
|
|
||||||
</span>
|
</span>
|
||||||
{summary.lastReplyAt ? (
|
{summary.lastReplyAt ? (
|
||||||
<span className="ml-1 text-muted-foreground/70">
|
<>
|
||||||
last {formatRelativeTime(summary.lastReplyAt)}
|
<span className="mx-1 font-normal text-muted-foreground/50">
|
||||||
</span>
|
·
|
||||||
|
</span>
|
||||||
|
<span className="inline-grid font-normal text-muted-foreground/70">
|
||||||
|
<span
|
||||||
|
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)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="col-start-1 row-start-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||||
|
data-testid="message-thread-summary-hover-action"
|
||||||
|
>
|
||||||
|
View thread
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,6 +158,40 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const inlineAddReactionButton = row.getByLabel("Add reaction");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
inlineAddReactionButton.evaluate((button) => {
|
||||||
|
return getComputedStyle(button).opacity;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe("0");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
inlineAddReactionButton.evaluate((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe("40x32");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
inlineAddReactionButton.evaluate((button) => {
|
||||||
|
return getComputedStyle(button).transitionProperty;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.not.toContain("width");
|
||||||
|
await row.hover();
|
||||||
|
await expect(inlineAddReactionButton).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
inlineAddReactionButton.evaluate((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe("40x32");
|
||||||
|
|
||||||
// Toggle the reaction back off: click the pill, which fires remove_reaction
|
// Toggle the reaction back off: click the pill, which fires remove_reaction
|
||||||
// -> emits a kind:5 deletion targeting the reaction event. The pill must
|
// -> emits a kind:5 deletion targeting the reaction event. The pill must
|
||||||
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
|
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
|||||||
const siblingReply = `Sibling threaded reply ${timestamp}`;
|
const siblingReply = `Sibling threaded reply ${timestamp}`;
|
||||||
const nestedReply = `Nested threaded reply ${timestamp}`;
|
const nestedReply = `Nested threaded reply ${timestamp}`;
|
||||||
const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
|
const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
|
||||||
const nestedReplyVisibleTopMaxPx = 260;
|
const nestedReplyVisibleTopMaxPx = 280;
|
||||||
const fillerReplies = Array.from(
|
const fillerReplies = Array.from(
|
||||||
{ length: 14 },
|
{ length: 14 },
|
||||||
(_, index) => `Thread filler reply ${index} ${timestamp}`,
|
(_, index) => `Thread filler reply ${index} ${timestamp}`,
|
||||||
@@ -473,6 +473,68 @@ test("opens a single-level thread panel with inline expansion", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
||||||
).toHaveCount(1);
|
).toHaveCount(1);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow
|
||||||
|
.getByTestId("message-thread-summary-participant")
|
||||||
|
.first()
|
||||||
|
.evaluate((wrapper) => {
|
||||||
|
const avatar = wrapper.firstElementChild;
|
||||||
|
if (!(avatar instanceof HTMLElement)) return "missing";
|
||||||
|
const rect = avatar.getBoundingClientRect();
|
||||||
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe("32x32");
|
||||||
|
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
|
||||||
|
Math.round(row.getBoundingClientRect().width),
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow
|
||||||
|
.getByTestId("message-thread-summary-last-reply")
|
||||||
|
.evaluate((label) =>
|
||||||
|
Number.parseFloat(getComputedStyle(label).opacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0.8);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow
|
||||||
|
.getByTestId("message-thread-summary-hover-action")
|
||||||
|
.evaluate((label) =>
|
||||||
|
Number.parseFloat(getComputedStyle(label).opacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeLessThan(0.1);
|
||||||
|
await rootSummaryRow.hover();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow
|
||||||
|
.getByTestId("message-thread-summary-last-reply")
|
||||||
|
.evaluate((label) =>
|
||||||
|
Number.parseFloat(getComputedStyle(label).opacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeLessThan(0.1);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow
|
||||||
|
.getByTestId("message-thread-summary-hover-action")
|
||||||
|
.evaluate((label) =>
|
||||||
|
Number.parseFloat(getComputedStyle(label).opacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0.8);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
rootSummaryRow.evaluate((row) =>
|
||||||
|
Math.round(row.getBoundingClientRect().width),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(rootSummaryWidthBeforeHover);
|
||||||
|
|
||||||
await threadPanel.getByTestId("message-thread-close").click();
|
await threadPanel.getByTestId("message-thread-close").click();
|
||||||
await expect(threadPanel).toBeHidden();
|
await expect(threadPanel).toBeHidden();
|
||||||
|
|||||||
Reference in New Issue
Block a user