feat(desktop): restore message quick reactions (#6892)

## Summary

- restore three learned quick-reaction shortcuts to the desktop message
action rail
- keep Add reaction (`SmilePlus`, `h-4 w-4`) immediately after shortcuts
and left of the restored divider
- preserve Reply, Copy link, More, custom emoji rendering, accessibility
labels, and responsive behavior
- hide shortcuts and the divider below `sm`, while keeping Add reaction
available

## Tests

At `24c4732c2341b846dccf4ae8fdc2455f700fc44d`:

- `pnpm exec biome check src/features/messages/ui/MessageActionBar.tsx
tests/e2e/message-copy-link.spec.ts tests/e2e/custom-emoji.spec.ts`
- `pnpm exec tsc --noEmit`
- `pnpm test` — 5,567 passed
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/message-copy-link.spec.ts
tests/e2e/custom-emoji.spec.ts --project=smoke` — 19 passed
- `pnpm exec playwright test tests/e2e/reaction-order.spec.ts
--project=smoke` — 2 passed
- pre-push: file-size-check, desktop-check, desktop-typecheck,
desktop-test

Vogue design review: **SHIP**.

## Screenshot

Restored desktop message action rail: three learned quick reactions, Add
reaction (`SmilePlus`), divider, Reply, Copy link, and More.

![Restored desktop message action
rail](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6892/restored-message-action-rail.png)

Signed-off-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
Co-authored-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
This commit is contained in:
morgmart
2026-08-26 10:32:46 -07:00
committed by GitHub
parent f177f4909d
commit 22cdda441d
3 changed files with 136 additions and 5 deletions

View File

@@ -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 (
<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,
@@ -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({
>
<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}
@@ -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>

View File

@@ -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,
}) => {

View File

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