mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add emoji reaction particles (#890)
This commit is contained in:
@@ -52,6 +52,9 @@ export function InboxMessageRow({
|
|||||||
() => toTimelineMessage(message),
|
() => toTimelineMessage(message),
|
||||||
[message],
|
[message],
|
||||||
);
|
);
|
||||||
|
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
reactions,
|
reactions,
|
||||||
canToggle: canToggleReactions,
|
canToggle: canToggleReactions,
|
||||||
@@ -92,11 +95,13 @@ export function InboxMessageRow({
|
|||||||
onReactionSelect={
|
onReactionSelect={
|
||||||
canToggleReactions ? handleReactionSelect : undefined
|
canToggleReactions ? handleReactionSelect : undefined
|
||||||
}
|
}
|
||||||
|
onReactionBadgeBurstRequest={
|
||||||
|
reactionPending ? undefined : setBadgeBurstEmoji
|
||||||
|
}
|
||||||
onReply={
|
onReply={
|
||||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||||
}
|
}
|
||||||
reactionErrorMessage={reactionErrorMessage}
|
reactionErrorMessage={reactionErrorMessage}
|
||||||
reactionPending={reactionPending}
|
|
||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +139,12 @@ export function InboxMessageRow({
|
|||||||
onSelect={(emoji) => {
|
onSelect={(emoji) => {
|
||||||
void handleReactionSelect(emoji);
|
void handleReactionSelect(emoji);
|
||||||
}}
|
}}
|
||||||
|
burstEmojiOnRender={badgeBurstEmoji}
|
||||||
|
onBurstEmojiRendered={(emoji) => {
|
||||||
|
setBadgeBurstEmoji((current) =>
|
||||||
|
current === emoji ? null : current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
pending={reactionPending}
|
pending={reactionPending}
|
||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
|
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
function copyToClipboard(text: string, successMessage: string) {
|
function copyToClipboard(text: string, successMessage: string) {
|
||||||
@@ -263,12 +263,12 @@ export function MessageActionBar({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onFollowThread,
|
onFollowThread,
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
|
onReactionBadgeBurstRequest,
|
||||||
onReactionSelect,
|
onReactionSelect,
|
||||||
onReply,
|
onReply,
|
||||||
onUnfollowThread,
|
onUnfollowThread,
|
||||||
reactionErrorMessage = null,
|
reactionErrorMessage = null,
|
||||||
reactions,
|
reactions,
|
||||||
reactionPending = false,
|
|
||||||
isFollowingThread,
|
isFollowingThread,
|
||||||
}: {
|
}: {
|
||||||
/** Channel UUID — required for the "Copy link" action; when omitted the
|
/** Channel UUID — required for the "Copy link" action; when omitted the
|
||||||
@@ -279,12 +279,12 @@ export function MessageActionBar({
|
|||||||
onEdit?: (message: TimelineMessage) => void;
|
onEdit?: (message: TimelineMessage) => void;
|
||||||
onFollowThread?: (message: TimelineMessage) => void;
|
onFollowThread?: (message: TimelineMessage) => void;
|
||||||
onMarkUnread?: (message: TimelineMessage) => void;
|
onMarkUnread?: (message: TimelineMessage) => void;
|
||||||
|
onReactionBadgeBurstRequest?: (emoji: string) => void;
|
||||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||||
onReply?: (message: TimelineMessage) => void;
|
onReply?: (message: TimelineMessage) => void;
|
||||||
onUnfollowThread?: (message: TimelineMessage) => void;
|
onUnfollowThread?: (message: TimelineMessage) => void;
|
||||||
reactionErrorMessage?: string | null;
|
reactionErrorMessage?: string | null;
|
||||||
reactions: TimelineReaction[];
|
reactions: TimelineReaction[];
|
||||||
reactionPending?: boolean;
|
|
||||||
isFollowingThread?: boolean;
|
isFollowingThread?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||||
@@ -307,6 +307,10 @@ export function MessageActionBar({
|
|||||||
const selectedReactionCount = reactions.filter(
|
const selectedReactionCount = reactions.filter(
|
||||||
(reaction) => reaction.reactedByCurrentUser,
|
(reaction) => reaction.reactedByCurrentUser,
|
||||||
).length;
|
).length;
|
||||||
|
const wouldAddReaction = (emoji: string) =>
|
||||||
|
!reactions.some(
|
||||||
|
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -334,7 +338,6 @@ export function MessageActionBar({
|
|||||||
aria-label="Open reactions"
|
aria-label="Open reactions"
|
||||||
className="h-6 w-6 rounded-full p-0"
|
className="h-6 w-6 rounded-full p-0"
|
||||||
data-testid={`react-message-${message.id}`}
|
data-testid={`react-message-${message.id}`}
|
||||||
disabled={reactionPending}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant={
|
variant={
|
||||||
@@ -343,11 +346,7 @@ export function MessageActionBar({
|
|||||||
: "ghost"
|
: "ghost"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{reactionPending ? (
|
<SmilePlus className="h-3 w-3" />
|
||||||
<Spinner className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<SmilePlus className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -374,6 +373,12 @@ export function MessageActionBar({
|
|||||||
}
|
}
|
||||||
// `value` is already a `native` glyph or a `:shortcode:` for
|
// `value` is already a `native` glyph or a `:shortcode:` for
|
||||||
// custom emoji; the toggle mutation resolves the URL.
|
// custom emoji; the toggle mutation resolves the URL.
|
||||||
|
if (
|
||||||
|
wouldAddReaction(value) &&
|
||||||
|
isPositiveEmojiParticle(value)
|
||||||
|
) {
|
||||||
|
onReactionBadgeBurstRequest?.(value);
|
||||||
|
}
|
||||||
void onReactionSelect(value).finally(() => {
|
void onReactionSelect(value).finally(() => {
|
||||||
setIsReactionPickerOpen(false);
|
setIsReactionPickerOpen(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import type { TimelineReaction } from "@/features/messages/types";
|
|||||||
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";
|
||||||
|
import {
|
||||||
|
isPositiveEmojiParticle,
|
||||||
|
useEmojiBurst,
|
||||||
|
} from "@/shared/ui/EmojiBurstProvider";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
const REACTION_PILL_BASE_CLASSES =
|
const REACTION_PILL_BASE_CLASSES =
|
||||||
@@ -15,6 +18,38 @@ const REACTION_PILL_BASE_CLASSES =
|
|||||||
const REACTION_GLYPH_CLASSES = "h-3.5 w-3.5 translate-y-px text-sm";
|
const REACTION_GLYPH_CLASSES = "h-3.5 w-3.5 translate-y-px text-sm";
|
||||||
const REACTION_PILL_HOVER_CLASSES =
|
const REACTION_PILL_HOVER_CLASSES =
|
||||||
"hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
|
"hover:bg-primary/10 hover:text-foreground focus-visible:bg-primary/10 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
|
||||||
|
const BADGE_BURST_STABLE_FRAMES = 2;
|
||||||
|
const BADGE_BURST_MAX_FRAMES = 12;
|
||||||
|
const BADGE_BURST_RECT_EPSILON = 0.5;
|
||||||
|
|
||||||
|
type BadgeBurstRect = {
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toBadgeBurstRect(rect: DOMRect): BadgeBurstRect {
|
||||||
|
return {
|
||||||
|
height: rect.height,
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameBadgeBurstRect(
|
||||||
|
left: BadgeBurstRect | null,
|
||||||
|
right: BadgeBurstRect,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
left !== null &&
|
||||||
|
Math.abs(left.left - right.left) <= BADGE_BURST_RECT_EPSILON &&
|
||||||
|
Math.abs(left.top - right.top) <= BADGE_BURST_RECT_EPSILON &&
|
||||||
|
Math.abs(left.width - right.width) <= BADGE_BURST_RECT_EPSILON &&
|
||||||
|
Math.abs(left.height - right.height) <= BADGE_BURST_RECT_EPSILON
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
|
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
|
||||||
@@ -99,6 +134,8 @@ export function MessageReactions({
|
|||||||
pending,
|
pending,
|
||||||
onSelect,
|
onSelect,
|
||||||
className,
|
className,
|
||||||
|
burstEmojiOnRender = null,
|
||||||
|
onBurstEmojiRendered,
|
||||||
}: {
|
}: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
reactions: TimelineReaction[];
|
reactions: TimelineReaction[];
|
||||||
@@ -106,7 +143,96 @@ export function MessageReactions({
|
|||||||
pending: boolean;
|
pending: boolean;
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
burstEmojiOnRender?: string | null;
|
||||||
|
onBurstEmojiRendered?: (emoji: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { burstEmoji } = useEmojiBurst();
|
||||||
|
const [pendingBadgeBurstEmoji, setPendingBadgeBurstEmoji] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const pillRefs = React.useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const deliveredBadgeBurstRef = React.useRef<string | null>(null);
|
||||||
|
const badgeBurstEmoji = burstEmojiOnRender ?? pendingBadgeBurstEmoji;
|
||||||
|
|
||||||
|
const registerPill = React.useCallback(
|
||||||
|
(emoji: string, element: HTMLButtonElement | null) => {
|
||||||
|
if (element) {
|
||||||
|
pillRefs.current.set(emoji, element);
|
||||||
|
} else {
|
||||||
|
pillRefs.current.delete(emoji);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!badgeBurstEmoji) {
|
||||||
|
deliveredBadgeBurstRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
deliveredBadgeBurstRef.current === badgeBurstEmoji ||
|
||||||
|
!isPositiveEmojiParticle(badgeBurstEmoji)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reaction = reactions.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.emoji === badgeBurstEmoji && candidate.reactedByCurrentUser,
|
||||||
|
);
|
||||||
|
if (!reaction || !pillRefs.current.get(badgeBurstEmoji)) return;
|
||||||
|
|
||||||
|
let frameId: number | null = null;
|
||||||
|
let frameCount = 0;
|
||||||
|
let stableFrameCount = 0;
|
||||||
|
let previousRect: BadgeBurstRect | null = null;
|
||||||
|
|
||||||
|
const cancelFrame = () => {
|
||||||
|
if (frameId === null) return;
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
frameId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitFromSettledBadge = () => {
|
||||||
|
frameId = null;
|
||||||
|
|
||||||
|
const pill = pillRefs.current.get(badgeBurstEmoji);
|
||||||
|
if (!pill || !document.documentElement.contains(pill)) return;
|
||||||
|
|
||||||
|
const nextRect = toBadgeBurstRect(pill.getBoundingClientRect());
|
||||||
|
if (!nextRect.width && !nextRect.height) return;
|
||||||
|
|
||||||
|
stableFrameCount = isSameBadgeBurstRect(previousRect, nextRect)
|
||||||
|
? stableFrameCount + 1
|
||||||
|
: 0;
|
||||||
|
previousRect = nextRect;
|
||||||
|
frameCount += 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
stableFrameCount < BADGE_BURST_STABLE_FRAMES &&
|
||||||
|
frameCount < BADGE_BURST_MAX_FRAMES
|
||||||
|
) {
|
||||||
|
frameId = window.requestAnimationFrame(emitFromSettledBadge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveredBadgeBurstRef.current = badgeBurstEmoji;
|
||||||
|
burstEmoji(badgeBurstEmoji, {
|
||||||
|
clientX: nextRect.left + nextRect.width / 2,
|
||||||
|
clientY: nextRect.top + nextRect.height / 2,
|
||||||
|
});
|
||||||
|
setPendingBadgeBurstEmoji((current) =>
|
||||||
|
current === badgeBurstEmoji ? null : current,
|
||||||
|
);
|
||||||
|
onBurstEmojiRendered?.(badgeBurstEmoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(emitFromSettledBadge);
|
||||||
|
|
||||||
|
return cancelFrame;
|
||||||
|
}, [badgeBurstEmoji, burstEmoji, onBurstEmojiRendered, reactions]);
|
||||||
|
|
||||||
if (reactions.length === 0) {
|
if (reactions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,6 +251,7 @@ export function MessageReactions({
|
|||||||
canToggle={canToggle}
|
canToggle={canToggle}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
reaction={reaction}
|
reaction={reaction}
|
||||||
|
registerPill={registerPill}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -133,6 +260,8 @@ export function MessageReactions({
|
|||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
reactions={reactions}
|
||||||
|
requestBadgeBurst={setPendingBadgeBurstEmoji}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,12 +272,20 @@ function InlineReactionPicker({
|
|||||||
messageId,
|
messageId,
|
||||||
onSelect,
|
onSelect,
|
||||||
pending,
|
pending,
|
||||||
|
reactions,
|
||||||
|
requestBadgeBurst,
|
||||||
}: {
|
}: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
reactions: TimelineReaction[];
|
||||||
|
requestBadgeBurst: (emoji: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const wouldAddReaction = (emoji: string) =>
|
||||||
|
!reactions.some(
|
||||||
|
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover onOpenChange={setOpen} open={open}>
|
<Popover onOpenChange={setOpen} open={open}>
|
||||||
@@ -173,11 +310,7 @@ function InlineReactionPicker({
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{pending ? (
|
<SmilePlus className="h-4 w-4" />
|
||||||
<Spinner className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<SmilePlus className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -192,6 +325,9 @@ function InlineReactionPicker({
|
|||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
autoFocus
|
autoFocus
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
|
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
|
||||||
|
requestBadgeBurst(value);
|
||||||
|
}
|
||||||
onSelect(value);
|
onSelect(value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -205,13 +341,16 @@ function ReactionPill({
|
|||||||
reaction,
|
reaction,
|
||||||
canToggle,
|
canToggle,
|
||||||
pending,
|
pending,
|
||||||
|
registerPill,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
reaction: TimelineReaction;
|
reaction: TimelineReaction;
|
||||||
canToggle: boolean;
|
canToggle: boolean;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
registerPill: (emoji: string, element: HTMLButtonElement | null) => void;
|
||||||
onSelect: (emoji: string) => void;
|
onSelect: (emoji: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { burstEmoji } = useEmojiBurst();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -248,6 +387,13 @@ function ReactionPill({
|
|||||||
return clearTimers;
|
return clearTimers;
|
||||||
}, [clearTimers]);
|
}, [clearTimers]);
|
||||||
|
|
||||||
|
const setPillRef = React.useCallback(
|
||||||
|
(element: HTMLButtonElement | null) => {
|
||||||
|
registerPill(reaction.emoji, element);
|
||||||
|
},
|
||||||
|
[reaction.emoji, registerPill],
|
||||||
|
);
|
||||||
|
|
||||||
const pillClasses = cn(
|
const pillClasses = cn(
|
||||||
REACTION_PILL_BASE_CLASSES,
|
REACTION_PILL_BASE_CLASSES,
|
||||||
"min-w-12 justify-center gap-1.5 px-2",
|
"min-w-12 justify-center gap-1.5 px-2",
|
||||||
@@ -261,8 +407,14 @@ function ReactionPill({
|
|||||||
: "cursor-default",
|
: "cursor-default",
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (!canToggle) return;
|
if (!canToggle) return;
|
||||||
|
if (
|
||||||
|
!reaction.reactedByCurrentUser &&
|
||||||
|
isPositiveEmojiParticle(reaction.emoji)
|
||||||
|
) {
|
||||||
|
burstEmoji(reaction.emoji, event);
|
||||||
|
}
|
||||||
onSelect(reaction.emoji);
|
onSelect(reaction.emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,6 +429,7 @@ function ReactionPill({
|
|||||||
className={pillClasses}
|
className={pillClasses}
|
||||||
disabled={!canToggle || pending}
|
disabled={!canToggle || pending}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
ref={setPillRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
||||||
@@ -303,6 +456,7 @@ function ReactionPill({
|
|||||||
className={pillClasses}
|
className={pillClasses}
|
||||||
disabled={!canToggle || pending}
|
disabled={!canToggle || pending}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
ref={setPillRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<EmojiGlyph
|
<EmojiGlyph
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const MessageRow = React.memo(
|
|||||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const {
|
const {
|
||||||
reactions,
|
reactions,
|
||||||
canToggle: canToggleReactions,
|
canToggle: canToggleReactions,
|
||||||
@@ -219,13 +222,15 @@ export const MessageRow = React.memo(
|
|||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onFollowThread={onFollowThread}
|
onFollowThread={onFollowThread}
|
||||||
onMarkUnread={onMarkUnread}
|
onMarkUnread={onMarkUnread}
|
||||||
|
onReactionBadgeBurstRequest={
|
||||||
|
reactionPending ? undefined : setBadgeBurstEmoji
|
||||||
|
}
|
||||||
onReactionSelect={
|
onReactionSelect={
|
||||||
canToggleReactions ? handleReactionSelect : undefined
|
canToggleReactions ? handleReactionSelect : undefined
|
||||||
}
|
}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onUnfollowThread={onUnfollowThread}
|
onUnfollowThread={onUnfollowThread}
|
||||||
reactionErrorMessage={reactionErrorMessage}
|
reactionErrorMessage={reactionErrorMessage}
|
||||||
reactionPending={reactionPending}
|
|
||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,6 +263,12 @@ export const MessageRow = React.memo(
|
|||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
canToggle={canToggleReactions}
|
canToggle={canToggleReactions}
|
||||||
pending={reactionPending}
|
pending={reactionPending}
|
||||||
|
burstEmojiOnRender={badgeBurstEmoji}
|
||||||
|
onBurstEmojiRendered={(emoji) => {
|
||||||
|
setBadgeBurstEmoji((current) =>
|
||||||
|
current === emoji ? null : current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
onSelect={(emoji) => {
|
onSelect={(emoji) => {
|
||||||
void handleReactionSelect(emoji);
|
void handleReactionSelect(emoji);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { resolveUserLabel } from "@/features/profile/lib/identity";
|
|||||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||||
import {
|
import {
|
||||||
MENTION_CHIP_BASE_CLASSES,
|
MENTION_CHIP_BASE_CLASSES,
|
||||||
MENTION_CHIP_HOVER_CLASSES,
|
MENTION_CHIP_HOVER_CLASSES,
|
||||||
} from "@/shared/ui/mentionChip";
|
} from "@/shared/ui/mentionChip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
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";
|
||||||
@@ -287,6 +287,9 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
remove: boolean,
|
remove: boolean,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||||
const {
|
const {
|
||||||
reactions,
|
reactions,
|
||||||
@@ -313,6 +316,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wouldAddReaction = (emoji: string) =>
|
||||||
|
!reactions.some(
|
||||||
|
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group/message relative rounded-2xl px-3 py-2 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
|
className="group/message relative rounded-2xl px-3 py-2 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
|
||||||
@@ -345,6 +353,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
canToggle={canToggleReactions}
|
canToggle={canToggleReactions}
|
||||||
pending={reactionPending}
|
pending={reactionPending}
|
||||||
className="mt-0.5 pt-0.5"
|
className="mt-0.5 pt-0.5"
|
||||||
|
burstEmojiOnRender={badgeBurstEmoji}
|
||||||
|
onBurstEmojiRendered={(emoji) => {
|
||||||
|
setBadgeBurstEmoji((current) =>
|
||||||
|
current === emoji ? null : current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
onSelect={(emoji) => {
|
onSelect={(emoji) => {
|
||||||
void handleReactionSelect(emoji);
|
void handleReactionSelect(emoji);
|
||||||
}}
|
}}
|
||||||
@@ -380,16 +394,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
<Button
|
<Button
|
||||||
aria-label="Open reactions"
|
aria-label="Open reactions"
|
||||||
className="h-6 w-6 rounded-full p-0"
|
className="h-6 w-6 rounded-full p-0"
|
||||||
disabled={reactionPending}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
||||||
>
|
>
|
||||||
{reactionPending ? (
|
<SmilePlus className="h-3 w-3" />
|
||||||
<Spinner className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<SmilePlus className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -410,6 +419,13 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
|||||||
) : null}
|
) : null}
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
|
if (
|
||||||
|
!reactionPending &&
|
||||||
|
wouldAddReaction(value) &&
|
||||||
|
isPositiveEmojiParticle(value)
|
||||||
|
) {
|
||||||
|
setBadgeBurstEmoji(value);
|
||||||
|
}
|
||||||
void handleReactionSelect(value).finally(() => {
|
void handleReactionSelect(value).finally(() => {
|
||||||
setIsReactionPickerOpen(false);
|
setIsReactionPickerOpen(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
|
||||||
import type {
|
import type {
|
||||||
TimelineMessage,
|
TimelineMessage,
|
||||||
TimelineReaction,
|
TimelineReaction,
|
||||||
} from "@/features/messages/types";
|
} from "@/features/messages/types";
|
||||||
|
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
|
||||||
|
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||||
|
|
||||||
type ReactionHandler = {
|
type ReactionHandler = {
|
||||||
/** Reactions sorted by count (desc) then emoji (asc). */
|
/** Reactions sorted by count (desc) then emoji (asc). */
|
||||||
@@ -18,6 +22,69 @@ type ReactionHandler = {
|
|||||||
select: (emoji: string) => Promise<void>;
|
select: (emoji: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sortReactions(reactions: TimelineReaction[]): TimelineReaction[] {
|
||||||
|
return [...reactions].sort((left, right) => {
|
||||||
|
if (left.count !== right.count) {
|
||||||
|
return right.count - left.count;
|
||||||
|
}
|
||||||
|
return left.emoji.localeCompare(right.emoji);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOptimisticReaction(
|
||||||
|
reactions: TimelineReaction[],
|
||||||
|
emoji: string,
|
||||||
|
remove: boolean,
|
||||||
|
emojiUrl?: string,
|
||||||
|
): TimelineReaction[] {
|
||||||
|
const existing = reactions.find((reaction) => reaction.emoji === emoji);
|
||||||
|
|
||||||
|
if (remove) {
|
||||||
|
if (!existing?.reactedByCurrentUser) return reactions;
|
||||||
|
|
||||||
|
const nextCount = Math.max(0, existing.count - 1);
|
||||||
|
if (nextCount === 0) {
|
||||||
|
return reactions.filter((reaction) => reaction.emoji !== emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactions.map((reaction) =>
|
||||||
|
reaction.emoji === emoji
|
||||||
|
? {
|
||||||
|
...reaction,
|
||||||
|
count: nextCount,
|
||||||
|
reactedByCurrentUser: false,
|
||||||
|
users: reaction.users.filter((user) => user.displayName !== "You"),
|
||||||
|
}
|
||||||
|
: reaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.reactedByCurrentUser) return reactions;
|
||||||
|
|
||||||
|
return reactions.map((reaction) =>
|
||||||
|
reaction.emoji === emoji
|
||||||
|
? {
|
||||||
|
...reaction,
|
||||||
|
count: reaction.count + 1,
|
||||||
|
reactedByCurrentUser: true,
|
||||||
|
}
|
||||||
|
: reaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...reactions,
|
||||||
|
{
|
||||||
|
emoji,
|
||||||
|
emojiUrl,
|
||||||
|
count: 1,
|
||||||
|
reactedByCurrentUser: true,
|
||||||
|
users: [{ pubkey: "", displayName: "You", avatarUrl: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared reaction state + toggle logic used by both MessageRow and
|
* Shared reaction state + toggle logic used by both MessageRow and
|
||||||
* SystemMessageRow. Keeps the pending/error/sorting concerns in one place.
|
* SystemMessageRow. Keeps the pending/error/sorting concerns in one place.
|
||||||
@@ -30,17 +97,22 @@ export function useReactionHandler(
|
|||||||
remove: boolean,
|
remove: boolean,
|
||||||
) => Promise<void>,
|
) => Promise<void>,
|
||||||
): ReactionHandler {
|
): ReactionHandler {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||||
|
const sourceReactions = message.reactions;
|
||||||
|
const [optimisticState, setOptimisticState] = React.useState<{
|
||||||
|
reactions: TimelineReaction[];
|
||||||
|
sourceReactions: TimelineReaction[] | undefined;
|
||||||
|
} | null>(null);
|
||||||
|
const optimisticReactions =
|
||||||
|
optimisticState && optimisticState.sourceReactions === sourceReactions
|
||||||
|
? optimisticState.reactions
|
||||||
|
: null;
|
||||||
|
|
||||||
const reactions = React.useMemo(() => {
|
const reactions = React.useMemo(() => {
|
||||||
return [...(message.reactions ?? [])].sort((left, right) => {
|
return sortReactions(optimisticReactions ?? sourceReactions ?? []);
|
||||||
if (left.count !== right.count) {
|
}, [sourceReactions, optimisticReactions]);
|
||||||
return right.count - left.count;
|
|
||||||
}
|
|
||||||
return left.emoji.localeCompare(right.emoji);
|
|
||||||
});
|
|
||||||
}, [message.reactions]);
|
|
||||||
|
|
||||||
const canToggle = Boolean(onToggleReaction && !message.pending);
|
const canToggle = Boolean(onToggleReaction && !message.pending);
|
||||||
|
|
||||||
@@ -56,9 +128,30 @@ export function useReactionHandler(
|
|||||||
|
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
const emojiUrl = reactionEmojiUrl(
|
||||||
|
emoji,
|
||||||
|
queryClient.getQueryData<CustomEmoji[]>(customEmojiQueryKey),
|
||||||
|
);
|
||||||
|
setOptimisticState((current) => {
|
||||||
|
const baseReactions =
|
||||||
|
current && current.sourceReactions === sourceReactions
|
||||||
|
? current.reactions
|
||||||
|
: reactions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reactions: applyOptimisticReaction(
|
||||||
|
baseReactions,
|
||||||
|
emoji,
|
||||||
|
remove,
|
||||||
|
emojiUrl,
|
||||||
|
),
|
||||||
|
sourceReactions,
|
||||||
|
};
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await onToggleReaction(message, emoji, remove);
|
await onToggleReaction(message, emoji, remove);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setOptimisticState(null);
|
||||||
const nextMessage =
|
const nextMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@@ -69,7 +162,14 @@ export function useReactionHandler(
|
|||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[message, onToggleReaction, pending, reactions],
|
[
|
||||||
|
message,
|
||||||
|
onToggleReaction,
|
||||||
|
pending,
|
||||||
|
queryClient,
|
||||||
|
reactions,
|
||||||
|
sourceReactions,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { reactions, canToggle, pending, errorMessage, select };
|
return { reactions, canToggle, pending, errorMessage, select };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "@/shared/styles/globals.css";
|
|||||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||||
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
||||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||||
|
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||||
import { Toaster } from "@/shared/ui/sonner";
|
import { Toaster } from "@/shared/ui/sonner";
|
||||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
@@ -18,10 +19,12 @@ function renderApp() {
|
|||||||
<WorkspacesProvider>
|
<WorkspacesProvider>
|
||||||
<ThemeProvider defaultTheme="houston">
|
<ThemeProvider defaultTheme="houston">
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<UpdaterProvider>
|
<EmojiBurstProvider>
|
||||||
<App />
|
<UpdaterProvider>
|
||||||
</UpdaterProvider>
|
<App />
|
||||||
<Toaster />
|
</UpdaterProvider>
|
||||||
|
<Toaster />
|
||||||
|
</EmojiBurstProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</WorkspacesProvider>
|
</WorkspacesProvider>
|
||||||
|
|||||||
488
desktop/src/shared/ui/EmojiBurstProvider.tsx
Normal file
488
desktop/src/shared/ui/EmojiBurstProvider.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type BurstPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmojiBurstOrigin =
|
||||||
|
| Element
|
||||||
|
| {
|
||||||
|
clientX?: number;
|
||||||
|
clientY?: number;
|
||||||
|
currentTarget?: EventTarget | null;
|
||||||
|
target?: EventTarget | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
type EmojiBurstContextValue = {
|
||||||
|
burstEmoji: (emoji: string, origin?: EmojiBurstOrigin) => void;
|
||||||
|
celebrateWithEmojiFloatBurst: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
xv: number;
|
||||||
|
yv: number;
|
||||||
|
rotation: number;
|
||||||
|
spin: number;
|
||||||
|
scale: number;
|
||||||
|
opacity: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
emoji: string;
|
||||||
|
fontSize: number;
|
||||||
|
radius: number;
|
||||||
|
gravity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOOP_CONTEXT: EmojiBurstContextValue = {
|
||||||
|
burstEmoji: () => {},
|
||||||
|
celebrateWithEmojiFloatBurst: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmojiBurstContext = React.createContext<EmojiBurstContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_ACTIVE = 760;
|
||||||
|
const MAX_DPR = 2;
|
||||||
|
const EMOJI_CACHE_PX = 64;
|
||||||
|
const PICKER_PARTICLES_PER_BURST = 5;
|
||||||
|
const PICKER_PARTICLE_LIFE_FRAMES = 108;
|
||||||
|
const CELEBRATION_PARTICLE_COUNT = 102;
|
||||||
|
const HEART_PARTICLE_EMOJIS = [
|
||||||
|
"❤️",
|
||||||
|
"🩷",
|
||||||
|
"🧡",
|
||||||
|
"💛",
|
||||||
|
"💚",
|
||||||
|
"🩵",
|
||||||
|
"💙",
|
||||||
|
"💜",
|
||||||
|
"🤎",
|
||||||
|
"🖤",
|
||||||
|
"🩶",
|
||||||
|
"🤍",
|
||||||
|
"❤️🔥",
|
||||||
|
"❤️🩹",
|
||||||
|
"💖",
|
||||||
|
"💕",
|
||||||
|
"💗",
|
||||||
|
"💓",
|
||||||
|
"💞",
|
||||||
|
"💘",
|
||||||
|
"💝",
|
||||||
|
"❣️",
|
||||||
|
"♥️",
|
||||||
|
];
|
||||||
|
const POSITIVE_REACTION_PARTICLE_EMOJIS = [
|
||||||
|
"👍",
|
||||||
|
"👏",
|
||||||
|
"🙌",
|
||||||
|
"🙏",
|
||||||
|
"💯",
|
||||||
|
"🔥",
|
||||||
|
"✨",
|
||||||
|
"⭐",
|
||||||
|
"🌟",
|
||||||
|
"🎉",
|
||||||
|
"🎊",
|
||||||
|
"🥳",
|
||||||
|
"🚀",
|
||||||
|
"💪",
|
||||||
|
];
|
||||||
|
const POSITIVE_FACE_PARTICLE_EMOJIS = [
|
||||||
|
"😀",
|
||||||
|
"😃",
|
||||||
|
"😄",
|
||||||
|
"😁",
|
||||||
|
"😊",
|
||||||
|
"😇",
|
||||||
|
"🙂",
|
||||||
|
"😍",
|
||||||
|
"🤩",
|
||||||
|
"🥰",
|
||||||
|
"😘",
|
||||||
|
"😋",
|
||||||
|
"😎",
|
||||||
|
"😂",
|
||||||
|
"🤣",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POSITIVE_EMOJI_PARTICLES = [
|
||||||
|
...HEART_PARTICLE_EMOJIS,
|
||||||
|
...POSITIVE_REACTION_PARTICLE_EMOJIS,
|
||||||
|
...POSITIVE_FACE_PARTICLE_EMOJIS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const CELEBRATION_EMOJIS = POSITIVE_EMOJI_PARTICLES;
|
||||||
|
const POSITIVE_EMOJI_PARTICLE_SET = new Set(POSITIVE_EMOJI_PARTICLES);
|
||||||
|
|
||||||
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function isElement(value: unknown): value is Element {
|
||||||
|
return typeof Element !== "undefined" && value instanceof Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementCenter(element: Element): BurstPoint | null {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (!rect.width && !rect.height) return null;
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFromOrigin(origin: EmojiBurstOrigin): BurstPoint | null {
|
||||||
|
if (!origin) return null;
|
||||||
|
if (isElement(origin)) return elementCenter(origin);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof origin.clientX === "number" &&
|
||||||
|
Number.isFinite(origin.clientX) &&
|
||||||
|
typeof origin.clientY === "number" &&
|
||||||
|
Number.isFinite(origin.clientY) &&
|
||||||
|
(origin.clientX !== 0 || origin.clientY !== 0)
|
||||||
|
) {
|
||||||
|
return { x: origin.clientX, y: origin.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = origin.currentTarget ?? origin.target;
|
||||||
|
return isElement(target) ? elementCenter(target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas(canvas: HTMLCanvasElement) {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const targetWidth = Math.round(width * dpr);
|
||||||
|
const targetHeight = Math.round(height * dpr);
|
||||||
|
|
||||||
|
if (canvas.width === targetWidth && canvas.height === targetHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
const cacheKey = `${emoji}:${dpr}`;
|
||||||
|
const existing = emojiCanvasCache.get(cacheKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
|
||||||
|
const size = Math.ceil(fontSize * 1.5);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return canvas;
|
||||||
|
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
context.fillText(emoji, size / 2, size / 2);
|
||||||
|
|
||||||
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticle(particle: Particle): boolean {
|
||||||
|
particle.life -= 1;
|
||||||
|
particle.rotation += particle.spin;
|
||||||
|
particle.yv += particle.gravity;
|
||||||
|
particle.xv *= 0.965;
|
||||||
|
particle.yv *= 0.998;
|
||||||
|
particle.x += particle.xv;
|
||||||
|
particle.y += particle.yv;
|
||||||
|
particle.scale += (1 - particle.scale) * 0.28;
|
||||||
|
particle.radius = particle.fontSize * particle.scale * 0.42;
|
||||||
|
|
||||||
|
const lifeRatio = particle.life / particle.maxLife;
|
||||||
|
if (lifeRatio < 0.24) {
|
||||||
|
particle.opacity = Math.max(0, lifeRatio / 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
return particle.life > 0 && particle.opacity > 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCollisions(particles: Particle[]) {
|
||||||
|
for (let i = 0; i < particles.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < particles.length; j += 1) {
|
||||||
|
const a = particles[i];
|
||||||
|
const b = particles[j];
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const distanceSquared = dx * dx + dy * dy;
|
||||||
|
const minDistance = a.radius + b.radius;
|
||||||
|
|
||||||
|
if (
|
||||||
|
distanceSquared >= minDistance * minDistance ||
|
||||||
|
distanceSquared < 0.01
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Math.sqrt(distanceSquared);
|
||||||
|
const nx = dx / distance;
|
||||||
|
const ny = dy / distance;
|
||||||
|
const separation = (minDistance - distance) * 0.5;
|
||||||
|
|
||||||
|
a.x -= nx * separation;
|
||||||
|
a.y -= ny * separation;
|
||||||
|
b.x += nx * separation;
|
||||||
|
b.y += ny * separation;
|
||||||
|
|
||||||
|
const dvx = a.xv - b.xv;
|
||||||
|
const dvy = a.yv - b.yv;
|
||||||
|
const velocityAlongNormal = dvx * nx + dvy * ny;
|
||||||
|
if (velocityAlongNormal <= 0) continue;
|
||||||
|
|
||||||
|
const impulse = velocityAlongNormal * 0.34;
|
||||||
|
a.xv -= impulse * nx;
|
||||||
|
a.yv -= impulse * ny;
|
||||||
|
b.xv += impulse * nx;
|
||||||
|
b.yv += impulse * ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportCenter(): BurstPoint {
|
||||||
|
return {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPickerEmojiBurst(
|
||||||
|
particles: Particle[],
|
||||||
|
point: BurstPoint,
|
||||||
|
emoji: string,
|
||||||
|
) {
|
||||||
|
if (particles.length + PICKER_PARTICLES_PER_BURST > MAX_ACTIVE) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < PICKER_PARTICLES_PER_BURST; i += 1) {
|
||||||
|
const horizontalDrift = (Math.random() - 0.5) * 4.4;
|
||||||
|
const initialLift = 2.1 + Math.random() * 2.35;
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
xv: horizontalDrift,
|
||||||
|
yv: -initialLift,
|
||||||
|
rotation: (Math.random() - 0.5) * 22,
|
||||||
|
spin: (Math.random() - 0.5) * 5.2,
|
||||||
|
scale: 0.25,
|
||||||
|
opacity: 1,
|
||||||
|
life: PICKER_PARTICLE_LIFE_FRAMES,
|
||||||
|
maxLife: PICKER_PARTICLE_LIFE_FRAMES,
|
||||||
|
emoji,
|
||||||
|
fontSize: 18 + Math.ceil(Math.random() * 24),
|
||||||
|
radius: 0,
|
||||||
|
gravity: -(0.018 + Math.random() * 0.018),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnEmojiFloatBurst(particles: Particle[]) {
|
||||||
|
const availableSlots = Math.max(0, MAX_ACTIVE - particles.length);
|
||||||
|
const count = Math.min(CELEBRATION_PARTICLE_COUNT, availableSlots);
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
const centerX = window.innerWidth / 2;
|
||||||
|
const launchBandWidth = window.innerWidth * 0.78;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const x = centerX + (Math.random() - 0.5) * launchBandWidth;
|
||||||
|
const y = window.innerHeight + 52 + Math.random() * 88;
|
||||||
|
const life = 142 + Math.floor(Math.random() * 72);
|
||||||
|
const fanDirection = (x - centerX) / Math.max(window.innerWidth / 2, 1);
|
||||||
|
const emoji =
|
||||||
|
CELEBRATION_EMOJIS[Math.floor(Math.random() * CELEBRATION_EMOJIS.length)];
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
xv:
|
||||||
|
fanDirection * (4.2 + Math.random() * 3.4) +
|
||||||
|
(Math.random() - 0.5) * 2.2,
|
||||||
|
yv: -(5.4 + Math.random() * 4.8),
|
||||||
|
rotation: (Math.random() - 0.5) * 70,
|
||||||
|
spin: (Math.random() - 0.5) * 7.6,
|
||||||
|
scale: 0.44 + Math.random() * 0.48,
|
||||||
|
opacity: 1,
|
||||||
|
life,
|
||||||
|
maxLife: life,
|
||||||
|
emoji,
|
||||||
|
fontSize: 22 + Math.ceil(Math.random() * 30),
|
||||||
|
radius: 0,
|
||||||
|
gravity: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiBurstProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
|
const contextRef = React.useRef<CanvasRenderingContext2D | null>(null);
|
||||||
|
const particlesRef = React.useRef<Particle[]>([]);
|
||||||
|
const animationFrameRef = React.useRef<number | null>(null);
|
||||||
|
const reducedMotionRef = React.useRef(false);
|
||||||
|
|
||||||
|
const startLoop = React.useCallback(() => {
|
||||||
|
if (animationFrameRef.current !== null) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = contextRef.current;
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
const activeCanvas = canvas;
|
||||||
|
const activeContext = context;
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
const particles = particlesRef.current;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
|
||||||
|
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (!updateParticle(particles[i])) {
|
||||||
|
particles[i] = particles[particles.length - 1];
|
||||||
|
particles.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeContext.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
activeContext.clearRect(0, 0, activeCanvas.width, activeCanvas.height);
|
||||||
|
|
||||||
|
if (particles.length === 0) {
|
||||||
|
activeContext.globalAlpha = 1;
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCollisions(particles);
|
||||||
|
|
||||||
|
for (const particle of particles) {
|
||||||
|
activeContext.globalAlpha = particle.opacity;
|
||||||
|
|
||||||
|
const emojiCanvas = getEmojiCanvas(particle.emoji);
|
||||||
|
const drawSize = particle.fontSize * particle.scale * 1.5;
|
||||||
|
const halfSize = drawSize / 2;
|
||||||
|
const radians = (particle.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(radians) * dpr;
|
||||||
|
const sin = Math.sin(radians) * dpr;
|
||||||
|
|
||||||
|
activeContext.setTransform(
|
||||||
|
cos,
|
||||||
|
sin,
|
||||||
|
-sin,
|
||||||
|
cos,
|
||||||
|
particle.x * dpr,
|
||||||
|
particle.y * dpr,
|
||||||
|
);
|
||||||
|
activeContext.drawImage(
|
||||||
|
emojiCanvas,
|
||||||
|
-halfSize,
|
||||||
|
-halfSize,
|
||||||
|
drawSize,
|
||||||
|
drawSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeContext.globalAlpha = 1;
|
||||||
|
animationFrameRef.current = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(frame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
contextRef.current = canvas.getContext("2d");
|
||||||
|
resizeCanvas(canvas);
|
||||||
|
|
||||||
|
const handleResize = () => resizeCanvas(canvas);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const syncReducedMotion = () => {
|
||||||
|
reducedMotionRef.current = mediaQuery.matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
syncReducedMotion();
|
||||||
|
mediaQuery.addEventListener("change", syncReducedMotion);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", syncReducedMotion);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const burstEmoji = React.useCallback(
|
||||||
|
(emoji: string, origin?: EmojiBurstOrigin) => {
|
||||||
|
const trimmedEmoji = emoji.trim();
|
||||||
|
if (!trimmedEmoji || reducedMotionRef.current) return;
|
||||||
|
|
||||||
|
spawnPickerEmojiBurst(
|
||||||
|
particlesRef.current,
|
||||||
|
pointFromOrigin(origin) ?? viewportCenter(),
|
||||||
|
trimmedEmoji,
|
||||||
|
);
|
||||||
|
startLoop();
|
||||||
|
},
|
||||||
|
[startLoop],
|
||||||
|
);
|
||||||
|
|
||||||
|
const celebrateWithEmojiFloatBurst = React.useCallback(() => {
|
||||||
|
if (reducedMotionRef.current) return;
|
||||||
|
|
||||||
|
spawnEmojiFloatBurst(particlesRef.current);
|
||||||
|
startLoop();
|
||||||
|
}, [startLoop]);
|
||||||
|
|
||||||
|
const value = React.useMemo<EmojiBurstContextValue>(
|
||||||
|
() => ({ burstEmoji, celebrateWithEmojiFloatBurst }),
|
||||||
|
[burstEmoji, celebrateWithEmojiFloatBurst],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmojiBurstContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none fixed inset-0 select-none"
|
||||||
|
style={{ contain: "strict", zIndex: 2147483000 }}
|
||||||
|
>
|
||||||
|
<canvas className="block" ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
</EmojiBurstContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmojiBurst(): EmojiBurstContextValue {
|
||||||
|
return React.useContext(EmojiBurstContext) ?? NOOP_CONTEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPositiveEmojiParticle(emoji: string): boolean {
|
||||||
|
return POSITIVE_EMOJI_PARTICLE_SET.has(emoji);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user