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
89 changes: 88 additions & 1 deletion desktop/src/features/messages/ui/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,23 @@ import { toast } from "sonner";

import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { getThreadReference } from "@/features/messages/lib/threading";
import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog";
import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import {
recordQuickReactionEmoji,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { Button } from "@/shared/ui/button";
import { HashArrowIn } from "@/shared/ui/icons";
Expand Down Expand Up @@ -329,6 +336,51 @@ function MoreActionsMenu({
);
}

function QuickReactionButton({
customEmojiUrl,
emoji,
onSelect,
}: {
customEmojiUrl?: string;
emoji: string;
onSelect: (emoji: string) => void;
}) {
const displayName = emojiDisplayName(emoji);
const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null;

return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`React with ${displayName}`}
className="flex h-8 w-8 items-center justify-center rounded-full text-base leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => onSelect(emoji)}
title={displayName}
type="button"
>
{mediaUrl ? (
<img
alt={emoji}
className="h-5 w-5 object-contain"
draggable={false}
src={mediaUrl}
/>
) : (
<span aria-hidden="true" className="translate-y-px">
{emoji}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>{displayName}</TooltipContent>
</Tooltip>
);
}

function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}

export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
Expand Down Expand Up @@ -372,6 +424,20 @@ export const MessageActionBar = React.memo(function MessageActionBar({
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const customEmoji = useCustomEmoji();
const quickReactionEmojis = useQuickReactionEmojis(3, customEmoji);
const quickReactionItems = React.useMemo(
() =>
quickReactionEmojis
.map((emoji) => ({
customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
emoji,
}))
.filter(
(item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl,
),
[customEmoji, quickReactionEmojis],
);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);

Expand Down Expand Up @@ -436,6 +502,19 @@ export const MessageActionBar = React.memo(function MessageActionBar({
>
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
<div className="flex items-center gap-0.5 p-1">
{hasReactionAction && quickReactionItems.length > 0 ? (
<div className="hidden items-center gap-0.5 sm:flex">
{quickReactionItems.map(({ customEmojiUrl, emoji }) => (
<QuickReactionButton
customEmojiUrl={customEmojiUrl}
emoji={emoji}
key={emoji}
onSelect={handleReactionSelection}
/>
))}
</div>
) : null}

{hasReactionAction ? (
<Popover
onOpenChange={setIsReactionPickerOpen}
Expand Down Expand Up @@ -483,6 +562,14 @@ export const MessageActionBar = React.memo(function MessageActionBar({
</Popover>
) : null}

{hasReactionAction && quickReactionItems.length > 0 ? (
<div
aria-hidden="true"
className="mx-0.5 hidden h-4 w-px bg-border/70 sm:block"
data-testid="message-action-divider"
/>
) : null}

{hasReplyAction ? (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
24 changes: 24 additions & 0 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,30 @@ async function quickReactionStorageContains(
}, emoji);
}

test("message quick reaction stays neutral after selecting a shortcut", async ({
page,
}) => {
await openGeneral(page);

const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();

const quickReactionButton = row.getByRole("button", {
name: "React with :+1:",
});
await expect(quickReactionButton).toBeVisible();
await quickReactionButton.click();

await expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible();
await row.hover();
await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true");
await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS);
await expect(messageReactionTrigger(row)).not.toHaveClass(
SELECTED_ACTION_CLASS,
);
});

test("message reaction action stays neutral after selecting from the picker", async ({
page,
}) => {
Expand Down
28 changes: 24 additions & 4 deletions desktop/tests/e2e/message-copy-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,42 @@ test("message action rail copies the same canonical thread link as More", async
await replyRow.hover();

const actionBar = replyRow.getByTestId(`message-action-bar-${replyId}`);
const quickReactions = actionBar.getByRole("button", {
name: /^React with /,
});
await expect(quickReactions).toHaveCount(0);
const orderedActionNames = await actionBar
.getByRole("button")
.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label")),
);
expect(orderedActionNames).toEqual([
"React with :+1:",
"React with :heart:",
"React with :joy:",
"Open reactions",
"Reply",
"Copy link",
"More actions",
]);

const pickerButton = actionBar.getByRole("button", {
name: "Open reactions",
});
await expect(pickerButton.locator("svg")).toHaveClass(/lucide-smile-plus/);

const divider = actionBar.getByTestId("message-action-divider");
await expect(divider).toBeVisible();
const [pickerBox, dividerBox, replyBox] = await Promise.all([
actionBar.getByRole("button", { name: "Open reactions" }).boundingBox(),
divider.boundingBox(),
actionBar.getByRole("button", { name: "Reply" }).boundingBox(),
]);
expect(pickerBox).not.toBeNull();
expect(dividerBox).not.toBeNull();
expect(replyBox).not.toBeNull();
if (!pickerBox || !dividerBox || !replyBox) {
throw new Error("Message action order bounds missing.");
}
expect(pickerBox.x + pickerBox.width).toBeLessThan(dividerBox.x);
expect(dividerBox.x + dividerBox.width).toBeLessThan(replyBox.x);

const copyLink = actionBar.getByTestId(`copy-link-message-${replyId}`);
await expect(copyLink).toHaveAccessibleName("Copy link");
await copyLink.hover();
Expand Down
Loading