mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Polish message reaction tray (#1002)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:", "🔥", "👍", "❤️"],
|
||||
);
|
||||
});
|
||||
319
desktop/src/features/messages/ui/useQuickReactionEmojis.ts
Normal file
319
desktop/src/features/messages/ui/useQuickReactionEmojis.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user