Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions docs/post-blobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ When no extra data is needed, the field is `null` (`blob=~` in Hoon).

## Entry schema

Blob entries are a discriminated union keyed on `type` and `version`. Definitions live in `packages/api/src/lib/content-helpers.ts` and are registered in `postBlobDataEntryDefinitions`, which drives both write-time validation and read-time parsing.
Blob entries are a discriminated union keyed on `type` and `version`. Definitions live in `packages/api/src/client/content-helpers.ts` and are registered in `postBlobDataEntryDefinitions`, which drives both write-time validation and read-time parsing.

Each concrete entry type should have:

Expand Down Expand Up @@ -68,9 +68,31 @@ Video upload metadata.
| `duration` | `number` (optional) |
| `posterUri` | `string` (optional) |

### `a2ui` v1

A2UI presentation metadata. This lets a post carry a small validated A2UI v0.9 component tree alongside normal text content.

Definitions and validation helpers live in `packages/api/src/client/a2ui.ts`; the entry is registered with the shared blob union through `A2UI.blobEntrySchema`.

| field | type |
| ---------- | ------------------ |
| `type` | `'a2ui'` |
| `version` | `1` |
| `messages` | `A2UI.Message[]` |
| `recipe` | `unknown` optional |

The current renderer expects one `createSurface` message and one `updateComponents` message in the entry. The `updateComponents` message describes the component tree rendered for that post. It does not update surfaces in previous messages or elsewhere in message history.

The supported v1 client subset is intentionally small:

- components: `Card`, `Column`, `Row`, `Text`, `Divider`, and `Button`
- button actions: `tlon.sendMessage`, which sends visible text in the current DM
- rendering policy: A2UI blocks render only in direct messages for now
- validation limits: component count, tree depth, text length, button count, and expanded render size

## Read/write behavior

- Writes happen through helpers in `packages/api/src/lib/content-helpers.ts`. `appendToPostBlob` is the base helper; `appendFileUploadToPostBlob` and `appendVideoToPostBlob` are convenience wrappers.
- Writes happen through helpers in `packages/api/src/client/content-helpers.ts`. `appendToPostBlob` is the base helper; `appendFileUploadToPostBlob` and `appendVideoToPostBlob` are convenience wrappers.
- `toPostData` builds blobs from finalized attachments.
- `PostDataDraft` does not store `blob`; blob is computed during finalization from attachments.
- The edit transport can carry a blob, but current frontend edit flows do not implement blob editing. Network edits preserve the original blob.
Expand All @@ -89,7 +111,7 @@ Video upload metadata.

## Adding a new entry type

1. Add a named schema and inferred type in `packages/api/src/lib/content-helpers.ts`.
1. Add a named schema and inferred type in `packages/api/src/client/content-helpers.ts`.
2. Add that schema to `postBlobDataEntryDefinitions`.
3. Add an `appendXToPostBlob` helper if the new entry will be written from more than one place.
4. Update the relevant attachment unions in `packages/api/src/types/attachment.ts` so the new entry can be finalized and passed into `toPostData`.
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tloncorp/api",
"version": "0.0.6",
"version": "0.0.8",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.0.7 was published but the version bump wasn't committed

"type": "module",
"files": [
"dist",
Expand Down
264 changes: 264 additions & 0 deletions packages/api/src/__tests__/a2ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { describe, expect, test } from 'vitest';

import { A2UI } from '../client/a2ui';
import { appendToPostBlob, parsePostBlob } from '../client/content-helpers';

const a2uiBlobEntry: A2UI.BlobEntry = {
type: 'a2ui',
version: 1,
messages: [
{
version: 'v0.9',
createSurface: {
surfaceId: 'weather-card',
catalogId: 'tlon.a2ui.basic.v1',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components: [
{ id: 'root', component: 'Card', child: 'body' },
{
id: 'body',
component: 'Column',
children: ['title', 'summary', 'refreshButton'],
},
{ id: 'title', component: 'Text', text: 'Weather' },
{ id: 'summary', component: 'Text', text: '72F and clear' },
{
id: 'refreshButton',
component: 'Button',
child: 'refreshLabel',
action: {
event: {
name: 'tlon.sendMessage',
context: { text: 'refresh weather' },
},
},
},
{ id: 'refreshLabel', component: 'Text', text: 'Refresh' },
],
},
},
],
};

describe('a2ui blob entries', () => {
test('validates supported a2ui payloads', () => {
expect(A2UI.validateBlobEntry(a2uiBlobEntry)).toBe(true);
});

test('parsePostBlob parses supported a2ui entries', () => {
const blob = appendToPostBlob(undefined, a2uiBlobEntry);

expect(parsePostBlob(blob)).toEqual([a2uiBlobEntry]);
});

test('rejects unsupported a2ui components and actions', () => {
expect(
parsePostBlob(
JSON.stringify([
{
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
components: [
{ id: 'root', component: 'Badge', text: 'unsupported' },
],
},
},
],
},
{
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
components: [
{ id: 'root', component: 'Button', child: 'label' },
{ id: 'label', component: 'Text', text: 'Call function' },
],
},
},
],
},
])
)
).toEqual([{ type: 'unknown' }, { type: 'unknown' }]);
});

