Polish thread summaries and reactions (#881)

This commit is contained in:
klopez4212
2026-06-05 20:50:19 +01:00
committed by GitHub
parent 7129cd6f23
commit 5c2476a71e
4 changed files with 231 additions and 24 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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();