mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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.  Signed-off-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz> Co-authored-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user