Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
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.
This commit is contained in:
@@ -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) |
|
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
|
||||||
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/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) |
|
| 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
|
### Settings & customization
|
||||||
|
|
||||||
|
|||||||
93
docs/handoff/features/room-app-ui/HANDOFF.md
Normal file
93
docs/handoff/features/room-app-ui/HANDOFF.md
Normal 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`
|
||||||
34
docs/handoff/features/room-app-ui/samples/poll-ui-state.json
Normal file
34
docs/handoff/features/room-app-ui/samples/poll-ui-state.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
205
src/app/features/room-app/RoomAppSchema.tsx
Normal file
205
src/app/features/room-app/RoomAppSchema.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/features/room-app/RoomAppView.css.ts
Normal file
74
src/app/features/room-app/RoomAppView.css.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
67
src/app/features/room-app/RoomAppView.tsx
Normal file
67
src/app/features/room-app/RoomAppView.tsx
Normal 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 room’s app UI is missing or invalid.</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/features/room-app/index.ts
Normal file
7
src/app/features/room-app/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { RoomAppView } from './RoomAppView';
|
||||||
|
export { RoomAppSchema } from './RoomAppSchema';
|
||||||
|
export {
|
||||||
|
sanitizeRoomAppCss,
|
||||||
|
scopeRoomAppCss,
|
||||||
|
isAllowedRoomAppImageSrc,
|
||||||
|
} from './sanitizeRoomAppCss';
|
||||||
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal file
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal 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://')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -204,6 +204,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
|||||||
},
|
},
|
||||||
name: 'Modify Widgets',
|
name: 'Modify Widgets',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
state: true,
|
||||||
|
key: StateEvent.PaarrotUi,
|
||||||
|
},
|
||||||
|
name: 'Set Room App UI',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
location: {
|
||||||
|
key: MessageEvent.PaarrotUiAction,
|
||||||
|
},
|
||||||
|
name: 'Send Room App Actions',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Button, Line, Text, config } from 'folds';
|
||||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -18,8 +18,11 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
import { isMediaDrawerAtom } from '../../state/mediaDrawer';
|
||||||
import { isForum } from '../../utils/room';
|
import { isForum, parsePaarrotUiContent } from '../../utils/room';
|
||||||
import { ForumRoomView } from './ForumRoomView';
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { RoomAppView } from '../room-app';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId: rawEventId } = useParams();
|
const { eventId: rawEventId } = useParams();
|
||||||
@@ -41,6 +44,16 @@ export function Room() {
|
|||||||
const showRightDrawer =
|
const showRightDrawer =
|
||||||
!skinny && (isPeopleDrawer || isMediaDrawer);
|
!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
|
// Update titlebar with current room ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveRoomId(room.roomId);
|
setActiveRoomId(room.roomId);
|
||||||
@@ -63,10 +76,38 @@ export function Room() {
|
|||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{!showMediaSolo &&
|
{!showMediaSolo &&
|
||||||
(forumRoom ? (
|
(showAppView ? (
|
||||||
|
<RoomAppView room={room} onShowChat={() => setPreferChat(true)} />
|
||||||
|
) : forumRoom ? (
|
||||||
<ForumRoomView room={room} eventId={eventId} />
|
<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 && <MediaDrawer room={room} solo />}
|
||||||
{!showMediaSolo && showRightDrawer && (
|
{!showMediaSolo && showRightDrawer && (
|
||||||
|
|||||||
@@ -2011,6 +2011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||||
},
|
},
|
||||||
[MessageEvent.RelayEmojiConfetti]: () => null,
|
[MessageEvent.RelayEmojiConfetti]: () => null,
|
||||||
|
[MessageEvent.PaarrotUiAction]: () => null,
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Membership,
|
Membership,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
PaarrotUiContent,
|
||||||
RoomToParents,
|
RoomToParents,
|
||||||
RoomType,
|
RoomType,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
@@ -125,6 +126,33 @@ export const getPaarrotRoomKind = (room: Room | null): string | undefined => {
|
|||||||
return typeof kind === 'string' ? kind : 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.
|
* 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;
|
* Matrix sets create type m.forum on forum spaces but SDK isSpaceRoom() is often still false;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export enum StateEvent {
|
|||||||
/** Paarrot room classification marker for custom room behaviors */
|
/** Paarrot room classification marker for custom room behaviors */
|
||||||
PaarrotRoomKind = 'im.paarrot.room.kind',
|
PaarrotRoomKind = 'im.paarrot.room.kind',
|
||||||
|
|
||||||
|
/** Bot-driven declarative room UI (takes over room view) */
|
||||||
|
PaarrotUi = 'im.paarrot.ui',
|
||||||
|
|
||||||
/** Matrix RTC call membership (MSC3401) */
|
/** Matrix RTC call membership (MSC3401) */
|
||||||
CallMember = 'org.matrix.msc3401.call.member',
|
CallMember = 'org.matrix.msc3401.call.member',
|
||||||
}
|
}
|
||||||
@@ -61,8 +64,114 @@ export enum MessageEvent {
|
|||||||
|
|
||||||
/** Relay emoji confetti burst tied to a target message */
|
/** Relay emoji confetti burst tied to a target message */
|
||||||
RelayEmojiConfetti = 'app.relay.emoji_confetti',
|
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 {
|
export enum RoomType {
|
||||||
Space = 'm.space',
|
Space = 'm.space',
|
||||||
Forum = 'm.forum',
|
Forum = 'm.forum',
|
||||||
|
|||||||
Reference in New Issue
Block a user