Compare commits

11 Commits

Author SHA1 Message Date
898d217451 Make sick emoji confetti a fullscreen downward spew with morph.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
🤢 now fills the viewport, morphs to 🤮 while particles spawn, hides the jumbo stand-in cleanly, and keeps green droplets out of the face morph.
2026-08-09 18:35:12 +10:00
e08b4ec22e Replace image lightbox with slate-style shadowbox.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
2026-08-09 15:58:47 +10:00
13523fea2b Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Sparkle 🎆/🎇 from the jumbo emoji with a lightweight particle fountain that piles on the floor. Prefer text/plain over sticky clipboard images on Linux so address-bar URLs do not also attach a leftover bitmap.
2026-08-09 15:50:19 +10:00
8a68a1e30a Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bots can publish a declarative panel UI that takes over a room; user
actions are sent as im.paarrot.ui.action timeline events.
2026-08-09 13:16:43 +10:00
32bf2cbed5 Use ACTIONS_TOKEN secret to dispatch cinny-mobile builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-03 16:30:50 +10:00
17706ae019 Add workflow to dispatch cinny-mobile submodule bump on push.
Some checks failed
Trigger cinny-mobile / dispatch (push) Failing after 1s
2026-08-03 16:28:35 +10:00
82b22fa739 Add local API actions for recent messages and unreads by scope. 2026-08-03 15:08:18 +10:00
0da4f0d6cb Add emoji confetti bursts and component playground.
Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics,
per-emoji particle profiles, and a fixed overlay that does not affect scroll.
Also add a Vite playground for live component previews and theme hover preview.
2026-07-29 20:40:43 +10:00
ea7642f0bb Surface OEM AUTO_START blocks in UnifiedPush settings status.
TCL App Boot can drop NEW_ENDPOINT; show a clear allow-auto-start hint when detected.
2026-07-28 02:39:35 +10:00
0fb3da20b9 Fail UnifiedPush reset when no endpoint arrives and surface lastFailure. 2026-07-28 01:38:01 +10:00
e460dbd34e Improve UnifiedPush reset UX: poll for endpoint and clearer status. 2026-07-28 00:40:02 +10:00
75 changed files with 6505 additions and 217 deletions

View File

@@ -0,0 +1,53 @@
name: Trigger cinny-mobile
on:
push:
branches:
- main
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message || '', '[skip mobile]') }}
steps:
- name: Dispatch update-submodule on cinny-mobile
env:
# Prefer a PAT with write:repository on cinny-mobile (secret: ACTIONS_TOKEN).
# Default GITHUB_TOKEN is usually scoped to this repo only.
ACTIONS_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API_BASE: ${{ github.server_url }}/api/v1
CINNY_SHA: ${{ github.sha }}
run: |
set -euo pipefail
TOKEN="${ACTIONS_TOKEN:-${GITHUB_TOKEN:-}}"
if [ -z "${TOKEN}" ]; then
echo "No ACTIONS_TOKEN/GITHUB_TOKEN available to dispatch cinny-mobile"
exit 1
fi
BODY=$(jq -n \
--arg ref "master" \
--arg sha "${CINNY_SHA}" \
'{ref: $ref, inputs: {cinny_sha: $sha}}')
echo "Dispatching litruv/cinny-mobile update-submodule.yml for ${CINNY_SHA}"
HTTP_CODE=$(curl -sS -o /tmp/dispatch-body.txt -w "%{http_code}" \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "${BODY}" \
"${API_BASE}/repos/litruv/cinny-mobile/actions/workflows/update-submodule.yml/dispatches?return_run_details=true")
echo "HTTP ${HTTP_CODE}"
cat /tmp/dispatch-body.txt || true
echo
case "${HTTP_CODE}" in
200|201|204) echo "Dispatch accepted" ;;
*)
echo "Dispatch failed"
exit 1
;;
esac

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ node_modules
devAssets
.DS_Store
.idea
.idea
.cache

View File

@@ -55,6 +55,13 @@ curl -X POST http://127.0.0.1:33384/message/current \
| `/channels` | GET | Get list of rooms |
| `/channel` | POST | Switch to a room |
| `/message/current` | POST | Send message to current room |
| `/room/current` | GET | Get current room info |
| `/messages/groups` | GET | Last N messages across groups (`?limit=10`) |
| `/messages/dms` | GET | Last N messages across DMs (`?limit=10`) |
| `/messages/combined` | GET | Last N messages across groups + DMs (`?limit=10`) |
| `/unreads/groups` | GET | Unread group conversations |
| `/unreads/dms` | GET | Unread DM conversations |
| `/unreads/combined` | GET | Unread groups + DMs |
## 🎮 Stream Deck Integration

View File

@@ -280,6 +280,123 @@ Get information about the currently active room.
---
### Get Recent Messages (Groups)
Get the most recent messages across joined group channels (non-DMs).
**Endpoint**: `GET /messages/groups`
**Query Parameters**:
- `limit` (optional) — number of messages to return (1100, default `10`)
**Response**:
```json
{
"success": true,
"data": {
"scope": "groups",
"limit": 10,
"count": 2,
"messages": [
{
"eventId": "$event1",
"roomId": "!abc123:matrix.org",
"roomName": "General",
"isDirect": false,
"sender": "@alice:matrix.org",
"senderName": "Alice",
"timestamp": 1710000000000,
"msgtype": "m.text",
"body": "Hello!"
}
]
}
}
```
---
### Get Recent Messages (DMs)
Get the most recent messages across joined direct messages.
**Endpoint**: `GET /messages/dms`
**Query Parameters**:
- `limit` (optional) — number of messages to return (1100, default `10`)
**Response**: same shape as groups, with `"scope": "dms"`.
---
### Get Recent Messages (Combined)
Get the most recent messages across groups and DMs together.
**Endpoint**: `GET /messages/combined`
**Query Parameters**:
- `limit` (optional) — number of messages to return (1100, default `10`)
**Response**: same shape as groups, with `"scope": "combined"`.
---
### Get Unread Conversations (Groups)
List joined group channels that currently have unread activity.
**Endpoint**: `GET /unreads/groups`
**Response**:
```json
{
"success": true,
"data": {
"scope": "groups",
"count": 1,
"unreads": [
{
"roomId": "!abc123:matrix.org",
"name": "General",
"isDirect": false,
"avatar": "mxc://matrix.org/abc123",
"total": 3,
"highlight": 1,
"latest": {
"eventId": "$event1",
"sender": "@alice:matrix.org",
"senderName": "Alice",
"timestamp": 1710000000000,
"msgtype": "m.text",
"body": "Hello!"
}
}
]
}
}
```
Muted rooms are excluded. Sorted by latest activity, then highlight/total counts.
---
### Get Unread Conversations (DMs)
**Endpoint**: `GET /unreads/dms`
**Response**: same shape as groups, with `"scope": "dms"`.
---
### Get Unread Conversations (Combined)
**Endpoint**: `GET /unreads/combined`
**Response**: same shape as groups, with `"scope": "combined"`.
---
## Error Responses
All endpoints return error responses in the following format:

View File

@@ -62,6 +62,7 @@ New feature? Copy [`handoff/TEMPLATE.md`](./handoff/TEMPLATE.md) into `handoff/f
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
| Room App UI (bot-driven) | [room-app-ui](./handoff/features/room-app-ui/HANDOFF.md) |
### Settings & customization

View File

@@ -50,6 +50,12 @@ No persistent storage. Actions read/write ephemeral app state (current room, cal
| `send-message` | `{ roomId, message }` | send result |
| `send-message-current` | `{ message }` | send to active room |
| `get-current-room` | — | current room id |
| `get-messages-groups` | `{ limit? }` | recent messages across group channels |
| `get-messages-dms` | `{ limit? }` | recent messages across DMs |
| `get-messages-combined` | `{ limit? }` | recent messages across groups + DMs |
| `get-unreads-groups` | — | unread group conversations |
| `get-unreads-dms` | — | unread DM conversations |
| `get-unreads-combined` | — | unread groups + DMs |
## Dependencies

View File

@@ -0,0 +1,93 @@
# Room App UI — handoff
## Summary
Bots can publish a declarative UI into Matrix room state (`im.paarrot.ui`). Paarrot takes over the room view with that UI. User interactions are sent as timeline events (`im.paarrot.ui.action`), not chat messages. The bot updates state to re-render panels.
## User-facing behavior
- When a room has a valid `im.paarrot.ui` state event, opening the room shows the app UI instead of the chat timeline (forum rooms still win over app UI).
- **Show chat** returns to the normal timeline without clearing bot state; a banner offers **Show app** to go back.
- Action events are hidden from the chat timeline (same as confetti relay events).
- Room settings → Permissions includes **Set Room App UI** and **Send Room App Actions**.
## Architecture
```
Bot → state im.paarrot.ui
→ Room.tsx detects via useStateEvent
→ RoomAppView → RoomAppSchema (declarative nodes)
→ user clicks → mx.sendEvent(im.paarrot.ui.action)
→ Bot updates im.paarrot.ui → live re-render
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/types/matrix/room.ts` | `StateEvent.PaarrotUi`, `MessageEvent.PaarrotUiAction`, node types |
| `cinny/src/app/utils/room.ts` | `getPaarrotUiContent`, `hasRoomAppUi` |
| `cinny/src/app/features/room/Room.tsx` | Takeover branch + Show chat / Show app |
| `cinny/src/app/features/room-app/` | `RoomAppView`, schema renderer, CSS sanitize/scope |
| `cinny/src/app/features/room/RoomTimeline.tsx` | Hides `im.paarrot.ui.action` |
| `cinny/src/app/features/room-settings/permissions/usePermissionItems.ts` | Permission labels |
## Data model
| Event | Kind | Content |
|-------|------|---------|
| `im.paarrot.ui` | state (`""`) | `{ version, title?, css?, root }` |
| `im.paarrot.ui.action` | timeline | `{ action, component_id, value?, values?, ui_event_id? }` |
See [samples/](./samples/) for pasteable Developer Tools payloads.
MVP node types: `panel`, `row`, `text`, `button`, `input`, `select`, `image`, `spacer`.
## Dependencies
- folds UI primitives
- matrix-js-sdk `sendEvent` / state sync
- No bot-supplied JavaScript; CSS is sanitized and scoped under `[data-room-app]`
## Integration points
- Same room takeover pattern as forums (`Room.tsx` branch)
- Developer Tools can send state/events for demos without a bot process
## Testing
### Manual
1. In Developer Tools → Send State Event, type `im.paarrot.ui`, paste `samples/trivia-ui-state.json`.
2. Open the room — app UI should replace the timeline.
3. Click an answer — an `im.paarrot.ui.action` event is sent (hidden in timeline; visible in raw/dev tools).
4. Click **Show chat**, then **Show app**.
5. Clear state content / redact state to restore normal chat.
### Automated
- None yet
## Known issues & gotchas
- CSS scoping is best-effort, not a full CSS parser
- Images allow `mxc://`, `https://`, and `http://` only
- Forum rooms take priority over app UI
- `sendEvent(..., as any)` cast mirrors other custom event types
## Future work
- `form` / `tabs` / `markdown` / `progress` nodes
- Bot SDK helpers
- Stronger CSS sandbox (Shadow DOM)
- Encrypt custom action payloads helpers if needed
## Related docs
- [samples/trivia-ui-state.json](./samples/trivia-ui-state.json)
- [samples/ui-action-event.json](./samples/ui-action-event.json)
## Add your extra things here
- Power level: bots need PL ≥ `state_default` (usually 50) or an explicit level for `im.paarrot.ui`
- Action events use normal message event power levels unless overridden for `im.paarrot.ui.action`

