Add emoji confetti bursts and component playground.

Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics,
per-emoji particle profiles, and a fixed overlay that does not affect scroll.
Also add a Vite playground for live component previews and theme hover preview.
This commit is contained in:
2026-07-29 20:40:36 +10:00
parent ea7642f0bb
commit 0da4f0d6cb
48 changed files with 3689 additions and 46 deletions

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
import {
BurstParticle,
drawBurstParticles,
spawnEmojiBurst,
stepBurstParticles,
} from './emojiBurstEngine';
const MAX_DPR = 2;
type EmojiConfettiBurstCanvasProps = {
burst: EmojiConfettiBurst;
onComplete: (burstId: string) => void;
};
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<BurstParticle[]>([]);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(null);
const spawnedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36);
const localOrigin = useMemo(
() => ({
x: canvasSize / 2,
y: canvasSize / 2,
maskRadius: burst.origin.maskRadius ?? 36,
}),
[burst.origin.maskRadius, canvasSize]
);
const updateLayerPosition = () => {
const layer = layerRef.current;
if (!layer) return false;
const jumbo = findJumboEmojiElement(burst.targetEventId);
if (!jumbo) return false;
const rect = jumbo.getBoundingClientRect();
layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`;
layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`;
return true;
};
useEffect(() => {
const onScrollOrResize = () => {
updateLayerPosition();
};
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [burst.targetEventId, canvasSize]);
useEffect(() => {
if (!canvasRef.current || spawnedRef.current) return undefined;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
onCompleteRef.current(burst.id);
return undefined;
}
let frameCleanup: (() => void) | undefined;
const start = (): boolean => {
if (spawnedRef.current || !canvasRef.current) return false;
if (!updateLayerPosition()) return false;
spawnedRef.current = true;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return false;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(canvasSize * dpr);
canvas.height = Math.round(canvasSize * dpr);
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
spawnEmojiBurst(
particlesRef.current,
burst.id,
localOrigin,
burst.emojis[0] ?? '🎉',
performance.now()
);
const tick = (now: number) => {
updateLayerPosition();
const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now;
stepBurstParticles(particlesRef.current, now, dtSeconds);
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (particlesRef.current.length > 0) {
drawBurstParticles(context, particlesRef.current, dpr, now);
frameRef.current = requestAnimationFrame(tick);
return;
}
onCompleteRef.current(burst.id);
frameRef.current = null;
lastFrameTimeRef.current = null;
};
lastFrameTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
frameCleanup = () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
return true;
};
if (start()) {
return frameCleanup;
}
let attempt = 0;
const retry = window.setInterval(() => {
attempt += 1;
if (start() || attempt >= 10) {
window.clearInterval(retry);
if (attempt >= 10 && !spawnedRef.current) {
onCompleteRef.current(burst.id);
}
}
}, 50);
return () => {
window.clearInterval(retry);
frameCleanup?.();
};
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
return createPortal(
<div
ref={layerRef}
aria-hidden
style={{
position: 'fixed',
width: canvasSize,
height: canvasSize,
pointerEvents: 'none',
zIndex: 4,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { EmojiConfettiBurst } from './types';
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
type EmojiConfettiOverlayProps = {
bursts: EmojiConfettiBurst[];
onBurstComplete: (burstId: string) => void;
};
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
if (bursts.length === 0) return null;
return (
<>
{bursts.map((burst) => (
<EmojiConfettiBurstCanvas
key={burst.id}
burst={burst}
onComplete={onBurstComplete}
/>
))}
</>
);
}

View File

@@ -0,0 +1,74 @@
export type BurstPoint = {
x: number;
y: number;
maskRadius?: number;
};
export function getBurstPointFromElement(element: Element): BurstPoint {
const rect = element.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
maskRadius: Math.max(rect.width, rect.height) * 0.5,
};
}
export function getElementCenter(element: Element): BurstPoint {
return getBurstPointFromElement(element);
}
export function findEmojiOriginInMessage(
targetEventId: string,
emoji: string
): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const matches = message.querySelectorAll('[data-emoticon]');
for (const element of matches) {
if (element.getAttribute('data-emoticon') === emoji) {
return getBurstPointFromElement(element);
}
}
return undefined;
}
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return undefined;
const jumboBody = message.querySelector('[data-jumbo-emoji]');
if (jumboBody) {
return getBurstPointFromElement(jumboBody);
}
const body = message.querySelector('[data-message-body]');
if (body) {
return getBurstPointFromElement(body);
}
return getBurstPointFromElement(message);
}
export function resolveBurstOrigin(
targetEventId: string | undefined,
emojis: string[],
explicitOrigin?: BurstPoint
): BurstPoint {
if (explicitOrigin) return explicitOrigin;
const primaryEmoji = emojis[0];
if (targetEventId && primaryEmoji) {
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
if (emojiOrigin) return emojiOrigin;
const messageOrigin = getMessageFallbackOrigin(targetEventId);
if (messageOrigin) return messageOrigin;
}
return {
x: window.innerWidth / 2,
y: window.innerHeight * 0.35,
};
}

View File

@@ -0,0 +1,357 @@
import { BurstPoint } from './burstOrigin';
import {
BurstMotionStyle,
EmojiBurstProfile,
getEmojiBurstProfile,
pickParticleEmoji,
sampleBurstAngle,
} from './emojiParticleProfiles';
/** Particles spawn over this window. */
export const BURST_SPAWN_MS = 500;
/** Global fade runs from 0.5s → 2s after burst start. */
export const BURST_FADE_START_MS = 500;
export const BURST_FADE_END_MS = 2000;
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
export type BurstParticle = {
burstId: string;
burstStartMs: number;
emoji: string;
fontSize: number;
spawnX: number;
spawnY: number;
maskRadius: number;
x: number;
y: number;
vx: number;
vy: number;
bornAt: number;
rotation: number;
spin: number;
peakScale: number;
style: BurstMotionStyle;
gravity: number;
drag: number;
phase: number;
twinkle?: boolean;
wobble?: boolean;
orbitRadius: number;
orbitSpeed: number;
orbitAngle: number;
bounceDamping: number;
bouncesLeft: number;
};
const EMOJI_CACHE_PX = 64;
const EMOJI_CACHE_SCALE = 2;
const MAX_DPR = 2;
const POP_IN_MS = 70;
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
function easeOutBack(t: number): number {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
}
function easeOutCubic(t: number): number {
return 1 - (1 - t) ** 3;
}
function lerp(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
const cacheKey = `${emoji}:${dpr}`;
const cached = emojiCanvasCache.get(cacheKey);
if (cached) return cached;
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (context) {
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 emergeOpacity(
x: number,
y: number,
spawnX: number,
spawnY: number,
maskRadius: number
): number {
const dist = Math.hypot(x - spawnX, y - spawnY);
const inner = maskRadius * 0.55;
const outer = maskRadius * 1.05;
if (dist <= inner) return 0;
if (dist >= outer) return 1;
return (dist - inner) / (outer - inner);
}
function spawnParticle(
particles: BurstParticle[],
burstId: string,
burstStartMs: number,
point: BurstPoint,
primaryEmoji: string,
profile: EmojiBurstProfile,
bornAt: number,
options?: { hero?: boolean }
) {
const hero = options?.hero ?? false;
const angle = sampleBurstAngle(profile);
const speed = hero
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
: lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const maskRadius = point.maskRadius ?? 28;
const jitter = hero ? 3 : 12;
const emoji = pickParticleEmoji(profile, primaryEmoji);
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
let vy = Math.sin(angle) * speed - launchUp;
if (profile.style === 'spiral') {
vx *= 0.35;
vy *= 0.35;
}
if (profile.style === 'sparkle') {
vx *= 0.75;
vy *= 0.75;
}
particles.push({
burstId,
burstStartMs,
emoji,
fontSize: hero
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
: lerp(profile.fontSizeMin, profile.fontSizeMax),
spawnX: point.x,
spawnY: point.y,
maskRadius,
x: point.x + (Math.random() - 0.5) * jitter,
y: point.y + (Math.random() - 0.5) * jitter,
vx,
vy,
bornAt,
rotation: (Math.random() - 0.5) * 40,
spin: lerp(profile.spinMin, profile.spinMax),
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
style: profile.style,
gravity: profile.gravity,
drag: profile.drag,
phase: Math.random() * Math.PI * 2,
twinkle: profile.twinkle,
wobble: profile.wobble,
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
orbitAngle: Math.random() * Math.PI * 2,
bounceDamping: 0.42 + Math.random() * 0.18,
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
});
}
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
export function spawnEmojiBurst(
particles: BurstParticle[],
burstId: string,
point: BurstPoint,
emoji: string,
now = performance.now()
) {
const profile = getEmojiBurstProfile(emoji);
const burstStartMs = now;
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
hero: true,
});
const particleCount = profile.particleCount;
for (let i = 0; i < particleCount; i += 1) {
const slot = i / particleCount;
const bornAt =
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
}
}
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
particle.orbitRadius += 42 * dtSeconds;
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
particle.y =
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
particle.vy += particle.gravity * dtSeconds * 0.35;
particle.rotation += particle.spin * dtSeconds;
}
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
particle.y = particle.spawnY + 6;
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
particle.vx *= 0.82;
particle.bouncesLeft -= 1;
particle.spin += (Math.random() - 0.5) * 120;
}
}
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
particle.vy += particle.gravity * dtSeconds;
particle.vx *= dragFactor;
particle.vy *= dragFactor;
particle.x += particle.vx * dtSeconds;
particle.y += particle.vy * dtSeconds;
particle.rotation += particle.spin * dtSeconds;
}
export function stepBurstParticles(
particles: BurstParticle[],
now: number,
dtSeconds: number
): void {
for (let i = particles.length - 1; i >= 0; i -= 1) {
const particle = particles[i];
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) {
particles[i] = particles[particles.length - 1];
particles.pop();
continue;
}
const ageMs = now - particle.bornAt;
if (ageMs < 0) continue;
const dragFactor = particle.drag ** (dtSeconds * 60);
switch (particle.style) {
case 'spiral':
stepSpiralParticle(particle, dtSeconds);
break;
case 'bounce':
stepBounceParticle(particle, dtSeconds, dragFactor);
break;
default:
stepDefaultParticle(particle, dtSeconds, dragFactor);
break;
}
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
}
}
type DrawState = {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
};
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
const burstElapsed = now - particle.burstStartMs;
if (burstElapsed > BURST_TOTAL_MS) return null;
const ageMs = now - particle.bornAt;
if (ageMs < 0) return null;
let scale = particle.peakScale;
let opacity = emergeOpacity(
particle.x,
particle.y,
particle.spawnX,
particle.spawnY,
particle.maskRadius
);
if (opacity <= 0) return null;
if (ageMs < POP_IN_MS) {
const pop = easeOutBack(ageMs / POP_IN_MS);
scale = 0.1 + pop * particle.peakScale;
opacity *= pop;
}
if (particle.twinkle) {
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
}
if (particle.wobble) {
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
}
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
}
if (burstElapsed >= BURST_FADE_START_MS) {
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
}
if (opacity <= 0.02) return null;
return {
x: particle.x,
y: particle.y,
scale,
opacity,
rotation: particle.rotation,
};
}
export function drawBurstParticles(
context: CanvasRenderingContext2D,
particles: BurstParticle[],
dpr: number,
now: number
) {
for (const particle of particles) {
const state = particleDrawState(particle, now);
if (!state) continue;
context.globalAlpha = state.opacity;
const emojiCanvas = getEmojiCanvas(particle.emoji);
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
const halfSize = drawSize / 2;
const radians = (state.rotation * Math.PI) / 180;
const cos = Math.cos(radians) * dpr;
const sin = Math.sin(radians) * dpr;
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
}
context.globalAlpha = 1;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
}
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
return particles.some((particle) => particle.burstId === burstId);
}

