diff --git a/docs/README.md b/docs/README.md index 6300679..dede24b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/handoff/features/room-app-ui/HANDOFF.md b/docs/handoff/features/room-app-ui/HANDOFF.md new file mode 100644 index 0000000..f6b521d --- /dev/null +++ b/docs/handoff/features/room-app-ui/HANDOFF.md @@ -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` diff --git a/docs/handoff/features/room-app-ui/samples/poll-ui-state.json b/docs/handoff/features/room-app-ui/samples/poll-ui-state.json new file mode 100644 index 0000000..ce0953e --- /dev/null +++ b/docs/handoff/features/room-app-ui/samples/poll-ui-state.json @@ -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" + } + ] + } +} diff --git a/docs/handoff/features/room-app-ui/samples/trivia-ui-state.json b/docs/handoff/features/room-app-ui/samples/trivia-ui-state.json new file mode 100644 index 0000000..c9f8c94 --- /dev/null +++ b/docs/handoff/features/room-app-ui/samples/trivia-ui-state.json @@ -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" + } + ] + } + ] + } +} diff --git a/docs/handoff/features/room-app-ui/samples/ui-action-event.json b/docs/handoff/features/room-app-ui/samples/ui-action-event.json new file mode 100644 index 0000000..315797a --- /dev/null +++ b/docs/handoff/features/room-app-ui/samples/ui-action-event.json @@ -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" +} diff --git a/src/app/features/room-app/RoomAppSchema.tsx b/src/app/features/room-app/RoomAppSchema.tsx new file mode 100644 index 0000000..707cbbd --- /dev/null +++ b/src/app/features/room-app/RoomAppSchema.tsx @@ -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; +}; + +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>({}); + 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 ( +
+ {node.children?.map((child) => renderNode(child))} +
+ ); + case 'row': + return ( +
+ {node.children?.map((child) => renderNode(child))} +
+ ); + case 'text': + return ( + + {node.text ?? ''} + + ); + case 'button': { + const variant = node.variant ?? 'Primary'; + return ( + + ); + } + case 'input': { + const name = fieldName(node); + const current = values[name] ?? node.value ?? ''; + return ( +
+ {node.label && ( + + {node.label} + + )} + setField(name, evt.currentTarget.value)} + /> +
+ ); + } + case 'select': { + const name = fieldName(node); + const current = values[name] ?? node.value ?? ''; + return ( +
+ {node.label && ( + + {node.label} + + )} + + + +
+ ); + } + 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 ( + {node.alt + ); + } + case 'spacer': + return
; + default: + return null; + } + }; + + return ( +
+ {scopedCss ? : null} + {renderNode(content.root)} +
+ ); +} diff --git a/src/app/features/room-app/RoomAppView.css.ts b/src/app/features/room-app/RoomAppView.css.ts new file mode 100644 index 0000000..b3fe436 --- /dev/null +++ b/src/app/features/room-app/RoomAppView.css.ts @@ -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', + }, +}); diff --git a/src/app/features/room-app/RoomAppView.tsx b/src/app/features/room-app/RoomAppView.tsx new file mode 100644 index 0000000..253eb6f --- /dev/null +++ b/src/app/features/room-app/RoomAppView.tsx @@ -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 ( + +
+ + + + {title} + + + + +
+ + {content ? ( + + ) : ( + This room’s app UI is missing or invalid. + )} + +
+ ); +} diff --git a/src/app/features/room-app/index.ts b/src/app/features/room-app/index.ts new file mode 100644 index 0000000..750a1dc --- /dev/null +++ b/src/app/features/room-app/index.ts @@ -0,0 +1,7 @@ +export { RoomAppView } from './RoomAppView'; +export { RoomAppSchema } from './RoomAppSchema'; +export { + sanitizeRoomAppCss, + scopeRoomAppCss, + isAllowedRoomAppImageSrc, +} from './sanitizeRoomAppCss'; diff --git a/src/app/features/room-app/sanitizeRoomAppCss.ts b/src/app/features/room-app/sanitizeRoomAppCss.ts new file mode 100644 index 0000000..70de5cf --- /dev/null +++ b/src/app/features/room-app/sanitizeRoomAppCss.ts @@ -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://') + ); +} diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index 5612890..658a610 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -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', + }, ], }; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 57439c9..c8d46bb 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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() { {!showMediaSolo && - (forumRoom ? ( + (showAppView ? ( + setPreferChat(true)} /> + ) : forumRoom ? ( ) : ( - + + {hasAppUi && preferChat && ( + + Room app available + + + )} + + + + ))} {showMediaSolo && } {!showMediaSolo && showRightDrawer && ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 5a3c89b..a67cba3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2011,6 +2011,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli return 'CALL_EVENT_PENDING' as unknown as React.ReactNode; }, [MessageEvent.RelayEmojiConfetti]: () => null, + [MessageEvent.PaarrotUiAction]: () => null, }, (mEventId, mEvent, item) => { if (!showHiddenEvents) return null; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 8c11b3b..a6944c0 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -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; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 5131ab5..8c38d3f 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -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', } @@ -61,8 +64,114 @@ export enum MessageEvent { /** 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; + ui_event_id?: string; +}; + export enum RoomType { Space = 'm.space', Forum = 'm.forum',