mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +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 { buildMessageLink } from "@/features/messages/lib/messageLink";
|
||||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||||
|
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
|
||||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||||
import type {
|
import type {
|
||||||
TimelineMessage,
|
TimelineMessage,
|
||||||
TimelineReaction,
|
TimelineReaction,
|
||||||
} from "@/features/messages/types";
|
} 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 { cn } from "@/shared/lib/cn";
|
||||||
|
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||||
|
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -43,6 +51,9 @@ import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
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) {
|
function copyToClipboard(text: string, successMessage: string) {
|
||||||
void navigator.clipboard
|
void navigator.clipboard
|
||||||
.writeText(text)
|
.writeText(text)
|
||||||
@@ -104,13 +115,13 @@ function MoreActionsMenu({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label="More actions"
|
aria-label="More actions"
|
||||||
className="h-6 w-6 rounded-full p-0"
|
className={ACTION_BUTTON_CLASS}
|
||||||
data-testid={`more-actions-${message.id}`}
|
data-testid={`more-actions-${message.id}`}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant={open ? "secondary" : "ghost"}
|
variant={open ? "secondary" : "ghost"}
|
||||||
>
|
>
|
||||||
<EllipsisVertical className="h-3 w-3" />
|
<EllipsisVertical className={ACTION_ICON_CLASS} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -256,6 +267,59 @@ function MoreActionsMenu({
|
|||||||
// MessageActionBar — reaction picker, reply button, and more-actions menu
|
// 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({
|
export function MessageActionBar({
|
||||||
channelId,
|
channelId,
|
||||||
message,
|
message,
|
||||||
@@ -289,6 +353,20 @@ export function MessageActionBar({
|
|||||||
}) {
|
}) {
|
||||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = 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 hasReplyAction = Boolean(onReply);
|
||||||
const hasReactionAction = Boolean(onReactionSelect);
|
const hasReactionAction = Boolean(onReactionSelect);
|
||||||
|
|
||||||
@@ -300,22 +378,53 @@ export function MessageActionBar({
|
|||||||
Boolean(onUnfollowThread) ||
|
Boolean(onUnfollowThread) ||
|
||||||
!message.pending;
|
!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) {
|
if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedReactionCount = reactions.filter(
|
|
||||||
(reaction) => reaction.reactedByCurrentUser,
|
|
||||||
).length;
|
|
||||||
const wouldAddReaction = (emoji: string) =>
|
|
||||||
!reactions.some(
|
|
||||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"opacity-100 sm:pointer-events-none sm:opacity-0",
|
||||||
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
|
"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",
|
"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}`}
|
data-testid={`message-action-bar-${message.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 p-1">
|
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
|
||||||
{hasReactionAction ? (
|
<div className="flex items-center gap-0.5 p-1">
|
||||||
<Popover
|
{hasReactionAction && quickReactionItems.length > 0 ? (
|
||||||
onOpenChange={setIsReactionPickerOpen}
|
<>
|
||||||
open={isReactionPickerOpen}
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<Button
|
||||||
<Button
|
aria-label="Reply"
|
||||||
aria-label="Open reactions"
|
className={ACTION_BUTTON_CLASS}
|
||||||
className="h-6 w-6 rounded-full p-0"
|
data-testid={`reply-message-${message.id}`}
|
||||||
data-testid={`react-message-${message.id}`}
|
onClick={() => {
|
||||||
size="sm"
|
onReply?.(message);
|
||||||
type="button"
|
}}
|
||||||
variant={
|
size="sm"
|
||||||
isReactionPickerOpen || selectedReactionCount > 0
|
type="button"
|
||||||
? "secondary"
|
variant="ghost"
|
||||||
: "ghost"
|
>
|
||||||
}
|
<CornerUpLeft className={ACTION_ICON_CLASS} />
|
||||||
>
|
</Button>
|
||||||
<SmilePlus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>React</TooltipContent>
|
<TooltipContent>Reply</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<PopoverContent
|
) : null}
|
||||||
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}
|
|
||||||
|
|
||||||
{hasReplyAction ? (
|
{hasMoreMenuActions ? (
|
||||||
<Tooltip>
|
<MoreActionsMenu
|
||||||
<TooltipTrigger asChild>
|
channelId={channelId}
|
||||||
<Button
|
message={message}
|
||||||
aria-label="Reply"
|
onDelete={onDelete}
|
||||||
className="h-6 w-6 rounded-full p-0"
|
onEdit={onEdit}
|
||||||
data-testid={`reply-message-${message.id}`}
|
onFollowThread={onFollowThread}
|
||||||
onClick={() => {
|
onMarkUnread={onMarkUnread}
|
||||||
onReply?.(message);
|
onOpenChange={setIsDropdownOpen}
|
||||||
}}
|
onUnfollowThread={onUnfollowThread}
|
||||||
size="sm"
|
open={isDropdownOpen}
|
||||||
type="button"
|
isFollowingThread={isFollowingThread}
|
||||||
variant="ghost"
|
/>
|
||||||
>
|
) : null}
|
||||||
<CornerUpLeft className="h-3 w-3" />
|
</div>
|
||||||
</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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||||
import type { TimelineReaction } from "@/features/messages/types";
|
import type { TimelineReaction } from "@/features/messages/types";
|
||||||
|
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||||
@@ -329,6 +330,7 @@ function InlineReactionPicker({
|
|||||||
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
|
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
|
||||||
requestBadgeBurst(value);
|
requestBadgeBurst(value);
|
||||||
}
|
}
|
||||||
|
recordQuickReactionEmoji(value);
|
||||||
onSelect(value);
|
onSelect(value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -416,6 +418,7 @@ function ReactionPill({
|
|||||||
) {
|
) {
|
||||||
burstEmoji(reaction.emoji, event);
|
burstEmoji(reaction.emoji, event);
|
||||||
}
|
}
|
||||||
|
recordQuickReactionEmoji(reaction.emoji);
|
||||||
onSelect(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 type { TimelineMessage } from "@/features/messages/types";
|
||||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||||
|
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
|
||||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
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 { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
import { MessageTimestamp } from "./MessageTimestamp";
|
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 SystemMessagePayload = {
|
||||||
type: string;
|
type: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -376,7 +380,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{canToggleReactions ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -399,12 +403,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Open reactions"
|
aria-label="Open reactions"
|
||||||
className="h-6 w-6 rounded-full p-0"
|
className={SYSTEM_ACTION_BUTTON_CLASS}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
||||||
>
|
>
|
||||||
<SmilePlus className="h-3 w-3" />
|
<SmilePlus className={SYSTEM_ACTION_ICON_CLASS} />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -432,9 +436,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
) {
|
) {
|
||||||
setBadgeBurstEmoji(value);
|
setBadgeBurstEmoji(value);
|
||||||
}
|
}
|
||||||
void handleReactionSelect(value).finally(() => {
|
void handleReactionSelect(value)
|
||||||
setIsReactionPickerOpen(false);
|
.then(() => {
|
||||||
});
|
recordQuickReactionEmoji(value);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setIsReactionPickerOpen(false);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</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
|
// 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
|
// src must be the localhost proxy URL — proving rewriteRelayUrl() ran. A raw
|
||||||
// relay URL here is the bug.
|
// 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).toBeVisible();
|
||||||
await expect(reactionImg).toHaveAttribute(
|
await expect(reactionImg).toHaveAttribute(
|
||||||
"src",
|
"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
|
// 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 id, because the timeline only honors deletions whose `e` tag is
|
||||||
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
|
// 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);
|
await expect(reactionImg).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,6 +329,8 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => {
|
|||||||
.first()
|
.first()
|
||||||
.click();
|
.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();
|
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) {
|
async function settle(page: import("@playwright/test").Page) {
|
||||||
await page.evaluate(() =>
|
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