Files
cinny/src/app/features/room/emoji-confetti/useEmojiConfetti.ts
litruv 0da4f0d6cb 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.
2026-07-29 20:40:43 +10:00

138 lines
3.8 KiB
TypeScript

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,
};
}