View File

@@ -0,0 +1,391 @@
export type BurstMotionStyle =
| 'explode'
| 'bounce'
| 'rise'
| 'sparkle'
| 'splash'
| 'spiral'
| 'punch'
| 'scatter'
| 'shower';
export type EmojiBurstProfile = {
style: BurstMotionStyle;
particleCount: number;
gravity: number;
drag: number;
speedMin: number;
speedMax: number;
launchUpMin: number;
launchUpMax: number;
spinMin: number;
spinMax: number;
fontSizeMin: number;
fontSizeMax: number;
heroFontSizeMin: number;
heroFontSizeMax: number;
companions?: string[];
companionChance?: number;
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
angleBias?: number;
/** Limit spawn to a cone (radians). Omit for full 360°. */
angleSpread?: number;
twinkle?: boolean;
wobble?: boolean;
};
const DEFAULT_PROFILE: EmojiBurstProfile = {
style: 'explode',
particleCount: 36,
gravity: 400,
drag: 0.935,
speedMin: 240,
speedMax: 560,
launchUpMin: 40,
launchUpMax: 130,
spinMin: -180,
spinMax: 180,
fontSizeMin: 14,
fontSizeMax: 26,
heroFontSizeMin: 26,
heroFontSizeMax: 34,
};
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
'😆': {
style: 'bounce',
particleCount: 28,
gravity: 520,
speedMin: 180,
speedMax: 360,
launchUpMin: 120,
launchUpMax: 220,
spinMin: -240,
spinMax: 240,
wobble: true,
},
'😂': {
style: 'bounce',
particleCount: 30,
gravity: 500,
speedMin: 160,
speedMax: 340,
launchUpMin: 110,
launchUpMax: 210,
spinMin: -220,
spinMax: 220,
wobble: true,
},
'🤣': {
style: 'bounce',
particleCount: 32,
gravity: 480,
speedMin: 200,
speedMax: 380,
launchUpMin: 130,
launchUpMax: 240,
wobble: true,
},
'🔥': {
style: 'rise',
particleCount: 24,
gravity: -120,
drag: 0.96,
speedMin: 80,
speedMax: 200,
launchUpMin: 60,
launchUpMax: 160,
spinMin: -90,
spinMax: 90,
fontSizeMin: 16,
fontSizeMax: 28,
twinkle: true,
wobble: true,
companions: ['✨'],
companionChance: 0.35,
},
'❤️': {
style: 'rise',
particleCount: 22,
gravity: -80,
drag: 0.97,
speedMin: 60,
speedMax: 150,
launchUpMin: 40,
launchUpMax: 110,
spinMin: -40,
spinMax: 40,
fontSizeMin: 14,
fontSizeMax: 24,
companions: ['💕', '💖'],
companionChance: 0.4,
},
'💕': {
style: 'rise',
particleCount: 20,
gravity: -70,
drag: 0.97,
speedMin: 50,
speedMax: 140,
launchUpMin: 35,
launchUpMax: 100,
spinMin: -35,
spinMax: 35,
},
'💖': {
style: 'rise',
particleCount: 20,
gravity: -90,
drag: 0.968,
speedMin: 55,
speedMax: 150,
launchUpMin: 45,
launchUpMax: 115,
twinkle: true,
},
'⭐': {
style: 'sparkle',
particleCount: 26,
gravity: 40,
drag: 0.94,
speedMin: 100,
speedMax: 260,
launchUpMin: 20,
launchUpMax: 80,
spinMin: -120,
spinMax: 120,
twinkle: true,
companions: ['✨'],
companionChance: 0.5,
},
'✨': {
style: 'sparkle',
particleCount: 30,
gravity: 30,
drag: 0.945,
speedMin: 90,
speedMax: 240,
launchUpMin: 15,
launchUpMax: 70,
spinMin: -200,
spinMax: 200,
twinkle: true,
},
'💀': {
style: 'scatter',
particleCount: 20,
gravity: 620,
drag: 0.92,
speedMin: 200,
speedMax: 420,
launchUpMin: 20,
launchUpMax: 90,
spinMin: -360,
spinMax: 360,
fontSizeMin: 16,
fontSizeMax: 28,
},
'🎉': {
style: 'shower',
particleCount: 40,
gravity: 280,
drag: 0.93,
speedMin: 200,
speedMax: 480,
launchUpMin: 80,
launchUpMax: 200,
companions: ['🎊', '✨', '🎈'],
companionChance: 0.45,
},
'🎊': {
style: 'shower',
particleCount: 38,
gravity: 260,
drag: 0.932,
speedMin: 190,
speedMax: 460,
launchUpMin: 70,
launchUpMax: 190,
companions: ['🎉', '✨'],
companionChance: 0.4,
},
'👏': {
style: 'punch',
particleCount: 18,
gravity: 320,
speedMin: 220,
speedMax: 400,
launchUpMin: 30,
launchUpMax: 100,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.85,
spinMin: -100,
spinMax: 100,
},
'👍': {
style: 'punch',
particleCount: 14,
gravity: 380,
speedMin: 260,
speedMax: 440,
launchUpMin: 100,
launchUpMax: 200,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.55,
spinMin: -60,
spinMax: 60,
},
'💯': {
style: 'punch',
particleCount: 16,
gravity: 300,
speedMin: 200,
speedMax: 380,
launchUpMin: 140,
launchUpMax: 240,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 0.45,
fontSizeMin: 16,
fontSizeMax: 30,
wobble: true,
},
'💦': {
style: 'splash',
particleCount: 32,
gravity: 540,
drag: 0.925,
speedMin: 280,
speedMax: 520,
launchUpMin: 160,
launchUpMax: 300,
spinMin: -140,
spinMax: 140,
angleBias: -Math.PI / 2,
angleSpread: Math.PI * 1.1,
},
'🌊': {
style: 'splash',
particleCount: 28,
gravity: 420,
drag: 0.93,
speedMin: 220,
speedMax: 460,
launchUpMin: 100,
launchUpMax: 220,
angleBias: -Math.PI / 2,
angleSpread: Math.PI,
companions: ['💧'],
companionChance: 0.3,
},
'⚡': {
style: 'scatter',
particleCount: 14,
gravity: 180,
drag: 0.88,
speedMin: 380,
speedMax: 680,
launchUpMin: 10,
launchUpMax: 60,
spinMin: -30,
spinMax: 30,
fontSizeMin: 18,
fontSizeMax: 32,
angleSpread: Math.PI * 1.2,
},
'🌀': {
style: 'spiral',
particleCount: 24,
gravity: 60,
drag: 0.96,
speedMin: 120,
speedMax: 260,
launchUpMin: 0,
launchUpMax: 40,
spinMin: -420,
spinMax: 420,
},
'🤯': {
style: 'explode',
particleCount: 34,
gravity: 360,
speedMin: 280,
speedMax: 580,
launchUpMin: 60,
launchUpMax: 160,
companions: ['💥', '✨', '⭐'],
companionChance: 0.5,
},
'😭': {
style: 'splash',
particleCount: 26,
gravity: 480,
drag: 0.94,
speedMin: 140,
speedMax: 300,
launchUpMin: 40,
launchUpMax: 120,
angleBias: Math.PI / 2,
angleSpread: Math.PI * 0.7,
companions: ['💧'],
companionChance: 0.55,
},
'🥳': {
style: 'shower',
particleCount: 36,
gravity: 250,
speedMin: 180,
speedMax: 440,
launchUpMin: 90,
launchUpMax: 210,
companions: ['🎉', '🎊', '🎈'],
companionChance: 0.4,
},
'🐱': {
style: 'bounce',
particleCount: 20,
gravity: 440,
speedMin: 150,
speedMax: 320,
launchUpMin: 90,
launchUpMax: 180,
spinMin: -160,
spinMax: 160,
},
'🐶': {
style: 'bounce',
particleCount: 20,
gravity: 460,
speedMin: 160,
speedMax: 330,
launchUpMin: 85,
launchUpMax: 175,
wobble: true,
},
};
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
const override = PROFILE_OVERRIDES[emoji];
if (!override) return DEFAULT_PROFILE;
return { ...DEFAULT_PROFILE, ...override };
}
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
if (!profile.companions?.length || !profile.companionChance) {
return primaryEmoji;
}
if (Math.random() >= profile.companionChance) {
return primaryEmoji;
}
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
return companion ?? primaryEmoji;
}
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
if (profile.angleSpread === undefined) {
return Math.random() * Math.PI * 2;
}
const bias = profile.angleBias ?? -Math.PI / 2;
const halfSpread = profile.angleSpread / 2;
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
}

