@novasamatech/product-react-renderer
v0.6.8
Published
React wrapper for custom renderer format from product-sdk
Keywords
Readme
@novasamatech/product-react-renderer
A custom React reconciler for rendering native UI widgets inside Polkadot host applications. Use it together with @novasamatech/product-sdk to render interactive widget trees in response to custom chat messages.
How it works
When the host app displays a custom chat message, it calls your script to produce a widget tree — a structured description of the UI to render natively (buttons, text, columns, etc.). This package implements a custom React reconciler that maps React components to that widget tree format, so you can use React features like useState, useEffect, and component composition to build your UI.
React component tree
↓ (React reconciler)
Widget tree (CustomRendererNode)
↓ (SCALE encoding)
Native Desktop/Mobile UIInstallation
npm install @novasamatech/product-react-renderer react --save -ESetup
Configure your tsconfig.json to use React JSX:
{
"compilerOptions": {
"jsx": "react-jsx"
}
}registerChatMessageRenderer
The primary entry point for rendering custom chat messages. Pass a mapPayload function that decodes the raw bytes sent by the host, and a renderFn that returns the React element tree. The return value is a callback you pass directly to chat.onCustomMessageRenderingRequest().
Static message
import { registerChatMessageRenderer, Text } from '@novasamatech/product-react-renderer';
chat.onCustomMessageRenderingRequest(
registerChatMessageRenderer(
() => undefined,
() => <Text style="headline">Hello from the product!</Text>,
),
);Decoding a payload
mapPayload converts the raw Uint8Array the host sends before your renderFn sees it. A common pattern is JSON:
import { registerChatMessageRenderer, Column, Text } from '@novasamatech/product-react-renderer';
type BalancePayload = { token: string; amount: string };
chat.onCustomMessageRenderingRequest(
registerChatMessageRenderer(
raw => JSON.parse(new TextDecoder().decode(raw)) as BalancePayload,
({ payload }) => (
<Column>
<Text style="headline">{payload.amount}</Text>
<Text color="textSecondary">{payload.token}</Text>
</Column>
),
),
);Interactive messages
Use standard React hooks for local state. Library automatically wires up callbacks to user interactions on Host side.
import { useState } from 'react';
import { registerChatMessageRenderer, Column, Text, Button } from '@novasamatech/product-react-renderer';
function VoteWidget() {
const [votes, setVotes] = useState(0);
return (
<Column horizontalAlignment="center" padding={16}>
<Text style="headline">Votes: {votes}</Text>
<Button text="Vote" variant="primary" onClick={() => setVotes(v => v + 1)} />
</Column>
);
}
chat.onCustomMessageRenderingRequest(
registerChatMessageRenderer(
() => undefined,
() => <VoteWidget />,
),
);Using messageId and messageType
Both are forwarded to renderFn so you can adapt the UI per message:
import { registerChatMessageRenderer, Text } from '@novasamatech/product-react-renderer';
chat.onCustomMessageRenderingRequest(
registerChatMessageRenderer(
() => undefined,
({ messageId, messageType }) => (
<Text color="textSecondary">
[{messageType}] {messageId}
</Text>
),
),
);Components
All components accept the shared layout props in addition to their own props.
<Text>
| Prop | Type | Description |
|------------|-------------------|------------------------------|
| style | TypographyStyle | Font style |
| color | ColorToken | Text color |
| children | ReactNode | Text content or nested nodes |
TypographyStyle: titleXL · headline · bodyM · bodyS · caption
<Text style="headline" color="textPrimary">Balance: 42 DOT</Text><Button>
| Prop | Type | Description |
|-----------|-----------------|-------------------------|
| text | string | Label (required) |
| onClick | () => void | Tap handler (required) |
| variant | ButtonVariant | Visual style |
| enabled | boolean | Defaults to true |
| loading | boolean | Shows loading indicator |
ButtonVariant: primary · secondary · text
<Button text="Send" variant="primary" onClick={handleSend} /><TextField>
| Prop | Type | Description |
|-----------------|---------------------------|---------------------------|
| value | string | Current value (required) |
| onValueChange | (value: string) => void | Change handler (required) |
| placeholder | string | Placeholder text |
| label | string | Field label |
| enabled | boolean | Defaults to true |
<TextField value={query} placeholder="Search…" onValueChange={setQuery} />onValueChange receives the decoded string value each time the user edits the field.
import { useState } from 'react';
import {
registerChatMessageRenderer,
Column,
Text,
TextField,
Button,
} from '@novasamatech/product-react-renderer';
function SearchForm() {
const [query, setQuery] = useState('');
function handleSubmit() {
// send the query somewhere
}
return (
<Column padding={16}>
<TextField value={query} placeholder="Search…" onValueChange={setQuery} />
<Button text="Search" variant="primary" onClick={handleSubmit} />
</Column>
);
}
chat.onCustomMessageRenderingRequest(
registerChatMessageRenderer(
() => undefined,
() => <SearchForm />,
),
);<Column>
Stacks children vertically.
| Prop | Type | Description |
|-----------------------|-----------------------|----------------------|
| horizontalAlignment | HorizontalAlignment | Cross-axis alignment |
| verticalArrangement | Arrangement | Main-axis spacing |
HorizontalAlignment: start · center · end
Arrangement: start · end · center · spaceBetween · spaceAround · spaceEvenly
<Column horizontalAlignment="center" verticalArrangement="spaceBetween" padding={16}>
<Text style="headline">Title</Text>
<Button text="OK" onClick={handleOk} />
</Column><Row>
Stacks children horizontally.
| Prop | Type | Description |
|-------------------------|---------------------|----------------------|
| verticalAlignment | VerticalAlignment | Cross-axis alignment |
| horizontalArrangement | Arrangement | Main-axis spacing |
VerticalAlignment: top · center · bottom
<Row verticalAlignment="center" horizontalArrangement="spaceBetween">
<Text>Label</Text>
<Text color="textSecondary">Value</Text>
</Row><Box>
Single-child container with optional content alignment.
| Prop | Type | Description |
|--------------------|--------------------|-----------------------------|
| contentAlignment | ContentAlignment | Alignment of the child node |
ContentAlignment: topStart · topCenter · topEnd · centerStart · center · centerEnd · bottomStart · bottomCenter · bottomEnd
<Box contentAlignment="center" background="backgroundSecondary" padding={8}>
<Text>Centered</Text>
</Box><Spacer>
Flexible space element. Use fillMaxWidth / fillMaxHeight or explicit width / height.
<Row>
<Text>Left</Text>
<Spacer fillMaxWidth />
<Text>Right</Text>
</Row>Layout props
Every component accepts these props to control sizing, spacing, and appearance.
Spacing
| Prop | Type | Description |
|-----------|-----------|---------------|
| padding | Padding | Inner spacing |
| margin | Padding | Outer spacing |
Padding is a single number (all sides) or [top, bottom, start, end] for individual sides.
Sizing
| Prop | Type | Description |
|-----------------|-----------|---------------------------------|
| width | number | Fixed width |
| height | number | Fixed height |
| minWidth | number | Minimum width |
| minHeight | number | Minimum height |
| fillMaxWidth | boolean | Expand to fill available width |
| fillMaxHeight | boolean | Expand to fill available height |
Background
background accepts either a ColorToken string or a BackgroundStyle object:
// Plain color
<Box background="backgroundSecondary" />
// Color + shape
<Box background={{ color: 'backgroundSecondary', shape: { tag: 'Rounded', value: 8 } }} />
<Box background={{ color: 'backgroundTertiary', shape: { tag: 'Circle' } }} />Border
<Box border={{ width: 1, color: 'textTertiary' }} />
// With a rounded corner
<Box border={{ width: 1, color: 'success', shape: { tag: 'Rounded', value: 4 } }} />Color tokens
| Token | Description |
|------------------------|-----------------------------|
| textPrimary | Primary text |
| textSecondary | Secondary / supporting text |
| textTertiary | Tertiary / hint text |
| backgroundPrimary | Primary surface |
| backgroundSecondary | Secondary surface |
| backgroundTertiary | Tertiary surface |
| success | Positive / success state |
| warning | Warning state |
| error | Error / destructive state |
createRenderer
The low-level primitive that registerChatMessageRenderer is built on. Use it directly when you need to manage the renderer lifecycle yourself or integrate it into a custom pipeline outside of the chat system.
createRenderer returns an object with two methods:
| Method | Description |
|---------------|------------------------------------------|
| mount(node) | Mount or update the element tree |
| unmount() | Tear down the tree and release resources |
Basic usage
import { createRenderer, Column, Text, Button } from '@novasamatech/product-react-renderer';
const renderer = createRenderer({
// Called after every commit with the serialized widget tree.
onRender(node) {
send(node);
},
// Subscribe to events from the host.
// Return an unsubscribe function.
subscribeActions: (callback) => {
return actionsSubscription.subscribe((actionId, payload) => {
callback(actionId, payload);
});
},
});
// Mount the initial tree.
renderer.mount(
<Column>
<Text style="headline">Hello</Text>
<Button text="OK" onClick={() => console.log('clicked')} />
</Column>,
);
// Unmount when done — cleans up the React tree and unsubscribes from actions.
renderer.unmount();Re-mounting with new content
mount can be called multiple times to update the tree. React reconciles the difference, preserving component state where the component type is the same.
// First render
renderer.mount(<Text style="headline">Loading…</Text>);
// Later — update in place
renderer.mount(<Text style="headline">Done!</Text>);Manual integration with onCustomMessageRenderingRequest
This is what registerChatMessageRenderer does internally. Writing it manually gives you full control over the teardown sequence:
import { createRenderer, Text } from '@novasamatech/product-react-renderer';
chat.onCustomMessageRenderingRequest(({ messageId, messageType, payload, subscribeActions }, render) => {
const renderer = createRenderer({ onRender: render, subscribeActions });
renderer.mount(<Text style="headline">{messageType}</Text>);
// Return the cleanup callback.
return () => {
renderer.unmount();
};
});