Compare commits
2 Commits
0fb3da20b9
...
0da4f0d6cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da4f0d6cb | |||
| ea7642f0bb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ node_modules
|
|||||||
devAssets
|
devAssets
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.cache
|
||||||
|
|||||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -83,6 +83,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
@@ -106,6 +107,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"monaco-editor": "0.52.2",
|
||||||
"prettier": "3.9.4",
|
"prettier": "3.9.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.1.3",
|
"vite": "8.1.3",
|
||||||
@@ -2563,6 +2565,31 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/@napi-rs/canvas": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz",
|
||||||
"integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -10157,6 +10191,13 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"check:eslint": "eslint src/*",
|
"check:eslint": "eslint src/*",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"playground": "vite"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||||
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
@@ -117,6 +119,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"monaco-editor": "0.52.2",
|
||||||
"prettier": "3.9.4",
|
"prettier": "3.9.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.1.3",
|
"vite": "8.1.3",
|
||||||
|
|||||||
149
playground-liveTsxPlugin.ts
Normal file
149
playground-liveTsxPlugin.ts
Normal file
@@ -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<string> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
12
playground.html
Normal file
12
playground.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Component Playground</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#0f1115;color:#e8eaed">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/playground/main.tsx?v=react-dedupe-1"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { Icon, Icons } from './icons';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Cursor } from '../plugins/text-area';
|
import { Cursor } from '../plugins/text-area';
|
||||||
@@ -154,7 +154,7 @@ function AccountDataEdit({
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { KeyboardEvent, MouseEvent } from 'react';
|
||||||
import { MsgType } from 'matrix-js-sdk';
|
import { MsgType } from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts } from 'linkifyjs';
|
import { Opts } from 'linkifyjs';
|
||||||
@@ -49,6 +49,12 @@ type RenderMessageContentProps = {
|
|||||||
linkifyOpts: Opts;
|
linkifyOpts: Opts;
|
||||||
outlineAttachment?: boolean;
|
outlineAttachment?: boolean;
|
||||||
disabledEmbedPatterns?: string[];
|
disabledEmbedPatterns?: string[];
|
||||||
|
targetEventId?: string;
|
||||||
|
onJumboEmojiClick?: (
|
||||||
|
targetEventId: string,
|
||||||
|
body: string,
|
||||||
|
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
export function RenderMessageContent({
|
export function RenderMessageContent({
|
||||||
displayName,
|
displayName,
|
||||||
@@ -63,6 +69,8 @@ export function RenderMessageContent({
|
|||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
disabledEmbedPatterns,
|
disabledEmbedPatterns,
|
||||||
|
targetEventId,
|
||||||
|
onJumboEmojiClick,
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||||
@@ -100,6 +108,11 @@ export function RenderMessageContent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const handleJumboEmojiClick =
|
||||||
|
targetEventId && onJumboEmojiClick
|
||||||
|
? (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) =>
|
||||||
|
onJumboEmojiClick(targetEventId, body, event)
|
||||||
|
: undefined;
|
||||||
const renderCaption = () => {
|
const renderCaption = () => {
|
||||||
const content: IImageContent = getContent();
|
const content: IImageContent = getContent();
|
||||||
if (content.filename && content.filename !== content.body) {
|
if (content.filename && content.filename !== content.body) {
|
||||||
@@ -182,6 +195,7 @@ export function RenderMessageContent({
|
|||||||
<MText
|
<MText
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
@@ -201,6 +215,7 @@ export function RenderMessageContent({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
@@ -219,6 +234,7 @@ export function RenderMessageContent({
|
|||||||
<MNotice
|
<MNotice
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
|
||||||
|
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
|
||||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||||
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||||
@@ -78,8 +79,9 @@ type MTextProps = {
|
|||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
style?: CSSProperties;
|
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;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -89,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
style={style}
|
style={style}
|
||||||
|
{...jumboEmojiInteraction}
|
||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
@@ -114,6 +123,7 @@ type MEmoteProps = {
|
|||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
};
|
};
|
||||||
export function MEmote({
|
export function MEmote({
|
||||||
displayName,
|
displayName,
|
||||||
@@ -121,6 +131,7 @@ export function MEmote({
|
|||||||
content,
|
content,
|
||||||
renderBody,
|
renderBody,
|
||||||
renderUrlsPreview,
|
renderUrlsPreview,
|
||||||
|
onJumboEmojiClick,
|
||||||
}: MEmoteProps) {
|
}: MEmoteProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
@@ -131,20 +142,28 @@ export function MEmote({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
emote
|
emote
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
>
|
>
|
||||||
<b>{`${displayName} `}</b>
|
<b>{`${displayName} `}</b>
|
||||||
{renderBody({
|
<span {...jumboEmojiInteraction}>
|
||||||
body: trimmedBody,
|
{renderBody({
|
||||||
customBody: trimmedCustomBody,
|
body: trimmedBody,
|
||||||
})}
|
customBody: trimmedCustomBody,
|
||||||
{edited && <MessageEditedContent />}
|
})}
|
||||||
|
{edited && <MessageEditedContent />}
|
||||||
|
</span>
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -156,8 +175,9 @@ type MNoticeProps = {
|
|||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => 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;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -167,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||||||
: undefined;
|
: undefined;
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||||
|
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
|
||||||
|
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
|
||||||
|
body: trimmedBody,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MessageTextBody
|
<MessageTextBody
|
||||||
notice
|
notice
|
||||||
preWrap={typeof customBody !== 'string'}
|
preWrap={typeof customBody !== 'string'}
|
||||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
jumboEmoji={isJumboEmoji}
|
||||||
|
{...jumboEmojiInteraction}
|
||||||
>
|
>
|
||||||
{renderBody({
|
{renderBody({
|
||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ export const MessageTextBody = recipe({
|
|||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
overflowY: 'visible',
|
overflowY: 'visible',
|
||||||
paddingBottom: config.space.S200,
|
paddingBottom: config.space.S200,
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emote: {
|
emote: {
|
||||||
@@ -263,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
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 { Icon, Icons } from '../../../components/icons';
|
||||||
import { Page, PageHeader } from '../../../components/page';
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds';
|
import { Box, Text, IconButton, Chip, Scroll, config, TextArea, color, Spinner, Button } from 'folds';
|
||||||
import { Icon, Icons } from '../../../components/icons';
|
import { Icon, Icons } from '../../../components/icons';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Page, PageHeader } from '../../../components/page';
|
import { Page, PageHeader } from '../../../components/page';
|
||||||
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
|||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Text size="L400">JSON Content</Text>
|
<Text size="L400">JSON Content</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<TextAreaComponent
|
<TextArea
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
name="contentTextArea"
|
name="contentTextArea"
|
||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ import {
|
|||||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
|
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||||
|
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||||
|
|
||||||
/** Information about a call member event for grouping */
|
/** Information about a call member event for grouping */
|
||||||
interface CallEventInfo {
|
interface CallEventInfo {
|
||||||
@@ -582,6 +584,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
);
|
);
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||||
|
useJumboEmojiConfetti(room);
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
@@ -1597,6 +1601,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
@@ -1705,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
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 special marker for call event (will be handled by eventRenderer)
|
||||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||||
},
|
},
|
||||||
|
[MessageEvent.RelayEmojiConfetti]: () => null,
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
@@ -2704,6 +2713,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Box>
|
</Box>
|
||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
|
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
|
|||||||
import { Message, Reactions, EncryptedContent } from './message';
|
import { Message, Reactions, EncryptedContent } from './message';
|
||||||
import { Reply } from '../../components/message';
|
import { Reply } from '../../components/message';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
|
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||||
|
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||||
import {
|
import {
|
||||||
LINKIFY_OPTS,
|
LINKIFY_OPTS,
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||||
|
useJumboEmojiConfetti(room);
|
||||||
|
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</EncryptedContent>
|
</EncryptedContent>
|
||||||
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||||
disabledEmbedPatterns={combinedEmbedFilters}
|
disabledEmbedPatterns={combinedEmbedFilters}
|
||||||
|
targetEventId={mEventId}
|
||||||
|
onJumboEmojiClick={handleJumboEmojiClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
|||||||
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
|
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
<EmojiConfettiOverlay
|
||||||
|
bursts={emojiConfettiBursts}
|
||||||
|
onBurstComplete={removeEmojiConfettiBurst}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<div className={roomViewCss.RoomViewBottomFloat}>
|
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||||
<RoomViewTyping room={room} />
|
<RoomViewTyping room={room} />
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { EmojiConfettiBurst } from './types';
|
||||||
|
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
||||||
|
import {
|
||||||
|
BurstParticle,
|
||||||
|
drawBurstParticles,
|
||||||
|
spawnEmojiBurst,
|
||||||
|
stepBurstParticles,
|
||||||
|
} from './emojiBurstEngine';
|
||||||
|
|
||||||
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
|
type EmojiConfettiBurstCanvasProps = {
|
||||||
|
burst: EmojiConfettiBurst;
|
||||||
|
onComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const particlesRef = useRef<BurstParticle[]>([]);
|
||||||
|
const frameRef = useRef<number | null>(null);
|
||||||
|
const lastFrameTimeRef = useRef<number | null>(null);
|
||||||
|
const spawnedRef = useRef(false);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36);
|
||||||
|
const localOrigin = useMemo(
|
||||||
|
() => ({
|
||||||
|
x: canvasSize / 2,
|
||||||
|
y: canvasSize / 2,
|
||||||
|
maskRadius: burst.origin.maskRadius ?? 36,
|
||||||
|
}),
|
||||||
|
[burst.origin.maskRadius, canvasSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateLayerPosition = () => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return false;
|
||||||
|
|
||||||
|
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
||||||
|
if (!jumbo) return false;
|
||||||
|
|
||||||
|
const rect = jumbo.getBoundingClientRect();
|
||||||
|
layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`;
|
||||||
|
layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScrollOrResize = () => {
|
||||||
|
updateLayerPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, true);
|
||||||
|
window.addEventListener('resize', onScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScrollOrResize, true);
|
||||||
|
window.removeEventListener('resize', onScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [burst.targetEventId, canvasSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || spawnedRef.current) return undefined;
|
||||||
|
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reducedMotion) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
const start = (): boolean => {
|
||||||
|
if (spawnedRef.current || !canvasRef.current) return false;
|
||||||
|
if (!updateLayerPosition()) return false;
|
||||||
|
|
||||||
|
spawnedRef.current = true;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return false;
|
||||||
|
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
canvas.width = Math.round(canvasSize * dpr);
|
||||||
|
canvas.height = Math.round(canvasSize * dpr);
|
||||||
|
canvas.style.width = `${canvasSize}px`;
|
||||||
|
canvas.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
|
spawnEmojiBurst(
|
||||||
|
particlesRef.current,
|
||||||
|
burst.id,
|
||||||
|
localOrigin,
|
||||||
|
burst.emojis[0] ?? '🎉',
|
||||||
|
performance.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
updateLayerPosition();
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
stepBurstParticles(particlesRef.current, now, dtSeconds);
|
||||||
|
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (particlesRef.current.length > 0) {
|
||||||
|
drawBurstParticles(context, particlesRef.current, dpr, now);
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
frameCleanup = () => {
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (start()) {
|
||||||
|
return frameCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
const retry = window.setInterval(() => {
|
||||||
|
attempt += 1;
|
||||||
|
if (start() || attempt >= 10) {
|
||||||
|
window.clearInterval(retry);
|
||||||
|
if (attempt >= 10 && !spawnedRef.current) {
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(retry);
|
||||||
|
frameCleanup?.();
|
||||||
|
};
|
||||||
|
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
width: canvasSize,
|
||||||
|
height: canvasSize,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { EmojiConfettiBurst } from './types';
|
||||||
|
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
|
||||||
|
|
||||||
|
type EmojiConfettiOverlayProps = {
|
||||||
|
bursts: EmojiConfettiBurst[];
|
||||||
|
onBurstComplete: (burstId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
|
||||||
|
if (bursts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{bursts.map((burst) => (
|
||||||
|
<EmojiConfettiBurstCanvas
|
||||||
|
key={burst.id}
|
||||||
|
burst={burst}
|
||||||
|
onComplete={onBurstComplete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export type BurstPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
maskRadius?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBurstPointFromElement(element: Element): BurstPoint {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
maskRadius: Math.max(rect.width, rect.height) * 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementCenter(element: Element): BurstPoint {
|
||||||
|
return getBurstPointFromElement(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiOriginInMessage(
|
||||||
|
targetEventId: string,
|
||||||
|
emoji: string
|
||||||
|
): BurstPoint | undefined {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return undefined;
|
||||||
|
|
||||||
|
const matches = message.querySelectorAll('[data-emoticon]');
|
||||||
|
for (const element of matches) {
|
||||||
|
if (element.getAttribute('data-emoticon') === emoji) {
|
||||||
|
return getBurstPointFromElement(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return undefined;
|
||||||
|
|
||||||
|
const jumboBody = message.querySelector('[data-jumbo-emoji]');
|
||||||
|
if (jumboBody) {
|
||||||
|
return getBurstPointFromElement(jumboBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = message.querySelector('[data-message-body]');
|
||||||
|
if (body) {
|
||||||
|
return getBurstPointFromElement(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBurstPointFromElement(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBurstOrigin(
|
||||||
|
targetEventId: string | undefined,
|
||||||
|
emojis: string[],
|
||||||
|
explicitOrigin?: BurstPoint
|
||||||
|
): BurstPoint {
|
||||||
|
if (explicitOrigin) return explicitOrigin;
|
||||||
|
|
||||||
|
const primaryEmoji = emojis[0];
|
||||||
|
if (targetEventId && primaryEmoji) {
|
||||||
|
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
|
||||||
|
if (emojiOrigin) return emojiOrigin;
|
||||||
|
|
||||||
|
const messageOrigin = getMessageFallbackOrigin(targetEventId);
|
||||||
|
if (messageOrigin) return messageOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: window.innerWidth / 2,
|
||||||
|
y: window.innerHeight * 0.35,
|
||||||
|
};
|
||||||
|
}
|
||||||
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { BurstPoint } from './burstOrigin';
|
||||||
|
import {
|
||||||
|
BurstMotionStyle,
|
||||||
|
EmojiBurstProfile,
|
||||||
|
getEmojiBurstProfile,
|
||||||
|
pickParticleEmoji,
|
||||||
|
sampleBurstAngle,
|
||||||
|
} from './emojiParticleProfiles';
|
||||||
|
|
||||||
|
/** Particles spawn over this window. */
|
||||||
|
export const BURST_SPAWN_MS = 500;
|
||||||
|
/** Global fade runs from 0.5s → 2s after burst start. */
|
||||||
|
export const BURST_FADE_START_MS = 500;
|
||||||
|
export const BURST_FADE_END_MS = 2000;
|
||||||
|
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
|
||||||
|
|
||||||
|
export type BurstParticle = {
|
||||||
|
burstId: string;
|
||||||
|
burstStartMs: number;
|
||||||
|
emoji: string;
|
||||||
|
fontSize: number;
|
||||||
|
spawnX: number;
|
||||||
|
spawnY: number;
|
||||||
|
maskRadius: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
bornAt: number;
|
||||||
|
rotation: number;
|
||||||
|
spin: number;
|
||||||
|
peakScale: number;
|
||||||
|
style: BurstMotionStyle;
|
||||||
|
gravity: number;
|
||||||
|
drag: number;
|
||||||
|
phase: number;
|
||||||
|
twinkle?: boolean;
|
||||||
|
wobble?: boolean;
|
||||||
|
orbitRadius: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
orbitAngle: number;
|
||||||
|
bounceDamping: number;
|
||||||
|
bouncesLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOJI_CACHE_PX = 64;
|
||||||
|
const EMOJI_CACHE_SCALE = 2;
|
||||||
|
const MAX_DPR = 2;
|
||||||
|
const POP_IN_MS = 70;
|
||||||
|
|
||||||
|
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
|
function easeOutBack(t: number): number {
|
||||||
|
const c1 = 1.70158;
|
||||||
|
const c3 = c1 + 1;
|
||||||
|
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutCubic(t: number): number {
|
||||||
|
return 1 - (1 - t) ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||||
|
const cacheKey = `${emoji}:${dpr}`;
|
||||||
|
const cached = emojiCanvasCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
|
||||||
|
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (context) {
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
context.fillText(emoji, size / 2, size / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emergeOpacity(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
spawnX: number,
|
||||||
|
spawnY: number,
|
||||||
|
maskRadius: number
|
||||||
|
): number {
|
||||||
|
const dist = Math.hypot(x - spawnX, y - spawnY);
|
||||||
|
const inner = maskRadius * 0.55;
|
||||||
|
const outer = maskRadius * 1.05;
|
||||||
|
if (dist <= inner) return 0;
|
||||||
|
if (dist >= outer) return 1;
|
||||||
|
return (dist - inner) / (outer - inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnParticle(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
burstId: string,
|
||||||
|
burstStartMs: number,
|
||||||
|
point: BurstPoint,
|
||||||
|
primaryEmoji: string,
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
bornAt: number,
|
||||||
|
options?: { hero?: boolean }
|
||||||
|
) {
|
||||||
|
const hero = options?.hero ?? false;
|
||||||
|
const angle = sampleBurstAngle(profile);
|
||||||
|
const speed = hero
|
||||||
|
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
|
||||||
|
: lerp(profile.speedMin, profile.speedMax);
|
||||||
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||||
|
const maskRadius = point.maskRadius ?? 28;
|
||||||
|
const jitter = hero ? 3 : 12;
|
||||||
|
const emoji = pickParticleEmoji(profile, primaryEmoji);
|
||||||
|
|
||||||
|
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
|
||||||
|
let vy = Math.sin(angle) * speed - launchUp;
|
||||||
|
|
||||||
|
if (profile.style === 'spiral') {
|
||||||
|
vx *= 0.35;
|
||||||
|
vy *= 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.style === 'sparkle') {
|
||||||
|
vx *= 0.75;
|
||||||
|
vy *= 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
particles.push({
|
||||||
|
burstId,
|
||||||
|
burstStartMs,
|
||||||
|
emoji,
|
||||||
|
fontSize: hero
|
||||||
|
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
|
||||||
|
: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
|
spawnX: point.x,
|
||||||
|
spawnY: point.y,
|
||||||
|
maskRadius,
|
||||||
|
x: point.x + (Math.random() - 0.5) * jitter,
|
||||||
|
y: point.y + (Math.random() - 0.5) * jitter,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
bornAt,
|
||||||
|
rotation: (Math.random() - 0.5) * 40,
|
||||||
|
spin: lerp(profile.spinMin, profile.spinMax),
|
||||||
|
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
|
||||||
|
style: profile.style,
|
||||||
|
gravity: profile.gravity,
|
||||||
|
drag: profile.drag,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
twinkle: profile.twinkle,
|
||||||
|
wobble: profile.wobble,
|
||||||
|
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
|
||||||
|
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
|
||||||
|
orbitAngle: Math.random() * Math.PI * 2,
|
||||||
|
bounceDamping: 0.42 + Math.random() * 0.18,
|
||||||
|
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
|
||||||
|
export function spawnEmojiBurst(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
burstId: string,
|
||||||
|
point: BurstPoint,
|
||||||
|
emoji: string,
|
||||||
|
now = performance.now()
|
||||||
|
) {
|
||||||
|
const profile = getEmojiBurstProfile(emoji);
|
||||||
|
const burstStartMs = now;
|
||||||
|
|
||||||
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
|
||||||
|
hero: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const particleCount = profile.particleCount;
|
||||||
|
for (let i = 0; i < particleCount; i += 1) {
|
||||||
|
const slot = i / particleCount;
|
||||||
|
const bornAt =
|
||||||
|
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
|
||||||
|
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
|
||||||
|
particle.orbitRadius += 42 * dtSeconds;
|
||||||
|
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
|
||||||
|
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
|
||||||
|
particle.y =
|
||||||
|
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
|
||||||
|
particle.vy += particle.gravity * dtSeconds * 0.35;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||||
|
particle.vy += particle.gravity * dtSeconds;
|
||||||
|
particle.vx *= dragFactor;
|
||||||
|
particle.vy *= dragFactor;
|
||||||
|
particle.x += particle.vx * dtSeconds;
|
||||||
|
particle.y += particle.vy * dtSeconds;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
|
||||||
|
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
|
||||||
|
particle.y = particle.spawnY + 6;
|
||||||
|
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
|
||||||
|
particle.vx *= 0.82;
|
||||||
|
particle.bouncesLeft -= 1;
|
||||||
|
particle.spin += (Math.random() - 0.5) * 120;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||||
|
particle.vy += particle.gravity * dtSeconds;
|
||||||
|
particle.vx *= dragFactor;
|
||||||
|
particle.vy *= dragFactor;
|
||||||
|
particle.x += particle.vx * dtSeconds;
|
||||||
|
particle.y += particle.vy * dtSeconds;
|
||||||
|
particle.rotation += particle.spin * dtSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepBurstParticles(
|
||||||
|
particles: BurstParticle[],
|
||||||
|
now: number,
|
||||||
|
dtSeconds: number
|
||||||
|
): void {
|
||||||
|
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||||
|
const particle = particles[i];
|
||||||
|
const burstElapsed = now - particle.burstStartMs;
|
||||||
|
|
||||||
|
if (burstElapsed > BURST_TOTAL_MS) {
|
||||||
|
particles[i] = particles[particles.length - 1];
|
||||||
|
particles.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageMs = now - particle.bornAt;
|
||||||
|
if (ageMs < 0) continue;
|
||||||
|
|
||||||
|
const dragFactor = particle.drag ** (dtSeconds * 60);
|
||||||
|
|
||||||
|
switch (particle.style) {
|
||||||
|
case 'spiral':
|
||||||
|
stepSpiralParticle(particle, dtSeconds);
|
||||||
|
break;
|
||||||
|
case 'bounce':
|
||||||
|
stepBounceParticle(particle, dtSeconds, dragFactor);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stepDefaultParticle(particle, dtSeconds, dragFactor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawState = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
opacity: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
|
||||||
|
const burstElapsed = now - particle.burstStartMs;
|
||||||
|
if (burstElapsed > BURST_TOTAL_MS) return null;
|
||||||
|
|
||||||
|
const ageMs = now - particle.bornAt;
|
||||||
|
if (ageMs < 0) return null;
|
||||||
|
|
||||||
|
let scale = particle.peakScale;
|
||||||
|
let opacity = emergeOpacity(
|
||||||
|
particle.x,
|
||||||
|
particle.y,
|
||||||
|
particle.spawnX,
|
||||||
|
particle.spawnY,
|
||||||
|
particle.maskRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opacity <= 0) return null;
|
||||||
|
|
||||||
|
if (ageMs < POP_IN_MS) {
|
||||||
|
const pop = easeOutBack(ageMs / POP_IN_MS);
|
||||||
|
scale = 0.1 + pop * particle.peakScale;
|
||||||
|
opacity *= pop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.twinkle) {
|
||||||
|
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.wobble) {
|
||||||
|
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
|
||||||
|
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (burstElapsed >= BURST_FADE_START_MS) {
|
||||||
|
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
|
||||||
|
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
|
||||||
|
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opacity <= 0.02) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: particle.x,
|
||||||
|
y: particle.y,
|
||||||
|
scale,
|
||||||
|
opacity,
|
||||||
|
rotation: particle.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBurstParticles(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
particles: BurstParticle[],
|
||||||
|
dpr: number,
|
||||||
|
now: number
|
||||||
|
) {
|
||||||
|
for (const particle of particles) {
|
||||||
|
const state = particleDrawState(particle, now);
|
||||||
|
if (!state) continue;
|
||||||
|
|
||||||
|
context.globalAlpha = state.opacity;
|
||||||
|
|
||||||
|
const emojiCanvas = getEmojiCanvas(particle.emoji);
|
||||||
|
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
|
||||||
|
const halfSize = drawSize / 2;
|
||||||
|
const radians = (state.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(radians) * dpr;
|
||||||
|
const sin = Math.sin(radians) * dpr;
|
||||||
|
|
||||||
|
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
|
||||||
|
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
|
||||||
|
return particles.some((particle) => particle.burstId === burstId);
|
||||||
|
}
|
||||||
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
export type BurstMotionStyle =
|
||||||
|
| 'explode'
|
||||||
|
| 'bounce'
|
||||||
|
| 'rise'
|
||||||
|
| 'sparkle'
|
||||||
|
| 'splash'
|
||||||
|
| 'spiral'
|
||||||
|
| 'punch'
|
||||||
|
| 'scatter'
|
||||||
|
| 'shower';
|
||||||
|
|
||||||
|
export type EmojiBurstProfile = {
|
||||||
|
style: BurstMotionStyle;
|
||||||
|
particleCount: number;
|
||||||
|
gravity: number;
|
||||||
|
drag: number;
|
||||||
|
speedMin: number;
|
||||||
|
speedMax: number;
|
||||||
|
launchUpMin: number;
|
||||||
|
launchUpMax: number;
|
||||||
|
spinMin: number;
|
||||||
|
spinMax: number;
|
||||||
|
fontSizeMin: number;
|
||||||
|
fontSizeMax: number;
|
||||||
|
heroFontSizeMin: number;
|
||||||
|
heroFontSizeMax: number;
|
||||||
|
companions?: string[];
|
||||||
|
companionChance?: number;
|
||||||
|
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
|
||||||
|
angleBias?: number;
|
||||||
|
/** Limit spawn to a cone (radians). Omit for full 360°. */
|
||||||
|
angleSpread?: number;
|
||||||
|
twinkle?: boolean;
|
||||||
|
wobble?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||||
|
style: 'explode',
|
||||||
|
particleCount: 36,
|
||||||
|
gravity: 400,
|
||||||
|
drag: 0.935,
|
||||||
|
speedMin: 240,
|
||||||
|
speedMax: 560,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 130,
|
||||||
|
spinMin: -180,
|
||||||
|
spinMax: 180,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 26,
|
||||||
|
heroFontSizeMin: 26,
|
||||||
|
heroFontSizeMax: 34,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
||||||
|
'😆': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 28,
|
||||||
|
gravity: 520,
|
||||||
|
speedMin: 180,
|
||||||
|
speedMax: 360,
|
||||||
|
launchUpMin: 120,
|
||||||
|
launchUpMax: 220,
|
||||||
|
spinMin: -240,
|
||||||
|
spinMax: 240,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'😂': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 30,
|
||||||
|
gravity: 500,
|
||||||
|
speedMin: 160,
|
||||||
|
speedMax: 340,
|
||||||
|
launchUpMin: 110,
|
||||||
|
launchUpMax: 210,
|
||||||
|
spinMin: -220,
|
||||||
|
spinMax: 220,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🤣': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 32,
|
||||||
|
gravity: 480,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 380,
|
||||||
|
launchUpMin: 130,
|
||||||
|
launchUpMax: 240,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🔥': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 24,
|
||||||
|
gravity: -120,
|
||||||
|
drag: 0.96,
|
||||||
|
speedMin: 80,
|
||||||
|
speedMax: 200,
|
||||||
|
launchUpMin: 60,
|
||||||
|
launchUpMax: 160,
|
||||||
|
spinMin: -90,
|
||||||
|
spinMax: 90,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
twinkle: true,
|
||||||
|
wobble: true,
|
||||||
|
companions: ['✨'],
|
||||||
|
companionChance: 0.35,
|
||||||
|
},
|
||||||
|
'❤️': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 22,
|
||||||
|
gravity: -80,
|
||||||
|
drag: 0.97,
|
||||||
|
speedMin: 60,
|
||||||
|
speedMax: 150,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 110,
|
||||||
|
spinMin: -40,
|
||||||
|
spinMax: 40,
|
||||||
|
fontSizeMin: 14,
|
||||||
|
fontSizeMax: 24,
|
||||||
|
companions: ['💕', '💖'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'💕': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: -70,
|
||||||
|
drag: 0.97,
|
||||||
|
speedMin: 50,
|
||||||
|
speedMax: 140,
|
||||||
|
launchUpMin: 35,
|
||||||
|
launchUpMax: 100,
|
||||||
|
spinMin: -35,
|
||||||
|
spinMax: 35,
|
||||||
|
},
|
||||||
|
'💖': {
|
||||||
|
style: 'rise',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: -90,
|
||||||
|
drag: 0.968,
|
||||||
|
speedMin: 55,
|
||||||
|
speedMax: 150,
|
||||||
|
launchUpMin: 45,
|
||||||
|
launchUpMax: 115,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
|
'⭐': {
|
||||||
|
style: 'sparkle',
|
||||||
|
particleCount: 26,
|
||||||
|
gravity: 40,
|
||||||
|
drag: 0.94,
|
||||||
|
speedMin: 100,
|
||||||
|
speedMax: 260,
|
||||||
|
launchUpMin: 20,
|
||||||
|
launchUpMax: 80,
|
||||||
|
spinMin: -120,
|
||||||
|
spinMax: 120,
|
||||||
|
twinkle: true,
|
||||||
|
companions: ['✨'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
},
|
||||||
|
'✨': {
|
||||||
|
style: 'sparkle',
|
||||||
|
particleCount: 30,
|
||||||
|
gravity: 30,
|
||||||
|
drag: 0.945,
|
||||||
|
speedMin: 90,
|
||||||
|
speedMax: 240,
|
||||||
|
launchUpMin: 15,
|
||||||
|
launchUpMax: 70,
|
||||||
|
spinMin: -200,
|
||||||
|
spinMax: 200,
|
||||||
|
twinkle: true,
|
||||||
|
},
|
||||||
|
'💀': {
|
||||||
|
style: 'scatter',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 620,
|
||||||
|
drag: 0.92,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 420,
|
||||||
|
launchUpMin: 20,
|
||||||
|
launchUpMax: 90,
|
||||||
|
spinMin: -360,
|
||||||
|
spinMax: 360,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 28,
|
||||||
|
},
|
||||||
|
'🎉': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 40,
|
||||||
|
gravity: 280,
|
||||||
|
drag: 0.93,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 480,
|
||||||
|
launchUpMin: 80,
|
||||||
|
launchUpMax: 200,
|
||||||
|
companions: ['🎊', '✨', '🎈'],
|
||||||
|
companionChance: 0.45,
|
||||||
|
},
|
||||||
|
'🎊': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 38,
|
||||||
|
gravity: 260,
|
||||||
|
drag: 0.932,
|
||||||
|
speedMin: 190,
|
||||||
|
speedMax: 460,
|
||||||
|
launchUpMin: 70,
|
||||||
|
launchUpMax: 190,
|
||||||
|
companions: ['🎉', '✨'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'👏': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 18,
|
||||||
|
gravity: 320,
|
||||||
|
speedMin: 220,
|
||||||
|
speedMax: 400,
|
||||||
|
launchUpMin: 30,
|
||||||
|
launchUpMax: 100,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.85,
|
||||||
|
spinMin: -100,
|
||||||
|
spinMax: 100,
|
||||||
|
},
|
||||||
|
'👍': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 14,
|
||||||
|
gravity: 380,
|
||||||
|
speedMin: 260,
|
||||||
|
speedMax: 440,
|
||||||
|
launchUpMin: 100,
|
||||||
|
launchUpMax: 200,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.55,
|
||||||
|
spinMin: -60,
|
||||||
|
spinMax: 60,
|
||||||
|
},
|
||||||
|
'💯': {
|
||||||
|
style: 'punch',
|
||||||
|
particleCount: 16,
|
||||||
|
gravity: 300,
|
||||||
|
speedMin: 200,
|
||||||
|
speedMax: 380,
|
||||||
|
launchUpMin: 140,
|
||||||
|
launchUpMax: 240,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.45,
|
||||||
|
fontSizeMin: 16,
|
||||||
|
fontSizeMax: 30,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'💦': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 32,
|
||||||
|
gravity: 540,
|
||||||
|
drag: 0.925,
|
||||||
|
speedMin: 280,
|
||||||
|
speedMax: 520,
|
||||||
|
launchUpMin: 160,
|
||||||
|
launchUpMax: 300,
|
||||||
|
spinMin: -140,
|
||||||
|
spinMax: 140,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.1,
|
||||||
|
},
|
||||||
|
'🌊': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 28,
|
||||||
|
gravity: 420,
|
||||||
|
drag: 0.93,
|
||||||
|
speedMin: 220,
|
||||||
|
speedMax: 460,
|
||||||
|
launchUpMin: 100,
|
||||||
|
launchUpMax: 220,
|
||||||
|
angleBias: -Math.PI / 2,
|
||||||
|
angleSpread: Math.PI,
|
||||||
|
companions: ['💧'],
|
||||||
|
companionChance: 0.3,
|
||||||
|
},
|
||||||
|
'⚡': {
|
||||||
|
style: 'scatter',
|
||||||
|
particleCount: 14,
|
||||||
|
gravity: 180,
|
||||||
|
drag: 0.88,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 680,
|
||||||
|
launchUpMin: 10,
|
||||||
|
launchUpMax: 60,
|
||||||
|
spinMin: -30,
|
||||||
|
spinMax: 30,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 32,
|
||||||
|
angleSpread: Math.PI * 1.2,
|
||||||
|
},
|
||||||
|
'🌀': {
|
||||||
|
style: 'spiral',
|
||||||
|
particleCount: 24,
|
||||||
|
gravity: 60,
|
||||||
|
drag: 0.96,
|
||||||
|
speedMin: 120,
|
||||||
|
speedMax: 260,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 40,
|
||||||
|
spinMin: -420,
|
||||||
|
spinMax: 420,
|
||||||
|
},
|
||||||
|
'🤯': {
|
||||||
|
style: 'explode',
|
||||||
|
particleCount: 34,
|
||||||
|
gravity: 360,
|
||||||
|
speedMin: 280,
|
||||||
|
speedMax: 580,
|
||||||
|
launchUpMin: 60,
|
||||||
|
launchUpMax: 160,
|
||||||
|
companions: ['💥', '✨', '⭐'],
|
||||||
|
companionChance: 0.5,
|
||||||
|
},
|
||||||
|
'😭': {
|
||||||
|
style: 'splash',
|
||||||
|
particleCount: 26,
|
||||||
|
gravity: 480,
|
||||||
|
drag: 0.94,
|
||||||
|
speedMin: 140,
|
||||||
|
speedMax: 300,
|
||||||
|
launchUpMin: 40,
|
||||||
|
launchUpMax: 120,
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 0.7,
|
||||||
|
companions: ['💧'],
|
||||||
|
companionChance: 0.55,
|
||||||
|
},
|
||||||
|
'🥳': {
|
||||||
|
style: 'shower',
|
||||||
|
particleCount: 36,
|
||||||
|
gravity: 250,
|
||||||
|
speedMin: 180,
|
||||||
|
speedMax: 440,
|
||||||
|
launchUpMin: 90,
|
||||||
|
launchUpMax: 210,
|
||||||
|
companions: ['🎉', '🎊', '🎈'],
|
||||||
|
companionChance: 0.4,
|
||||||
|
},
|
||||||
|
'🐱': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 440,
|
||||||
|
speedMin: 150,
|
||||||
|
speedMax: 320,
|
||||||
|
launchUpMin: 90,
|
||||||
|
launchUpMax: 180,
|
||||||
|
spinMin: -160,
|
||||||
|
spinMax: 160,
|
||||||
|
},
|
||||||
|
'🐶': {
|
||||||
|
style: 'bounce',
|
||||||
|
particleCount: 20,
|
||||||
|
gravity: 460,
|
||||||
|
speedMin: 160,
|
||||||
|
speedMax: 330,
|
||||||
|
launchUpMin: 85,
|
||||||
|
launchUpMax: 175,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
||||||
|
const override = PROFILE_OVERRIDES[emoji];
|
||||||
|
if (!override) return DEFAULT_PROFILE;
|
||||||
|
return { ...DEFAULT_PROFILE, ...override };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
|
if (!profile.companions?.length || !profile.companionChance) {
|
||||||
|
return primaryEmoji;
|
||||||
|
}
|
||||||
|
if (Math.random() >= profile.companionChance) {
|
||||||
|
return primaryEmoji;
|
||||||
|
}
|
||||||
|
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
|
||||||
|
return companion ?? primaryEmoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
|
||||||
|
if (profile.angleSpread === undefined) {
|
||||||
|
return Math.random() * Math.PI * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bias = profile.angleBias ?? -Math.PI / 2;
|
||||||
|
const halfSpread = profile.angleSpread / 2;
|
||||||
|
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
|
||||||
|
}
|
||||||
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
|
||||||
|
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const jumbo = message.querySelector('[data-jumbo-emoji]');
|
||||||
|
return jumbo instanceof HTMLElement ? jumbo : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
||||||
|
return Math.max(300, Math.round(maskRadius * 6.5));
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
|
||||||
|
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
||||||
|
|
||||||
|
export function getJumboEmojiInteractionProps({
|
||||||
|
body,
|
||||||
|
isJumboEmoji,
|
||||||
|
onJumboEmojiClick,
|
||||||
|
}: {
|
||||||
|
body: string;
|
||||||
|
isJumboEmoji: boolean;
|
||||||
|
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||||
|
}) {
|
||||||
|
if (!isJumboEmoji || !onJumboEmojiClick) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||||
|
if ('key' in event) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
onJumboEmojiClick(body, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'button' as const,
|
||||||
|
tabIndex: 0,
|
||||||
|
title: 'Throw emoji confetti',
|
||||||
|
onClick: activate,
|
||||||
|
onKeyDown: activate,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
import { extractJumboEmojis } from './sendEmojiConfetti';
|
||||||
|
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
|
||||||
|
|
||||||
|
export function resolveClickedEmoji(
|
||||||
|
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
|
||||||
|
fallbackBody: string
|
||||||
|
): { emoji: string; origin: BurstPoint } | null {
|
||||||
|
const currentTarget = event.currentTarget as HTMLElement;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
|
||||||
|
if (emoticonEl instanceof HTMLElement) {
|
||||||
|
const emoji = emoticonEl.getAttribute('data-emoticon');
|
||||||
|
if (emoji) {
|
||||||
|
return {
|
||||||
|
emoji,
|
||||||
|
origin: getBurstPointFromElement(emoticonEl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = extractJumboEmojis(fallbackBody);
|
||||||
|
if (emojis.length === 0) return null;
|
||||||
|
|
||||||
|
if ('clientX' in event) {
|
||||||
|
return {
|
||||||
|
emoji: emojis[0],
|
||||||
|
origin: {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
|
||||||
|
if (firstEmoticon instanceof HTMLElement) {
|
||||||
|
const emoji = firstEmoticon.getAttribute('data-emoticon');
|
||||||
|
if (emoji) {
|
||||||
|
return {
|
||||||
|
emoji,
|
||||||
|
origin: getBurstPointFromElement(firstEmoticon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji: emojis[0],
|
||||||
|
origin: getBurstPointFromElement(currentTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
|
||||||
|
import { trimReplyFromBody } from '../../../utils/room';
|
||||||
|
import { emojis } from '../../../plugins/emoji';
|
||||||
|
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
|
||||||
|
|
||||||
|
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
|
||||||
|
|
||||||
|
export function extractJumboEmojis(body: string): string[] {
|
||||||
|
const trimmedBody = trimReplyFromBody(body).trim();
|
||||||
|
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
|
||||||
|
|
||||||
|
const found: string[] = [];
|
||||||
|
|
||||||
|
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
|
||||||
|
const emoji = match[1];
|
||||||
|
if (emoji) found.push(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
|
||||||
|
const shortcode = match[1];
|
||||||
|
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
|
||||||
|
if (resolved) found.push(resolved.unicode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.length > 0 ? found : ['🎉'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendEmojiConfettiEvent(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
targetEventId: string,
|
||||||
|
emojis: string[]
|
||||||
|
) {
|
||||||
|
const content: EmojiConfettiContent = {
|
||||||
|
emojis,
|
||||||
|
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
|
||||||
|
target_event_id: targetEventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
|
||||||
|
}
|
||||||
16
src/app/features/room/emoji-confetti/types.ts
Normal file
16
src/app/features/room/emoji-confetti/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { BurstPoint } from './burstOrigin';
|
||||||
|
|
||||||
|
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
|
||||||
|
|
||||||
|
export type EmojiConfettiContent = {
|
||||||
|
emojis?: string[];
|
||||||
|
msgtype?: string;
|
||||||
|
target_event_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmojiConfettiBurst = {
|
||||||
|
id: string;
|
||||||
|
targetEventId: string;
|
||||||
|
emojis: string[];
|
||||||
|
origin: BurstPoint;
|
||||||
|
};
|
||||||
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||||
|
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
|
||||||
|
import {
|
||||||
|
EMOJI_CONFETTI_EVENT_TYPE,
|
||||||
|
EmojiConfettiBurst,
|
||||||
|
EmojiConfettiContent,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function scheduleBurstFromValues(
|
||||||
|
burstId: string,
|
||||||
|
emojis: string[],
|
||||||
|
targetEventId: string | undefined,
|
||||||
|
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||||
|
attempt = 0,
|
||||||
|
explicitOrigin?: BurstPoint
|
||||||
|
) {
|
||||||
|
const targetElement =
|
||||||
|
targetEventId &&
|
||||||
|
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||||
|
|
||||||
|
if (targetEventId && !targetElement && attempt < 8) {
|
||||||
|
window.setTimeout(
|
||||||
|
() =>
|
||||||
|
scheduleBurstFromValues(
|
||||||
|
burstId,
|
||||||
|
emojis,
|
||||||
|
targetEventId,
|
||||||
|
onBurst,
|
||||||
|
attempt + 1,
|
||||||
|
explicitOrigin
|
||||||
|
),
|
||||||
|
50
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBurst({
|
||||||
|
id: burstId,
|
||||||
|
targetEventId: targetEventId ?? '',
|
||||||
|
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||||
|
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBurst(
|
||||||
|
event: MatrixEvent,
|
||||||
|
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||||
|
attempt = 0
|
||||||
|
) {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
|
||||||
|
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
|
||||||
|
const confettiContent = content as EmojiConfettiContent;
|
||||||
|
const emojis = Array.isArray(confettiContent.emojis)
|
||||||
|
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||||
|
targetEventId:
|
||||||
|
typeof confettiContent.target_event_id === 'string'
|
||||||
|
? confettiContent.target_event_id
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmojiConfetti(room: Room) {
|
||||||
|
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
|
||||||
|
const seenEventIdsRef = useRef(new Set<string>());
|
||||||
|
const ownEventIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
|
||||||
|
setBursts((current) => [...current, burst]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerOwnEventId = useCallback((eventId: string) => {
|
||||||
|
ownEventIdsRef.current.add(eventId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const queueBurst = useCallback((event: MatrixEvent) => {
|
||||||
|
const eventId = event.getId();
|
||||||
|
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
|
||||||
|
|
||||||
|
seenEventIdsRef.current.add(eventId);
|
||||||
|
|
||||||
|
if (ownEventIdsRef.current.has(eventId)) {
|
||||||
|
ownEventIdsRef.current.delete(eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBurst(event, addBurst);
|
||||||
|
}, [addBurst]);
|
||||||
|
|
||||||
|
const triggerLocalBurst = useCallback(
|
||||||
|
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
|
||||||
|
const burstId = `local-${targetEventId}-${Date.now()}`;
|
||||||
|
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
|
||||||
|
},
|
||||||
|
[addBurst]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline: (
|
||||||
|
event: MatrixEvent,
|
||||||
|
eventRoom: Room | undefined,
|
||||||
|
toStartOfTimeline?: boolean,
|
||||||
|
removed?: boolean
|
||||||
|
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
|
||||||
|
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
|
||||||
|
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
|
||||||
|
|
||||||
|
queueBurst(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
room.on(RoomEvent.Timeline, handleTimeline);
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Timeline, handleTimeline);
|
||||||
|
};
|
||||||
|
}, [queueBurst, room]);
|
||||||
|
|
||||||
|
const removeBurst = useCallback((burstId: string) => {
|
||||||
|
setBursts((current) => current.filter((burst) => burst.id !== burstId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bursts,
|
||||||
|
removeBurst,
|
||||||
|
triggerLocalBurst,
|
||||||
|
registerOwnEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { resolveClickedEmoji } from './resolveClickedEmoji';
|
||||||
|
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
|
||||||
|
import { useEmojiConfetti } from './useEmojiConfetti';
|
||||||
|
|
||||||
|
export function useJumboEmojiConfetti(room: Room) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
|
||||||
|
|
||||||
|
const handleJumboEmojiClick = useCallback(
|
||||||
|
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||||
|
const clicked = resolveClickedEmoji(event, body);
|
||||||
|
if (!clicked) return;
|
||||||
|
|
||||||
|
const emojis = [clicked.emoji];
|
||||||
|
|
||||||
|
triggerLocalBurst(targetEventId, emojis, clicked.origin);
|
||||||
|
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
|
||||||
|
.then((response) => {
|
||||||
|
const eventId = response?.event_id;
|
||||||
|
if (eventId) registerOwnEventId(eventId);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[emoji-confetti] Failed to send confetti event:', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bursts,
|
||||||
|
removeBurst,
|
||||||
|
handleJumboEmojiClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import dayjs from 'dayjs';
|
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 { 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 { Icon, Icons } from '../../../components/icons';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
LightTheme,
|
LightTheme,
|
||||||
Theme,
|
Theme,
|
||||||
ThemeKind,
|
ThemeKind,
|
||||||
|
themePreviewIdAtom,
|
||||||
useSystemThemeKind,
|
useSystemThemeKind,
|
||||||
useThemeNames,
|
useThemeNames,
|
||||||
useThemes,
|
useThemes,
|
||||||
@@ -41,28 +43,49 @@ type ThemeSelectorProps = {
|
|||||||
onSelect: (theme: Theme) => void;
|
onSelect: (theme: Theme) => void;
|
||||||
};
|
};
|
||||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
|
||||||
<Menu {...props} ref={ref}>
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
{themes.map((theme) => (
|
useEffect(
|
||||||
<MenuItem
|
() => () => {
|
||||||
key={theme.id}
|
setPreviewId(undefined);
|
||||||
size="300"
|
},
|
||||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
[setPreviewId]
|
||||||
radii="300"
|
);
|
||||||
onClick={() => onSelect(theme)}
|
|
||||||
>
|
const clearPreview = () => setPreviewId(undefined);
|
||||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
|
||||||
</MenuItem>
|
|
||||||
))}
|
return (
|
||||||
</Box>
|
<Menu
|
||||||
</Menu>
|
{...props}
|
||||||
)
|
ref={ref}
|
||||||
|
onMouseLeave={clearPreview}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<MenuItem
|
||||||
|
key={theme.id}
|
||||||
|
size="300"
|
||||||
|
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onMouseEnter={() => previewTheme(theme)}
|
||||||
|
onFocus={() => previewTheme(theme)}
|
||||||
|
onClick={() => onSelect(theme)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||||
const themes = useThemes();
|
const themes = useThemes();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||||
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeSelect = (theme: Theme) => {
|
const handleThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setThemeId(theme.id);
|
setThemeId(theme.id);
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setMenuCords(undefined),
|
onDeactivate: closeMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
|
|||||||
const themeKind = useSystemThemeKind();
|
const themeKind = useSystemThemeKind();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
const themes = useThemes();
|
const themes = useThemes();
|
||||||
|
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||||
|
|
||||||
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLightThemeSelect = (theme: Theme) => {
|
const handleLightThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setLightThemeId(theme.id);
|
setLightThemeId(theme.id);
|
||||||
setLTCords(undefined);
|
setLTCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDarkThemeSelect = (theme: Theme) => {
|
const handleDarkThemeSelect = (theme: Theme) => {
|
||||||
|
setPreviewId(undefined);
|
||||||
setDarkThemeId(theme.id);
|
setDarkThemeId(theme.id);
|
||||||
setDTCords(undefined);
|
setDTCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeLightMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setLTCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDarkMenu = () => {
|
||||||
|
setPreviewId(undefined);
|
||||||
|
setDTCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box wrap="Wrap" gap="400">
|
<Box wrap="Wrap" gap="400">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setLTCords(undefined),
|
onDeactivate: closeLightMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => setDTCords(undefined),
|
onDeactivate: closeDarkMenu,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ type PushStatus = {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
distributors: string[];
|
distributors: string[];
|
||||||
lastFailure: string;
|
lastFailure: string;
|
||||||
|
autoStartBlocked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||||
@@ -196,6 +197,7 @@ function AndroidPushNotifications() {
|
|||||||
endpoint: s.endpoint || '',
|
endpoint: s.endpoint || '',
|
||||||
distributors,
|
distributors,
|
||||||
lastFailure: s.lastFailure || '',
|
lastFailure: s.lastFailure || '',
|
||||||
|
autoStartBlocked: Boolean(s.autoStartBlocked),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
@@ -246,6 +248,13 @@ function AndroidPushNotifications() {
|
|||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (status.lastFailure) {
|
if (status.lastFailure) {
|
||||||
return (
|
return (
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lightTheme } from 'folds';
|
import { lightTheme } from 'folds';
|
||||||
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||||
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css';
|
||||||
@@ -6,6 +7,9 @@ import { settingsAtom } from '../state/settings';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
|
||||||
|
|
||||||
|
/** Ephemeral theme id while hovering theme options in settings (not persisted). */
|
||||||
|
export const themePreviewIdAtom = atom<string | undefined>(undefined);
|
||||||
|
|
||||||
export enum ThemeKind {
|
export enum ThemeKind {
|
||||||
Light = 'light',
|
Light = 'light',
|
||||||
Dark = 'dark',
|
Dark = 'dark',
|
||||||
@@ -190,6 +194,16 @@ export const useActiveTheme = (): Theme => {
|
|||||||
return selectedTheme;
|
return selectedTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Active theme, or hover-preview theme when browsing the theme menu. */
|
||||||
|
export const useDisplayTheme = (): Theme => {
|
||||||
|
const activeTheme = useActiveTheme();
|
||||||
|
const themes = useThemes();
|
||||||
|
const previewId = useAtomValue(themePreviewIdAtom);
|
||||||
|
|
||||||
|
if (!previewId) return activeTheme;
|
||||||
|
return themes.find((theme) => theme.id === previewId) ?? activeTheme;
|
||||||
|
};
|
||||||
|
|
||||||
const ThemeContext = createContext<Theme | null>(null);
|
const ThemeContext = createContext<Theme | null>(null);
|
||||||
export const ThemeContextProvider = ThemeContext.Provider;
|
export const ThemeContextProvider = ThemeContext.Provider;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
LightTheme,
|
LightTheme,
|
||||||
ThemeContextProvider,
|
ThemeContextProvider,
|
||||||
ThemeKind,
|
ThemeKind,
|
||||||
useActiveTheme,
|
useDisplayTheme,
|
||||||
useSystemThemeKind,
|
useSystemThemeKind,
|
||||||
} from '../hooks/useTheme';
|
} from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
@@ -29,21 +29,21 @@ export function UnAuthRouteThemeManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
|
||||||
const activeTheme = useActiveTheme();
|
const displayTheme = useDisplayTheme();
|
||||||
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.className = '';
|
document.body.className = '';
|
||||||
document.body.classList.add(configClass, varsClass);
|
document.body.classList.add(configClass, varsClass);
|
||||||
|
|
||||||
document.body.classList.add(...activeTheme.classNames);
|
document.body.classList.add(...displayTheme.classNames);
|
||||||
|
|
||||||
if (monochromeMode) {
|
if (monochromeMode) {
|
||||||
document.body.style.filter = 'grayscale(1)';
|
document.body.style.filter = 'grayscale(1)';
|
||||||
} else {
|
} else {
|
||||||
document.body.style.filter = '';
|
document.body.style.filter = '';
|
||||||
}
|
}
|
||||||
}, [activeTheme, monochromeMode]);
|
}, [displayTheme, monochromeMode]);
|
||||||
|
|
||||||
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
|
return <ThemeContextProvider value={displayTheme}>{children}</ThemeContextProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
|||||||
text,
|
text,
|
||||||
EMOJI_REG_G,
|
EMOJI_REG_G,
|
||||||
(match, pushIndex) => (
|
(match, pushIndex) => (
|
||||||
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase} data-emoticon={match[0]}>
|
||||||
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
||||||
{match[0]}
|
{match[0]}
|
||||||
</span>
|
</span>
|
||||||
@@ -551,8 +551,15 @@ export const getReactCustomHtmlParser = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||||
|
const emoticonLabel =
|
||||||
|
(typeof props.alt === 'string' && props.alt) ||
|
||||||
|
(typeof props.title === 'string' && props.title) ||
|
||||||
|
undefined;
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase}>
|
<span
|
||||||
|
className={css.EmoticonBase}
|
||||||
|
{...(emoticonLabel ? { 'data-emoticon': emoticonLabel } : {})}
|
||||||
|
>
|
||||||
<span className={css.Emoticon()}>
|
<span className={css.Emoticon()}>
|
||||||
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
|
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type UnifiedPushStatus = {
|
|||||||
distributor: string;
|
distributor: string;
|
||||||
distributors: string[] | string;
|
distributors: string[] | string;
|
||||||
lastFailure?: string;
|
lastFailure?: string;
|
||||||
|
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
|
||||||
|
autoStartBlocked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnifiedPushEndpointEvent = {
|
type UnifiedPushEndpointEvent = {
|
||||||
|
|||||||
365
src/playground/App.tsx
Normal file
365
src/playground/App.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { useDeferredValue, useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
import Editor, { loader } from '@monaco-editor/react';
|
||||||
|
import * as monaco from 'monaco-editor';
|
||||||
|
import './monacoSetup';
|
||||||
|
import { getCatalog, harnessForModule, type CatalogEntry } from './catalog';
|
||||||
|
import { DEFAULT_SOURCE } from './defaultSource';
|
||||||
|
import { loadManualSession, type ManualSessionInput } from './liveClient';
|
||||||
|
import { PlaygroundProviders } from './mocks/PlaygroundProviders';
|
||||||
|
import { PreviewErrorBoundary } from './PreviewErrorBoundary';
|
||||||
|
import { ResizableStage } from './ResizableStage';
|
||||||
|
import { LivePreview } from './LivePreview';
|
||||||
|
import { usePlaygroundMatrix } from './usePlaygroundMatrix';
|
||||||
|
|
||||||
|
// Use the npm monaco build — the CDN AMD loader breaks UMD deps like sanitize-html.
|
||||||
|
loader.config({ monaco });
|
||||||
|
|
||||||
|
type EditorTab = 'component' | 'harness';
|
||||||
|
|
||||||
|
async function pushLiveSource(source: string) {
|
||||||
|
await fetch('/__playground/live', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
body: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchComponentSource(filePath: string): Promise<string> {
|
||||||
|
const res = await fetch(`/__playground/source?path=${encodeURIComponent(filePath)}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${filePath}: ${res.status}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushComponentSource(filePath: string, source: string) {
|
||||||
|
await fetch(`/__playground/source?path=${encodeURIComponent(filePath)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
body: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const catalog = useMemo(() => getCatalog(), []);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [harnessSource, setHarnessSource] = useState(DEFAULT_SOURCE);
|
||||||
|
const [componentSource, setComponentSource] = useState<string>('');
|
||||||
|
const [editorTab, setEditorTab] = useState<EditorTab>('harness');
|
||||||
|
const [selected, setSelected] = useState<CatalogEntry | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [showTokenForm, setShowTokenForm] = useState(false);
|
||||||
|
const [manual, setManual] = useState<ManualSessionInput>(() => {
|
||||||
|
const saved = loadManualSession();
|
||||||
|
return (
|
||||||
|
saved ?? {
|
||||||
|
baseUrl: 'https://matrix.org',
|
||||||
|
userId: '@you:matrix.org',
|
||||||
|
deviceId: 'PLAYGROUND',
|
||||||
|
accessToken: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const deferredHarness = useDeferredValue(harnessSource);
|
||||||
|
const deferredComponent = useDeferredValue(componentSource);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const matrix = usePlaygroundMatrix();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/__playground/live')
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => {
|
||||||
|
if (text.trim()) setHarnessSource(text);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
void pushLiveSource(deferredHarness);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
}, [deferredHarness]);
|
||||||
|
|
||||||
|
// Upgrade stale RoomInput harness (bare <RoomInput /> or mocks.room) to useRoom().
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected?.importPath.endsWith('/RoomInput')) return;
|
||||||
|
if (harnessSource.includes('useRoom()')) return;
|
||||||
|
setHarnessSource(harnessForModule(selected.importPath));
|
||||||
|
}, [selected, harnessSource]);
|
||||||
|
|
||||||
|
// Writes the real component file — strings/markup edits land in src/app/…
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected || editorTab !== 'component') return;
|
||||||
|
if (!deferredComponent) return;
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
void pushComponentSource(selected.filePath, deferredComponent).catch((err) => {
|
||||||
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
}, [deferredComponent, editorTab, selected]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) return catalog;
|
||||||
|
return catalog.filter((e) => e.label.toLowerCase().includes(q));
|
||||||
|
}, [catalog, filter]);
|
||||||
|
|
||||||
|
const openEntry = (entry: CatalogEntry) => {
|
||||||
|
setSelected(entry);
|
||||||
|
setLoadError(null);
|
||||||
|
setEditorTab('component');
|
||||||
|
startTransition(() => {
|
||||||
|
setHarnessSource(harnessForModule(entry.importPath));
|
||||||
|
});
|
||||||
|
void fetchComponentSource(entry.filePath)
|
||||||
|
.then((text) => setComponentSource(text))
|
||||||
|
.catch((err) => {
|
||||||
|
setComponentSource('');
|
||||||
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveReady = matrix.mode === 'live' && matrix.status === 'ready' && matrix.client;
|
||||||
|
const previewClient = liveReady ? matrix.client! : undefined;
|
||||||
|
const previewRoom = liveReady ? matrix.room ?? undefined : undefined;
|
||||||
|
|
||||||
|
const editorValue = editorTab === 'component' ? componentSource : harnessSource;
|
||||||
|
const editorPath =
|
||||||
|
editorTab === 'component' && selected
|
||||||
|
? selected.filePath
|
||||||
|
: 'playground-live.tsx';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<strong>Component Playground</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
— Component tab edits real source · Harness tab only mounts it
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(null);
|
||||||
|
setComponentSource('');
|
||||||
|
setEditorTab('harness');
|
||||||
|
setHarnessSource(DEFAULT_SOURCE);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset message preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="conn-bar">
|
||||||
|
<div className="conn-modes">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={matrix.mode === 'mock' ? 'conn-btn active' : 'conn-btn'}
|
||||||
|
onClick={() => matrix.useMocks()}
|
||||||
|
>
|
||||||
|
Mocks
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={matrix.mode === 'live' ? 'conn-btn active' : 'conn-btn'}
|
||||||
|
disabled={matrix.status === 'connecting'}
|
||||||
|
onClick={() => {
|
||||||
|
void matrix.connectLive();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
matrix.hasPaarrotSession
|
||||||
|
? `Use logged-in session ${matrix.paarrotUserId}`
|
||||||
|
: 'Needs Paarrot login on this origin, or a pasted token'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{matrix.status === 'connecting' ? 'Connecting…' : 'Paarrot session'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="conn-btn"
|
||||||
|
onClick={() => setShowTokenForm((v) => !v)}
|
||||||
|
>
|
||||||
|
Token…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="conn-status">
|
||||||
|
{matrix.mode === 'mock' && <span className="muted">Using lightweight mocks</span>}
|
||||||
|
{matrix.mode === 'live' && matrix.status === 'connecting' && (
|
||||||
|
<span className="muted">Syncing (memory store, no crypto)…</span>
|
||||||
|
)}
|
||||||
|
{matrix.mode === 'live' && matrix.status === 'ready' && (
|
||||||
|
<span className="conn-ok">{matrix.sessionLabel}</span>
|
||||||
|
)}
|
||||||
|
{matrix.mode === 'live' && matrix.status === 'error' && (
|
||||||
|
<span className="conn-err">{matrix.error}</span>
|
||||||
|
)}
|
||||||
|
{!matrix.hasPaarrotSession && matrix.mode !== 'live' && (
|
||||||
|
<span className="muted">Tip: log into Paarrot at / first, then hit Paarrot session</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{liveReady && matrix.rooms.length > 0 && (
|
||||||
|
<label className="conn-room">
|
||||||
|
Room
|
||||||
|
<select
|
||||||
|
value={matrix.room?.roomId ?? ''}
|
||||||
|
onChange={(e) => matrix.selectRoom(e.target.value)}
|
||||||
|
>
|
||||||
|
{matrix.rooms.map((r) => (
|
||||||
|
<option key={r.roomId} value={r.roomId}>
|
||||||
|
{r.name || r.roomId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTokenForm && (
|
||||||
|
<form
|
||||||
|
className="token-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowTokenForm(false);
|
||||||
|
void matrix.connectLive(manual);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
placeholder="Homeserver URL"
|
||||||
|
value={manual.baseUrl}
|
||||||
|
onChange={(e) => setManual((m) => ({ ...m, baseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="@user:server"
|
||||||
|
value={manual.userId}
|
||||||
|
onChange={(e) => setManual((m) => ({ ...m, userId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Device ID"
|
||||||
|
value={manual.deviceId}
|
||||||
|
onChange={(e) => setManual((m) => ({ ...m, deviceId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Access token"
|
||||||
|
value={manual.accessToken}
|
||||||
|
onChange={(e) => setManual((m) => ({ ...m, accessToken: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="ghost">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="panes">
|
||||||
|
<aside className="pane catalog-pane">
|
||||||
|
<div className="pane-label">Cinny modules ({filtered.length})</div>
|
||||||
|
<input
|
||||||
|
className="filter"
|
||||||
|
placeholder="Filter components…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="catalog-list">
|
||||||
|
{filtered.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
className={selected?.id === entry.id ? 'catalog-item active' : 'catalog-item'}
|
||||||
|
onClick={() => openEntry(entry)}
|
||||||
|
title={entry.filePath}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="pane editor-pane">
|
||||||
|
<div className="pane-label editor-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={editorTab === 'component' ? 'tab active' : 'tab'}
|
||||||
|
disabled={!selected}
|
||||||
|
onClick={() => setEditorTab('component')}
|
||||||
|
title={selected ? `Edit ${selected.filePath}` : 'Pick a module first'}
|
||||||
|
>
|
||||||
|
Component
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={editorTab === 'harness' ? 'tab active' : 'tab'}
|
||||||
|
onClick={() => setEditorTab('harness')}
|
||||||
|
>
|
||||||
|
Harness
|
||||||
|
</button>
|
||||||
|
<span className="muted tab-hint">
|
||||||
|
{editorTab === 'component' && selected
|
||||||
|
? `writes ${selected.filePath}`
|
||||||
|
: 'mount-only · not saved'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadError && <pre className="error-block editor-error">{loadError}</pre>}
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="typescript"
|
||||||
|
path={editorPath}
|
||||||
|
theme="vs-dark"
|
||||||
|
value={editorValue}
|
||||||
|
onChange={(value) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="pane preview-pane">
|
||||||
|
<div className="pane-label">Preview</div>
|
||||||
|
<div className="preview-body">
|
||||||
|
<ResizableStage>
|
||||||
|
<PlaygroundProviders client={previewClient} room={previewRoom}>
|
||||||
|
<PreviewErrorBoundary resetKey={`${deferredHarness}:${selected?.filePath ?? ''}`}>
|
||||||
|
<LivePreview />
|
||||||
|
</PreviewErrorBoundary>
|
||||||
|
</PlaygroundProviders>
|
||||||
|
</ResizableStage>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/playground/LivePreview.tsx
Normal file
59
src/playground/LivePreview.tsx
Normal file
@@ -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<React.ComponentType | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 <pre className="error-block">{error}</pre>;
|
||||||
|
if (!Comp) return <div className="muted">Compiling harness…</div>;
|
||||||
|
return <Comp />;
|
||||||
|
}
|
||||||
40
src/playground/PreviewErrorBoundary.tsx
Normal file
40
src/playground/PreviewErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||||
|
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 <pre className="error-block">{this.state.error}</pre>;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/playground/ResizableStage.tsx
Normal file
86
src/playground/ResizableStage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="stage-wrap">
|
||||||
|
<div className="stage-meta">
|
||||||
|
{size.width} × {size.height}
|
||||||
|
</div>
|
||||||
|
<div className="stage" style={stageStyle}>
|
||||||
|
<div className="stage-canvas">{children}</div>
|
||||||
|
<button type="button" className="handle handle-e" aria-label="Resize width" onPointerDown={startDrag('e')} />
|
||||||
|
<button type="button" className="handle handle-s" aria-label="Resize height" onPointerDown={startDrag('s')} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="handle handle-se"
|
||||||
|
aria-label="Resize both"
|
||||||
|
onPointerDown={startDrag('se')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
128
src/playground/catalog.ts
Normal file
128
src/playground/catalog.ts
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropRef} style={{ width: '100%' }}>
|
||||||
|
<RoomInput
|
||||||
|
editor={editor}
|
||||||
|
fileDropContainerRef={dropRef}
|
||||||
|
roomId={room.roomId}
|
||||||
|
room={room}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
43
src/playground/defaultLive.tsx
Normal file
43
src/playground/defaultLive.tsx
Normal file
@@ -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 (
|
||||||
|
<Box direction="Column" gap="200" style={{ width: '100%', maxWidth: 520 }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Preview harness — swap imports to any `@cinny/...` export
|
||||||
|
</Text>
|
||||||
|
<Message
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
collapse={false}
|
||||||
|
highlight={false}
|
||||||
|
messageLayout={MessageLayout.Modern}
|
||||||
|
messageSpacing="400"
|
||||||
|
canDelete
|
||||||
|
canSendReaction
|
||||||
|
canPinEvent
|
||||||
|
hour24Clock={false}
|
||||||
|
dateFormatString="D MMM YYYY"
|
||||||
|
onUserClick={() => undefined}
|
||||||
|
onUsernameClick={() => undefined}
|
||||||
|
onReplyClick={() => undefined}
|
||||||
|
onReactionToggle={() => undefined}
|
||||||
|
>
|
||||||
|
<Text style={{ margin: 0 }}>{mEvent.getContent().body}</Text>
|
||||||
|
</Message>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/playground/defaultSource.ts
Normal file
3
src/playground/defaultSource.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import defaultLive from './defaultLive.tsx?raw';
|
||||||
|
|
||||||
|
export const DEFAULT_SOURCE = defaultLive;
|
||||||
151
src/playground/liveClient.ts
Normal file
151
src/playground/liveClient.ts
Normal file
@@ -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<void> {
|
||||||
|
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<MatrixClient> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
49
src/playground/main.tsx
Normal file
49
src/playground/main.tsx
Normal file
@@ -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 (
|
||||||
|
<pre style={{ padding: 24, color: '#ffb4b4', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{this.state.error}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BootErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</BootErrorBoundary>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
106
src/playground/mocks/PlaygroundProviders.tsx
Normal file
106
src/playground/mocks/PlaygroundProviders.tsx
Normal file
@@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div className="preview-frame">
|
||||||
|
<ThemeContextProvider value={DarkTheme}>
|
||||||
|
<TooltipContainerProvider value={portal ?? undefined}>
|
||||||
|
<PopOutContainerProvider value={portal ?? undefined}>
|
||||||
|
<OverlayContainerProvider value={portal ?? undefined}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<JotaiProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<SpecVersionsProvider value={SPEC_VERSIONS}>
|
||||||
|
<MatrixClientProvider value={resolvedClient}>
|
||||||
|
<RoomProvider value={resolvedRoom}>
|
||||||
|
<IsDirectRoomProvider value={isDirect}>
|
||||||
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
|
<Box
|
||||||
|
className={ContainerColor({ variant: 'Surface' })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '100%',
|
||||||
|
height: '100%',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</PowerLevelsContextProvider>
|
||||||
|
</IsDirectRoomProvider>
|
||||||
|
</RoomProvider>
|
||||||
|
</MatrixClientProvider>
|
||||||
|
</SpecVersionsProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</JotaiProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</OverlayContainerProvider>
|
||||||
|
</PopOutContainerProvider>
|
||||||
|
</TooltipContainerProvider>
|
||||||
|
</ThemeContextProvider>
|
||||||
|
{/* Host for folds Overlay/Dialog portals — contained by .preview-frame */}
|
||||||
|
<div className="preview-portal" ref={setPortal} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/playground/mocks/index.ts
Normal file
2
src/playground/mocks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { mocks, createMockMatrixClient, createMockRoom, createMockMatrixEvent, MOCK_POWER_LEVELS } from './matrix';
|
||||||
|
export { PlaygroundProviders } from './PlaygroundProviders';
|
||||||
245
src/playground/mocks/matrix.ts
Normal file
245
src/playground/mocks/matrix.ts
Normal file
@@ -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<T extends AnyFn>(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<string, unknown>, 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<string, unknown>;
|
||||||
|
}> = {}
|
||||||
|
): 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<string, { displayName?: string; avatarMxc?: string }>;
|
||||||
|
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<string, MatrixEvent> = {
|
||||||
|
[StateEvent.RoomPowerLevels]: mockStateEvent(
|
||||||
|
StateEvent.RoomPowerLevels,
|
||||||
|
powerLevels as unknown as Record<string, unknown>
|
||||||
|
),
|
||||||
|
[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,
|
||||||
|
};
|
||||||
12
src/playground/monacoSetup.ts
Normal file
12
src/playground/monacoSetup.ts
Normal file
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
65
src/playground/shims/loglevel.js
Normal file
65
src/playground/shims/loglevel.js
Normal file
@@ -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;
|
||||||
414
src/playground/styles.css
Normal file
414
src/playground/styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/playground/usePlaygroundMatrix.ts
Normal file
123
src/playground/usePlaygroundMatrix.ts
Normal file
@@ -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<LiveState>(initial);
|
||||||
|
const clientRef = useRef<MatrixClient | null>(null);
|
||||||
|
const roomIdRef = useRef<string | undefined>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/playground/vite-env.d.ts
vendored
Normal file
11
src/playground/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*?raw' {
|
||||||
|
const content: string;
|
||||||
|
default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '/@playground/live.tsx' {
|
||||||
|
const Comp: React.ComponentType;
|
||||||
|
export default Comp;
|
||||||
|
}
|
||||||
@@ -58,6 +58,9 @@ export enum MessageEvent {
|
|||||||
Sticker = 'm.sticker',
|
Sticker = 'm.sticker',
|
||||||
RoomRedaction = 'm.room.redaction',
|
RoomRedaction = 'm.room.redaction',
|
||||||
Reaction = 'm.reaction',
|
Reaction = 'm.reaction',
|
||||||
|
|
||||||
|
/** Relay emoji confetti burst tied to a target message */
|
||||||
|
RelayEmojiConfetti = 'app.relay.emoji_confetti',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RoomType {
|
export enum RoomType {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { VitePWA } from 'vite-plugin-pwa';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import buildConfig from './build.config';
|
import buildConfig from './build.config';
|
||||||
|
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
|
||||||
|
|
||||||
|
const projectRoot = path.resolve();
|
||||||
|
|
||||||
const copyFiles = {
|
const copyFiles = {
|
||||||
targets: [
|
targets: [
|
||||||
@@ -217,6 +220,30 @@ export default defineConfig({
|
|||||||
publicDir: false,
|
publicDir: false,
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'],
|
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: {
|
server: {
|
||||||
port: 38347,
|
port: 38347,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
@@ -232,6 +259,7 @@ export default defineConfig({
|
|||||||
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
|
||||||
serveGifWorker(),
|
serveGifWorker(),
|
||||||
servePublicAssets(),
|
servePublicAssets(),
|
||||||
|
liveTsxPlugin(projectRoot, readDefaultLiveSource(projectRoot)),
|
||||||
topLevelAwait({
|
topLevelAwait({
|
||||||
// The export name of top-level await promise for each chunk module
|
// The export name of top-level await promise for each chunk module
|
||||||
promiseExportName: '__tla',
|
promiseExportName: '__tla',
|
||||||
@@ -258,6 +286,16 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react/jsx-runtime',
|
||||||
|
'react/jsx-dev-runtime',
|
||||||
|
'loglevel',
|
||||||
|
'matrix-js-sdk',
|
||||||
|
'@monaco-editor/react',
|
||||||
|
],
|
||||||
|
needsInterop: ['loglevel'],
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
define: {
|
define: {
|
||||||
global: 'globalThis',
|
global: 'globalThis',
|
||||||
@@ -277,6 +315,10 @@ export default defineConfig({
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
copyPublicDir: false,
|
copyPublicDir: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: path.resolve(projectRoot, 'index.html'),
|
||||||
|
playground: path.resolve(projectRoot, 'playground.html'),
|
||||||
|
},
|
||||||
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user