View File

@@ -0,0 +1,34 @@
{
"version": 1,
"title": "Poll Booth",
"root": {
"type": "panel",
"id": "poll",
"children": [
{
"type": "text",
"id": "prompt",
"text": "Where should we go for lunch?"
},
{
"type": "select",
"id": "choice",
"name": "choice",
"label": "Your vote",
"options": [
{ "label": "Ramen", "value": "ramen" },
{ "label": "Pizza", "value": "pizza" },
{ "label": "Salad", "value": "salad" }
],
"value": "ramen"
},
{
"type": "button",
"id": "vote",
"label": "Submit vote",
"action": "vote",
"variant": "Primary"
}
]
}
}

View File

@@ -0,0 +1,88 @@
{
"version": 1,
"title": "Trivia Night",
"css": ".score { font-weight: 700; } .hint { opacity: 0.75; }",
"root": {
"type": "panel",
"id": "main",
"children": [
{
"type": "text",
"id": "q",
"text": "Capital of France?"
},
{
"type": "text",
"id": "hint",
"className": "hint",
"text": "Pick one answer below."
},
{
"type": "spacer",
"id": "sp1",
"size": 16
},
{
"type": "row",
"id": "answers",
"children": [
{
"type": "button",
"id": "a",
"label": "Paris",
"action": "answer",
"value": "paris",
"variant": "Primary"
},
{
"type": "button",
"id": "b",
"label": "Lyon",
"action": "answer",
"value": "lyon",
"variant": "Secondary"
},
{
"type": "button",
"id": "c",
"label": "Marseille",
"action": "answer",
"value": "marseille",
"variant": "Secondary"
}
]
},
{
"type": "spacer",
"id": "sp2",
"size": 24
},
{
"type": "text",
"id": "score_label",
"className": "score",
"text": "Score: 0"
},
{
"type": "input",
"id": "nick",
"name": "nickname",
"label": "Display name on the board",
"placeholder": "Optional nickname"
},
{
"type": "row",
"id": "submit_row",
"children": [
{
"type": "button",
"id": "join",
"label": "Join board",
"action": "join",
"variant": "Success"
}
]
}
]
}
}

View File

@@ -0,0 +1,9 @@
{
"action": "answer",
"component_id": "a",
"value": "paris",
"values": {
"nickname": "Ada"
},
"ui_event_id": "$REPLACE_WITH_CURRENT_im.paarrot.ui_EVENT_ID"
}

41
package-lock.json generated
View File

@@ -83,6 +83,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -106,6 +107,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",
@@ -2563,6 +2565,31 @@
"node": ">= 18"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz",
@@ -8801,6 +8828,13 @@
"resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz",
"integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10157,6 +10191,13 @@
"node": ">=0.10.0"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",

View File

@@ -14,7 +14,8 @@
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"playground": "vite"
},
"keywords": [],
"author": "Ajay Bura",
@@ -94,6 +95,7 @@
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@monaco-editor/react": "4.7.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -117,6 +119,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"monaco-editor": "0.52.2",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",

149
playground-liveTsxPlugin.ts Normal file
View 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
View 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>

View File