View File

@@ -0,0 +1,11 @@
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (!message) return null;
const jumbo = message.querySelector('[data-jumbo-emoji]');
return jumbo instanceof HTMLElement ? jumbo : null;
}
export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5));
}

View File

@@ -0,0 +1,34 @@
import { KeyboardEvent, MouseEvent } from 'react';
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
export function getJumboEmojiInteractionProps({
body,
isJumboEmoji,
onJumboEmojiClick,
}: {
body: string;
isJumboEmoji: boolean;
onJumboEmojiClick?: JumboEmojiClickHandler;
}) {
if (!isJumboEmoji || !onJumboEmojiClick) {
return {};
}
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
if ('key' in event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
}
event.stopPropagation();
onJumboEmojiClick(body, event);
};
return {
role: 'button' as const,
tabIndex: 0,
title: 'Throw emoji confetti',
onClick: activate,
onKeyDown: activate,
};
}

View File

@@ -0,0 +1,54 @@
import { KeyboardEvent, MouseEvent } from 'react';
import { extractJumboEmojis } from './sendEmojiConfetti';
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
export function resolveClickedEmoji(
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
fallbackBody: string
): { emoji: string; origin: BurstPoint } | null {
const currentTarget = event.currentTarget as HTMLElement;
const target = event.target as HTMLElement;
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
if (emoticonEl instanceof HTMLElement) {
const emoji = emoticonEl.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(emoticonEl),
};
}
}
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
return null;
}
const emojis = extractJumboEmojis(fallbackBody);
if (emojis.length === 0) return null;
if ('clientX' in event) {
return {
emoji: emojis[0],
origin: {
x: event.clientX,
y: event.clientY,
},
};
}
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
if (firstEmoticon instanceof HTMLElement) {
const emoji = firstEmoticon.getAttribute('data-emoticon');
if (emoji) {
return {
emoji,
origin: getBurstPointFromElement(firstEmoticon),
};
}
}
return {
emoji: emojis[0],
origin: getBurstPointFromElement(currentTarget),
};
}

