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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default defineConfig({
"**/invites-settings-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/message-copy-link.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
"**/custom-emoji-ui.spec.ts",
Expand Down
141 changes: 50 additions & 91 deletions desktop/src/features/messages/ui/MessageActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,16 @@ 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,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
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 All @@ -53,6 +46,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
const ACTION_ICON_CLASS = "!h-4 !w-4";

/** Copying a message link is offered from both the hover action bar and the
* More menu; both paths share this exact link-building + toast behavior. */
function copyMessageLink(channelId: string, message: TimelineMessage) {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyTextToClipboard(link, "Link copied to clipboard");
}

/** Gate shared by every copy-link surface: pending sends have no delivered
* event to link to, huddle system rows aren't linkable, and callers without
* a channelId (e.g. inbox preview rows) can't build the link. */
function canCopyMessageLink(
message: TimelineMessage,
channelId: string | null | undefined,
): channelId is string {
return (
!message.pending &&
message.kind !== KIND_HUDDLE_STARTED &&
Boolean(channelId)
);
}

function MoreActionsMenu({
channelId,
message,
Expand Down Expand Up @@ -242,17 +261,11 @@ function MoreActionsMenu({
</DropdownMenuItem>
) : null}

{hasCopyActions && channelId ? (
{canCopyMessageLink(message, channelId) ? (
<DropdownMenuItem
data-testid={`copy-message-link-${message.id}`}
onClick={() => {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyTextToClipboard(link, "Link copied to clipboard");
copyMessageLink(channelId, message);
}}
>
<Link2 className="h-4 w-4" />
Expand Down Expand Up @@ -316,51 +329,6 @@ 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 @@ -404,20 +372,6 @@ 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(4, 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 @@ -482,22 +436,6 @@ 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>
<div className="mx-0.5 hidden h-4 w-px bg-border/70 sm:block" />
</>
) : null}

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

{canCopyMessageLink(message, channelId) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy link"
className={ACTION_BUTTON_CLASS}
data-testid={`copy-link-message-${message.id}`}
onClick={() => {
copyMessageLink(channelId, message);
}}
size="sm"
type="button"
variant="ghost"
>
<Link2 className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</Tooltip>
) : null}

{hasMoreMenuActions ? (
<MoreActionsMenu
channelId={channelId}
Expand Down
9 changes: 5 additions & 4 deletions desktop/tests/e2e/channel-activity-popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,10 +773,11 @@ test.describe("channel activity hover preview", () => {
await rootRow.hover();
const actionBar = page.getByTestId(`message-action-bar-${root.id}`);
await expect(actionBar).toBeVisible();
await actionBar
.getByRole("button", { name: /^React with / })
.first()
.click();
await actionBar.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();
await expect(
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
).toBeVisible();
Expand Down
21 changes: 10 additions & 11 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ async function quickReactionStorageContains(
}, emoji);
}

test("message quick reaction tray stays neutral after selecting a tray emoji", async ({
test("message reaction action stays neutral after selecting from the picker", async ({
page,
}) => {
await openGeneral(page);
Expand All @@ -294,19 +294,18 @@ test("message quick reaction tray stays neutral after selecting a tray emoji", a
await expect(row).toBeVisible();
await row.hover();

const quickReactionButton = row.getByRole("button", {
name: "React with :+1:",
});
await expect(quickReactionButton).toBeVisible();
await quickReactionButton.click();
const reactionTrigger = messageReactionTrigger(row);
await expect(reactionTrigger).toBeVisible();
await reactionTrigger.click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().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,
);
await expect(reactionTrigger).not.toHaveAttribute("aria-pressed", "true");
await expect(reactionTrigger).not.toHaveClass(SELECTED_ACTION_CLASS);
});

test("emoji picker keeps Frequently used live within the app session", async ({
Expand Down
10 changes: 6 additions & 4 deletions desktop/tests/e2e/inbox-reactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
},
);

// Open the inbox item and react via the hover action bar's quick reaction.
// Open the inbox item and react through Add reaction and the picker.
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
await item.click();
const detail = page.getByTestId("home-inbox-detail");
Expand Down Expand Up @@ -152,9 +152,11 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
}
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);

await selectedMessage
.getByRole("button", { name: "React with :+1:" })
.click();
await actionBar.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();

// The pill must appear AND persist: the post-toggle refetch replaces the
// optimistic state with fetched reaction events. Give the refetch time to
Expand Down
Loading
Loading