Polish message reaction tray (#1002)

This commit is contained in:
klopez4212
2026-06-15 21:27:10 +01:00
committed by GitHub
parent c30d7274c2
commit 81296d9766
7 changed files with 611 additions and 114 deletions

View File

@@ -15,12 +15,20 @@ 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 type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import {
recordQuickReactionEmoji,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
AlertDialog,
AlertDialogAction,
@@ -43,6 +51,9 @@ import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
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";
function copyToClipboard(text: string, successMessage: string) {
void navigator.clipboard
.writeText(text)
@@ -104,13 +115,13 @@ function MoreActionsMenu({
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
className="h-6 w-6 rounded-full p-0"
className={ACTION_BUTTON_CLASS}
data-testid={`more-actions-${message.id}`}
size="sm"
type="button"
variant={open ? "secondary" : "ghost"}
>
<EllipsisVertical className="h-3 w-3" />
<EllipsisVertical className={ACTION_ICON_CLASS} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -256,6 +267,59 @@ function MoreActionsMenu({
// MessageActionBar — reaction picker, reply button, and more-actions menu
// ---------------------------------------------------------------------------
function QuickReactionButton({
active,
customEmojiUrl,
emoji,
onSelect,
}: {
active: boolean;
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}`}
aria-pressed={active}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-base leading-none transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
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 function MessageActionBar({
channelId,
message,
@@ -289,6 +353,20 @@ export 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);
@@ -300,22 +378,53 @@ export function MessageActionBar({
Boolean(onUnfollowThread) ||
!message.pending;
const selectedReactionCount = reactions.filter(
(reaction) => reaction.reactedByCurrentUser,
).length;
const selectedReactionEmojis = new Set(
reactions
.filter((reaction) => reaction.reactedByCurrentUser)
.map((reaction) => reaction.emoji),
);
const wouldAddReaction = React.useCallback(
(emoji: string) =>
!reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
),
[reactions],
);
const handleReactionSelection = React.useCallback(
(emoji: string, closePicker = false) => {
if (!onReactionSelect) {
return;
}
if (wouldAddReaction(emoji) && isPositiveEmojiParticle(emoji)) {
onReactionBadgeBurstRequest?.(emoji);
}
void onReactionSelect(emoji)
.then(() => {
recordQuickReactionEmoji(emoji);
})
.catch(() => {})
.finally(() => {
if (closePicker) {
setIsReactionPickerOpen(false);
}
});
},
[onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction],
);
if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) {
return null;
}
const selectedReactionCount = reactions.filter(
(reaction) => reaction.reactedByCurrentUser,
).length;
const wouldAddReaction = (emoji: string) =>
!reactions.some(
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
);
return (
<div
className={cn(
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-opacity duration-150 ease-out",
"-m-1 p-1 transition-opacity duration-150 ease-out",
"opacity-100 sm:pointer-events-none sm:opacity-0",
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100",
@@ -325,104 +434,112 @@ export function MessageActionBar({
)}
data-testid={`message-action-bar-${message.id}`}
>
<div className="flex items-center gap-1 p-1">
{hasReactionAction ? (
<Popover
onOpenChange={setIsReactionPickerOpen}
open={isReactionPickerOpen}
>
<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
active={selectedReactionEmojis.has(emoji)}
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}
open={isReactionPickerOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className={ACTION_BUTTON_CLASS}
data-testid={`react-message-${message.id}`}
size="sm"
type="button"
variant={
isReactionPickerOpen || selectedReactionCount > 0
? "secondary"
: "ghost"
}
>
<SmilePlus className={ACTION_ICON_CLASS} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<EmojiPicker
autoFocus
onSelect={(value) => {
// `value` is already a `native` glyph or a `:shortcode:` for
// custom emoji; the toggle mutation resolves the URL.
handleReactionSelection(value, true);
}}
/>
</PopoverContent>
</Popover>
) : null}
{hasReplyAction ? (
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
data-testid={`react-message-${message.id}`}
size="sm"
type="button"
variant={
isReactionPickerOpen || selectedReactionCount > 0
? "secondary"
: "ghost"
}
>
<SmilePlus className="h-3 w-3" />
</Button>
</PopoverTrigger>
<Button
aria-label="Reply"
className={ACTION_BUTTON_CLASS}
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply?.(message);
}}
size="sm"
type="button"
variant="ghost"
>
<CornerUpLeft className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
<TooltipContent>Reply</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<EmojiPicker
autoFocus
onSelect={(value) => {
if (!onReactionSelect) {
return;
}
// `value` is already a `native` glyph or a `:shortcode:` for
// custom emoji; the toggle mutation resolves the URL.
if (
wouldAddReaction(value) &&
isPositiveEmojiParticle(value)
) {
onReactionBadgeBurstRequest?.(value);
}
void onReactionSelect(value).finally(() => {
setIsReactionPickerOpen(false);
});
}}
/>
</PopoverContent>
</Popover>
) : null}
) : null}
{hasReplyAction ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Reply"
className="h-6 w-6 rounded-full p-0"
data-testid={`reply-message-${message.id}`}
onClick={() => {
onReply?.(message);
}}
size="sm"
type="button"
variant="ghost"
>
<CornerUpLeft className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Reply</TooltipContent>
</Tooltip>
) : null}
{hasMoreMenuActions ? (
<MoreActionsMenu
channelId={channelId}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onOpenChange={setIsDropdownOpen}
onUnfollowThread={onUnfollowThread}
open={isDropdownOpen}
isFollowingThread={isFollowingThread}
/>
) : null}
{hasMoreMenuActions ? (
<MoreActionsMenu
channelId={channelId}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onOpenChange={setIsDropdownOpen}
onUnfollowThread={onUnfollowThread}
open={isDropdownOpen}
isFollowingThread={isFollowingThread}
/>
) : null}
</div>
</div>
</div>
);

View File

@@ -3,6 +3,7 @@ import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineReaction } from "@/features/messages/types";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -329,6 +330,7 @@ function InlineReactionPicker({
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
requestBadgeBurst(value);
}
recordQuickReactionEmoji(value);
onSelect(value);
setOpen(false);
}}
@@ -416,6 +418,7 @@ function ReactionPill({
) {
burstEmoji(reaction.emoji, event);
}
recordQuickReactionEmoji(reaction.emoji);
onSelect(reaction.emoji);
};

View File

@@ -5,6 +5,7 @@ import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
@@ -21,6 +22,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { MessageTimestamp } from "./MessageTimestamp";
const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0";
const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4";
type SystemMessagePayload = {
type: string;
actor?: string;
@@ -376,7 +380,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
) : null}
</div>
</div>
<div className="absolute right-2 top-1 z-10">
<div className="absolute right-2 top-1 z-10 sm:top-0 sm:-translate-y-1/2">
{canToggleReactions ? (
<div
className={cn(
@@ -399,12 +403,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className="h-6 w-6 rounded-full p-0"
className={SYSTEM_ACTION_BUTTON_CLASS}
size="sm"
type="button"
variant={isReactionPickerOpen ? "secondary" : "ghost"}
>
<SmilePlus className="h-3 w-3" />
<SmilePlus className={SYSTEM_ACTION_ICON_CLASS} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
@@ -432,9 +436,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
) {
setBadgeBurstEmoji(value);
}
void handleReactionSelect(value).finally(() => {
setIsReactionPickerOpen(false);
});
void handleReactionSelect(value)
.then(() => {
recordQuickReactionEmoji(value);
})
.catch(() => {})
.finally(() => {
setIsReactionPickerOpen(false);
});
}}
/>
</PopoverContent>

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveQuickReactionEmojis } from "./useQuickReactionEmojis.ts";
function entry(emoji) {
return { emoji };
}
test("quick reactions backfill defaults after stale custom emoji", () => {
assert.deepEqual(
resolveQuickReactionEmojis(
[
entry(":gone_one:"),
entry(":gone_two:"),
entry(":gone_three:"),
entry(":gone_four:"),
],
4,
[],
),
["👍", "❤️", "😂", "🎉"],
);
});
test("quick reactions skip stale custom emoji before applying the limit", () => {
assert.deepEqual(
resolveQuickReactionEmojis(
[
entry(":gone_one:"),
entry(":gone_two:"),
entry(":gone_three:"),
entry(":gone_four:"),
entry(":shipit:"),
entry("🔥"),
],
4,
[{ shortcode: "shipit", url: "https://relay/shipit.png" }],
),
[":shipit:", "🔥", "👍", "❤️"],
);
});

View File

@@ -0,0 +1,319 @@
import * as React from "react";
import {
loadActiveWorkspaceId,
loadWorkspaces,
} from "@/features/workspaces/workspaceStorage";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
const QUICK_REACTION_UPDATED_EVENT = "buzz:quick-reaction-emojis-updated";
const DEFAULT_QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉"] as const;
const MAX_STORED_REACTIONS = 24;
const sessionQuickReactionEmojis = new Map<string, string[]>();
type QuickReactionEntry = {
count: number;
emoji: string;
lastUsedAt: number;
};
function canUseLocalStorage() {
if (typeof window === "undefined") return false;
try {
return Boolean(window.localStorage);
} catch {
return false;
}
}
function getActiveWorkspaceScope() {
if (!canUseLocalStorage()) return null;
try {
return loadActiveWorkspaceId() ?? loadWorkspaces()[0]?.id ?? null;
} catch {
return null;
}
}
function quickReactionStorageKey(workspaceScope: string | null) {
return workspaceScope
? `${QUICK_REACTION_STORAGE_KEY}:${workspaceScope}`
: QUICK_REACTION_STORAGE_KEY;
}
function quickReactionSessionKey(
limit: number,
workspaceScope: string | null,
customEmojiSignature: string,
) {
return `${workspaceScope ?? "global"}:${customEmojiSignature}:${limit}`;
}
function normalizeEntry(entry: unknown): QuickReactionEntry | null {
if (!entry || typeof entry !== "object") return null;
const candidate = entry as Partial<QuickReactionEntry>;
if (
typeof candidate.emoji !== "string" ||
candidate.emoji.trim().length === 0
) {
return null;
}
return {
count: Math.max(1, Math.floor(Number(candidate.count) || 1)),
emoji: candidate.emoji,
lastUsedAt: Math.max(0, Number(candidate.lastUsedAt) || 0),
};
}
function sortEntries(entries: QuickReactionEntry[]) {
return [...entries].sort((left, right) => {
const countDelta = right.count - left.count;
if (countDelta !== 0) return countDelta;
return right.lastUsedAt - left.lastUsedAt;
});
}
function readQuickReactionEntries(storageKey: string) {
if (!canUseLocalStorage()) return [];
try {
const raw = window.localStorage.getItem(storageKey);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
return sortEntries(
parsed
.map((entry) => normalizeEntry(entry))
.filter((entry): entry is QuickReactionEntry => entry !== null),
);
} catch {
return [];
}
}
function writeQuickReactionEntries(
entries: QuickReactionEntry[],
storageKey: string,
) {
if (!canUseLocalStorage()) return;
try {
window.localStorage.setItem(
storageKey,
JSON.stringify(sortEntries(entries).slice(0, MAX_STORED_REACTIONS)),
);
} catch {
// Ignore storage failures; the reaction itself should still work.
}
}
function customEmojiSignature(customEmoji: ReadonlyArray<CustomEmoji>) {
return customEmoji
.map((emoji) => emoji.shortcode.toLowerCase())
.sort()
.join(",");
}
function customEmojiShortcodesFromSignature(signature: string) {
return new Set(signature ? signature.split(",") : []);
}
function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
function canRenderQuickReactionEmoji(
emoji: string,
customEmojiShortcodes: ReadonlySet<string>,
) {
if (!isCustomEmojiShortcode(emoji)) return true;
return customEmojiShortcodes.has(emoji.slice(1, -1).toLowerCase());
}
function resolveQuickReactionEmojisWithShortcodes(
entries: ReadonlyArray<Pick<QuickReactionEntry, "emoji">>,
limit: number,
customEmojiShortcodes: ReadonlySet<string>,
) {
const seen = new Set<string>();
const next: string[] = [];
for (const entry of entries) {
if (seen.has(entry.emoji)) continue;
if (!canRenderQuickReactionEmoji(entry.emoji, customEmojiShortcodes)) {
continue;
}
seen.add(entry.emoji);
next.push(entry.emoji);
if (next.length >= limit) return next;
}
for (const emoji of DEFAULT_QUICK_REACTIONS) {
if (seen.has(emoji)) continue;
seen.add(emoji);
next.push(emoji);
if (next.length >= limit) return next;
}
return next;
}
export function resolveQuickReactionEmojis(
entries: ReadonlyArray<Pick<QuickReactionEntry, "emoji">>,
limit: number,
customEmoji: ReadonlyArray<CustomEmoji> = [],
) {
return resolveQuickReactionEmojisWithShortcodes(
entries,
limit,
customEmojiShortcodesFromSignature(customEmojiSignature(customEmoji)),
);
}
function getQuickReactionEmojis(
limit: number,
workspaceScope: string | null,
customEmojiSignature: string,
) {
return resolveQuickReactionEmojisWithShortcodes(
readQuickReactionEntries(quickReactionStorageKey(workspaceScope)),
limit,
customEmojiShortcodesFromSignature(customEmojiSignature),
);
}
function getSessionQuickReactionEmojis(
limit: number,
workspaceScope: string | null,
customEmojiSignature: string,
) {
const sessionKey = quickReactionSessionKey(
limit,
workspaceScope,
customEmojiSignature,
);
const cached = sessionQuickReactionEmojis.get(sessionKey);
if (cached) return cached;
const emojis = getQuickReactionEmojis(
limit,
workspaceScope,
customEmojiSignature,
);
sessionQuickReactionEmojis.set(sessionKey, emojis);
return emojis;
}
function invalidateSessionQuickReactions(workspaceScope: string | null) {
const prefix = `${workspaceScope ?? "global"}:`;
for (const key of sessionQuickReactionEmojis.keys()) {
if (key.startsWith(prefix)) {
sessionQuickReactionEmojis.delete(key);
}
}
}
function notifyQuickReactionUpdate(workspaceScope: string | null) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(QUICK_REACTION_UPDATED_EVENT, {
detail: { workspaceScope },
}),
);
}
export function recordQuickReactionEmoji(emoji: string) {
const trimmed = emoji.trim();
if (!trimmed) return;
const workspaceScope = getActiveWorkspaceScope();
const storageKey = quickReactionStorageKey(workspaceScope);
const entries = readQuickReactionEntries(storageKey);
const existing = entries.find((entry) => entry.emoji === trimmed);
let didAddEntry = false;
if (existing) {
existing.count += 1;
existing.lastUsedAt = Date.now();
} else {
didAddEntry = true;
entries.push({
count: 1,
emoji: trimmed,
lastUsedAt: Date.now(),
});
}
writeQuickReactionEntries(entries, storageKey);
if (didAddEntry) {
invalidateSessionQuickReactions(workspaceScope);
notifyQuickReactionUpdate(workspaceScope);
}
}
export function useQuickReactionEmojis(
limit = 4,
customEmoji: ReadonlyArray<CustomEmoji> = [],
) {
const workspaceScope = getActiveWorkspaceScope();
const customEmojiCacheKey = customEmojiSignature(customEmoji);
const [emojis, setEmojis] = React.useState(() =>
getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey),
);
React.useEffect(() => {
if (typeof window === "undefined") return;
const storageKey = quickReactionStorageKey(workspaceScope);
const sessionKey = quickReactionSessionKey(
limit,
workspaceScope,
customEmojiCacheKey,
);
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) {
sessionQuickReactionEmojis.delete(sessionKey);
setEmojis(
getSessionQuickReactionEmojis(
limit,
workspaceScope,
customEmojiCacheKey,
),
);
}
};
const handleLocalUpdate = (event: Event) => {
if (
event instanceof CustomEvent &&
event.detail?.workspaceScope === workspaceScope
) {
setEmojis(
getSessionQuickReactionEmojis(
limit,
workspaceScope,
customEmojiCacheKey,
),
);
}
};
window.addEventListener("storage", handleStorage);
window.addEventListener(QUICK_REACTION_UPDATED_EVENT, handleLocalUpdate);
setEmojis(
getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey),
);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(
QUICK_REACTION_UPDATED_EVENT,
handleLocalUpdate,
);
};
}, [customEmojiCacheKey, limit, workspaceScope]);
return emojis;
}

View File

@@ -151,7 +151,12 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
// The reaction pill renders the custom emoji as an <img alt=":react:">. Its
// src must be the localhost proxy URL — proving rewriteRelayUrl() ran. A raw
// relay URL here is the bug.
const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
const reactionPill = row.getByLabel(
`Toggle :${REACTION_SHORTCODE}: reaction`,
);
const reactionImg = reactionPill.locator(
`img[alt=':${REACTION_SHORTCODE}:']`,
);
await expect(reactionImg).toBeVisible();
await expect(reactionImg).toHaveAttribute(
"src",
@@ -199,7 +204,7 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
// 64-hex id, because the timeline only honors deletions whose `e` tag is
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
await row.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`).click();
await reactionPill.click();
await expect(reactionImg).toHaveCount(0);
});
@@ -324,6 +329,8 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => {
.first()
.click();
const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
const reactionImg = row
.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`)
.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
});

View File

@@ -16,7 +16,7 @@ const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:${MOCK_RELAY_URL}:${MOCK_PU
async function settle(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
);
}