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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user