View File

@@ -0,0 +1,42 @@
import { MatrixClient } from 'matrix-js-sdk';
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
import { trimReplyFromBody } from '../../../utils/room';
import { emojis } from '../../../plugins/emoji';
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
export function extractJumboEmojis(body: string): string[] {
const trimmedBody = trimReplyFromBody(body).trim();
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
const found: string[] = [];
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
const emoji = match[1];
if (emoji) found.push(emoji);
}
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
const shortcode = match[1];
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
if (resolved) found.push(resolved.unicode);
}
return found.length > 0 ? found : ['🎉'];
}
export function sendEmojiConfettiEvent(
mx: MatrixClient,
roomId: string,
targetEventId: string,
emojis: string[]
) {
const content: EmojiConfettiContent = {
emojis,
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
target_event_id: targetEventId,
};
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
}

View File

@@ -0,0 +1,16 @@
import type { BurstPoint } from './burstOrigin';
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
export type EmojiConfettiContent = {
emojis?: string[];
msgtype?: string;
target_event_id?: string;
};
export type EmojiConfettiBurst = {
id: string;
targetEventId: string;
emojis: string[];
origin: BurstPoint;
};

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
import {
EMOJI_CONFETTI_EVENT_TYPE,
EmojiConfettiBurst,
EmojiConfettiContent,
} from './types';
function scheduleBurstFromValues(
burstId: string,
emojis: string[],
targetEventId: string | undefined,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0,
explicitOrigin?: BurstPoint
) {
const targetElement =
targetEventId &&
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
if (targetEventId && !targetElement && attempt < 8) {
window.setTimeout(
() =>
scheduleBurstFromValues(
burstId,
emojis,
targetEventId,
onBurst,
attempt + 1,
explicitOrigin
),
50
);
return;
}
onBurst({
id: burstId,
targetEventId: targetEventId ?? '',
emojis: emojis.length > 0 ? emojis : ['🎉'],
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
});
}
function scheduleBurst(
event: MatrixEvent,
onBurst: (burst: EmojiConfettiBurst) => void,
attempt = 0
) {
const eventId = event.getId();
if (!eventId) return;
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
}
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
const confettiContent = content as EmojiConfettiContent;
const emojis = Array.isArray(confettiContent.emojis)
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
: [];
return {
emojis: emojis.length > 0 ? emojis : ['🎉'],
targetEventId:
typeof confettiContent.target_event_id === 'string'
? confettiContent.target_event_id
: undefined,
};
}
export function useEmojiConfetti(room: Room) {
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
const seenEventIdsRef = useRef(new Set<string>());
const ownEventIdsRef = useRef(new Set<string>());
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
setBursts((current) => [...current, burst]);
}, []);
const registerOwnEventId = useCallback((eventId: string) => {
ownEventIdsRef.current.add(eventId);
}, []);
const queueBurst = useCallback((event: MatrixEvent) => {
const eventId = event.getId();
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
seenEventIdsRef.current.add(eventId);
if (ownEventIdsRef.current.has(eventId)) {
ownEventIdsRef.current.delete(eventId);
return;
}
scheduleBurst(event, addBurst);
}, [addBurst]);
const triggerLocalBurst = useCallback(
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
const burstId = `local-${targetEventId}-${Date.now()}`;
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
},
[addBurst]
);
useEffect(() => {
const handleTimeline: (
event: MatrixEvent,
eventRoom: Room | undefined,
toStartOfTimeline?: boolean,
removed?: boolean
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
queueBurst(event);
};
room.on(RoomEvent.Timeline, handleTimeline);
return () => {
room.off(RoomEvent.Timeline, handleTimeline);
};
}, [queueBurst, room]);
const removeBurst = useCallback((burstId: string) => {
setBursts((current) => current.filter((burst) => burst.id !== burstId));
}, []);
return {
bursts,
removeBurst,
triggerLocalBurst,
registerOwnEventId,
};
}

View File

@@ -0,0 +1,37 @@
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { resolveClickedEmoji } from './resolveClickedEmoji';
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
import { useEmojiConfetti } from './useEmojiConfetti';
export function useJumboEmojiConfetti(room: Room) {
const mx = useMatrixClient();
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
const handleJumboEmojiClick = useCallback(
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
const clicked = resolveClickedEmoji(event, body);
if (!clicked) return;
const emojis = [clicked.emoji];
triggerLocalBurst(targetEventId, emojis, clicked.origin);
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
.then((response) => {
const eventId = response?.event_id;
if (eventId) registerOwnEventId(eventId);
})
.catch((error) => {
console.error('[emoji-confetti] Failed to send confetti event:', error);
});
},
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
);
return {
bursts,
removeBurst,
handleJumboEmojiClick,
};
}