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.
206 lines
6.0 KiB
TypeScript
206 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|