From b9857c6e8c137aa009d48158baefa9d03b8685a5 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 6 Jun 2026 10:21:13 +0100 Subject: [PATCH 1/3] Add emoji reaction particles --- .../src/features/home/ui/InboxMessageRow.tsx | 13 +- .../features/messages/ui/MessageActionBar.tsx | 23 +- .../features/messages/ui/MessageReactions.tsx | 168 +++++- .../src/features/messages/ui/MessageRow.tsx | 11 +- .../features/messages/ui/SystemMessageRow.tsx | 30 +- .../messages/ui/useReactionHandler.ts | 93 +++- desktop/src/main.tsx | 11 +- desktop/src/shared/ui/EmojiBurstProvider.tsx | 488 ++++++++++++++++++ 8 files changed, 800 insertions(+), 37 deletions(-) create mode 100644 desktop/src/shared/ui/EmojiBurstProvider.tsx diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index c2b2f6c7e0..08a80df44f 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -52,6 +52,9 @@ export function InboxMessageRow({ () => toTimelineMessage(message), [message], ); + const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( + 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} /> @@ -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} /> diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index d230ff0914..a0d42c456f 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -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; 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 (
- {reactionPending ? ( - - ) : ( - - )} + @@ -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); }); diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index c2ea3ded08..f8f9a689ab 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -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 = "-translate-y-px h-3.5 w-3.5 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()); + const deliveredBadgeBurstRef = React.useRef(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}
@@ -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 ( @@ -173,11 +310,7 @@ function InlineReactionPicker({ disabled={pending} type="button" > - {pending ? ( - - ) : ( - - )} + @@ -192,6 +325,9 @@ function InlineReactionPicker({ { + 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 | null>(null); const closeTimeout = React.useRef | 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) => { 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" > @@ -303,6 +456,7 @@ function ReactionPill({ className={pillClasses} disabled={!canToggle || pending} onClick={handleClick} + ref={setPillRef} type="button" > ( null, ); + const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( + 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} /> @@ -258,6 +263,10 @@ 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); }} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 112c4f2c7e..0a80607b70 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -10,8 +10,8 @@ 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 { 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"; @@ -293,6 +293,9 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ remove: boolean, ) => Promise; }) { + const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( + null, + ); const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const { reactions, @@ -319,6 +322,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ return null; } + const wouldAddReaction = (emoji: string) => + !reactions.some( + (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser, + ); + return (
{ + setBadgeBurstEmoji((current) => + current === emoji ? null : current, + ); + }} onSelect={(emoji) => { void handleReactionSelect(emoji); }} @@ -386,16 +400,11 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ @@ -416,6 +425,13 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ ) : null} { + if ( + !reactionPending && + wouldAddReaction(value) && + isPositiveEmojiParticle(value) + ) { + setBadgeBurstEmoji(value); + } void handleReactionSelect(value).finally(() => { setIsReactionPickerOpen(false); }); diff --git a/desktop/src/features/messages/ui/useReactionHandler.ts b/desktop/src/features/messages/ui/useReactionHandler.ts index fa430349b9..3f0b04e93a 100644 --- a/desktop/src/features/messages/ui/useReactionHandler.ts +++ b/desktop/src/features/messages/ui/useReactionHandler.ts @@ -18,6 +18,67 @@ type ReactionHandler = { select: (emoji: string) => Promise; }; +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, +): 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, + 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. @@ -32,15 +93,19 @@ export function useReactionHandler( ): ReactionHandler { const [pending, setPending] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(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 +121,21 @@ export function useReactionHandler( setErrorMessage(null); setPending(true); + setOptimisticState((current) => { + const baseReactions = + current && current.sourceReactions === sourceReactions + ? current.reactions + : reactions; + + return { + reactions: applyOptimisticReaction(baseReactions, emoji, remove), + sourceReactions, + }; + }); try { await onToggleReaction(message, emoji, remove); } catch (error) { + setOptimisticState(null); const nextMessage = error instanceof Error ? error.message @@ -69,7 +146,7 @@ export function useReactionHandler( setPending(false); } }, - [message, onToggleReaction, pending, reactions], + [message, onToggleReaction, pending, reactions, sourceReactions], ); return { reactions, canToggle, pending, errorMessage, select }; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index e706404ed0..67d8290b0f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -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() { - - - - + + + + + + diff --git a/desktop/src/shared/ui/EmojiBurstProvider.tsx b/desktop/src/shared/ui/EmojiBurstProvider.tsx new file mode 100644 index 0000000000..77a400c124 --- /dev/null +++ b/desktop/src/shared/ui/EmojiBurstProvider.tsx @@ -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( + 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(); + +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(null); + const contextRef = React.useRef(null); + const particlesRef = React.useRef([]); + const animationFrameRef = React.useRef(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( + () => ({ burstEmoji, celebrateWithEmojiFloatBurst }), + [burstEmoji, celebrateWithEmojiFloatBurst], + ); + + return ( + + {children} + + + ); +} + +export function useEmojiBurst(): EmojiBurstContextValue { + return React.useContext(EmojiBurstContext) ?? NOOP_CONTEXT; +} + +export function isPositiveEmojiParticle(emoji: string): boolean { + return POSITIVE_EMOJI_PARTICLE_SET.has(emoji); +} From 3153d141bf90862fcb51ca9ef2003dd9d90a8acc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 6 Jun 2026 10:35:19 +0100 Subject: [PATCH 2/3] Fix emoji particle formatting --- desktop/src/features/messages/ui/MessageRow.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 57cc470c2f..ce58cf8585 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -265,7 +265,9 @@ export const MessageRow = React.memo( pending={reactionPending} burstEmojiOnRender={badgeBurstEmoji} onBurstEmojiRendered={(emoji) => { - setBadgeBurstEmoji((current) => (current === emoji ? null : current)); + setBadgeBurstEmoji((current) => + current === emoji ? null : current, + ); }} onSelect={(emoji) => { void handleReactionSelect(emoji); From 6d8dba78cddd9b80f85d2d6ce0de8958161071db Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 6 Jun 2026 11:03:19 +0100 Subject: [PATCH 3/3] Preserve custom emoji in optimistic reactions --- .../messages/ui/useReactionHandler.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/useReactionHandler.ts b/desktop/src/features/messages/ui/useReactionHandler.ts index 3f0b04e93a..1e78a2ccea 100644 --- a/desktop/src/features/messages/ui/useReactionHandler.ts +++ b/desktop/src/features/messages/ui/useReactionHandler.ts @@ -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). */ @@ -31,6 +35,7 @@ function applyOptimisticReaction( reactions: TimelineReaction[], emoji: string, remove: boolean, + emojiUrl?: string, ): TimelineReaction[] { const existing = reactions.find((reaction) => reaction.emoji === emoji); @@ -72,6 +77,7 @@ function applyOptimisticReaction( ...reactions, { emoji, + emojiUrl, count: 1, reactedByCurrentUser: true, users: [{ pubkey: "", displayName: "You", avatarUrl: null }], @@ -91,6 +97,7 @@ export function useReactionHandler( remove: boolean, ) => Promise, ): ReactionHandler { + const queryClient = useQueryClient(); const [pending, setPending] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(null); const sourceReactions = message.reactions; @@ -121,6 +128,10 @@ export function useReactionHandler( setErrorMessage(null); setPending(true); + const emojiUrl = reactionEmojiUrl( + emoji, + queryClient.getQueryData(customEmojiQueryKey), + ); setOptimisticState((current) => { const baseReactions = current && current.sourceReactions === sourceReactions @@ -128,7 +139,12 @@ export function useReactionHandler( : reactions; return { - reactions: applyOptimisticReaction(baseReactions, emoji, remove), + reactions: applyOptimisticReaction( + baseReactions, + emoji, + remove, + emojiUrl, + ), sourceReactions, }; }); @@ -146,7 +162,14 @@ export function useReactionHandler( setPending(false); } }, - [message, onToggleReaction, pending, reactions, sourceReactions], + [ + message, + onToggleReaction, + pending, + queryClient, + reactions, + sourceReactions, + ], ); return { reactions, canToggle, pending, errorMessage, select };