Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions desktop/src/features/messages/ui/MessageReactions.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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",
);

Expand All @@ -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>
);
Expand All @@ -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>
Expand Down
56 changes: 39 additions & 17 deletions desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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>
);
Expand All @@ -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
? []
Expand All @@ -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
Expand All @@ -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)}
Expand All @@ -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>
Expand Down
34 changes: 34 additions & 0 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
klopez4212 marked this conversation as resolved.
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
Expand Down
64 changes: 63 additions & 1 deletion desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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");
Comment thread
klopez4212 marked this conversation as resolved.

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