@@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Box, Text, IconButton, Input, Button, TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Icon, Icons } from './icons';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
@@ -154,7 +154,7 @@ function AccountDataEdit({
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { KeyboardEvent, MouseEvent } from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
@@ -49,6 +49,12 @@ type RenderMessageContentProps = {
linkifyOpts: Opts;
outlineAttachment?: boolean;
disabledEmbedPatterns?: string[];
targetEventId?: string;
onJumboEmojiClick?: (
targetEventId: string,
body: string,
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
) => void;
};
export function RenderMessageContent({
displayName,
@@ -63,6 +69,8 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
disabledEmbedPatterns,
targetEventId,
onJumboEmojiClick,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
@@ -100,6 +108,11 @@ export function RenderMessageContent({
</>
);
};
const handleJumboEmojiClick =
targetEventId && onJumboEmojiClick
? (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) =>
onJumboEmojiClick(targetEventId, body, event)
: undefined;
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
@@ -182,6 +195,7 @@ export function RenderMessageContent({
<MText
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}
@@ -201,6 +215,7 @@ export function RenderMessageContent({
displayName={displayName}
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}
@@ -219,6 +234,7 @@ export function RenderMessageContent({
<MNotice
edited={edited}
content={getContent()}
onJumboEmojiClick={handleJumboEmojiClick}
renderBody={(props) => (
<RenderBody
{...props}

View File

@@ -1,42 +1,127 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
import { DefaultReset } from 'folds';
export const ImageViewer = style([
export const Root = style([
DefaultReset,
{
height: '100%',
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
boxSizing: 'border-box',
},
]);
export const ImageViewerHeader = style([
export const Backdrop = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.72)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
cursor: 'zoom-out',
},
]);
export const ImageViewerContent = style([
export const Stage = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
position: 'relative',
zIndex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(92vh, 900px)',
width: '100%',
minWidth: 0,
pointerEvents: 'none',
},
]);
export const Chrome = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
minWidth: 0,
color: '#fff',
pointerEvents: 'auto',
},
]);
export const Title = style([
DefaultReset,
{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
lineHeight: '18px',
color: 'rgba(255, 255, 255, 0.9)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
]);
export const ImageViewerImg = style([
export const ChromeButton = style([
DefaultReset,
{
objectFit: 'contain',
width: 'auto',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
padding: 6,
border: 'none',
borderRadius: 4,
background: 'rgba(0, 0, 0, 0.4)',
color: '#fff',
cursor: 'pointer',
},
]);
export const Frame = style([
DefaultReset,
{
position: 'relative',
alignSelf: 'center',
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(80vh, 820px)',
overflow: 'hidden',
cursor: 'zoom-in',
touchAction: 'none',
pointerEvents: 'auto',
selectors: {
'&[data-zoomed]': {
cursor: 'zoom-out',
overflow: 'visible',
},
'&[data-pannable]': {
cursor: 'grab',
},
'&[data-dragging]': {
cursor: 'grabbing',
},
},
},
]);
export const Image = style([
DefaultReset,
{
display: 'block',
borderRadius: 8,
background: 'transparent',
transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))',
transformOrigin: '0 0',
userSelect: 'none',
},
]);

View File

@@ -1,16 +1,62 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getCurrentAccessToken } from '../../utils/auth';
const ZOOM_MIN = 1;
const ZOOM_MAX = 5;
const ZOOM_CLICK = 2.5;
const DRAG_CLICK_SLOP = 6;
type ZoomState = {
scale: number;
tx: number;
ty: number;
};
type PinchSession = {
startDist: number;
startScale: number;
startTx: number;
startTy: number;
startMidX: number;
startMidY: number;
};
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
function imageViewportMax() {
return {
w: Math.min(window.innerWidth * 0.96, 1100),
h: Math.min(window.innerHeight * 0.8, 820),
};
}
function pointerDistance(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pointerMidpoint(
a: { x: number; y: number },
b: { x: number; y: number }
): { x: number; y: number } {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
export type ImageViewerProps = {
alt: string;
src: string;
@@ -19,18 +65,215 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const frameRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{
id: number;
x: number;
y: number;
tx: number;
ty: number;
moved: boolean;
} | null>(null);
const pinchRef = useRef<PinchSession | null>(null);
/** Skip the next click after a pinch so we don't toggle zoom. */
const suppressClickRef = useRef(false);
const baseSizeRef = useRef({ w: 0, h: 0 });
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
const [dragging, setDragging] = useState(false);
const applyZoomCss = useCallback((next: ZoomState) => {
const img = imgRef.current;
if (!img) return;
img.style.setProperty('--img-zoom', String(next.scale));
img.style.setProperty('--img-tx', `${next.tx}px`);
img.style.setProperty('--img-ty', `${next.ty}px`);
}, []);
const clampPan = useCallback((state: ZoomState): ZoomState => {
const img = imgRef.current;
const frame = frameRef.current;
if (!img || !frame) return state;
const baseW = img.offsetWidth || baseSizeRef.current.w;
const baseH = img.offsetHeight || baseSizeRef.current.h;
const frameW = frame.clientWidth;
const frameH = frame.clientHeight;
if (!baseW || !baseH || !frameW || !frameH) return state;
const scaledW = baseW * state.scale;
const scaledH = baseH * state.scale;
let { tx, ty } = state;
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
return { ...state, tx, ty };
}, []);
const commitZoom = useCallback(
(next: ZoomState) => {
const clamped = clampPan(next);
zoomRef.current = clamped;
applyZoomCss(clamped);
setZoom(clamped);
},
[applyZoomCss, clampPan]
);
const resetZoom = useCallback(() => {
dragRef.current = null;
pinchRef.current = null;
setDragging(false);
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const fitImage = useCallback(() => {
const img = imgRef.current;
if (!img) return;
const iw = img.naturalWidth || 0;
const ih = img.naturalHeight || 0;
if (iw <= 0 || ih <= 0) return;
const { w: maxW, h: maxH } = imageViewportMax();
const s = Math.min(1, maxW / iw, maxH / ih);
const w = Math.max(1, Math.round(iw * s));
const h = Math.max(1, Math.round(ih * s));
img.style.width = `${w}px`;
img.style.height = `${h}px`;
baseSizeRef.current = { w, h };
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const zoomAround = useCallback(
(px: number, py: number, newScale: number) => {
const z = zoomRef.current;
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
const ix = (px - z.tx) / z.scale;
const iy = (py - z.ty) / z.scale;
commitZoom({
scale,
tx: px - ix * scale,
ty: py - iy * scale,
});
},
[commitZoom]
);
const framePoint = (clientX: number, clientY: number) => {
const frame = frameRef.current;
if (!frame) return { x: 0, y: 0 };
const r = frame.getBoundingClientRect();
return { x: clientX - r.left, y: clientY - r.top };
};
const beginPinch = useCallback(() => {
const pts = [...pointersRef.current.values()];
if (pts.length < 2) return;
const [a, b] = pts;
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const z = zoomRef.current;
pinchRef.current = {
startDist: Math.max(1, pointerDistance(a, b)),
startScale: z.scale,
startTx: z.tx,
startTy: z.ty,
startMidX: local.x,
startMidY: local.y,
};
dragRef.current = null;
setDragging(true);
}, []);
const updatePinch = useCallback(() => {
const pinch = pinchRef.current;
const pts = [...pointersRef.current.values()];
if (!pinch || pts.length < 2) return;
const [a, b] = pts;
const dist = Math.max(1, pointerDistance(a, b));
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
);
// Keep the original content under the pinch midpoint while scaling + panning.
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
commitZoom({
scale,
tx: local.x - ix * scale,
ty: local.y - iy * scale,
});
suppressClickRef.current = true;
}, [commitZoom]);
useLayoutEffect(() => {
resetZoom();
}, [src, resetZoom]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [requestClose]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return undefined;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const img = imgRef.current;
if (!img?.src) return;
const factor = Math.exp(-event.deltaY * 0.002);
const r = frame.getBoundingClientRect();
const x = event.clientX - r.left;
const y = event.clientY - r.top;
zoomAround(x, y, zoomRef.current.scale * factor);
};
// Block browser gesture zoom / scroll while pinching on the frame.
const blockGesture = (event: Event) => {
if (pointersRef.current.size >= 2 || pinchRef.current) {
event.preventDefault();
}
};
frame.addEventListener('wheel', onWheel, { passive: false });
frame.addEventListener('touchmove', blockGesture, { passive: false });
return () => {
frame.removeEventListener('wheel', onWheel);
frame.removeEventListener('touchmove', blockGesture);
};
}, [zoomAround]);
useEffect(() => {
const onResize = () => fitImage();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [fitImage]);
const handleDownload = async () => {
try {
// Always use current session's token to avoid stale tokens during account switches
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
// Fallback: try to fetch via standard fetch as blob
try {
const response = await fetch(src);
if (response.ok) {
@@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>(
FileSaver.saveAs(blob, alt);
}
} catch {
// If all else fails, open in new tab to let browser handle it
window.open(src, '_blank');
}
}
};
const handleClick = (event: React.MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
if (dragRef.current?.moved) {
dragRef.current = null;
return;
}
const img = imgRef.current;
if (!img?.src) return;
event.preventDefault();
const { x, y } = framePoint(event.clientX, event.clientY);
if (zoomRef.current.scale <= 1.01) {
zoomAround(x, y, ZOOM_CLICK);
} else {
resetZoom();
}
};
const handlePointerDown = (event: React.PointerEvent) => {
if (event.button !== 0 && event.pointerType === 'mouse') return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
frameRef.current?.setPointerCapture(event.pointerId);
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
if (zoomRef.current.scale <= 1.01) return;
const z = zoomRef.current;
dragRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
tx: z.tx,
ty: z.ty,
moved: false,
};
setDragging(true);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!pointersRef.current.has(event.pointerId)) return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointersRef.current.size >= 2 && pinchRef.current) {
event.preventDefault();
updatePinch();
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true;
commitZoom({
scale: zoomRef.current.scale,
tx: drag.tx + dx,
ty: drag.ty + dy,
});
};
const handlePointerUp = (event: React.PointerEvent) => {
pointersRef.current.delete(event.pointerId);
frameRef.current?.releasePointerCapture(event.pointerId);
if (pinchRef.current) {
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
pinchRef.current = null;
setDragging(false);
// Extra Things: one finger left after pinch — hand off to pan if still zoomed.
if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) {
const [id, pt] = [...pointersRef.current.entries()][0];
const z = zoomRef.current;
dragRef.current = {
id,
x: pt.x,
y: pt.y,
tx: z.tx,
ty: z.ty,
moved: true,
};
setDragging(true);
} else {
dragRef.current = null;
}
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
setDragging(false);
if (!drag.moved) dragRef.current = null;
else dragRef.current = { ...drag, moved: true };
};
const zoomed = zoom.scale > 1.01;
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
<div
className={classNames(css.Root, className)}
role="dialog"
aria-modal="true"
aria-label={alt || 'Image viewer'}
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
<div className={css.Stage}>
<div className={css.Chrome}>
<div className={css.Title} title={alt}>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
</div>
<button
type="button"
className={css.ChromeButton}
aria-label="Download"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
<Icon size="50" src={Icons.Download} />
</button>
<button
type="button"
className={css.ChromeButton}
aria-label="Close"
onClick={requestClose}
>
<Icon size="50" src={Icons.Cross} />
</button>
</div>
<div
ref={frameRef}
className={css.Frame}
data-zoomed={zoomed || undefined}
data-pannable={zoomed || undefined}
data-dragging={dragging || undefined}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<img
ref={imgRef}
className={css.Image}
src={src}
alt={alt}
draggable={false}
onLoad={fitImage}
/>
</div>
</div>
</div>
);
}
);

View File

@@ -27,6 +27,7 @@ import {
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia';
import { getJumboEmojiInteractionProps, JumboEmojiClickHandler } from '../../features/room/emoji-confetti/jumboEmojiInteraction';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
@@ -78,8 +79,9 @@ type MTextProps = {
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
export function MText({ edited, content, renderBody, renderUrlsPreview, style, onJumboEmojiClick }: MTextProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -89,13 +91,20 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
style={style}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,
@@ -114,6 +123,7 @@ type MEmoteProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MEmote({
displayName,
@@ -121,6 +131,7 @@ export function MEmote({
content,
renderBody,
renderUrlsPreview,
onJumboEmojiClick,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
@@ -131,20 +142,28 @@ export function MEmote({
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
<span {...jumboEmojiInteraction}>
{renderBody({
body: trimmedBody,
customBody: trimmedCustomBody,
})}
{edited && <MessageEditedContent />}
</span>
</MessageTextBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
@@ -156,8 +175,9 @@ type MNoticeProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
onJumboEmojiClick?: JumboEmojiClickHandler;
};
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
export function MNotice({ edited, content, renderBody, renderUrlsPreview, onJumboEmojiClick }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
@@ -167,13 +187,20 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
: undefined;
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
const isJumboEmoji = JUMBO_EMOJI_REG.test(trimmedBody);
const jumboEmojiInteraction = getJumboEmojiInteractionProps({
body: trimmedBody,
isJumboEmoji,
onJumboEmojiClick,
});
return (
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
jumboEmoji={isJumboEmoji}
{...jumboEmojiInteraction}
>
{renderBody({
body: trimmedBody,

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open={viewer} backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</FocusTrap>
</Overlay>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (

View File

@@ -243,6 +243,9 @@ export const MessageTextBody = recipe({
overflow: 'visible',
overflowY: 'visible',
paddingBottom: config.space.S200,
cursor: 'pointer',
position: 'relative',
zIndex: 5,
},
},
emote: {
@@ -263,6 +266,8 @@ globalStyle(`${jumboEmojiClass} .${htmlCss.EmoticonBase}`, {
padding: 0,
overflow: 'visible',
verticalAlign: 'middle',
position: 'relative',
zIndex: 1,
});
globalStyle(`${jumboEmojiClass} .${htmlCss.Emoticon.classNames.base}`, {

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds';
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
@@ -87,25 +87,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</Avatar>
</AvatarPresence>
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</FocusTrap>
</Overlay>
)}
</div>

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds';
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds';
import { Icon, Icons } from '../../../components/icons';
import { Page, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}

View File

@@ -1,5 +1,5 @@
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 { MatrixError } from 'matrix-js-sdk';
import { Page, PageHeader } from '../../../components/page';
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
<Box shrink="No">
<Text size="L400">JSON Content</Text>
</Box>
<TextAreaComponent
<TextArea
ref={textAreaRef}
name="contentTextArea"
style={{ fontFamily: 'monospace' }}

View File

@@ -0,0 +1,205 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Button, Input, Text, config } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import {
PaarrotUiActionContent,
PaarrotUiButtonNode,
PaarrotUiContent,
PaarrotUiNode,
} from '../../../types/matrix/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { isAllowedRoomAppImageSrc, scopeRoomAppCss } from './sanitizeRoomAppCss';
import * as css from './RoomAppView.css';
const SCOPE = '[data-room-app]';
type RoomAppSchemaProps = {
mx: MatrixClient;
content: PaarrotUiContent;
uiEventId?: string;
useAuthentication: boolean;
onAction: (payload: PaarrotUiActionContent) => void | Promise<void>;
};
function fieldName(node: { id: string; name?: string }): string {
return node.name || node.id;
}
function spacerSize(size?: number): 'sm' | 'md' | 'lg' {
if (typeof size === 'number' && size <= 8) return 'sm';
if (typeof size === 'number' && size >= 24) return 'lg';
return 'md';
}
export function RoomAppSchema({
mx,
content,
uiEventId,
useAuthentication,
onAction,
}: RoomAppSchemaProps) {
const [values, setValues] = useState<Record<string, string>>({});
const [sending, setSending] = useState(false);
const scopedCss = useMemo(
() => (content.css ? scopeRoomAppCss(content.css, SCOPE) : ''),
[content.css]
);
const setField = useCallback((name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleButton = useCallback(
async (node: PaarrotUiButtonNode) => {
if (node.disabled || sending) return;
setSending(true);
try {
await onAction({
action: node.action,
component_id: node.id,
value: node.value,
values: { ...values },
ui_event_id: uiEventId,
});
} finally {
setSending(false);
}
},
[onAction, sending, uiEventId, values]
);
const renderNode = (node: PaarrotUiNode): React.ReactNode => {
switch (node.type) {
case 'panel':
return (
<div key={node.id} className={css.Panel} data-room-app-node={node.id}>
{node.children?.map((child) => renderNode(child))}
</div>
);
case 'row':
return (
<div key={node.id} className={css.Row} data-room-app-node={node.id}>
{node.children?.map((child) => renderNode(child))}
</div>
);
case 'text':
return (
<Text
key={node.id}
as="p"
size="T400"
className={node.className}
data-room-app-node={node.id}
>
{node.text ?? ''}
</Text>
);
case 'button': {
const variant = node.variant ?? 'Primary';
return (
<Button
key={node.id}
type="button"
size="400"
variant={variant}
radii="300"
disabled={node.disabled || sending}
onClick={() => handleButton(node)}
data-room-app-node={node.id}
>
<Text size="B400">{node.label}</Text>
</Button>
);
}
case 'input': {
const name = fieldName(node);
const current = values[name] ?? node.value ?? '';
return (
<div key={node.id} className={css.Field} data-room-app-node={node.id}>
{node.label && (
<Text as="label" size="L400" htmlFor={`room-app-${node.id}`}>
{node.label}
</Text>
)}
<Input
id={`room-app-${node.id}`}
name={name}
variant="Background"
radii="300"
type={node.inputType ?? 'text'}
placeholder={node.placeholder}
value={current}
onChange={(evt) => setField(name, evt.currentTarget.value)}
/>
</div>
);
}
case 'select': {
const name = fieldName(node);
const current = values[name] ?? node.value ?? '';
return (
<div key={node.id} className={css.Field} data-room-app-node={node.id}>
{node.label && (
<Text as="label" size="L400" htmlFor={`room-app-${node.id}`}>
{node.label}
</Text>
)}
<Box as="span" grow="Yes" style={{ position: 'relative' }}>
<select
id={`room-app-${node.id}`}
name={name}
value={current}
onChange={(evt) => setField(name, evt.currentTarget.value)}
style={{
width: '100%',
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: '1px solid var(--bq-border, CurrentColor)',
background: 'transparent',
color: 'inherit',
font: 'inherit',
}}
>
{(node.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Box>
</div>
);
}
case 'image': {
if (!isAllowedRoomAppImageSrc(node.src)) return null;
const httpSrc = node.src.startsWith('mxc://')
? mxcUrlToHttp(mx, node.src, useAuthentication) ?? undefined
: node.src;
if (!httpSrc) return null;
return (
<img
key={node.id}
className={css.Image}
src={httpSrc}
alt={node.alt ?? ''}
width={node.width}
height={node.height}
data-room-app-node={node.id}
/>
);
}
case 'spacer':
return <div key={node.id} className={css.Spacer({ size: spacerSize(node.size) })} />;
default:
return null;
}
};
return (
<div data-room-app="">
{scopedCss ? <style>{scopedCss}</style> : null}
{renderNode(content.root)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, config, toRem } from 'folds';
export const Root = style([
DefaultReset,
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
minWidth: 0,
},
]);
export const Header = style({
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
});
export const Body = style([
DefaultReset,
{
flexGrow: 1,
minHeight: 0,
overflow: 'auto',
padding: config.space.S400,
},
]);
export const Panel = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S300,
width: '100%',
});
export const Row = style({
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
gap: config.space.S200,
alignItems: 'center',
});
export const Field = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
minWidth: toRem(160),
flexGrow: 1,
});
export const Image = style({
maxWidth: '100%',
height: 'auto',
borderRadius: config.radii.R300,
});
export const Spacer = recipe({
base: {
flexShrink: 0,
},
variants: {
size: {
sm: { height: config.space.S200 },
md: { height: config.space.S400 },
lg: { height: config.space.S600 },
},
},
defaultVariants: {
size: 'md',
},
});

View File

@@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import { Box, Button, Header, Text, config } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Page } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useRoomName } from '../../hooks/useRoomMeta';
import {
MessageEvent,
PaarrotUiActionContent,
StateEvent,
} from '../../../types/matrix/room';
import { parsePaarrotUiContent } from '../../utils/room';
import { RoomAppSchema } from './RoomAppSchema';
import * as css from './RoomAppView.css';
type RoomAppViewProps = {
room: Room;
onShowChat: () => void;
};
export function RoomAppView({ room, onShowChat }: RoomAppViewProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomName = useRoomName(room);
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
const content = parsePaarrotUiContent(uiEvent?.getContent());
const title = content?.title || roomName || 'Room App';
const handleAction = useCallback(
async (payload: PaarrotUiActionContent) => {
await mx.sendEvent(room.roomId, MessageEvent.PaarrotUiAction as any, payload);
},
[mx, room.roomId]
);
return (
<Page>
<Header className={css.Header} size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200" minWidth="0">
<Text size="H6" truncate>
{title}
</Text>
</Box>
<Button size="300" variant="Secondary" radii="300" onClick={onShowChat}>
<Text size="B300">Show chat</Text>
</Button>
</Box>
</Header>
<Box className={css.Body} grow="Yes" direction="Column" style={{ gap: config.space.S400 }}>
{content ? (
<RoomAppSchema
mx={mx}
content={content}
uiEventId={uiEvent?.getId()}
useAuthentication={useAuthentication}
onAction={handleAction}
/>
) : (
<Text size="T400">This rooms app UI is missing or invalid.</Text>
)}
</Box>
</Page>
);
}

View File

@@ -0,0 +1,7 @@
export { RoomAppView } from './RoomAppView';
export { RoomAppSchema } from './RoomAppSchema';
export {
sanitizeRoomAppCss,
scopeRoomAppCss,
isAllowedRoomAppImageSrc,
} from './sanitizeRoomAppCss';

View File

@@ -0,0 +1,91 @@
/**
* Strip dangerous CSS constructs from bot-supplied stylesheets.
* Not a full CSS parser — best-effort for MVP scoped injection.
*/
export function sanitizeRoomAppCss(css: string): string {
let out = css;
out = out.replace(/@import\b[^;{]*;?/gi, '');
out = out.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, 'url(about:blank)');
out = out.replace(/expression\s*\([^)]*\)/gi, 'initial');
out = out.replace(/-moz-binding\s*:[^;]+;?/gi, '');
out = out.replace(/behavior\s*:[^;]+;?/gi, '');
out = out.replace(/@charset\b[^;]*;?/gi, '');
return out;
}
/**
* Prefix plain selectors with a scope so bot CSS cannot escape the room app root.
* At-rules (@media, @supports, @keyframes) are kept with nested rules re-scoped where possible.
*/
export function scopeRoomAppCss(css: string, scopeSelector: string): string {
const sanitized = sanitizeRoomAppCss(css).trim();
if (!sanitized) return '';
const scopeRule = (selectors: string, body: string): string => {
const scoped = selectors
.split(',')
.map((s) => {
const sel = s.trim();
if (!sel) return '';
if (sel.startsWith(scopeSelector)) return sel;
return `${scopeSelector} ${sel}`;
})
.filter(Boolean)
.join(', ');
return `${scoped}{${body}}`;
};
const rewriteBlock = (block: string): string => {
const trimmed = block.trim();
if (!trimmed) return '';
if (trimmed.startsWith('@')) {
const open = trimmed.indexOf('{');
if (open === -1) return `${trimmed};`;
const header = trimmed.slice(0, open).trim();
const inner = trimmed.slice(open + 1);
// @keyframes / @font-face: keep as-is (namespaced risk is low for MVP)
if (/^@(keyframes|font-face)\b/i.test(header)) {
return `${header}{${inner}}`;
}
// @media / @supports: re-scope nested rules
const nested = rewriteCssChunk(inner);
return `${header}{${nested}}`;
}
const open = trimmed.indexOf('{');
if (open === -1) return '';
const selectors = trimmed.slice(0, open);
const body = trimmed.slice(open + 1);
return scopeRule(selectors, body);
};
const rewriteCssChunk = (chunk: string): string => {
const parts: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < chunk.length; i += 1) {
const ch = chunk[i];
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) {
parts.push(rewriteBlock(chunk.slice(start, i)));
start = i + 1;
}
}
}
return parts.filter(Boolean).join('\n');
};
return rewriteCssChunk(sanitized);
}
export function isAllowedRoomAppImageSrc(src: string): boolean {
const trimmed = src.trim();
return (
trimmed.startsWith('mxc://') ||
trimmed.startsWith('https://') ||
trimmed.startsWith('http://')
);
}

View File

@@ -204,6 +204,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
},
name: 'Modify Widgets',
},
{
location: {
state: true,
key: StateEvent.PaarrotUi,
},
name: 'Set Room App UI',
},
{
location: {
key: MessageEvent.PaarrotUiAction,
},
name: 'Send Room App Actions',
},
],
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { Box, Line } from 'folds';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Button, Line, Text, config } from 'folds';
import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -18,8 +18,11 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
import { isForum } from '../../utils/room';
import { isForum, parsePaarrotUiContent } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomAppView } from '../room-app';
export function Room() {
const { eventId: rawEventId } = useParams();
@@ -41,6 +44,16 @@ export function Room() {
const showRightDrawer =
!skinny && (isPeopleDrawer || isMediaDrawer);
const uiEvent = useStateEvent(room, StateEvent.PaarrotUi);
const hasAppUi = useMemo(() => !!parsePaarrotUiContent(uiEvent?.getContent()), [uiEvent]);
const [preferChat, setPreferChat] = useState(false);
useEffect(() => {
setPreferChat(false);
}, [room.roomId]);
const showAppView = !forumRoom && hasAppUi && !preferChat;
// Update titlebar with current room ID
useEffect(() => {
setActiveRoomId(room.roomId);
@@ -63,10 +76,38 @@ export function Room() {
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{!showMediaSolo &&
(forumRoom ? (
(showAppView ? (
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
) : forumRoom ? (
<ForumRoomView room={room} eventId={eventId} />
) : (
<RoomView room={room} eventId={eventId} />
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
{hasAppUi && preferChat && (
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid var(--bq-surface-border, CurrentColor)',
}}
>
<Text size="T300">Room app available</Text>
<Button
size="300"
variant="Primary"
radii="300"
onClick={() => setPreferChat(false)}
>
<Text size="B300">Show app</Text>
</Button>
</Box>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0, minHeight: 0 }}>
<RoomView room={room} eventId={eventId} />
</Box>
</Box>
))}
{showMediaSolo && <MediaDrawer room={room} solo />}
{!showMediaSolo && showRightDrawer && (

View File

@@ -137,6 +137,8 @@ import {
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
/** Information about a call member event for grouping */
interface CallEventInfo {
@@ -582,6 +584,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -1597,6 +1601,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -1705,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
);
}
@@ -2002,6 +2010,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Return special marker for call event (will be handled by eventRenderer)
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
},
[MessageEvent.RelayEmojiConfetti]: () => null,
[MessageEvent.PaarrotUiAction]: () => null,
},
(mEventId, mEvent, item) => {
if (!showHiddenEvents) return null;
@@ -2704,6 +2714,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Box>
</TimelineFloat>
)}
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
</Box>
);
}

View File

@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
import { Message, Reactions, EncryptedContent } from './message';
import { Reply } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
import {
LINKIFY_OPTS,
factoryRenderLinkifyWithMention,
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
useJumboEmojiConfetti(room);
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const direct = useIsDirectRoom();
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</EncryptedContent>
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
disabledEmbedPatterns={combinedEmbedFilters}
targetEventId={mEventId}
onJumboEmojiClick={handleJumboEmojiClick}
/>
)}
</Message>
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
</Box>
</Scroll>
<EmojiConfettiOverlay
bursts={emojiConfettiBursts}
onBurstComplete={removeEmojiConfettiBurst}
/>
</Box>
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />

View File

@@ -0,0 +1,375 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { EmojiConfettiBurst } from './types';
import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
import {
BurstParticle,
drawBurstParticles,
spawnEmojiBurst,
stepBurstParticles,
} from './emojiBurstEngine';
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
import {
createFireworkSim,
drawFireworkSim,
FireworkSim,
getFireworkDpr,
stepFireworkSim,
syncFireworkHero,
} from './fireworkParticleEngine';
const MAX_DPR = 2;
type EmojiConfettiBurstCanvasProps = {
burst: EmojiConfettiBurst;
onComplete: (burstId: string) => void;
};
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
const primaryEmoji = burst.emojis[0] ?? '🎉';
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
if (fullscreen) {
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
type BurstCanvasProps = {
burst: EmojiConfettiBurst;
primaryEmoji: string;
onComplete: (burstId: string) => void;
};
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<FireworkSim | null>(null);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(null);
const spawnedRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
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;
}
const canvas = canvasRef.current;
const context = canvas.getContext('2d', { alpha: true });
if (!context) {
onCompleteRef.current(burst.id);
return undefined;
}
context.imageSmoothingEnabled = false;
spawnedRef.current = true;
const resize = () => {
const dpr = getFireworkDpr();
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
return { width, height, dpr };
};
const { width, height } = resize();
const profile = getEmojiBurstProfile(primaryEmoji);
const glyph = measureJumboGlyph(burst.targetEventId);
const pinToJumbo = Boolean(profile.morphTo && glyph);
const origin = {
x: glyph ? glyph.x : burst.origin.x,
y: glyph ? glyph.y : burst.origin.y,
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
};
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
if (!pinToJumbo) {
origin.x = Math.min(width - 24, Math.max(24, origin.x));
origin.y = Math.min(height - 24, Math.max(24, origin.y));
}
simRef.current = createFireworkSim(
width,
height,
origin,
primaryEmoji,
profile,
performance.now(),
glyph?.size
);
const revealJumbo = () => {
setJumboEmojiHidden(burst.targetEventId, false);
};
const syncHero = () => {
const sim = simRef.current;
if (!sim) return;
if (!sim.hero) {
revealJumbo();
return;
}
const metrics = measureJumboGlyph(burst.targetEventId);
syncFireworkHero(sim, metrics);
// Re-query every frame so React remounts still stay hidden.
setJumboEmojiHidden(burst.targetEventId, true);
};
const onResize = () => {
// Extra Things: match CSS size; sim keeps its launch-time world size.
resize();
syncHero();
};
window.addEventListener('resize', onResize);
window.addEventListener('scroll', syncHero, true);
const finish = () => {
revealJumbo();
onCompleteRef.current(burst.id);
frameRef.current = null;
lastFrameTimeRef.current = null;
};
// Hide immediately before first paint of the stand-in.
if (pinToJumbo) {
setJumboEmojiHidden(burst.targetEventId, true);
}
const tick = (now: number) => {
const sim = simRef.current;
if (!sim) return;
syncHero();
const last = lastFrameTimeRef.current ?? now;
const dtSeconds = Math.min((now - last) / 1000, 0.05);
lastFrameTimeRef.current = now;
const alive = stepFireworkSim(sim, now, dtSeconds);
// Hero may have just been dismissed — reveal real jumbo immediately.
if (!sim.hero) {
revealJumbo();
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (alive) {
drawFireworkSim(context, sim, now);
frameRef.current = requestAnimationFrame(tick);
return;
}
finish();
};
lastFrameTimeRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', syncHero, true);
revealJumbo();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
simRef.current = null;
// Extra Things: allow Strict Mode remount to start a fresh burst.
spawnedRef.current = false;
};
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
return createPortal(
<div
aria-hidden
style={{
position: 'fixed',
inset: 0,
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: 40,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
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, primaryEmoji, 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.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
return createPortal(
<div
ref={layerRef}
aria-hidden
style={{
position: 'fixed',
width: canvasSize,
height: canvasSize,
pointerEvents: 'none',
zIndex: 4,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,500 @@
export type BurstMotionStyle =
| 'explode'
| 'bounce'
| 'rise'
| 'sparkle'
| 'splash'
| 'spiral'
| 'punch'
| 'scatter'
| 'shower'
| 'firework';
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;
/** Full-viewport overlay (Box2D pile for fireworks). */
fullscreen?: boolean;
/** Soft color wash applied to matching particle glyphs (e.g. green spit). */
tintColor?: string;
/** Emojis that receive `tintColor` when drawn. */
tintEmojis?: string[];
/** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */
morphTo?: string;
/** Delay before `morphTo` kicks in. */
morphAfterMs?: number;
/** How long the stand-in hero face stays before the real jumbo returns.
* Omit to keep it up until the last particle spawns. */
heroDurationMs?: number;
};
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: 'firework',
fullscreen: true,
particleCount: 320,
gravity: 980,
drag: 1,
speedMin: 420,
speedMax: 980,
launchUpMin: 180,
launchUpMax: 420,
spinMin: -520,
spinMax: 520,
fontSizeMin: 16,
fontSizeMax: 30,
heroFontSizeMin: 40,
heroFontSizeMax: 52,
companions: ['🎇', '✨', '💥', '⭐', '🎉'],
companionChance: 0.55,
},
'🎇': {
style: 'firework',
fullscreen: true,
particleCount: 280,
gravity: 960,
drag: 1,
speedMin: 380,
speedMax: 920,
launchUpMin: 160,
launchUpMax: 400,
spinMin: -480,
spinMax: 480,
fontSizeMin: 14,
fontSizeMax: 28,
heroFontSizeMin: 36,
heroFontSizeMax: 48,
companions: ['🎆', '✨', '⭐', '💥'],
companionChance: 0.5,
twinkle: true,
},
// Full-viewport spew — downward cone, green-tinted droplets, piles on the floor.
'🤢': {
style: 'firework',
fullscreen: true,
particleCount: 420,
gravity: 1200,
drag: 1,
speedMin: 380,
speedMax: 980,
launchUpMin: 0,
launchUpMax: 40,
spinMin: -560,
spinMax: 560,
fontSizeMin: 18,
fontSizeMax: 34,
heroFontSizeMin: 44,
heroFontSizeMax: 58,
// Downward throw-up cone (π/2 = down in canvas space).
angleBias: Math.PI / 2,
angleSpread: Math.PI * 1.05,
companions: ['💦', '💧', '💚'],
companionChance: 1,
tintColor: 'rgba(72, 190, 48, 0.72)',
tintEmojis: ['💦', '💧'],
morphTo: '🤮',
morphAfterMs: 220,
wobble: true,
},
'🤮': {
style: 'firework',
fullscreen: true,
particleCount: 460,
gravity: 1250,
drag: 1,
speedMin: 420,
speedMax: 1050,
launchUpMin: 0,
launchUpMax: 30,
spinMin: -600,
spinMax: 600,
fontSizeMin: 18,
fontSizeMax: 36,
heroFontSizeMin: 46,
heroFontSizeMax: 60,
angleBias: Math.PI / 2,
angleSpread: Math.PI * 1.15,
companions: ['💦', '💧', '💚'],
companionChance: 1,
tintColor: 'rgba(72, 190, 48, 0.72)',
tintEmojis: ['💦', '💧'],
wobble: true,
},
'🐱': {
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 isFullscreenBurstEmoji(emoji: string): boolean {
return getEmojiBurstProfile(emoji).fullscreen === true;
}
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
if (!profile.companions?.length || !profile.companionChance) {
return primaryEmoji;
}
if (Math.random() >= profile.companionChance) {
return primaryEmoji;
}
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
return companion ?? primaryEmoji;
}
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
if (profile.angleSpread === undefined) {
return Math.random() * Math.PI * 2;
}
const bias = profile.angleBias ?? -Math.PI / 2;
const halfSpread = profile.angleSpread / 2;
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
}

View File

@@ -0,0 +1,65 @@
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 type JumboGlyphMetrics = {
/** Outer jumbo mount (message body) — hide/show this. */
mount: HTMLElement;
/** Visual glyph center in viewport coords. */
x: number;
y: number;
/** CSS px size matching the rendered emoji (usually computed font-size). */
size: number;
};
/**
* Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas
* stand-in matches unicode emoji; fall back to the glyph box for images.
*/
export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null {
const mount = findJumboEmojiElement(targetEventId);
if (!mount) return null;
const glyph =
(mount.querySelector('[data-emoticon]') as HTMLElement | null) ||
(mount.querySelector('img') as HTMLElement | null) ||
mount;
const rect = glyph.getBoundingClientRect();
const fontSize =
parseFloat(getComputedStyle(glyph).fontSize) ||
parseFloat(getComputedStyle(mount).fontSize) ||
0;
const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG';
const size = isImg
? Math.max(rect.width, rect.height)
: fontSize > 0
? fontSize
: Math.max(rect.width, rect.height);
return {
mount,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
size,
};
}
export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) {
const mount = findJumboEmojiElement(targetEventId);
if (!mount) return;
if (hidden) {
mount.style.visibility = 'hidden';
} else {
mount.style.removeProperty('visibility');
}
}
export function getLocalBurstCanvasSize(maskRadius: number): number {
return Math.max(300, Math.round(maskRadius * 6.5));
}

View File

@@ -0,0 +1,515 @@
/**
* Old-school fullscreen firework fountain — no physics engine.
* Position/velocity arrays, gravity, floor bounce, settle into a pile.
* Same vibe as late-90s/MSN page effects, just with emoji.
*/
import { BurstPoint } from './burstOrigin';
import {
pickParticleEmoji,
sampleBurstAngle,
type EmojiBurstProfile,
} from './emojiParticleProfiles';
const EMOJI_CACHE_PX = 40;
const EMOJI_CACHE_SCALE = 2;
const SPAWN_WINDOW_MS = 2200;
const SETTLE_HOLD_MS = 7800;
const FADE_MS = 800;
export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS;
const SPARKLE_IN_MS = 120;
const MAX_SPAWNS_PER_FRAME = 6;
const FLOOR_PAD = 8;
const BOUNCE = 0.38;
const DRAG = 0.992;
const SETTLE_SPEED = 55;
const PILE_CELL = 22;
type SpawnItem = {
atMs: number;
emoji: string;
fontSize: number;
};
export type FireworkParticle = {
x: number;
y: number;
vx: number;
vy: number;
spin: number;
angle: number;
emoji: string;
emojiCanvas: HTMLCanvasElement;
drawSize: number;
halfSize: number;
bornAt: number;
settled: boolean;
};
type HeroFace = {
emoji: string;
emojiCanvas: HTMLCanvasElement;
/** Target CSS pixel size of the glyph (matches jumbo font-size). */
drawSize: number;
halfSize: number;
/** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */
canvasScale: number;
morphed: boolean;
};
export type FireworkSim = {
particles: FireworkParticle[];
profile: EmojiBurstProfile;
primaryEmoji: string;
morphAtMs: number | null;
/** When to drop the stand-in hero and reveal the real jumbo again. */
heroUntilMs: number | null;
hero: HeroFace | null;
width: number;
height: number;
origin: BurstPoint;
startMs: number;
spawnPlan: SpawnItem[];
spawnCursor: number;
floorY: number;
/** Column stack heights for the settled pile (in cells). */
pileCols: Uint16Array;
pileCanvas: HTMLCanvasElement | null;
pileCtx: CanvasRenderingContext2D | null;
flyingCount: number;
};
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
function lerp(min: number, max: number): number {
return min + Math.random() * (max - min);
}
const HERO_PAD_RATIO = 0.24;
type EmojiBitmap = {
canvas: HTMLCanvasElement;
/** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */
canvasScale: number;
};
function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement {
return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas;
}
function getEmojiBitmap(
emoji: string,
tintColor: string | undefined,
cssPx: number | undefined,
padRatio: number
): EmojiBitmap {
const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX));
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1;
const fontPx = Math.round(targetCss * dpr);
const pad = Math.ceil(fontPx * padRatio);
const size = fontPx + pad * 2;
const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`;
const cached = emojiCanvasCache.get(cacheKey);
if (cached) {
return { canvas: cached, canvasScale: size / fontPx };
}
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.imageSmoothingEnabled = false;
ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
// Slight optical nudge — color emoji fonts sit high in the em box.
ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06);
if (tintColor) {
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = tintColor;
ctx.fillRect(0, 0, size, size);
ctx.globalCompositeOperation = 'source-over';
}
}
emojiCanvasCache.set(cacheKey, canvas);
return { canvas, canvasScale: size / Math.max(fontPx, 1) };
}
function setHeroEmoji(hero: HeroFace, emoji: string) {
const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO);
hero.emoji = emoji;
hero.emojiCanvas = bitmap.canvas;
hero.canvasScale = bitmap.canvasScale;
}
function setHeroSize(hero: HeroFace, cssPx: number) {
if (!(cssPx > 0)) return;
if (Math.abs(cssPx - hero.drawSize) < 0.5) return;
hero.drawSize = cssPx;
hero.halfSize = cssPx / 2;
const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO);
hero.emojiCanvas = bitmap.canvas;
hero.canvasScale = bitmap.canvasScale;
}
function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined {
if (!profile.tintColor || !profile.tintEmojis?.length) return undefined;
return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined;
}
/** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */
function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
const morphTo = profile.morphTo;
const companions = (profile.companions ?? []).filter((e) => e !== morphTo);
if (morphTo && companions.length) {
return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji;
}
const picked = pickParticleEmoji(profile, primaryEmoji);
return picked === morphTo ? primaryEmoji : picked;
}
function buildSpawnPlan(
profile: EmojiBurstProfile,
primaryEmoji: string,
startMs: number
): SpawnItem[] {
// Anchored morphing hero is drawn separately — skip a flying hero twin.
const plan: SpawnItem[] = profile.morphTo
? []
: [
{
atMs: startMs,
emoji: primaryEmoji,
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
},
];
const count = profile.particleCount;
for (let i = 0; i < count; i += 1) {
const slot = (i + 0.5) / count;
const eased = slot * slot;
plan.push({
atMs:
startMs +
80 +
eased * SPAWN_WINDOW_MS +
(Math.random() - 0.5) * 70,
emoji: pickSpewEmoji(profile, primaryEmoji),
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
});
}
plan.sort((a, b) => a.atMs - b.atMs);
return plan;
}
function applyMorph(sim: FireworkSim, now: number) {
const to = sim.profile.morphTo;
if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return;
if (sim.hero?.morphed) return;
// Only the stand-in face morphs — spray stays droplets / companions.
if (sim.hero) {
setHeroEmoji(sim.hero, to);
sim.hero.morphed = true;
}
}
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
const canvas = document.createElement('canvas');
canvas.width = sim.width;
canvas.height = sim.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('firework pile unsupported');
ctx.imageSmoothingEnabled = false;
sim.pileCanvas = canvas;
sim.pileCtx = ctx;
return ctx;
}
function stampSettled(sim: FireworkSim, p: FireworkParticle) {
const ctx = ensurePile(sim);
const rad = (p.angle * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
ctx.setTransform(cos, sin, -sin, cos, p.x, p.y);
ctx.globalAlpha = 1;
ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
const { profile, origin } = sim;
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
const directed = profile.angleSpread !== undefined;
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
const speed = lerp(profile.speedMin, profile.speedMax);
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
const emoji = item.emoji;
// Directed cones (e.g. throw-up) push along the sampled angle.
// Undirected fireworks keep the classic upward launch kick.
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40;
let vy = Math.sin(angle) * speed;
if (directed) {
const bias = profile.angleBias ?? angle;
vx += Math.cos(bias) * launchUp * 0.25;
vy += Math.sin(bias) * launchUp;
} else {
vy -= launchUp;
}
const tint = particleTint(profile, emoji);
sim.particles.push({
x: origin.x + (Math.random() - 0.5) * jitter,
y: origin.y + (Math.random() - 0.5) * jitter,
vx,
vy,
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
angle: (Math.random() - 0.5) * 40,
emoji,
emojiCanvas: getEmojiCanvas(emoji, tint),
drawSize,
halfSize: drawSize / 2,
bornAt: now,
settled: false,
});
sim.flyingCount += 1;
}
function settleParticle(sim: FireworkSim, p: FireworkParticle) {
const cols = sim.pileCols.length;
let col = Math.floor(p.x / PILE_CELL);
if (col < 0) col = 0;
if (col >= cols) col = cols - 1;
// Prefer the intended column; spill to neighbors if that stack is already tall.
let bestCol = col;
let bestH = sim.pileCols[col];
for (const delta of [0, -1, 1, -2, 2]) {
const c = col + delta;
if (c < 0 || c >= cols) continue;
if (sim.pileCols[c] < bestH) {
bestH = sim.pileCols[c];
bestCol = c;
}
}
const stack = sim.pileCols[bestCol];
sim.pileCols[bestCol] = stack + 1;
p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6;
p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize;
p.vx = 0;
p.vy = 0;
p.spin = 0;
p.settled = true;
sim.flyingCount = Math.max(0, sim.flyingCount - 1);
stampSettled(sim, p);
}
export function createFireworkSim(
widthPx: number,
heightPx: number,
originPx: BurstPoint,
primaryEmoji: string,
profile: EmojiBurstProfile,
startMs = performance.now(),
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
heroSizePx?: number
): FireworkSim {
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
const fallbackHero =
lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95;
const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero;
const hero: HeroFace | null = profile.morphTo
? (() => {
const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO);
return {
emoji: primaryEmoji,
emojiCanvas: bitmap.canvas,
drawSize: heroSize,
halfSize: heroSize / 2,
canvasScale: bitmap.canvasScale,
morphed: false,
};
})()
: null;
const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs);
// Keep the spewing stand-in up for the whole fountain — not just a short flash.
const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs;
const heroUntilMs = profile.morphTo
? profile.heroDurationMs != null
? startMs + profile.heroDurationMs
: lastSpawnAt + 120
: null;
return {
particles: [],
profile,
primaryEmoji,
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
heroUntilMs,
hero,
width: widthPx,
height: heightPx,
origin: originPx,
startMs,
spawnPlan,
spawnCursor: 0,
floorY: heightPx - FLOOR_PAD,
pileCols: new Uint16Array(colCount),
pileCanvas: null,
pileCtx: null,
flyingCount: 0,
};
}
/** Keep the stand-in face glued to the live jumbo metrics. */
export function syncFireworkHero(
sim: FireworkSim,
metrics: { x: number; y: number; size: number } | null
) {
if (!sim.hero || !metrics) return;
sim.origin.x = metrics.x;
sim.origin.y = metrics.y;
setHeroSize(sim.hero, metrics.size);
}
/** @returns false when the burst should be removed. */
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
applyMorph(sim, now);
if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) {
sim.hero = null;
}
const dt = Math.min(dtSeconds, 0.05);
const g = sim.profile.gravity;
let spawned = 0;
while (
spawned < MAX_SPAWNS_PER_FRAME &&
sim.spawnCursor < sim.spawnPlan.length &&
sim.spawnPlan[sim.spawnCursor].atMs <= now
) {
spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now);
sim.spawnCursor += 1;
spawned += 1;
}
const floor = sim.floorY;
const left = 4;
const right = sim.width - 4;
for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i];
if (p.settled) continue;
p.vy += g * dt;
p.vx *= DRAG;
p.vy *= DRAG;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.angle += p.spin * dt;
if (p.x < left) {
p.x = left;
p.vx = Math.abs(p.vx) * BOUNCE;
} else if (p.x > right) {
p.x = right;
p.vx = -Math.abs(p.vx) * BOUNCE;
}
if (p.y >= floor - p.halfSize) {
p.y = floor - p.halfSize;
const speed = Math.hypot(p.vx, p.vy);
if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) {
settleParticle(sim, p);
} else {
p.vy = -Math.abs(p.vy) * BOUNCE;
p.vx *= 0.85;
p.spin *= 0.7;
}
}
}
return true;
}
export function drawFireworkSim(
context: CanvasRenderingContext2D,
sim: FireworkSim,
now: number
) {
const elapsed = now - sim.startMs;
let fade = 1;
if (elapsed >= SETTLE_HOLD_MS) {
fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS);
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.imageSmoothingEnabled = false;
if (sim.pileCanvas) {
context.globalAlpha = fade;
context.drawImage(sim.pileCanvas, 0, 0);
}
// Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away.
if (sim.hero && fade > 0.02) {
const hero = sim.hero;
const heaveAmp = Math.max(1.5, hero.drawSize * 0.02);
const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp);
// Scale includes bitmap padding so the glyph matches jumbo size without clipping.
const box = hero.drawSize * hero.canvasScale;
const half = box / 2;
context.globalAlpha = fade;
context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave);
context.drawImage(hero.emojiCanvas, -half, -half, box, box);
context.setTransform(1, 0, 0, 1, 0, 0);
}
for (let i = 0; i < sim.particles.length; i += 1) {
const p = sim.particles[i];
if (p.settled) continue;
const ageMs = now - p.bornAt;
if (ageMs < 0) continue;
let opacity = fade;
let scale = 1;
if (ageMs < SPARKLE_IN_MS) {
const t = ageMs / SPARKLE_IN_MS;
const appear = t * t * (3 - 2 * t);
scale = 0.5 + 0.65 * appear;
opacity *= appear;
}
if (opacity <= 0.02) continue;
const rad = (p.angle * Math.PI) / 180;
const size = p.drawSize * scale;
const half = size / 2;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
context.globalAlpha = opacity;
context.setTransform(cos, sin, -sin, cos, p.x, p.y);
context.drawImage(p.emojiCanvas, -half, -half, size, size);
}
context.globalAlpha = 1;
context.setTransform(1, 0, 0, 1, 0, 0);
}
export function getFireworkDpr(): number {
return 1;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import React, {
} from 'react';
import dayjs from 'dayjs';
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
import { useSetAtom } from 'jotai';
import { Icon, Icons } from '../../../components/icons';
import { HexColorPicker } from 'react-colorful';
import { isKeyHotkey } from 'is-hotkey';
@@ -24,6 +25,7 @@ import {
LightTheme,
Theme,
ThemeKind,
themePreviewIdAtom,
useSystemThemeKind,
useThemeNames,
useThemes,
@@ -41,28 +43,49 @@ type ThemeSelectorProps = {
onSelect: (theme: Theme) => void;
};
const ThemeSelector = as<'div', ThemeSelectorProps>(
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
<Menu {...props} ref={ref}>
<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"
onClick={() => onSelect(theme)}
>
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
</MenuItem>
))}
</Box>
</Menu>
)
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
const setPreviewId = useSetAtom(themePreviewIdAtom);
useEffect(
() => () => {
setPreviewId(undefined);
},
[setPreviewId]
);
const clearPreview = () => setPreviewId(undefined);
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
return (
<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 }) {
const themes = useThemes();
const themeNames = useThemeNames();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
};
const handleThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setThemeId(theme.id);
setMenuCords(undefined);
};
const closeMenu = () => {
setPreviewId(undefined);
setMenuCords(undefined);
};
return (
<>
<Button
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
const themes = useThemes();
const setPreviewId = useSetAtom(themePreviewIdAtom);
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
};
const handleLightThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setLightThemeId(theme.id);
setLTCords(undefined);
};
const handleDarkThemeSelect = (theme: Theme) => {
setPreviewId(undefined);
setDarkThemeId(theme.id);
setDTCords(undefined);
};
const closeLightMenu = () => {
setPreviewId(undefined);
setLTCords(undefined);
};
const closeDarkMenu = () => {
setPreviewId(undefined);
setDTCords(undefined);
};
return (
<Box wrap="Wrap" gap="400">
<SettingTile
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setLTCords(undefined),
onDeactivate: closeLightMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setDTCords(undefined),
onDeactivate: closeDarkMenu,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',

View File

@@ -170,22 +170,34 @@ type PushStatus = {
registered: boolean;
distributor: string;
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => {
setLoading(true);
const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus(
s
? {
registered: s.registered,
distributor: s.distributor || '',
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback(
useCallback(async () => {
await requestResetPushRegistration();
setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh();
if (!result.success) {
setLastError(
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
);
}
}, [refresh])
);
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading ||
pingState.status === AsyncStatus.Loading;
const statusDescription = (() => {
if (loading) return 'Loading status…';
if (status === undefined) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
);
}
if (status.registered) {
return (
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</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) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
</Text>
);
}
return (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
</Text>
);
})();
return (
<Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
<SettingTile
title="Background Notifications"
description={
loading ? (
'Loading status…'
) : status === undefined ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Failed to read push status.
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
<>
{statusDescription}
{lastError ? (
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
{lastError}
</Text>
</>
) : (
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
) : null}
</>
}
after={loading ? <Spinner variant="Secondary" /> : undefined}
/>
<SettingTile
title="Change Distributor"
description="Re-open the UnifiedPush distributor selection dialog."
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
after={
<Button
size="300"

View File

@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
useCallback(
async (evt) => {
(evt) => {
const files = getDataTransferFiles(evt.clipboardData);
if (files && files.length > 0) {
evt.preventDefault();
onPaste(files);
return;
}
// On Linux with Tauri, browser clipboard API doesn't work for images
// Use our custom Tauri command with arboard/Wayland support
if (isTauri() && isLinux()) {
const clipboardImage = await readClipboardImage();
if (clipboardImage) {
onPaste([clipboardImage]);
}
// Address-bar / plain-text copies still win. On Linux the native clipboard
// often still has a previous image (or a junk bitmap) alongside text/plain;
// don't treat those as an image paste.
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
if (text.length > 0) {
return;
}
// Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron.
if (!(isTauri() && isLinux())) return;
// preventDefault before the async read, or the editor also inserts text.
evt.preventDefault();
void readClipboardImage().then((clipboardImage) => {
if (clipboardImage) onPaste([clipboardImage]);
});
},
[onPaste]
);

View File

@@ -1,4 +1,5 @@
import { lightTheme } from 'folds';
import { atom, useAtomValue } from 'jotai';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.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 { 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 {
Light = 'light',
Dark = 'dark',
@@ -190,6 +194,16 @@ export const useActiveTheme = (): Theme => {
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);
export const ThemeContextProvider = ThemeContext.Provider;

View File

@@ -11,7 +11,14 @@ import { MatrixClient } from 'matrix-js-sdk';
import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix';
import { getCallService } from './features/call/useCall';
import { RoomType, StateEvent } from '../types/matrix/room';
import {
getNotificationType,
getUnreadInfo,
reactionOrEditEvent,
roomHaveNotification,
roomHaveUnread,
} from './utils/room';
import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room';
/**
* Global reference to the router navigate function
@@ -120,6 +127,30 @@ export function initPaarrotAPI(matrixClient: MatrixClient) {
result = await getCurrentRoom(matrixClient);
break;
case 'get-messages-groups':
result = await getMessagesByScope(matrixClient, 'groups', action.params.limit);
break;
case 'get-messages-dms':
result = await getMessagesByScope(matrixClient, 'dms', action.params.limit);
break;
case 'get-messages-combined':
result = await getMessagesByScope(matrixClient, 'combined', action.params.limit);
break;
case 'get-unreads-groups':
result = await getUnreadsByScope(matrixClient, 'groups');
break;
case 'get-unreads-dms':
result = await getUnreadsByScope(matrixClient, 'dms');
break;
case 'get-unreads-combined':
result = await getUnreadsByScope(matrixClient, 'combined');
break;
default:
throw new Error(`Unknown action: ${action.action}`);
}
@@ -410,6 +441,207 @@ async function getCurrentRoom(matrixClient: any) {
};
}
const DEFAULT_MESSAGE_LIMIT = 10;
const MAX_MESSAGE_LIMIT = 100;
type MessageScope = 'groups' | 'dms' | 'combined';
type ApiMessage = {
eventId: string;
roomId: string;
roomName: string;
isDirect: boolean;
sender: string;
senderName: string;
timestamp: number;
msgtype: string;
body: string;
};
function isDirectRoom(room: any): boolean {
return room?.guessDMUserId?.() !== null;
}
function parseMessageLimit(limit?: number | string): number {
const parsedLimit = Number(limit);
return Math.min(
Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1),
MAX_MESSAGE_LIMIT
);
}
/**
* Collect recent room messages from the local live timeline (newest last).
* Skips reactions, edits, and redacted events.
*/
function collectRecentMessages(room: any, limit: number): ApiMessage[] {
const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? [];
const isDirect = isDirectRoom(room);
const roomId = room.roomId;
const roomName = room.name || 'Unnamed Room';
const messages: ApiMessage[] = [];
for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) {
const evt = liveEvents[i];
if (!evt) continue;
if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue;
if (reactionOrEditEvent(evt)) continue;
const type = evt.getType?.();
if (
type !== MessageEvent.RoomMessage &&
type !== MessageEvent.RoomMessageEncrypted &&
type !== MessageEvent.Sticker
) {
continue;
}
const content = evt.getContent?.() ?? {};
const sender = evt.getSender?.() ?? '';
const member = sender ? room.getMember?.(sender) : null;
if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) {
messages.push({
eventId: evt.getId?.() ?? '',
roomId,
roomName,
isDirect,
sender,
senderName: member?.name ?? sender,
timestamp: evt.getTs?.() ?? 0,
msgtype: 'm.encrypted',
body: '[encrypted]',
});
continue;
}
messages.push({
eventId: evt.getId?.() ?? '',
roomId,
roomName,
isDirect,
sender,
senderName: member?.name ?? sender,
timestamp: evt.getTs?.() ?? 0,
msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'),
body: content.body || '',
});
}
return messages.reverse();
}
function getRoomsForScope(matrixClient: any, scope: MessageScope) {
const rooms = (matrixClient?.getRooms?.() || []).filter(
(room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join'
);
if (scope === 'groups') {
return rooms.filter((room: any) => !isDirectRoom(room));
}
if (scope === 'dms') {
return rooms.filter((room: any) => isDirectRoom(room));
}
return rooms;
}
/**
* Get recent messages across groups, DMs, or both (combined).
*/
async function getMessagesByScope(
matrixClient: any,
scope: MessageScope,
limit?: number | string
) {
const limitNum = parseMessageLimit(limit);
const rooms = getRoomsForScope(matrixClient, scope);
const candidates: ApiMessage[] = [];
for (const room of rooms) {
candidates.push(...collectRecentMessages(room, limitNum));
}
candidates.sort((a, b) => a.timestamp - b.timestamp);
const messages = candidates.slice(-limitNum);
return {
scope,
limit: limitNum,
count: messages.length,
messages,
};
}
/**
* List unread conversations across groups, DMs, or both (combined).
* Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded).
*/
async function getUnreadsByScope(matrixClient: any, scope: MessageScope) {
const rooms = getRoomsForScope(matrixClient, scope);
const unreads: Array<{
roomId: string;
name: string;
isDirect: boolean;
avatar: string | null;
total: number;
highlight: number;
latest?: {
eventId: string;
sender: string;
senderName: string;
timestamp: number;
msgtype: string;
body: string;
};
}> = [];
for (const room of rooms) {
if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) {
continue;
}
if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) {
continue;
}
const unreadInfo = getUnreadInfo(room);
const latestMessages = collectRecentMessages(room, 1);
const latest = latestMessages[0];
unreads.push({
roomId: room.roomId,
name: room.name || 'Unnamed Room',
isDirect: isDirectRoom(room),
avatar: room.getMxcAvatarUrl?.() || null,
total: unreadInfo.total,
highlight: unreadInfo.highlight,
latest: latest
? {
eventId: latest.eventId,
sender: latest.sender,
senderName: latest.senderName,
timestamp: latest.timestamp,
msgtype: latest.msgtype,
body: latest.body,
}
: undefined,
});
}
unreads.sort((a, b) => {
const aTs = a.latest?.timestamp ?? 0;
const bTs = b.latest?.timestamp ?? 0;
if (bTs !== aTs) return bTs - aTs;
if (b.highlight !== a.highlight) return b.highlight - a.highlight;
return b.total - a.total;
});
return {
scope,
count: unreads.length,
unreads,
};
}
/**
* Get the currently active room ID
* Extracts room ID from the current URL pathname

View File

@@ -5,7 +5,7 @@ import {
LightTheme,
ThemeContextProvider,
ThemeKind,
useActiveTheme,
useDisplayTheme,
useSystemThemeKind,
} from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
@@ -29,21 +29,21 @@ export function UnAuthRouteThemeManager() {
}
export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const activeTheme = useActiveTheme();
const displayTheme = useDisplayTheme();
const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode');
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
document.body.classList.add(...activeTheme.classNames);
document.body.classList.add(...displayTheme.classNames);
if (monochromeMode) {
document.body.style.filter = 'grayscale(1)';
} else {
document.body.style.filter = '';
}
}, [activeTheme, monochromeMode]);
}, [displayTheme, monochromeMode]);
return <ThemeContextProvider value={activeTheme}>{children}</ThemeContextProvider>;
return <ThemeContextProvider value={displayTheme}>{children}</ThemeContextProvider>;
}

View File

@@ -188,7 +188,7 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
text,
EMOJI_REG_G,
(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]))}>
{match[0]}
</span>
@@ -551,8 +551,15 @@ export const getReactCustomHtmlParser = (
);
}
if (htmlSrc && 'data-mx-emoticon' in props) {
const emoticonLabel =
(typeof props.alt === 'string' && props.alt) ||
(typeof props.title === 'string' && props.title) ||
undefined;
return (
<span className={css.EmoticonBase}>
<span
className={css.EmoticonBase}
{...(emoticonLabel ? { 'data-emoticon': emoticonLabel } : {})}
>
<span className={css.Emoticon()}>
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
</span>

View File

@@ -15,6 +15,9 @@ type UnifiedPushStatus = {
registered: boolean;
distributor: string;
distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
};
type UnifiedPushEndpointEvent = {
@@ -542,7 +545,23 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
return result ?? { success: false };
if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register().
for (let i = 0; i < 40; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) {
return { success: true };
}
if (status?.lastFailure) {
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
return { success: false };
}
}
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };

View File

@@ -24,6 +24,7 @@ import {
Membership,
MessageEvent,
NotificationType,
PaarrotUiContent,
RoomToParents,
RoomType,
StateEvent,
@@ -125,6 +126,33 @@ export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
return typeof kind === 'string' ? kind : undefined;
};
/**
* Valid bot-driven room UI state (im.paarrot.ui with version + root node).
*/
export const parsePaarrotUiContent = (raw: unknown): PaarrotUiContent | undefined => {
if (!raw || typeof raw !== 'object') return undefined;
const content = raw as PaarrotUiContent;
if (
typeof content.version !== 'number' ||
!content.root ||
typeof content.root !== 'object' ||
typeof content.root.type !== 'string' ||
typeof content.root.id !== 'string'
) {
return undefined;
}
return content;
};
export const getPaarrotUiContent = (room: Room | null): PaarrotUiContent | undefined => {
if (!room) return undefined;
const event = room.currentState?.getStateEvents(StateEvent.PaarrotUi, '');
if (!event) return undefined;
return parsePaarrotUiContent(event.getContent());
};
export const hasRoomAppUi = (room: Room | null): boolean => getPaarrotUiContent(room) !== undefined;
/**
* m.forum space root (forum board lobby), not a plain m.space or a lone m.forum topic channel.
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;

View File

@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
try {
const electron = (window as any).electron;
if (electron?.clipboard?.readImage) {
const dataUrl = await electron.clipboard.readImage();
const result = await electron.clipboard.readImage();
// IPC may return a data URL string or { success, data } depending on path.
const dataUrl =
typeof result === 'string'
? result
: result && typeof result === 'object' && typeof result.data === 'string'
? result.data
: null;
if (!dataUrl) return null;
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
if (blob.size < 32) return null;
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
}
} catch (err) {
@@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise<File | null> => {
// Convert data URL to File
const response = await fetch(dataUrl);
const blob = await response.blob();
if (blob.size < 32) return null;
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
} catch (err) {
console.warn('Failed to read Tauri clipboard image:', err);

365
src/playground/App.tsx Normal file
View 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>
);
}

View 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 />;
}

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

View 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
View 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} />;
}
`;
}

View 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>
);
}

View File

@@ -0,0 +1,3 @@
import defaultLive from './defaultLive.tsx?raw';
export const DEFAULT_SOURCE = defaultLive;

View 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
View 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>
);

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export { mocks, createMockMatrixClient, createMockRoom, createMockMatrixEvent, MOCK_POWER_LEVELS } from './matrix';
export { PlaygroundProviders } from './PlaygroundProviders';

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

View 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();
},
};

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

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

View File

@@ -48,6 +48,9 @@ export enum StateEvent {
/** Paarrot room classification marker for custom room behaviors */
PaarrotRoomKind = 'im.paarrot.room.kind',
/** Bot-driven declarative room UI (takes over room view) */
PaarrotUi = 'im.paarrot.ui',
/** Matrix RTC call membership (MSC3401) */
CallMember = 'org.matrix.msc3401.call.member',
}
@@ -58,8 +61,117 @@ export enum MessageEvent {
Sticker = 'm.sticker',
RoomRedaction = 'm.room.redaction',
Reaction = 'm.reaction',
/** Relay emoji confetti burst tied to a target message */
RelayEmojiConfetti = 'app.relay.emoji_confetti',
/** User interaction with bot-driven room UI (im.paarrot.ui) */
PaarrotUiAction = 'im.paarrot.ui.action',
}
/** Declarative UI node types for im.paarrot.ui */
export type PaarrotUiNodeType =
| 'panel'
| 'row'
| 'text'
| 'button'
| 'input'
| 'select'
| 'image'
| 'spacer';
export type PaarrotUiNodeBase = {
type: PaarrotUiNodeType;
id: string;
};
export type PaarrotUiPanelNode = PaarrotUiNodeBase & {
type: 'panel';
children?: PaarrotUiNode[];
};
export type PaarrotUiRowNode = PaarrotUiNodeBase & {
type: 'row';
children?: PaarrotUiNode[];
};
export type PaarrotUiTextNode = PaarrotUiNodeBase & {
type: 'text';
text?: string;
className?: string;
};
export type PaarrotUiButtonNode = PaarrotUiNodeBase & {
type: 'button';
label: string;
action: string;
value?: string | number | boolean;
disabled?: boolean;
variant?: 'Primary' | 'Secondary' | 'Success' | 'Warning' | 'Critical';
};
export type PaarrotUiInputNode = PaarrotUiNodeBase & {
type: 'input';
name?: string;
label?: string;
placeholder?: string;
value?: string;
inputType?: 'text' | 'number' | 'password';
};
export type PaarrotUiSelectOption = {
label: string;
value: string;
};
export type PaarrotUiSelectNode = PaarrotUiNodeBase & {
type: 'select';
name?: string;
label?: string;
options?: PaarrotUiSelectOption[];
value?: string;
};
export type PaarrotUiImageNode = PaarrotUiNodeBase & {
type: 'image';
src: string;
alt?: string;
width?: number;
height?: number;
};
export type PaarrotUiSpacerNode = PaarrotUiNodeBase & {
type: 'spacer';
size?: number;
};
export type PaarrotUiNode =
| PaarrotUiPanelNode
| PaarrotUiRowNode
| PaarrotUiTextNode
| PaarrotUiButtonNode
| PaarrotUiInputNode
| PaarrotUiSelectNode
| PaarrotUiImageNode
| PaarrotUiSpacerNode;
/** Content of state event im.paarrot.ui */
export type PaarrotUiContent = {
version: number;
title?: string;
css?: string;
root: PaarrotUiNode;
};
/** Content of timeline event im.paarrot.ui.action */
export type PaarrotUiActionContent = {
action: string;
component_id: string;
value?: string | number | boolean;
values?: Record<string, string>;
ui_event_id?: string;
};
export enum RoomType {
Space = 'm.space',
Forum = 'm.forum',

View File

@@ -10,6 +10,9 @@ import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import path from 'path';
import buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
const projectRoot = path.resolve();
const copyFiles = {
targets: [
@@ -217,6 +220,30 @@ export default defineConfig({
publicDir: false,
base: buildConfig.base,
assetsInclude: ['**/*.ogg', '**/*.mp3', '**/*.wav'],
resolve: {
// Keep a single React instance — dual copies cause resolveDispatcher() === null.
dedupe: ['react', 'react-dom'],
alias: [
{ find: '@cinny', replacement: path.resolve(projectRoot, 'src') },
{
find: '@playground/mocks',
replacement: path.resolve(projectRoot, 'src/playground/mocks/index.ts'),
},
// Exact match only — do not rewrite loglevel/lib/...
{
find: /^loglevel$/,
replacement: path.resolve(projectRoot, 'src/playground/shims/loglevel.js'),
},
{
find: /^react$/,
replacement: path.resolve(projectRoot, 'node_modules/react'),
},
{
find: /^react-dom$/,
replacement: path.resolve(projectRoot, 'node_modules/react-dom'),
},
],
},
server: {
port: 38347,
host: '0.0.0.0',
@@ -232,6 +259,7 @@ export default defineConfig({
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
serveGifWorker(),
servePublicAssets(),
liveTsxPlugin(projectRoot, readDefaultLiveSource(projectRoot)),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__tla',
@@ -258,6 +286,16 @@ export default defineConfig({
}),
],
optimizeDeps: {
include: [
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'loglevel',
'matrix-js-sdk',
'@monaco-editor/react',
],
needsInterop: ['loglevel'],
rolldownOptions: {
define: {
global: 'globalThis',
@@ -277,6 +315,10 @@ export default defineConfig({
sourcemap: true,
copyPublicDir: false,
rollupOptions: {
input: {
main: path.resolve(projectRoot, 'index.html'),
playground: path.resolve(projectRoot, 'playground.html'),
},
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
},
},