Add emoji confetti bursts and component playground.
Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics, per-emoji particle profiles, and a fixed overlay that does not affect scroll. Also add a Vite playground for live component previews and theme hover preview.
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user