diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx
index 9c490edb076..02dcdcaf6d1 100644
--- a/desktop/src/features/messages/ui/MessageActionBar.tsx
+++ b/desktop/src/features/messages/ui/MessageActionBar.tsx
@@ -18,6 +18,7 @@ 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";
@@ -25,9 +26,15 @@ 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";
@@ -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 (
+
+
+
+
+ {displayName}
+
+ );
+}
+
+function isCustomEmojiShortcode(emoji: string) {
+ return emoji.startsWith(":") && emoji.endsWith(":");
+}
+
export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
@@ -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);
@@ -436,6 +502,19 @@ export const MessageActionBar = React.memo(function MessageActionBar({
>
+ {hasReactionAction && quickReactionItems.length > 0 ? (
+
+ {quickReactionItems.map(({ customEmojiUrl, emoji }) => (
+
+ ))}
+
+ ) : null}
+
{hasReactionAction ? (
) : null}
+ {hasReactionAction && quickReactionItems.length > 0 ? (
+
+ ) : null}
+
{hasReplyAction ? (
diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts
index 9a4bf7f07cc..23dbb5e5f4e 100644
--- a/desktop/tests/e2e/custom-emoji.spec.ts
+++ b/desktop/tests/e2e/custom-emoji.spec.ts
@@ -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,
}) => {
diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts
index a7ef2f10c62..b2e1b0f5a23 100644
--- a/desktop/tests/e2e/message-copy-link.spec.ts
+++ b/desktop/tests/e2e/message-copy-link.spec.ts
@@ -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();