test('rejects malformed a2ui button optional fields', () => {
expect(
A2UI.validateBlobEntry({
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components: [
{
id: 'root',
component: 'Button',
child: 'label',
disabled: 'false',
action: {
event: {
name: 'tlon.sendMessage',
context: { text: 'refresh weather' },
},
},
},
{ id: 'label', component: 'Text', text: 'Refresh' },
],
},
},
],
})
).toBe(false);

expect(
A2UI.validateBlobEntry({
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components: [
{
id: 'root',
component: 'Button',
child: 'label',
variant: 'danger',
action: {
event: {
name: 'tlon.sendMessage',
context: { text: 'refresh weather' },
},
},
},
{ id: 'label', component: 'Text', text: 'Refresh' },
],
},
},
],
})
).toBe(false);
});

test('rejects malformed a2ui text optional fields', () => {
expect(
A2UI.validateBlobEntry({
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components: [
{
id: 'root',
component: 'Text',
text: 'Weather',
variant: 999,
},
],
},
},
],
})
).toBe(false);
});

test('rejects duplicate child references in containers', () => {
expect(
A2UI.validateBlobEntry({
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components: [
{
id: 'root',
component: 'Column',
children: ['summary', 'summary'],
},
{ id: 'summary', component: 'Text', text: '72F and clear' },
],
},
},
],
})
).toBe(false);
});

test('rejects shared child references that expand beyond render limits', () => {
const layerIds = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((prefix) =>
Array.from({ length: 7 }, (_, index) => `${prefix}${index}`)
);
const components: A2UI.Component[] = [
{ id: 'root', component: 'Column', children: layerIds[0] },
...layerIds.flatMap((ids, layerIndex) =>
ids.map((id) =>
layerIndex === layerIds.length - 1
? ({ id, component: 'Text', text: 'x' } as const)
: ({
id,
component: 'Column',
children: layerIds[layerIndex + 1],
} as const)
)
),
];

expect(components).toHaveLength(50);
expect(
A2UI.validateBlobEntry({
...a2uiBlobEntry,
messages: [
a2uiBlobEntry.messages[0],
{
version: 'v0.9',
updateComponents: {
surfaceId: 'weather-card',
root: 'root',
components,
},
},
],
})
).toBe(false);
});

test('ignores non-object messages when finding update message', () => {
const entry = {
...a2uiBlobEntry,
messages: [42, ...a2uiBlobEntry.messages],
} as unknown as A2UI.BlobEntry;

expect(A2UI.validateBlobEntry(entry)).toBe(true);
expect(A2UI.getUpdateMessage(entry)).toEqual(a2uiBlobEntry.messages[1]);
expect(A2UI.getRootComponentId(entry)).toBe('root');
});
});
39 changes: 39 additions & 0 deletions packages/api/src/__tests__/postContent.a2ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from 'vitest';

import { convertContent } from '../client/postContent';

test('convertContent renders supported a2ui blob entries before story content', () => {
const a2ui = {
type: 'a2ui',
version: 1,
messages: [
{
version: 'v0.9',
createSurface: {
surfaceId: 'approval-card',
catalogId: 'tlon.a2ui.basic.v1',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'approval-card',
root: 'root',
components: [
{ id: 'root', component: 'Card', child: 'body' },
{ id: 'body', component: 'Column', children: ['title'] },
{ id: 'title', component: 'Text', text: 'Approve DM?' },
],
},
},
],
};

const content = convertContent(
[{ inline: ['Fallback text'] }],
JSON.stringify([a2ui])
);

expect(content[0]).toEqual({ type: 'a2ui', a2ui });
expect(content[1]).toMatchObject({ type: 'paragraph' });
});
Loading
Loading