mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add emoji reaction particles (#890)
This commit is contained in:
@@ -52,6 +52,9 @@ export function InboxMessageRow({
|
||||
() => toTimelineMessage(message),
|
||||
[message],
|
||||
);
|
||||
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
@@ -92,11 +95,13 @@ export function InboxMessageRow({
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReactionBadgeBurstRequest={
|
||||
reactionPending ? undefined : setBadgeBurstEmoji
|
||||
}
|
||||
onReply={
|
||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||
}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,6 +139,12 @@ export function InboxMessageRow({
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
burstEmojiOnRender={badgeBurstEmoji}
|
||||
onBurstEmojiRendered={(emoji) => {
|
||||
setBadgeBurstEmoji((current) =>
|
||||
current === emoji ? null : current,
|
||||
);
|
||||
}}
|
||||
pending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
function copyToClipboard(text: string, successMessage: string) {
|
||||
@@ -263,12 +263,12 @@ export function MessageActionBar({
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onReactionBadgeBurstRequest,
|
||||
onReactionSelect,
|
||||
onReply,
|
||||
onUnfollowThread,
|
||||
reactionErrorMessage = null,
|
||||
reactions,
|
||||
reactionPending = false,
|
||||
isFollowingThread,
|
||||
}: {
|
||||
/** Channel UUID — required for the "Copy link" action; when omitted the
|
||||
@@ -279,12 +279,12 @@ export function MessageActionBar({
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onReactionBadgeBurstRequest?: (emoji: string) => void;
|
||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onUnfollowThread?: (message: TimelineMessage) => void;
|
||||
reactionErrorMessage?: string | null;
|
||||
reactions: TimelineReaction[];
|
||||
reactionPending?: boolean;
|
||||
isFollowingThread?: boolean;
|
||||
}) {
|
||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||
@@ -307,6 +307,10 @@ export function MessageActionBar({
|
||||
const selectedReactionCount = reactions.filter(
|
||||
(reaction) => reaction.reactedByCurrentUser,
|
||||
).length;
|
||||
const wouldAddReaction = (emoji: string) =>
|
||||
!reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -334,7 +338,6 @@ export function MessageActionBar({
|
||||
aria-label="Open reactions"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
data-testid={`react-message-${message.id}`}
|
||||
disabled={reactionPending}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={
|
||||
@@ -343,11 +346,7 @@ export function MessageActionBar({
|
||||
: "ghost"
|
||||
}
|
||||
>
|
||||
{reactionPending ? (
|
||||
<Spinner className="h-3 w-3" />
|
||||
) : (
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
)}
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -374,6 +373,12 @@ export function MessageActionBar({
|
||||
}
|
||||
// `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);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,11 @@ import type { TimelineReaction } from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import {
|
||||
isPositiveEmojiParticle,
|
||||
useEmojiBurst,
|
||||
} from "@/shared/ui/EmojiBurstProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
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_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";
|
||||
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,
|
||||
@@ -99,6 +134,8 @@ export function MessageReactions({
|
||||
pending,
|
||||
onSelect,
|
||||
className,
|
||||
burstEmojiOnRender = null,
|
||||
onBurstEmojiRendered,
|
||||
}: {
|
||||
messageId: string;
|
||||
reactions: TimelineReaction[];
|
||||
@@ -106,7 +143,96 @@ export function MessageReactions({
|
||||
pending: boolean;
|
||||
onSelect: (emoji: string) => void;
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -125,6 +251,7 @@ export function MessageReactions({
|
||||
canToggle={canToggle}
|
||||
pending={pending}
|
||||
reaction={reaction}
|
||||
registerPill={registerPill}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -133,6 +260,8 @@ export function MessageReactions({
|
||||
messageId={messageId}
|
||||
onSelect={onSelect}
|
||||
pending={pending}
|
||||
reactions={reactions}
|
||||
requestBadgeBurst={setPendingBadgeBurstEmoji}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -143,12 +272,20 @@ function InlineReactionPicker({
|
||||
messageId,
|
||||
onSelect,
|
||||
pending,
|
||||
reactions,
|
||||
requestBadgeBurst,
|
||||
}: {
|
||||
messageId: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
pending: boolean;
|
||||
reactions: TimelineReaction[];
|
||||
requestBadgeBurst: (emoji: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const wouldAddReaction = (emoji: string) =>
|
||||
!reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
@@ -173,11 +310,7 @@ function InlineReactionPicker({
|
||||
disabled={pending}
|
||||
type="button"
|
||||
>
|
||||
{pending ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
)}
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -192,6 +325,9 @@ function InlineReactionPicker({
|
||||
<EmojiPicker
|
||||
autoFocus
|
||||
onSelect={(value) => {
|
||||
if (wouldAddReaction(value) && isPositiveEmojiParticle(value)) {
|
||||
requestBadgeBurst(value);
|
||||
}
|
||||
onSelect(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -205,13 +341,16 @@ function ReactionPill({
|
||||
reaction,
|
||||
canToggle,
|
||||
pending,
|
||||
registerPill,
|
||||
onSelect,
|
||||
}: {
|
||||
reaction: TimelineReaction;
|
||||
canToggle: boolean;
|
||||
pending: boolean;
|
||||
registerPill: (emoji: string, element: HTMLButtonElement | null) => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
}) {
|
||||
const { burstEmoji } = useEmojiBurst();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -248,6 +387,13 @@ function ReactionPill({
|
||||
return clearTimers;
|
||||
}, [clearTimers]);
|
||||
|
||||
const setPillRef = React.useCallback(
|
||||
(element: HTMLButtonElement | null) => {
|
||||
registerPill(reaction.emoji, element);
|
||||
},
|
||||
[reaction.emoji, registerPill],
|
||||
);
|
||||
|
||||
const pillClasses = cn(
|
||||
REACTION_PILL_BASE_CLASSES,
|
||||
"min-w-12 justify-center gap-1.5 px-2",
|
||||
@@ -261,8 +407,14 @@ function ReactionPill({
|
||||
: "cursor-default",
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!canToggle) return;
|
||||
if (
|
||||
!reaction.reactedByCurrentUser &&
|
||||
isPositiveEmojiParticle(reaction.emoji)
|
||||
) {
|
||||
burstEmoji(reaction.emoji, event);
|
||||
}
|
||||
onSelect(reaction.emoji);
|
||||
};
|
||||
|
||||
@@ -277,6 +429,7 @@ function ReactionPill({
|
||||
className={pillClasses}
|
||||
disabled={!canToggle || pending}
|
||||
onClick={handleClick}
|
||||
ref={setPillRef}
|
||||
type="button"
|
||||
>
|
||||
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
||||
@@ -303,6 +456,7 @@ function ReactionPill({
|
||||
className={pillClasses}
|
||||
disabled={!canToggle || pending}
|
||||
onClick={handleClick}
|
||||
ref={setPillRef}
|
||||
type="button"
|
||||
>
|
||||
<EmojiGlyph
|
||||
|
||||
@@ -67,6 +67,9 @@ export const MessageRow = React.memo(
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
reactions,
|
||||
canToggle: canToggleReactions,
|
||||
@@ -219,13 +222,15 @@ export const MessageRow = React.memo(
|
||||
onEdit={onEdit}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReactionBadgeBurstRequest={
|
||||
reactionPending ? undefined : setBadgeBurstEmoji
|
||||
}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
@@ -258,6 +263,12 @@ export const MessageRow = React.memo(
|
||||
reactions={reactions}
|
||||
canToggle={canToggleReactions}
|
||||
pending={reactionPending}
|
||||
burstEmojiOnRender={badgeBurstEmoji}
|
||||
onBurstEmojiRendered={(emoji) => {
|
||||
setBadgeBurstEmoji((current) =>
|
||||
current === emoji ? null : current,
|
||||
);
|
||||
}}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||
import {
|
||||
MENTION_CHIP_BASE_CLASSES,
|
||||
MENTION_CHIP_HOVER_CLASSES,
|
||||
} from "@/shared/ui/mentionChip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
@@ -287,6 +287,9 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||
const {
|
||||
reactions,
|
||||
@@ -313,6 +316,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
return null;
|
||||
}
|
||||
|
||||
const wouldAddReaction = (emoji: string) =>
|
||||
!reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
pending={reactionPending}
|
||||
className="mt-0.5 pt-0.5"
|
||||
burstEmojiOnRender={badgeBurstEmoji}
|
||||
onBurstEmojiRendered={(emoji) => {
|
||||
setBadgeBurstEmoji((current) =>
|
||||
current === emoji ? null : current,
|
||||
);
|
||||
}}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
}}
|
||||
@@ -380,16 +394,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
<Button
|
||||
aria-label="Open reactions"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
disabled={reactionPending}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={isReactionPickerOpen ? "secondary" : "ghost"}
|
||||
>
|
||||
{reactionPending ? (
|
||||
<Spinner className="h-3 w-3" />
|
||||
) : (
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
)}
|
||||
<SmilePlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -410,6 +419,13 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
) : null}
|
||||
<EmojiPicker
|
||||
onSelect={(value) => {
|
||||
if (
|
||||
!reactionPending &&
|
||||
wouldAddReaction(value) &&
|
||||
isPositiveEmojiParticle(value)
|
||||
) {
|
||||
setBadgeBurstEmoji(value);
|
||||
}
|
||||
void handleReactionSelect(value).finally(() => {
|
||||
setIsReactionPickerOpen(false);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
|
||||
import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
|
||||
type ReactionHandler = {
|
||||
/** Reactions sorted by count (desc) then emoji (asc). */
|
||||
@@ -18,6 +22,69 @@ type ReactionHandler = {
|
||||
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
|
||||
* SystemMessageRow. Keeps the pending/error/sorting concerns in one place.
|
||||
@@ -30,17 +97,22 @@ export function useReactionHandler(
|
||||
remove: boolean,
|
||||
) => Promise<void>,
|
||||
): ReactionHandler {
|
||||
const queryClient = useQueryClient();
|
||||
const [pending, setPending] = React.useState(false);
|
||||
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(() => {
|
||||
return [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
}, [message.reactions]);
|
||||
return sortReactions(optimisticReactions ?? sourceReactions ?? []);
|
||||
}, [sourceReactions, optimisticReactions]);
|
||||
|
||||
const canToggle = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
@@ -56,9 +128,30 @@ export function useReactionHandler(
|
||||
|
||||
setErrorMessage(null);
|
||||
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 {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
setOptimisticState(null);
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -69,7 +162,14 @@ export function useReactionHandler(
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, pending, reactions],
|
||||
[
|
||||
message,
|
||||
onToggleReaction,
|
||||
pending,
|
||||
queryClient,
|
||||
reactions,
|
||||
sourceReactions,
|
||||
],
|
||||
);
|
||||
|
||||
return { reactions, canToggle, pending, errorMessage, select };
|
||||
|
||||
@@ -5,6 +5,7 @@ import "@/shared/styles/globals.css";
|
||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
|
||||
@@ -18,10 +19,12 @@ function renderApp() {
|
||||
<WorkspacesProvider>
|
||||
<ThemeProvider defaultTheme="houston">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
<EmojiBurstProvider>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
</EmojiBurstProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</WorkspacesProvider>
|
||||
|
||||
@@ -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