diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 81c02cd21e1..c2ea3ded083 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -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 ( {reaction.emoji} @@ -101,9 +114,10 @@ export function MessageReactions({ return (
{reactions.map((reaction) => ( ))} + {canToggle ? ( + + ) : null}
); } +function InlineReactionPicker({ + messageId, + onSelect, + pending, +}: { + messageId: string; + onSelect: (emoji: string) => void; + pending: boolean; +}) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + + + React + + + { + onSelect(value); + setOpen(false); + }} + /> + + + ); +} + 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" > - + {reaction.count} ); @@ -219,7 +305,10 @@ function ReactionPill({ onClick={handleClick} type="button" > - + {reaction.count}
diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 4e44edb4340..9ee082f5740 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -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({ > ); @@ -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 ( -
+
{depthGuideOffsets.length > 0 ? (
onOpenThread(message)} @@ -113,15 +120,30 @@ export function MessageThreadSummaryRow({ ))}
-
- - {summary.replyCount}{" "} - {summary.replyCount === 1 ? "reply" : "replies"} +
+ + {summary.replyCount} {replyLabel} {summary.lastReplyAt ? ( - - last {formatRelativeTime(summary.lastReplyAt)} - + <> + + ยท + + + + last reply {formatLastReplyTime(summary.lastReplyAt)} + + + View thread + + + ) : null}
diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 4d891560782..fa125ca5d4b 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -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 diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 829f28d380c..030095a6c86 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -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();