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.
This commit is contained in:
2026-08-09 13:16:43 +10:00
parent 32bf2cbed5
commit 8a68a1e30a
15 changed files with 866 additions and 5 deletions

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