diff --git a/.gitignore b/.gitignore index 1af58a9..3ab2eff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea +.cache diff --git a/package-lock.json b/package-lock.json index 5df0e1d..64120f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@monaco-editor/react": "4.7.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@types/chroma-js": "3.1.2", @@ -106,6 +107,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", + "monaco-editor": "0.52.2", "prettier": "3.9.4", "typescript": "6.0.3", "vite": "8.1.3", @@ -2563,6 +2565,31 @@ "node": ">= 18" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/canvas": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", @@ -8801,6 +8828,13 @@ "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==" }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10157,6 +10191,13 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index a08614f..45bc99e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "check:eslint": "eslint src/*", "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "playground": "vite" }, "keywords": [], "author": "Ajay Bura", @@ -94,6 +95,7 @@ }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@monaco-editor/react": "4.7.0", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@types/chroma-js": "3.1.2", @@ -117,6 +119,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", + "monaco-editor": "0.52.2", "prettier": "3.9.4", "typescript": "6.0.3", "vite": "8.1.3", diff --git a/playground-liveTsxPlugin.ts b/playground-liveTsxPlugin.ts new file mode 100644 index 0000000..edcc50c --- /dev/null +++ b/playground-liveTsxPlugin.ts @@ -0,0 +1,149 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { Plugin, ViteDevServer } from 'vite'; + +export type LiveTsxApi = { + getSource: () => string; + setSource: (source: string) => void; +}; + +const ALLOWED_PREFIXES = ['src/app/components/', 'src/app/features/']; + +function resolveAppSourceFile(projectRoot: string, relPath: string): string | null { + const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (normalized.includes('..') || path.isAbsolute(normalized)) return null; + if (!ALLOWED_PREFIXES.some((p) => normalized.startsWith(p))) return null; + if (!/\.(tsx|ts)$/.test(normalized)) return null; + const abs = path.resolve(projectRoot, normalized); + const root = path.resolve(projectRoot); + if (!abs.startsWith(root + path.sep) && abs !== root) return null; + return abs; +} + +function readBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function bumpLivePreview(server: ViteDevServer | undefined) { + if (!server) return; + server.ws.send({ + type: 'custom', + event: 'playground:live-updated', + data: { revision: Date.now() }, + }); +} + +export function liveTsxPlugin(projectRoot: string, initialSource: string): Plugin { + const liveFile = path.resolve(projectRoot, '.cache/playground-live.tsx'); + + fs.mkdirSync(path.dirname(liveFile), { recursive: true }); + fs.writeFileSync(liveFile, initialSource, 'utf8'); + + let source = initialSource; + let server: ViteDevServer | undefined; + + const api: LiveTsxApi = { + getSource: () => source, + setSource: (next) => { + source = next; + fs.writeFileSync(liveFile, next, 'utf8'); + if (!server) return; + for (const m of server.moduleGraph.urlToModuleMap.values()) { + if (m.file === liveFile) server.moduleGraph.invalidateModule(m); + } + const byId = server.moduleGraph.getModuleById(liveFile); + if (byId) server.moduleGraph.invalidateModule(byId); + bumpLivePreview(server); + }, + }; + + return { + name: 'playground-live-tsx', + configureServer(devServer) { + server = devServer; + (devServer as ViteDevServer & { playgroundLive?: LiveTsxApi }).playgroundLive = api; + + devServer.middlewares.use('/__playground/live', (req, res, next) => { + if (req.method === 'GET') { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(source); + return; + } + if (req.method === 'POST') { + void readBody(req).then((body) => { + api.setSource(body); + res.statusCode = 204; + res.end(); + }); + return; + } + next(); + }); + + // Read/write real Cinny component sources (strings, markup, etc.). + devServer.middlewares.use('/__playground/source', (req, res, next) => { + const url = new URL(req.url ?? '', 'http://playground.local'); + const rel = url.searchParams.get('path') ?? ''; + const abs = resolveAppSourceFile(projectRoot, rel); + if (!abs) { + res.statusCode = 400; + res.end('Invalid or disallowed path'); + return; + } + + if (req.method === 'GET') { + if (!fs.existsSync(abs)) { + res.statusCode = 404; + res.end('Not found'); + return; + } + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(fs.readFileSync(abs, 'utf8')); + return; + } + + if (req.method === 'POST') { + void readBody(req).then((body) => { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, 'utf8'); + const mod = + server?.moduleGraph.getModuleById(abs) ?? + [...(server?.moduleGraph.urlToModuleMap.values() ?? [])].find((m) => m.file === abs); + if (mod && server) server.moduleGraph.invalidateModule(mod); + bumpLivePreview(server); + res.statusCode = 204; + res.end(); + }); + return; + } + + next(); + }); + }, + resolveId(id) { + const clean = id.split('?')[0]; + if ( + clean === '/@playground/live.tsx' || + clean === 'virtual:playground-live' || + clean.endsWith('/.cache/playground-live.tsx') + ) { + return liveFile; + } + return undefined; + }, + }; +} + +export function readDefaultLiveSource(projectRoot: string): string { + return fs.readFileSync(path.join(projectRoot, 'src/playground/defaultLive.tsx'), 'utf8'); +} + +export function projectRootDir(): string { + return path.dirname(fileURLToPath(import.meta.url)); +} diff --git a/playground.html b/playground.html new file mode 100644 index 0000000..8724897 --- /dev/null +++ b/playground.html @@ -0,0 +1,12 @@ + + + + + + Component Playground + + +
+ + + diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 3b9aec5..5a7febb 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -1,5 +1,5 @@ import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds'; +import { Box, Text, IconButton, Input, Button, TextArea, color, Spinner, Chip, Scroll, config } from 'folds'; import { Icon, Icons } from './icons'; import { MatrixError } from 'matrix-js-sdk'; import { Cursor } from '../plugins/text-area'; @@ -154,7 +154,7 @@ function AccountDataEdit({ JSON Content - | KeyboardEvent + ) => void; }; export function RenderMessageContent({ displayName, @@ -63,6 +69,8 @@ export function RenderMessageContent({ linkifyOpts, outlineAttachment, disabledEmbedPatterns, + targetEventId, + onJumboEmojiClick, }: RenderMessageContentProps) { const renderUrlsPreview = (urls: string[]) => { let filteredUrls = urls.filter((url) => !testMatrixTo(url)); @@ -100,6 +108,11 @@ export function RenderMessageContent({ ); }; + const handleJumboEmojiClick = + targetEventId && onJumboEmojiClick + ? (body: string, event: MouseEvent | KeyboardEvent) => + onJumboEmojiClick(targetEventId, body, event) + : undefined; const renderCaption = () => { const content: IImageContent = getContent(); if (content.filename && content.filename !== content.body) { @@ -182,6 +195,7 @@ export function RenderMessageContent({ ( ( ( ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; style?: CSSProperties; + onJumboEmojiClick?: JumboEmojiClickHandler; }; -export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) { +export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) { const { body, formatted_body: customBody } = content; if (typeof body !== 'string') return ; @@ -89,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: : undefined; const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody); + const jumboEmojiInteraction = getJumboEmojiInteractionProps({ + body: trimmedBody, + isJumboEmoji, + onJumboEmojiClick, + }); return ( <> {renderBody({ body: trimmedBody, @@ -114,6 +123,7 @@ type MEmoteProps = { content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; + onJumboEmojiClick?: JumboEmojiClickHandler; }; export function MEmote({ displayName, @@ -121,6 +131,7 @@ export function MEmote({ content, renderBody, renderUrlsPreview, + onJumboEmojiClick, }: MEmoteProps) { const { body, formatted_body: customBody } = content; @@ -131,20 +142,28 @@ export function MEmote({ : undefined; const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody); + const jumboEmojiInteraction = getJumboEmojiInteractionProps({ + body: trimmedBody, + isJumboEmoji, + onJumboEmojiClick, + }); return ( <> {`${displayName} `} - {renderBody({ - body: trimmedBody, - customBody: trimmedCustomBody, - })} - {edited && } + + {renderBody({ + body: trimmedBody, + customBody: trimmedCustomBody, + })} + {edited && } + {renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)} @@ -156,8 +175,9 @@ type MNoticeProps = { content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; + onJumboEmojiClick?: JumboEmojiClickHandler; }; -export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) { +export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) { const { body, formatted_body: customBody } = content; if (typeof body !== 'string') return ; @@ -167,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot : undefined; const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody); + const jumboEmojiInteraction = getJumboEmojiInteractionProps({ + body: trimmedBody, + isJumboEmoji, + onJumboEmojiClick, + }); return ( <> {renderBody({ body: trimmedBody, diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 69d6209..c920f9a 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -243,6 +243,9 @@ export const MessageTextBody = recipe({ overflow: 'visible', overflowY: 'visible', paddingBottom: config.space.S200, + cursor: 'pointer', + position: 'relative', + zIndex: 5, }, }, emote: { @@ -263,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, { padding: 0, overflow: 'visible', verticalAlign: 'middle', + position: 'relative', + zIndex: 1, }); globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, { diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index 6dc1601..2373665 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; import { MatrixError } from 'matrix-js-sdk'; -import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds'; +import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds'; import { Icon, Icons } from '../../../components/icons'; import { Page, PageHeader } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro JSON Content - JSON Content - )} @@ -1705,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} + targetEventId={mEventId} + onJumboEmojiClick={handleJumboEmojiClick} /> ); } @@ -2002,6 +2010,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Return special marker for call event (will be handled by eventRenderer) return 'CALL_EVENT_PENDING' as unknown as React.ReactNode; }, + [MessageEvent.RelayEmojiConfetti]: () => null, }, (mEventId, mEvent, item) => { if (!showHiddenEvents) return null; @@ -2704,6 +2713,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli )} + ); } diff --git a/src/app/features/room/ThreadView.tsx b/src/app/features/room/ThreadView.tsx index cf6c611..357544f 100644 --- a/src/app/features/room/ThreadView.tsx +++ b/src/app/features/room/ThreadView.tsx @@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css'; import { Message, Reactions, EncryptedContent } from './message'; import { Reply } from '../../components/message'; import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay'; +import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti'; import { LINKIFY_OPTS, factoryRenderLinkifyWithMention, @@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } = + useJumboEmojiConfetti(room); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const direct = useIsDirectRoom(); @@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} + targetEventId={mEventId} + onJumboEmojiClick={handleJumboEmojiClick} /> )} @@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} disabledEmbedPatterns={combinedEmbedFilters} + targetEventId={mEventId} + onJumboEmojiClick={handleJumboEmojiClick} /> )} @@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) { {events.map((evt, idx, arr) => renderEvent(evt, idx, arr))} +
diff --git a/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx new file mode 100644 index 0000000..c533837 --- /dev/null +++ b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx @@ -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(null); + const layerRef = useRef(null); + const particlesRef = useRef([]); + const frameRef = useRef(null); + const lastFrameTimeRef = useRef(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( +
+ +
, + document.body + ); +} diff --git a/src/app/features/room/emoji-confetti/EmojiConfettiOverlay.tsx b/src/app/features/room/emoji-confetti/EmojiConfettiOverlay.tsx new file mode 100644 index 0000000..ff0a4aa --- /dev/null +++ b/src/app/features/room/emoji-confetti/EmojiConfettiOverlay.tsx @@ -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) => ( + + ))} + + ); +} diff --git a/src/app/features/room/emoji-confetti/burstOrigin.ts b/src/app/features/room/emoji-confetti/burstOrigin.ts new file mode 100644 index 0000000..3fdc8ab --- /dev/null +++ b/src/app/features/room/emoji-confetti/burstOrigin.ts @@ -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, + }; +} diff --git a/src/app/features/room/emoji-confetti/emojiBurstEngine.ts b/src/app/features/room/emoji-confetti/emojiBurstEngine.ts new file mode 100644 index 0000000..ccd22b9 --- /dev/null +++ b/src/app/features/room/emoji-confetti/emojiBurstEngine.ts @@ -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(); + +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); +} diff --git a/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts b/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts new file mode 100644 index 0000000..e9d5fa6 --- /dev/null +++ b/src/app/features/room/emoji-confetti/emojiParticleProfiles.ts @@ -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> = { + 'πŸ˜†': { + 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); +} diff --git a/src/app/features/room/emoji-confetti/findJumboMount.ts b/src/app/features/room/emoji-confetti/findJumboMount.ts new file mode 100644 index 0000000..6d7e8c9 --- /dev/null +++ b/src/app/features/room/emoji-confetti/findJumboMount.ts @@ -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)); +} diff --git a/src/app/features/room/emoji-confetti/jumboEmojiInteraction.ts b/src/app/features/room/emoji-confetti/jumboEmojiInteraction.ts new file mode 100644 index 0000000..51e965c --- /dev/null +++ b/src/app/features/room/emoji-confetti/jumboEmojiInteraction.ts @@ -0,0 +1,34 @@ +import { KeyboardEvent, MouseEvent } from 'react'; + +export type JumboEmojiClickHandler = (body: string, event: MouseEvent | KeyboardEvent) => void; + +export function getJumboEmojiInteractionProps({ + body, + isJumboEmoji, + onJumboEmojiClick, +}: { + body: string; + isJumboEmoji: boolean; + onJumboEmojiClick?: JumboEmojiClickHandler; +}) { + if (!isJumboEmoji || !onJumboEmojiClick) { + return {}; + } + + const activate = (event: MouseEvent | KeyboardEvent) => { + 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, + }; +} diff --git a/src/app/features/room/emoji-confetti/resolveClickedEmoji.ts b/src/app/features/room/emoji-confetti/resolveClickedEmoji.ts new file mode 100644 index 0000000..cc14439 --- /dev/null +++ b/src/app/features/room/emoji-confetti/resolveClickedEmoji.ts @@ -0,0 +1,54 @@ +import { KeyboardEvent, MouseEvent } from 'react'; +import { extractJumboEmojis } from './sendEmojiConfetti'; +import { BurstPoint, getBurstPointFromElement } from './burstOrigin'; + +export function resolveClickedEmoji( + event: MouseEvent | KeyboardEvent, + 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), + }; +} diff --git a/src/app/features/room/emoji-confetti/sendEmojiConfetti.ts b/src/app/features/room/emoji-confetti/sendEmojiConfetti.ts new file mode 100644 index 0000000..27f1a91 --- /dev/null +++ b/src/app/features/room/emoji-confetti/sendEmojiConfetti.ts @@ -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); +} diff --git a/src/app/features/room/emoji-confetti/types.ts b/src/app/features/room/emoji-confetti/types.ts new file mode 100644 index 0000000..b34d8d7 --- /dev/null +++ b/src/app/features/room/emoji-confetti/types.ts @@ -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; +}; diff --git a/src/app/features/room/emoji-confetti/useEmojiConfetti.ts b/src/app/features/room/emoji-confetti/useEmojiConfetti.ts new file mode 100644 index 0000000..9fd2abd --- /dev/null +++ b/src/app/features/room/emoji-confetti/useEmojiConfetti.ts @@ -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([]); + const seenEventIdsRef = useRef(new Set()); + const ownEventIdsRef = useRef(new Set()); + + 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, + }; +} diff --git a/src/app/features/room/emoji-confetti/useJumboEmojiConfetti.ts b/src/app/features/room/emoji-confetti/useJumboEmojiConfetti.ts new file mode 100644 index 0000000..e211cfe --- /dev/null +++ b/src/app/features/room/emoji-confetti/useJumboEmojiConfetti.ts @@ -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 | KeyboardEvent) => { + 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, + }; +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5edd732..0d1c2f0 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import dayjs from 'dayjs'; import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds'; +import { useSetAtom } from 'jotai'; import { Icon, Icons } from '../../../components/icons'; import { HexColorPicker } from 'react-colorful'; import { isKeyHotkey } from 'is-hotkey'; @@ -24,6 +25,7 @@ import { LightTheme, Theme, ThemeKind, + themePreviewIdAtom, useSystemThemeKind, useThemeNames, useThemes, @@ -41,28 +43,49 @@ type ThemeSelectorProps = { onSelect: (theme: Theme) => void; }; const ThemeSelector = as<'div', ThemeSelectorProps>( - ({ themeNames, themes, selected, onSelect, ...props }, ref) => ( - - - {themes.map((theme) => ( - onSelect(theme)} - > - {themeNames[theme.id] ?? theme.id} - - ))} - - - ) + ({ themeNames, themes, selected, onSelect, ...props }, ref) => { + const setPreviewId = useSetAtom(themePreviewIdAtom); + + useEffect( + () => () => { + setPreviewId(undefined); + }, + [setPreviewId] + ); + + const clearPreview = () => setPreviewId(undefined); + const previewTheme = (theme: Theme) => setPreviewId(theme.id); + + return ( + + + {themes.map((theme) => ( + previewTheme(theme)} + onFocus={() => previewTheme(theme)} + onClick={() => onSelect(theme)} + > + {themeNames[theme.id] ?? theme.id} + + ))} + + + ); + } ); function SelectTheme({ disabled }: { disabled?: boolean }) { const themes = useThemes(); const themeNames = useThemeNames(); + const setPreviewId = useSetAtom(themePreviewIdAtom); const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); const [menuCords, setMenuCords] = useState(); const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; @@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) { }; const handleThemeSelect = (theme: Theme) => { + setPreviewId(undefined); setThemeId(theme.id); setMenuCords(undefined); }; + const closeMenu = () => { + setPreviewId(undefined); + setMenuCords(undefined); + }; + return ( <> +
+ + +
+
+ + + +
+ +
+ {matrix.mode === 'mock' && Using lightweight mocks} + {matrix.mode === 'live' && matrix.status === 'connecting' && ( + Syncing (memory store, no crypto)… + )} + {matrix.mode === 'live' && matrix.status === 'ready' && ( + {matrix.sessionLabel} + )} + {matrix.mode === 'live' && matrix.status === 'error' && ( + {matrix.error} + )} + {!matrix.hasPaarrotSession && matrix.mode !== 'live' && ( + Tip: log into Paarrot at / first, then hit Paarrot session + )} +
+ + {liveReady && matrix.rooms.length > 0 && ( + + )} +
+ + {showTokenForm && ( +
{ + e.preventDefault(); + setShowTokenForm(false); + void matrix.connectLive(manual); + }} + > + setManual((m) => ({ ...m, baseUrl: e.target.value }))} + /> + setManual((m) => ({ ...m, userId: e.target.value }))} + /> + setManual((m) => ({ ...m, deviceId: e.target.value }))} + /> + setManual((m) => ({ ...m, accessToken: e.target.value }))} + /> + +
+ )} + +
+ + +
+
+ + + + {editorTab === 'component' && selected + ? `writes ${selected.filePath}` + : 'mount-only Β· not saved'} + +
+ {loadError &&
{loadError}
} + { + const next = value ?? ''; + if (editorTab === 'component') setComponentSource(next); + else setHarnessSource(next); + }} + options={{ + minimap: { enabled: false }, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + scrollBeyondLastLine: false, + wordWrap: 'on', + tabSize: 2, + automaticLayout: true, + padding: { top: 12 }, + readOnly: editorTab === 'component' && !selected, + }} + beforeMount={(monaco) => { + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + jsx: monaco.languages.typescript.JsxEmit.ReactJSX, + target: monaco.languages.typescript.ScriptTarget.ES2020, + allowNonTsExtensions: true, + esModuleInterop: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + paths: { + '@cinny/*': ['*'], + '@playground/mocks': ['*'], + }, + }); + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: false, + }); + }} + /> +
+ +
+
Preview
+
+ + + + + + + +
+
+
+ + ); +} diff --git a/src/playground/LivePreview.tsx b/src/playground/LivePreview.tsx new file mode 100644 index 0000000..7a8d7a4 --- /dev/null +++ b/src/playground/LivePreview.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; + +/** + * Dynamically remounts the Vite virtual live module when the harness changes. + */ +export function LivePreview() { + const [revision, setRevision] = useState(0); + const [Comp, setComp] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const onUpdate = () => setRevision((n) => n + 1); + const handler = () => onUpdate(); + + // Vite custom event from liveTsxPlugin + if (import.meta.hot) { + import.meta.hot.on('playground:live-updated', handler); + } + + return () => { + if (import.meta.hot) { + import.meta.hot.off('playground:live-updated', handler); + } + }; + }, []); + + useEffect(() => { + let cancelled = false; + setError(null); + setComp(null); + + import(/* @vite-ignore */ `/@playground/live.tsx?t=${revision}`) + .then((mod) => { + if (cancelled) return; + const candidate = mod.default; + if (typeof candidate !== 'function') { + setError('Live module must `export default` a React component.'); + return; + } + setComp(() => candidate); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = + err instanceof Error + ? `${err.message}\n\n${err.stack ?? ''}` + : String(err); + setError(message); + }); + + return () => { + cancelled = true; + }; + }, [revision]); + + if (error) return
{error}
; + if (!Comp) return
Compiling harness…
; + return ; +} diff --git a/src/playground/PreviewErrorBoundary.tsx b/src/playground/PreviewErrorBoundary.tsx new file mode 100644 index 0000000..8776708 --- /dev/null +++ b/src/playground/PreviewErrorBoundary.tsx @@ -0,0 +1,40 @@ +import React, { Component, type ErrorInfo, type ReactNode } from 'react'; + +type Props = { + resetKey: string; + children: ReactNode; +}; + +type State = { + error: string | null; +}; + +export class PreviewErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { + error: `${error.message}\n\n${error.stack ?? ''}`, + }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[playground preview]', error, info); + this.setState({ + error: `${error.message}\n\n${error.stack ?? ''}\n\n${info.componentStack ?? ''}`, + }); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null }); + } + } + + render() { + if (this.state.error) { + return
{this.state.error}
; + } + return this.props.children; + } +} diff --git a/src/playground/ResizableStage.tsx b/src/playground/ResizableStage.tsx new file mode 100644 index 0000000..4cc36f8 --- /dev/null +++ b/src/playground/ResizableStage.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; + +type Props = { + children: React.ReactNode; +}; + +const MIN = 160; +const MAX = 1200; + +export function ResizableStage({ children }: Props) { + const [size, setSize] = useState({ width: 520, height: 480 }); + const dragRef = useRef<{ + edge: 'e' | 's' | 'se'; + startX: number; + startY: number; + startW: number; + startH: number; + } | null>(null); + + const onPointerMove = useCallback((e: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const dx = e.clientX - drag.startX; + const dy = e.clientY - drag.startY; + setSize((prev) => { + let width = prev.width; + let height = prev.height; + if (drag.edge === 'e' || drag.edge === 'se') { + width = clamp(drag.startW + dx, MIN, MAX); + } + if (drag.edge === 's' || drag.edge === 'se') { + height = clamp(drag.startH + dy, MIN, MAX); + } + return { width, height }; + }); + }, []); + + const onPointerUp = useCallback(() => { + dragRef.current = null; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + }, [onPointerMove]); + + const startDrag = (edge: 'e' | 's' | 'se') => (e: React.PointerEvent) => { + e.preventDefault(); + dragRef.current = { + edge, + startX: e.clientX, + startY: e.clientY, + startW: size.width, + startH: size.height, + }; + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + }; + + useEffect(() => () => onPointerUp(), [onPointerUp]); + + const stageStyle: CSSProperties = { + width: size.width, + height: size.height, + }; + + return ( +
+
+ {size.width} Γ— {size.height} +
+
+
{children}
+
+
+ ); +} + +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)); +} diff --git a/src/playground/catalog.ts b/src/playground/catalog.ts new file mode 100644 index 0000000..3ca142f --- /dev/null +++ b/src/playground/catalog.ts @@ -0,0 +1,128 @@ +/** + * Path list of Cinny UI modules the playground can open. + */ +const componentModules = import.meta.glob( + [ + '../app/components/**/*.{tsx,ts}', + '../app/features/**/*.{tsx,ts}', + ], + { eager: false } +); + +export type CatalogEntry = { + id: string; + label: string; + importPath: string; + /** Path under cinny/, e.g. src/app/components/.../Foo.tsx */ + filePath: string; +}; + +function toFilePath(viteKey: string): string | null { + const normalized = viteKey.replace(/\\/g, '/'); + let rel = normalized; + const appIdx = rel.indexOf('/app/'); + if (appIdx !== -1) { + rel = `src${rel.slice(appIdx)}`; + } else if (rel.startsWith('../app/')) { + rel = `src/${rel.slice('../'.length)}`; + } else if (rel.startsWith('../')) { + rel = `src/app/${rel.slice('../'.length)}`; + } else { + return null; + } + if (!/\.(tsx|ts)$/.test(rel)) return null; + return rel; +} + +function toImportPath(viteKey: string): string | null { + const normalized = viteKey.replace(/\\/g, '/'); + let rel = normalized; + const appIdx = rel.indexOf('/app/'); + if (appIdx !== -1) { + rel = rel.slice(appIdx + '/app/'.length); + } else if (rel.startsWith('../')) { + rel = rel.replace(/^\.\.\//, ''); + } + rel = rel.replace(/\.(tsx|ts)$/, ''); + if (rel.endsWith('.css') || rel.includes('.css')) return null; + return `@cinny/app/${rel}`; +} + +function toLabel(importPath: string): string { + return importPath.replace(/^@cinny\//, ''); +} + +export function getCatalog(): CatalogEntry[] { + const entries: CatalogEntry[] = []; + for (const key of Object.keys(componentModules)) { + if (/\.css(\.ts)?$/.test(key)) continue; + if (/\.test\./.test(key) || /\.spec\./.test(key)) continue; + // Prefer UI modules β€” plain .ts files are usually helpers/hooks, not preview targets. + if (key.endsWith('.ts') && !key.endsWith('.tsx')) continue; + const importPath = toImportPath(key); + const filePath = toFilePath(key); + if (!importPath || !filePath) continue; + entries.push({ + id: key, + label: toLabel(importPath), + importPath, + filePath, + }); + } + return entries.sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * Generated harness source. Prefer a clean named import the user can edit. + */ +export function harnessForModule(importPath: string): string { + const nameGuess = + importPath + .split('/') + .pop() + ?.replace(/[^a-zA-Z0-9_$]/g, '') || 'Component'; + + if (nameGuess === 'RoomInput') { + return `/** + * Preview harness for ${importPath} + * + * Uses the room from PlaygroundProviders (mock or live Paarrot session). + * Switch to the Component tab to edit the real source. + */ +import { useRef } from 'react'; +import { RoomInput } from '${importPath}'; +import { useEditor } from '@cinny/app/components/editor'; +import { useRoom } from '@cinny/app/hooks/useRoom'; + +export default function Preview() { + const editor = useEditor(); + const dropRef = useRef(null); + const room = useRoom(); + + return ( +
+ +
+ ); +} +`; + } + + return `/** + * Preview harness for ${importPath} + * + * This only mounts the component. To edit labels/strings/markup, switch to the + * Component tab (writes the real source file under src/app/…). + */ +import { ${nameGuess} } from '${importPath}'; + +export default function Preview() { + return <${nameGuess} />; +} +`; +} diff --git a/src/playground/defaultLive.tsx b/src/playground/defaultLive.tsx new file mode 100644 index 0000000..046cf96 --- /dev/null +++ b/src/playground/defaultLive.tsx @@ -0,0 +1,43 @@ +/** + * Live preview harness β€” edit freely. Nothing is saved. + * + * - Import any Cinny module via `@cinny/...` + * - Use mocks / PlaygroundProviders from `@playground/mocks` + * - `export default` the component to render in the stage + */ +import { Message } from '@cinny/app/features/room/message'; +import { MessageLayout } from '@cinny/app/state/settings'; +import { Box, Text } from 'folds'; +import { mocks } from '@playground/mocks'; + +export default function Preview() { + const room = mocks.room; + const mEvent = mocks.event; + + return ( + + + Preview harness β€” swap imports to any `@cinny/...` export + + undefined} + onUsernameClick={() => undefined} + onReplyClick={() => undefined} + onReactionToggle={() => undefined} + > + {mEvent.getContent().body} + + + ); +} diff --git a/src/playground/defaultSource.ts b/src/playground/defaultSource.ts new file mode 100644 index 0000000..c218eb5 --- /dev/null +++ b/src/playground/defaultSource.ts @@ -0,0 +1,3 @@ +import defaultLive from './defaultLive.tsx?raw'; + +export const DEFAULT_SOURCE = defaultLive; diff --git a/src/playground/liveClient.ts b/src/playground/liveClient.ts new file mode 100644 index 0000000..25b8952 --- /dev/null +++ b/src/playground/liveClient.ts @@ -0,0 +1,151 @@ +import { + ClientEvent, + createClient, + MemoryStore, + SyncState, + type MatrixClient, + type Room, +} from 'matrix-js-sdk'; +import { getCurrentSession, type Session } from '@cinny/app/state/sessions'; + +export type LiveConnectMode = 'mock' | 'live'; + +export type ManualSessionInput = { + baseUrl: string; + userId: string; + deviceId: string; + accessToken: string; +}; + +const MANUAL_SESSION_KEY = 'playgroundManualSession'; + +/** Session already used by Paarrot/Cinny on this origin (localStorage). */ +export function peekPaarrotSession(): Session | undefined { + return getCurrentSession(); +} + +export function loadManualSession(): ManualSessionInput | null { + try { + const raw = sessionStorage.getItem(MANUAL_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ManualSessionInput; + if (parsed?.baseUrl && parsed?.userId && parsed?.deviceId && parsed?.accessToken) { + return parsed; + } + } catch { + /* ignore */ + } + return null; +} + +export function saveManualSession(input: ManualSessionInput | null) { + if (!input) { + sessionStorage.removeItem(MANUAL_SESSION_KEY); + return; + } + sessionStorage.setItem(MANUAL_SESSION_KEY, JSON.stringify(input)); +} + +function normalizeSession(input: Session | ManualSessionInput): Session { + return { + baseUrl: input.baseUrl.replace(/\/$/, ''), + userId: input.userId, + deviceId: input.deviceId, + accessToken: input.accessToken, + }; +} + +function resolveSession(explicit?: Session | ManualSessionInput): Session { + if (explicit) return normalizeSession(explicit); + + const fromPaarrot = peekPaarrotSession(); + if (fromPaarrot) return normalizeSession(fromPaarrot); + + const manual = loadManualSession(); + if (manual) return normalizeSession(manual); + + throw new Error( + 'No Paarrot session found. Log in at / (same origin), or paste homeserver + token in the playground.' + ); +} + +function waitForPrepared(mx: MatrixClient, timeoutMs = 90_000): Promise { + return new Promise((resolve, reject) => { + const finish = (ok: boolean, err?: Error) => { + clearTimeout(timer); + mx.off(ClientEvent.Sync, onSync); + if (ok) resolve(); + else reject(err ?? new Error('Sync failed')); + }; + + const timer = window.setTimeout( + () => finish(false, new Error('Timed out waiting for Matrix sync')), + timeoutMs + ); + + const onSync = (state: SyncState | null) => { + if (state === SyncState.Prepared || state === SyncState.Syncing) { + finish(true); + } + }; + + mx.on(ClientEvent.Sync, onSync); + const cur = mx.getSyncState(); + if (cur === SyncState.Prepared || cur === SyncState.Syncing) { + finish(true); + } + }); +} + +/** + * Live Matrix client for the playground. + * Uses MemoryStore so it won't fight Paarrot's IndexedDB sync/crypto stores. + * Skips Rust crypto β€” encrypted timelines won't decrypt, but profiles/rooms/settings APIs work. + */ +export async function connectLiveMatrixClient( + session?: Session | ManualSessionInput +): Promise { + const finalSession = resolveSession(session); + + const mx = createClient({ + baseUrl: finalSession.baseUrl, + accessToken: finalSession.accessToken, + userId: finalSession.userId, + deviceId: finalSession.deviceId, + store: new MemoryStore({ localStorage: globalThis.localStorage }), + timelineSupport: true, + }); + + mx.setMaxListeners(50); + await mx.startClient({ + lazyLoadMembers: true, + initialSyncLimit: 20, + }); + await waitForPrepared(mx); + return mx; +} + +export function stopLiveMatrixClient(mx: MatrixClient | null | undefined) { + if (!mx) return; + try { + mx.stopClient(); + } catch { + /* ignore */ + } +} + +export function listJoinedRooms(mx: MatrixClient): Room[] { + return mx + .getRooms() + .filter((r) => r.getMyMembership() === 'join') + .sort((a, b) => (a.name || a.roomId).localeCompare(b.name || b.roomId)); +} + +export function pickDefaultRoom(mx: MatrixClient, preferredId?: string): Room | undefined { + const rooms = listJoinedRooms(mx); + if (preferredId) { + const hit = rooms.find((r) => r.roomId === preferredId); + if (hit) return hit; + } + return rooms[0]; +} diff --git a/src/playground/main.tsx b/src/playground/main.tsx new file mode 100644 index 0000000..86f2f0c --- /dev/null +++ b/src/playground/main.tsx @@ -0,0 +1,49 @@ +import { StrictMode, Component, type ErrorInfo, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import 'folds/dist/style.css'; +import { configClass, varsClass } from 'folds'; +import { App } from './App'; +import './styles.css'; + +document.body.classList.add(configClass, varsClass); + +// Playground shares the Cinny origin β€” drop any SW/caches so stale shims cannot stick. +if ('serviceWorker' in navigator) { + void navigator.serviceWorker.getRegistrations().then((regs) => { + for (const reg of regs) void reg.unregister(); + }); +} +if (typeof caches !== 'undefined') { + void caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k)))); +} + +class BootErrorBoundary extends Component<{ children: ReactNode }, { error: string | null }> { + state = { error: null as string | null }; + + static getDerivedStateFromError(error: Error) { + return { error: error.message }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[playground boot]', error, info); + } + + render() { + if (this.state.error) { + return ( +
+          {this.state.error}
+        
+ ); + } + return this.props.children; + } +} + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/src/playground/mocks/PlaygroundProviders.tsx b/src/playground/mocks/PlaygroundProviders.tsx new file mode 100644 index 0000000..b3f1450 --- /dev/null +++ b/src/playground/mocks/PlaygroundProviders.tsx @@ -0,0 +1,106 @@ +import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { Provider as JotaiProvider } from 'jotai'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { + Box, + OverlayContainerProvider, + PopOutContainerProvider, + TooltipContainerProvider, + configClass, + varsClass, +} from 'folds'; +import { MatrixClientProvider } from '@cinny/app/hooks/useMatrixClient'; +import { RoomProvider, IsDirectRoomProvider } from '@cinny/app/hooks/useRoom'; +import { SpecVersionsProvider } from '@cinny/app/hooks/useSpecVersions'; +import { DarkTheme, ThemeContextProvider } from '@cinny/app/hooks/useTheme'; +import { + PowerLevelsContextProvider, + type IPowerLevels, +} from '@cinny/app/hooks/usePowerLevels'; +import { ContainerColor } from '@cinny/app/styles/ContainerColor.css'; +import type { MatrixClient, Room } from 'matrix-js-sdk'; +import { MOCK_POWER_LEVELS, createMockRoom, mocks } from './matrix'; + +type Props = { + children: ReactNode; + client?: MatrixClient; + room?: Room; + isDirect?: boolean; + powerLevels?: IPowerLevels; +}; + +const SPEC_VERSIONS = { + versions: ['v1.11', 'v1.12'], + unstable_features: { + 'org.matrix.msc3916.stable': true, + }, +}; + +/** + * Wraps a preview in the providers Cinny UI pieces usually expect. + * Overlay/popout/tooltip portals stay inside the stage (not the full window). + */ +export function PlaygroundProviders({ + children, + client, + room, + isDirect = false, + powerLevels = MOCK_POWER_LEVELS as IPowerLevels, +}: Props) { + const [portal, setPortal] = useState(null); + const queryClient = useMemo(() => new QueryClient(), []); + const resolvedClient = client ?? mocks.client; + // Always provide a room with `.client` set β€” RoomInput/useStateEvent require it. + const resolvedRoom = useMemo(() => { + if (room) return room; + return createMockRoom({ client: resolvedClient }); + }, [room, resolvedClient]); + + useEffect(() => { + document.body.classList.add(configClass, varsClass, ...DarkTheme.classNames); + }, []); + + return ( +
+ + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + {/* Host for folds Overlay/Dialog portals β€” contained by .preview-frame */} +
+
+ ); +} diff --git a/src/playground/mocks/index.ts b/src/playground/mocks/index.ts new file mode 100644 index 0000000..55424de --- /dev/null +++ b/src/playground/mocks/index.ts @@ -0,0 +1,2 @@ +export { mocks, createMockMatrixClient, createMockRoom, createMockMatrixEvent, MOCK_POWER_LEVELS } from './matrix'; +export { PlaygroundProviders } from './PlaygroundProviders'; diff --git a/src/playground/mocks/matrix.ts b/src/playground/mocks/matrix.ts new file mode 100644 index 0000000..34fa978 --- /dev/null +++ b/src/playground/mocks/matrix.ts @@ -0,0 +1,245 @@ +import type { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; +import { MessageEvent, StateEvent } from '@cinny/types/matrix/room'; +import type { IPowerLevels } from '@cinny/app/hooks/usePowerLevels'; + +type AnyFn = (...args: never[]) => unknown; + +function fn(impl?: T): T { + return (impl ?? ((() => undefined) as T)) as T; +} + +export const MOCK_USER_ID = '@alice:example.org'; +export const MOCK_OTHER_USER_ID = '@bob:example.org'; +export const MOCK_ROOM_ID = '!room:example.org'; +export const MOCK_EVENT_ID = '$event:example.org'; + +/** Default power levels β€” user can send messages / invite. */ +export const MOCK_POWER_LEVELS: IPowerLevels = { + users_default: 0, + state_default: 50, + events_default: 0, + invite: 0, + redact: 50, + kick: 50, + ban: 50, + historical: 0, + events: { + 'm.room.message': 0, + 'm.reaction': 0, + }, + users: { + [MOCK_USER_ID]: 100, + }, + notifications: { + room: 50, + }, +}; + +function mockStateEvent(type: string, content: Record, stateKey = ''): MatrixEvent { + return { + getId: () => `$${type}`, + getSender: () => MOCK_USER_ID, + getTs: () => Date.now(), + getType: () => type, + getContent: () => content, + getWireContent: () => content, + isRedacted: () => false, + getStateKey: () => stateKey, + isEncrypted: () => false, + getRoomId: () => MOCK_ROOM_ID, + event: { sender: MOCK_USER_ID }, + } as unknown as MatrixEvent; +} + +/** Lightweight MatrixEvent stand-in for UI previews. */ +export function createMockMatrixEvent( + overrides: Partial<{ + id: string; + sender: string; + body: string; + ts: number; + type: string; + redacted: boolean; + content: Record; + }> = {} +): MatrixEvent { + const id = overrides.id ?? MOCK_EVENT_ID; + const sender = overrides.sender ?? MOCK_OTHER_USER_ID; + const content = { + body: overrides.body ?? 'Hello from the playground preview.', + msgtype: 'm.text', + ...(overrides.content ?? {}), + }; + const ts = overrides.ts ?? Date.now() - 60_000; + const type = overrides.type ?? MessageEvent.RoomMessage; + const redacted = overrides.redacted ?? false; + + return { + getId: () => id, + getSender: () => sender, + getTs: () => ts, + getType: () => type, + getContent: () => (redacted ? {} : content), + getWireContent: () => content, + isRedacted: () => redacted, + getStateKey: () => undefined, + isEncrypted: () => false, + threadRootId: undefined, + getRoomId: () => MOCK_ROOM_ID, + } as unknown as MatrixEvent; +} + +/** Lightweight Room stand-in. */ +export function createMockRoom( + overrides: Partial<{ + roomId: string; + name: string; + client: MatrixClient; + members: Record; + powerLevels: IPowerLevels; + }> = {} +): Room { + const roomId = overrides.roomId ?? MOCK_ROOM_ID; + const members = { + [MOCK_USER_ID]: { displayName: 'Alice' }, + [MOCK_OTHER_USER_ID]: { displayName: 'Bob' }, + ...(overrides.members ?? {}), + }; + const powerLevels = overrides.powerLevels ?? MOCK_POWER_LEVELS; + + const stateByType: Record = { + [StateEvent.RoomPowerLevels]: mockStateEvent( + StateEvent.RoomPowerLevels, + powerLevels as unknown as Record + ), + [StateEvent.RoomCreate]: mockStateEvent(StateEvent.RoomCreate, { + room_version: '11', + creator: MOCK_USER_ID, + }), + [StateEvent.RoomPinnedEvents]: mockStateEvent(StateEvent.RoomPinnedEvents, { pinned: [] }), + }; + + const getStateEvents = ((type: string, stateKey?: string) => { + const ev = stateByType[type]; + if (stateKey !== undefined) return ev ?? null; + return ev ? [ev] : []; + }) as Room['currentState']['getStateEvents']; + + const liveState = { + getStateEvents, + }; + + const client = overrides.client ?? ({ + on: fn(() => undefined), + removeListener: fn(() => undefined), + once: fn(() => undefined), + off: fn(() => undefined), + } as unknown as MatrixClient); + + return { + roomId, + name: overrides.name ?? 'Preview Room', + client, + getMyMembership: () => 'join', + getMember: (userId: string) => { + const m = members[userId]; + if (!m) return null; + return { + userId, + name: m.displayName ?? userId, + rawDisplayName: m.displayName ?? userId, + getAvatarUrl: () => null, + getMxcAvatarUrl: () => m.avatarMxc ?? null, + }; + }, + getCanonicalAlias: () => null, + getAltAliases: () => [], + getTimelineForEvent: () => null, + getLiveTimeline: () => ({ + getTimelineSet: () => ({ relations: { getChildEventsForEvent: () => null } }), + getEvents: () => [], + getState: () => liveState, + }), + currentState: { + getStateEvents, + }, + } as unknown as Room; +} + +/** Lightweight MatrixClient stand-in used by MatrixClientProvider. */ +export function createMockMatrixClient( + overrides: Partial<{ userId: string }> = {} +): MatrixClient { + const userId = overrides.userId ?? MOCK_USER_ID; + const users = new Map< + string, + { + userId: string; + presence: string; + presenceStatusMsg?: string; + currentlyActive: boolean; + getLastActiveTs: () => number; + on: AnyFn; + removeListener: AnyFn; + once: AnyFn; + off: AnyFn; + } + >(); + + const ensureUser = (id: string) => { + if (!users.has(id)) { + users.set(id, { + userId: id, + presence: 'online', + currentlyActive: true, + getLastActiveTs: () => Date.now(), + on: fn(() => undefined), + removeListener: fn(() => undefined), + once: fn(() => undefined), + off: fn(() => undefined), + }); + } + return users.get(id)!; + }; + ensureUser(userId); + ensureUser(MOCK_OTHER_USER_ID); + + const client = { + getUserId: () => userId, + getSafeUserId: () => userId, + getDomain: () => 'example.org', + getUser: (id: string) => ensureUser(id), + getRoom: (id: string | undefined) => + id ? createMockRoom({ roomId: id, client: client as MatrixClient }) : null, + getRooms: () => [createMockRoom({ client: client as MatrixClient })], + isRoomEncrypted: () => false, + getAccountData: () => undefined, + sendEvent: fn(async () => ({ event_id: '$sent' })), + sendStateEvent: fn(async () => ({ event_id: '$state' })), + redactEvent: fn(async () => ({ event_id: '$redact' })), + reportEvent: fn(async () => ({})), + downloadKeys: fn(async () => ({})), + mxcUrlToHttp: (mxc: string) => mxc, + on: fn(() => undefined), + removeListener: fn(() => undefined), + once: fn(() => undefined), + } as unknown as MatrixClient; + + return client; +} + +const defaultClient = createMockMatrixClient(); + +export const mocks = { + userId: MOCK_USER_ID, + otherUserId: MOCK_OTHER_USER_ID, + roomId: MOCK_ROOM_ID, + eventId: MOCK_EVENT_ID, + powerLevels: MOCK_POWER_LEVELS, + client: defaultClient, + room: createMockRoom({ client: defaultClient }), + event: createMockMatrixEvent(), + createClient: createMockMatrixClient, + createRoom: createMockRoom, + createEvent: createMockMatrixEvent, +}; diff --git a/src/playground/monacoSetup.ts b/src/playground/monacoSetup.ts new file mode 100644 index 0000000..d630874 --- /dev/null +++ b/src/playground/monacoSetup.ts @@ -0,0 +1,12 @@ +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +// Vite-friendly Monaco workers (no AMD CDN loader). +self.MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + if (label === 'json') return new jsonWorker(); + if (label === 'typescript' || label === 'javascript') return new tsWorker(); + return new editorWorker(); + }, +}; diff --git a/src/playground/shims/loglevel.js b/src/playground/shims/loglevel.js new file mode 100644 index 0000000..a37fd26 --- /dev/null +++ b/src/playground/shims/loglevel.js @@ -0,0 +1,65 @@ +/** + * Standalone loglevel stand-in for the playground. + * Avoids Vite CJS interop fights with the real package; matrix-js-sdk only needs + * getLogger / levels / methodFactory / setLevel / rebuild. + */ + +const levels = { + TRACE: 0, + DEBUG: 1, + INFO: 2, + WARN: 3, + ERROR: 4, + SILENT: 5, +}; + +const loggers = new Map(); + +function defaultMethodFactory(methodName) { + return (...args) => { + const supported = + methodName === 'error' || + methodName === 'warn' || + methodName === 'trace' || + methodName === 'info' || + methodName === 'debug'; + if (supported) console[methodName](...args); + else console.log(...args); + }; +} + +function createLogger(name) { + const logger = { + name, + prefix: undefined, + methodFactory: defaultMethodFactory, + getChild: undefined, + setLevel(_level, _persist) {}, + getLevel() { + return levels.WARN; + }, + setDefaultLevel() {}, + enableAll() {}, + disableAll() {}, + rebuild() { + for (const methodName of ['trace', 'debug', 'info', 'warn', 'error']) { + logger[methodName] = logger.methodFactory(methodName, levels.DEBUG, name); + } + }, + }; + logger.rebuild(); + // matrix-js-sdk expects console-like `.log` (e.g. logger.log.bind(logger)). + logger.log = logger.info; + return logger; +} + +const loglevel = createLogger('root'); +loglevel.levels = levels; +loglevel.getLogger = (name) => { + const key = String(name ?? 'default'); + if (!loggers.has(key)) loggers.set(key, createLogger(key)); + return loggers.get(key); +}; +loglevel.methodFactory = defaultMethodFactory; + +export default loglevel; diff --git a/src/playground/styles.css b/src/playground/styles.css new file mode 100644 index 0000000..dd8e307 --- /dev/null +++ b/src/playground/styles.css @@ -0,0 +1,414 @@ +:root { + color-scheme: dark; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; + background: #0f1115; + color: #e8eaed; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + height: 100%; +} + +.app { + height: 100%; + display: grid; + grid-template-rows: auto auto auto 1fr; +} + +.conn-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + padding: 8px 14px; + border-bottom: 1px solid #232833; + background: #12161d; +} + +.conn-modes { + display: flex; + gap: 6px; +} + +.conn-btn { + border: 1px solid #2f3640; + background: transparent; + color: #c7ced8; + border-radius: 8px; + padding: 5px 10px; + cursor: pointer; + font-size: 12px; +} + +.conn-btn:hover:not(:disabled) { + background: #1c2230; +} + +.conn-btn.active { + background: #243147; + border-color: #3d7eff; + color: #fff; +} + +.conn-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.conn-status { + flex: 1; + min-width: 180px; + font-size: 12px; +} + +.conn-ok { + color: #7dcea0; +} + +.conn-err { + color: #ffb4b4; +} + +.conn-room { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #9aa3af; +} + +.conn-room select { + max-width: 240px; + border: 1px solid #2f3640; + border-radius: 8px; + background: #0c0e12; + color: #e8eaed; + padding: 5px 8px; +} + +.token-form { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; + gap: 8px; + padding: 8px 14px 10px; + border-bottom: 1px solid #232833; + background: #0f131a; +} + +.token-form input { + border: 1px solid #2f3640; + border-radius: 8px; + background: #0c0e12; + color: #e8eaed; + padding: 7px 10px; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +@media (max-width: 1100px) { + .token-form { + grid-template-columns: 1fr; + } +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #232833; + background: #141820; +} + +.topbar-actions { + display: flex; + gap: 8px; +} + +.muted { + color: #9aa3af; + font-size: 13px; +} + +.ghost { + border: 1px solid #2f3640; + background: transparent; + color: #e8eaed; + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; +} + +.ghost:hover { + background: #1c2230; +} + +.panes { + display: grid; + grid-template-columns: minmax(200px, 280px) 1.2fr 1fr; + min-height: 0; +} + +.pane { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto 1fr; + border-right: 1px solid #232833; +} + +.pane:last-child { + border-right: 0; +} + +.pane-label { + padding: 8px 12px; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8b949e; + border-bottom: 1px solid #232833; + background: #12161d; +} + +.catalog-pane { + grid-template-rows: auto auto 1fr; + background: #10141b; +} + +.filter { + margin: 8px 10px; + border: 1px solid #2f3640; + border-radius: 8px; + background: #0c0e12; + color: #e8eaed; + padding: 8px 10px; + font-size: 13px; +} + +.catalog-list { + overflow: auto; + padding: 4px 6px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.catalog-item { + text-align: left; + border: 0; + background: transparent; + color: #c7ced8; + border-radius: 6px; + padding: 7px 8px; + font-size: 12px; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.catalog-item:hover { + background: #1a2130; +} + +.catalog-item.active { + background: #243147; + color: #fff; +} + +.editor-pane { + background: #1e1e1e; + grid-template-rows: auto 1fr; +} + +.editor-tabs { + display: flex; + align-items: center; + gap: 8px; + text-transform: none; + letter-spacing: 0; +} + +.tab { + border: 1px solid #2f3640; + background: transparent; + color: #9aa3af; + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.tab:hover:not(:disabled) { + background: #1c2230; + color: #e8eaed; +} + +.tab.active { + background: #243147; + border-color: #3d7eff; + color: #fff; +} + +.tab:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.tab-hint { + margin-left: auto; + font-size: 11px; + text-transform: none; + letter-spacing: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 55%; +} + +.editor-error { + margin: 8px 12px; + max-width: none; +} + +.preview-pane { + background: #0f1115; +} + +.preview-body { + overflow: auto; + padding: 24px; + display: grid; + place-items: center; +} + +.stage-wrap { + display: grid; + gap: 8px; + justify-items: center; +} + +.stage-meta { + font-size: 12px; + color: #8b949e; + font-variant-numeric: tabular-nums; +} + +.stage { + position: relative; + background: + linear-gradient(45deg, #161a22 25%, transparent 25%), + linear-gradient(-45deg, #161a22 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #161a22 75%), + linear-gradient(-45deg, transparent 75%, #161a22 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + background-color: #0c0e12; + border: 1px solid #2a3140; + border-radius: 10px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); + /* Contain folds position:fixed overlays inside the stage. */ + transform: translateZ(0); + overflow: hidden; +} + +.stage-canvas { + width: 100%; + height: 100%; + overflow: auto; + padding: 12px; + display: grid; + place-items: stretch; + align-content: stretch; +} + +.preview-frame { + position: relative; + width: 100%; + height: 100%; + min-height: 200px; + /* Nested containing block so Overlay fixed coords map to the frame. */ + transform: translateZ(0); + overflow: hidden; + border-radius: 8px; +} + +.preview-portal { + position: absolute; + inset: 0; + z-index: 30; + pointer-events: none; +} + +.preview-portal > * { + pointer-events: auto; +} + +.handle { + position: absolute; + padding: 0; + border: 0; + background: #3d7eff; + opacity: 0.85; +} + +.handle:hover { + opacity: 1; +} + +.handle-e { + top: 12px; + right: -4px; + width: 8px; + height: calc(100% - 24px); + cursor: ew-resize; + border-radius: 999px; +} + +.handle-s { + left: 12px; + bottom: -4px; + height: 8px; + width: calc(100% - 24px); + cursor: ns-resize; + border-radius: 999px; +} + +.handle-se { + right: -5px; + bottom: -5px; + width: 12px; + height: 12px; + cursor: nwse-resize; + border-radius: 3px; +} + +.error-block { + margin: 0; + max-width: 560px; + white-space: pre-wrap; + word-break: break-word; + padding: 14px 16px; + border-radius: 10px; + background: #2a1215; + border: 1px solid #5c1f28; + color: #ffb4b4; + font-size: 13px; + line-height: 1.45; +} + +@media (max-width: 1100px) { + .panes { + grid-template-columns: 1fr; + grid-template-rows: 180px 1fr 1fr; + } +} diff --git a/src/playground/usePlaygroundMatrix.ts b/src/playground/usePlaygroundMatrix.ts new file mode 100644 index 0000000..4df8b48 --- /dev/null +++ b/src/playground/usePlaygroundMatrix.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MatrixClient, Room } from 'matrix-js-sdk'; +import { + connectLiveMatrixClient, + listJoinedRooms, + loadManualSession, + peekPaarrotSession, + pickDefaultRoom, + saveManualSession, + stopLiveMatrixClient, + type LiveConnectMode, + type ManualSessionInput, +} from './liveClient'; + +type LiveState = { + mode: LiveConnectMode; + status: 'idle' | 'connecting' | 'ready' | 'error'; + error: string | null; + client: MatrixClient | null; + room: Room | null; + rooms: Room[]; + sessionLabel: string | null; +}; + +const initial: LiveState = { + mode: 'mock', + status: 'idle', + error: null, + client: null, + room: null, + rooms: [], + sessionLabel: null, +}; + +export function usePlaygroundMatrix() { + const [state, setState] = useState(initial); + const clientRef = useRef(null); + const roomIdRef = useRef(undefined); + const paarrotSession = peekPaarrotSession(); + const hasPaarrotSession = Boolean(paarrotSession); + const hasManualSession = Boolean(loadManualSession()); + + const teardown = useCallback(() => { + stopLiveMatrixClient(clientRef.current); + clientRef.current = null; + }, []); + + useEffect(() => () => teardown(), [teardown]); + + const selectRoom = useCallback((roomId: string) => { + roomIdRef.current = roomId; + setState((prev) => { + if (!prev.client) return prev; + const room = pickDefaultRoom(prev.client, roomId) ?? null; + return { ...prev, room }; + }); + }, []); + + const connectLive = useCallback( + async (manual?: ManualSessionInput) => { + teardown(); + setState((prev) => ({ + ...prev, + mode: 'live', + status: 'connecting', + error: null, + client: null, + room: null, + rooms: [], + sessionLabel: null, + })); + + try { + if (manual) saveManualSession(manual); + const mx = await connectLiveMatrixClient(manual); + clientRef.current = mx; + const rooms = listJoinedRooms(mx); + const room = pickDefaultRoom(mx, roomIdRef.current) ?? null; + if (room) roomIdRef.current = room.roomId; + + const hs = + peekPaarrotSession()?.baseUrl ?? + loadManualSession()?.baseUrl ?? + ''; + setState({ + mode: 'live', + status: 'ready', + error: null, + client: mx, + room, + rooms, + sessionLabel: `${mx.getUserId() ?? '?'} Β· ${hs}`, + }); + } catch (err) { + clientRef.current = null; + setState({ + ...initial, + mode: 'live', + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [teardown] + ); + + const useMocks = useCallback(() => { + teardown(); + roomIdRef.current = undefined; + setState(initial); + }, [teardown]); + + return { + ...state, + hasPaarrotSession, + hasManualSession, + paarrotUserId: paarrotSession?.userId ?? null, + paarrotBaseUrl: paarrotSession?.baseUrl ?? null, + connectLive, + useMocks, + selectRoom, + }; +} diff --git a/src/playground/vite-env.d.ts b/src/playground/vite-env.d.ts new file mode 100644 index 0000000..6b029a6 --- /dev/null +++ b/src/playground/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +declare module '*?raw' { + const content: string; + default content; +} + +declare module '/@playground/live.tsx' { + const Comp: React.ComponentType; + export default Comp; +} diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 142b7a8..5131ab5 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -58,6 +58,9 @@ export enum MessageEvent { Sticker = 'm.sticker', RoomRedaction = 'm.room.redaction', Reaction = 'm.reaction', + + /** Relay emoji confetti burst tied to a target message */ + RelayEmojiConfetti = 'app.relay.emoji_confetti', } export enum RoomType { diff --git a/vite.config.js b/vite.config.js index 7d3faf7..87591f4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,9 @@ import { VitePWA } from 'vite-plugin-pwa'; import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; +import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin'; + +const projectRoot = path.resolve(); const copyFiles = { targets: [ @@ -217,6 +220,30 @@ export default defineConfig({ publicDir: false, base: buildConfig.base, assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'], + resolve: { + // Keep a single React instance β€” dual copies cause resolveDispatcher() === null. + dedupe: ['react', 'react-dom'], + alias: [ + { find: '@cinny', replacement: path.resolve(projectRoot, 'src') }, + { + find: '@playground/mocks', + replacement: path.resolve(projectRoot, 'src/playground/mocks/index.ts'), + }, + // Exact match only β€” do not rewrite loglevel/lib/... + { + find: /^loglevel$/, + replacement: path.resolve(projectRoot, 'src/playground/shims/loglevel.js'), + }, + { + find: /^react$/, + replacement: path.resolve(projectRoot, 'node_modules/react'), + }, + { + find: /^react-dom$/, + replacement: path.resolve(projectRoot, 'node_modules/react-dom'), + }, + ], + }, server: { port: 38347, host: '0.0.0.0', @@ -232,6 +259,7 @@ export default defineConfig({ serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), serveGifWorker(), servePublicAssets(), + liveTsxPlugin(projectRoot, readDefaultLiveSource(projectRoot)), topLevelAwait({ // The export name of top-level await promise for each chunk module promiseExportName: '__tla', @@ -258,6 +286,16 @@ export default defineConfig({ }), ], optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'loglevel', + 'matrix-js-sdk', + '@monaco-editor/react', + ], + needsInterop: ['loglevel'], rolldownOptions: { define: { global: 'globalThis', @@ -277,6 +315,10 @@ export default defineConfig({ sourcemap: true, copyPublicDir: false, rollupOptions: { + input: { + main: path.resolve(projectRoot, 'index.html'), + playground: path.resolve(projectRoot, 'playground.html'), + }, plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], }, },