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