mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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 { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||
import type { TimelineReaction } from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
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,
|
||||
@@ -37,7 +47,10 @@ function EmojiGlyph({
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block leading-none", className)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center leading-none",
|
||||
className,
|
||||
)}
|
||||
title={displayName}
|
||||
>
|
||||
{reaction.emoji}
|
||||
@@ -101,9 +114,10 @@ export function MessageReactions({
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
data-testid="message-reactions"
|
||||
>
|
||||
{reactions.map((reaction) => (
|
||||
<ReactionPill
|
||||
@@ -114,10 +128,79 @@ export function MessageReactions({
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{canToggle ? (
|
||||
<InlineReactionPicker
|
||||
messageId={messageId}
|
||||
onSelect={onSelect}
|
||||
pending={pending}
|
||||
/>
|
||||
) : null}
|
||||
</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({
|
||||
reaction,
|
||||
canToggle,
|
||||
@@ -166,12 +249,15 @@ function ReactionPill({
|
||||
}, [clearTimers]);
|
||||
|
||||
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
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -193,7 +279,7 @@ function ReactionPill({
|
||||
onClick={handleClick}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
@@ -219,7 +305,10 @@ function ReactionPill({
|
||||
onClick={handleClick}
|
||||
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>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -6,21 +6,23 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
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 formatRelativeTime(unixSeconds: number): string {
|
||||
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`;
|
||||
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h`;
|
||||
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d`;
|
||||
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`;
|
||||
|
||||
return new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
|
||||
const date = new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return `on ${date}`;
|
||||
}
|
||||
|
||||
function ParticipantAvatar({
|
||||
@@ -38,9 +40,9 @@ function ParticipantAvatar({
|
||||
>
|
||||
<UserAvatar
|
||||
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}
|
||||
size="xs"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -62,7 +64,11 @@ export function MessageThreadSummaryRow({
|
||||
visibleDepth > 0
|
||||
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
|
||||
: 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 =
|
||||
visibleDepth === 0
|
||||
? []
|
||||
@@ -75,7 +81,7 @@ export function MessageThreadSummaryRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative pb-1 pt-1">
|
||||
<div className="relative pb-1 pt-0.5">
|
||||
{depthGuideOffsets.length > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -96,7 +102,8 @@ export function MessageThreadSummaryRow({
|
||||
) : null}
|
||||
|
||||
<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-testid="message-thread-summary"
|
||||
onClick={() => onOpenThread(message)}
|
||||
@@ -113,15 +120,30 @@ export function MessageThreadSummaryRow({
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
<span className="transition-colors group-hover:text-foreground">
|
||||
{summary.replyCount}{" "}
|
||||
{summary.replyCount === 1 ? "reply" : "replies"}
|
||||
<div>
|
||||
<span className="font-medium transition-colors group-hover:text-foreground">
|
||||
{summary.replyCount} {replyLabel}
|
||||
</span>
|
||||
{summary.lastReplyAt ? (
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
last {formatRelativeTime(summary.lastReplyAt)}
|
||||
</span>
|
||||
<>
|
||||
<span className="mx-1 font-normal text-muted-foreground/50">
|
||||
·
|
||||
</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}
|
||||
</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
|
||||
// -> emits a kind:5 deletion targeting the reaction event. The pill must
|
||||
// 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 nestedReply = `Nested threaded reply ${timestamp}`;
|
||||
const nestedReplyFromBob = `Nested reply from Bob ${timestamp}`;
|
||||
const nestedReplyVisibleTopMaxPx = 260;
|
||||
const nestedReplyVisibleTopMaxPx = 280;
|
||||
const fillerReplies = Array.from(
|
||||
{ length: 14 },
|
||||
(_, index) => `Thread filler reply ${index} ${timestamp}`,
|
||||
@@ -473,6 +473,68 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
await expect(
|
||||
rootSummaryRow.getByTestId("message-thread-summary-participant"),
|
||||
).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 expect(threadPanel).toBeHidden();
|
||||
|
||||
Reference in New Issue
Block a user