diff --git a/docs/post-blobs.md b/docs/post-blobs.md index 9e82b01ca7..b55e25fe48 100644 --- a/docs/post-blobs.md +++ b/docs/post-blobs.md @@ -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: @@ -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. @@ -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`. diff --git a/packages/api/package.json b/packages/api/package.json index d77f986525..5799e46e66 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@tloncorp/api", - "version": "0.0.6", + "version": "0.0.8", "type": "module", "files": [ "dist", diff --git a/packages/api/src/__tests__/a2ui.test.ts b/packages/api/src/__tests__/a2ui.test.ts new file mode 100644 index 0000000000..45eb323653 --- /dev/null +++ b/packages/api/src/__tests__/a2ui.test.ts @@ -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'); + }); +}); diff --git a/packages/api/src/__tests__/postContent.a2ui.test.ts b/packages/api/src/__tests__/postContent.a2ui.test.ts new file mode 100644 index 0000000000..6b03720d96 --- /dev/null +++ b/packages/api/src/__tests__/postContent.a2ui.test.ts @@ -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' }); +}); diff --git a/packages/api/src/client/a2ui.ts b/packages/api/src/client/a2ui.ts new file mode 100644 index 0000000000..43bbf6702d --- /dev/null +++ b/packages/api/src/client/a2ui.ts @@ -0,0 +1,413 @@ +import { z } from 'zod'; + +const ACTION_SEND_MESSAGE = 'tlon.sendMessage'; + +type ComponentBase = { + id: string; + weight?: number; +}; + +export namespace A2UI { + export type Text = ComponentBase & { + component: 'Text'; + text: string; + variant?: 'body' | 'caption' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5'; + }; + + export type Container = ComponentBase & { + component: 'Row' | 'Column'; + children: string[]; + justify?: 'start' | 'center' | 'end' | 'spaceBetween' | 'spaceAround'; + align?: 'start' | 'center' | 'end' | 'stretch'; + }; + + export type Card = ComponentBase & { + component: 'Card'; + child: string; + }; + + export type Divider = ComponentBase & { + component: 'Divider'; + }; + + export type SendMessageEvent = { + name: typeof ACTION_SEND_MESSAGE; + context?: { + text?: string; + }; + }; + + export type EventAction = { + event: SendMessageEvent; + }; + + export type ButtonAction = EventAction; + + export type Button = ComponentBase & { + component: 'Button'; + child: string; + disabled?: boolean; + variant?: 'default' | 'primary' | 'secondary' | 'borderless'; + action: ButtonAction; + }; + + export type Component = Text | Container | Card | Divider | Button; + + export type CreateSurfaceMessage = { + version: 'v0.9'; + createSurface: { + surfaceId: string; + catalogId: string; + }; + }; + + export type UpdateComponentsMessage = { + version: 'v0.9'; + updateComponents: { + surfaceId: string; + components: Component[]; + root?: string; + }; + }; + + export type Message = CreateSurfaceMessage | UpdateComponentsMessage; + + export type BlobEntry = { + type: 'a2ui'; + version: 1; + messages: Message[]; + recipe?: unknown; + }; +} + +const LIMITS = { + maxBytes: 32 * 1024, + maxComponents: 50, + maxDepth: 8, + maxChildren: 12, + maxButtons: 8, + maxTextNodeLength: 1000, + maxButtonMessageLength: 1000, + maxTotalTextLength: 8000, +} as const; + +const CONTAINER_JUSTIFY_VALUES = [ + 'start', + 'center', + 'end', + 'spaceBetween', + 'spaceAround', +] as const; + +const CONTAINER_ALIGN_VALUES = ['start', 'center', 'end', 'stretch'] as const; + +const TEXT_VARIANT_VALUES = [ + 'body', + 'caption', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', +] as const; + +const BUTTON_VARIANT_VALUES = [ + 'default', + 'primary', + 'secondary', + 'borderless', +] as const; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isValidWeight(value: unknown): boolean { + return ( + value === undefined || + (typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= 12) + ); +} + +function isValidContainerJustify(value: unknown): boolean { + return ( + value === undefined || + CONTAINER_JUSTIFY_VALUES.includes( + value as (typeof CONTAINER_JUSTIFY_VALUES)[number] + ) + ); +} + +function isValidContainerAlign(value: unknown): boolean { + return ( + value === undefined || + CONTAINER_ALIGN_VALUES.includes( + value as (typeof CONTAINER_ALIGN_VALUES)[number] + ) + ); +} + +function isValidTextVariant(value: unknown): boolean { + return ( + value === undefined || + TEXT_VARIANT_VALUES.includes(value as (typeof TEXT_VARIANT_VALUES)[number]) + ); +} + +function isValidButtonVariant(value: unknown): boolean { + return ( + value === undefined || + BUTTON_VARIANT_VALUES.includes( + value as (typeof BUTTON_VARIANT_VALUES)[number] + ) + ); +} + +function validateComponent(component: unknown): component is A2UI.Component { + if (!isPlainObject(component) || !isNonEmptyString(component.id)) { + return false; + } + if (!isValidWeight(component.weight)) { + return false; + } + + switch (component.component) { + case 'Text': + return ( + typeof component.text === 'string' && + component.text.length <= LIMITS.maxTextNodeLength && + isValidTextVariant(component.variant) + ); + case 'Row': + case 'Column': + return ( + Array.isArray(component.children) && + component.children.length <= LIMITS.maxChildren && + component.children.every((child) => isNonEmptyString(child)) && + new Set(component.children).size === component.children.length && + isValidContainerJustify(component.justify) && + isValidContainerAlign(component.align) + ); + case 'Card': + return isNonEmptyString(component.child); + case 'Divider': + return true; + case 'Button': { + const action = component.action; + const event = isPlainObject(action) ? action.event : null; + const context = isPlainObject(event) ? event.context : undefined; + return ( + isNonEmptyString(component.child) && + (component.disabled === undefined || + typeof component.disabled === 'boolean') && + isValidButtonVariant(component.variant) && + isPlainObject(action) && + isPlainObject(event) && + event.name === ACTION_SEND_MESSAGE && + (context === undefined || isPlainObject(context)) && + (context === undefined || + context.text === undefined || + (typeof context.text === 'string' && + context.text.length <= LIMITS.maxButtonMessageLength)) + ); + } + default: + return false; + } +} + +type ValidatedEnvelope = { + createMessage: A2UI.CreateSurfaceMessage; + updateMessage: A2UI.UpdateComponentsMessage; + components: A2UI.Component[]; +}; + +function validateEnvelope(entry: unknown): ValidatedEnvelope | null { + if (!isPlainObject(entry) || entry.type !== 'a2ui' || entry.version !== 1) { + return null; + } + + if (JSON.stringify(entry).length > LIMITS.maxBytes) { + return null; + } + + if (!Array.isArray(entry.messages)) { + return null; + } + + const createMessage = entry.messages.find( + (message): message is A2UI.CreateSurfaceMessage => + isPlainObject(message) && 'createSurface' in message + ); + const updateMessage = entry.messages.find( + (message): message is A2UI.UpdateComponentsMessage => + isPlainObject(message) && 'updateComponents' in message + ); + + if ( + !createMessage || + !updateMessage || + createMessage.version !== 'v0.9' || + updateMessage.version !== 'v0.9' || + !isPlainObject(createMessage.createSurface) || + !isPlainObject(updateMessage.updateComponents) + ) { + return null; + } + + const surfaceId = createMessage.createSurface.surfaceId; + const updateSurfaceId = updateMessage.updateComponents.surfaceId; + const catalogId = createMessage.createSurface.catalogId; + const components = updateMessage.updateComponents.components; + + if ( + !isNonEmptyString(surfaceId) || + surfaceId !== updateSurfaceId || + !isNonEmptyString(catalogId) || + !Array.isArray(components) || + components.length === 0 || + components.length > LIMITS.maxComponents + ) { + return null; + } + + if (!components.every(validateComponent)) { + return null; + } + + return { createMessage, updateMessage, components }; +} + +function indexComponents( + components: A2UI.Component[] +): Map | null { + const byId = new Map(); + let buttonCount = 0; + let totalTextLength = 0; + + for (const component of components) { + if (byId.has(component.id)) { + return null; + } + byId.set(component.id, component); + if (component.component === 'Button') { + buttonCount += 1; + totalTextLength += component.action.event.context?.text?.length ?? 0; + } else if (component.component === 'Text') { + totalTextLength += component.text.length; + } + } + + if ( + buttonCount > LIMITS.maxButtons || + totalTextLength > LIMITS.maxTotalTextLength + ) { + return null; + } + + return byId; +} + +function validateReachableTree( + root: string, + components: Map +): boolean { + if (!isNonEmptyString(root) || !components.has(root)) { + return false; + } + + const visiting = new Set(); + let expandedComponentCount = 0; + const maxExpandedComponents = LIMITS.maxComponents * LIMITS.maxChildren; + + function visit(id: string, depth: number): boolean { + if (depth > LIMITS.maxDepth || visiting.has(id)) { + return false; + } + expandedComponentCount += 1; + if (expandedComponentCount > maxExpandedComponents) { + return false; + } + const component = components.get(id); + if (!component) { + return false; + } + visiting.add(id); + const children = + component.component === 'Row' || component.component === 'Column' + ? component.children + : component.component === 'Card' || component.component === 'Button' + ? [component.child] + : []; + if (children.length > LIMITS.maxChildren) { + return false; + } + for (const child of children) { + if (!visit(child, depth + 1)) { + return false; + } + } + visiting.delete(id); + return true; + } + + return visit(root, 1); +} + +export function getUpdateMessage( + entry: A2UI.BlobEntry +): A2UI.UpdateComponentsMessage | null { + return ( + entry.messages.find( + (message): message is A2UI.UpdateComponentsMessage => + isPlainObject(message) && 'updateComponents' in message + ) ?? null + ); +} + +export function getRootComponentId(entry: A2UI.BlobEntry): string | null { + const update = getUpdateMessage(entry); + if (!update) { + return null; + } + return ( + update.updateComponents.root ?? + update.updateComponents.components[0]?.id ?? + null + ); +} + +export function validateBlobEntry(entry: unknown): entry is A2UI.BlobEntry { + const envelope = validateEnvelope(entry); + if (!envelope) { + return false; + } + + const components = indexComponents(envelope.components); + if (!components) { + return false; + } + + const root = + envelope.updateMessage.updateComponents.root ?? envelope.components[0]?.id; + return validateReachableTree(root, components); +} + +export const blobEntrySchema = z.custom(validateBlobEntry); + +export const A2UI = { + action: { + sendMessage: ACTION_SEND_MESSAGE, + }, + getUpdateMessage, + getRootComponentId, + validateBlobEntry, + blobEntrySchema, +} as const; diff --git a/packages/api/src/client/content-helpers.ts b/packages/api/src/client/content-helpers.ts index 1931f5b7e0..7badceb8c7 100644 --- a/packages/api/src/client/content-helpers.ts +++ b/packages/api/src/client/content-helpers.ts @@ -20,6 +20,9 @@ import { constructStory, pathToCite, } from '../urbit'; +import { A2UI } from './a2ui'; + +export * from './a2ui'; const logger = createDevLogger('content-helpers', false); @@ -679,6 +682,7 @@ const postBlobDataEntryDefinitions = [ PostBlobDataEntryFileSchema, PostBlobDataEntryVoiceMemoSchema, PostBlobDataEntryVideoSchema, + A2UI.blobEntrySchema, ] as const; export const PostBlobDataEntrySchema = z.union(postBlobDataEntryDefinitions); diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index dd349cf14a..2cb0b759d4 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -1,4 +1,5 @@ export { udToDate } from './apiUtils'; +export * from './a2ui'; export * from './channelContentConfig'; export * from './channelsApi'; export * from './chatApi'; diff --git a/packages/api/src/client/postContent.ts b/packages/api/src/client/postContent.ts index dfa5319a50..a80da29930 100644 --- a/packages/api/src/client/postContent.ts +++ b/packages/api/src/client/postContent.ts @@ -1,3 +1,4 @@ +import type { A2UI } from '../client/a2ui'; import { formatUd } from '../client/apiUtils'; import { PostBlobDataEntryFile, @@ -116,6 +117,11 @@ export type VoiceMemoBlockData = { voiceMemo: PostBlobDataEntryVoiceMemo; }; +export type A2UIBlockData = { + type: 'a2ui'; + a2ui: A2UI.BlobEntry; +}; + export type LinkBlockData = { type: 'link'; url: string; @@ -161,6 +167,7 @@ export type BlockData = | BlockquoteBlockData | ParagraphBlockData | ImageBlockData + | A2UIBlockData | VideoBlockData | FileUploadBlockData | VoiceMemoBlockData @@ -397,6 +404,14 @@ export function convertContent( break; } + case 'a2ui': { + out.push({ + type: 'a2ui', + a2ui: entry, + }); + break; + } + case 'unknown': { out.push({ type: 'blockquote', diff --git a/packages/app/fixtures/A2UI.fixture.tsx b/packages/app/fixtures/A2UI.fixture.tsx new file mode 100644 index 0000000000..8fe0fe6513 --- /dev/null +++ b/packages/app/fixtures/A2UI.fixture.tsx @@ -0,0 +1,732 @@ +// tamagui-ignore +import type { JSONContent } from '@tloncorp/api/urbit'; +import type { JSONValue } from '@tloncorp/shared'; +import type { A2UI } from '@tloncorp/shared/logic'; +import { appendToPostBlob } from '@tloncorp/shared/logic'; +import React, { PropsWithChildren, useMemo, useState } from 'react'; + +import { ChatMessage, ScrollView, View } from '../ui'; +import { + DraftInputContext, + DraftInputContextProvider, +} from '../ui/components/draftInputs/shared'; +import { ChannelProvider } from '../ui/contexts/channel'; +import { FixtureWrapper } from './FixtureWrapper'; +import { exampleContacts, makePost, verse } from './contentHelpers'; +import { group, tlonLocalIntros } from './fakeData'; + +const dmChannelId = '~sampel-palnet'; + +const weatherA2UI: A2UI.BlobEntry = { + type: 'a2ui', + version: 1, + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'weather-brooklyn', + catalogId: 'tlon.a2ui.basic.v1', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'weather-brooklyn', + root: 'root', + components: [ + { id: 'root', component: 'Card', child: 'main-column' }, + { + id: 'main-column', + component: 'Column', + align: 'center', + children: [ + 'temp-row', + 'tempDivider', + 'location', + 'description', + 'forecastDivider', + 'forecast-row', + ], + }, + { + id: 'temp-row', + component: 'Row', + align: 'center', + justify: 'center', + children: ['temp-high-column', 'temp-low-column'], + }, + { + id: 'temp-high-column', + component: 'Column', + align: 'center', + children: ['temp-high-label', 'temp-high'], + }, + { + id: 'temp-high-label', + component: 'Text', + variant: 'caption', + text: 'High', + }, + { + id: 'temp-high', + component: 'Text', + variant: 'h1', + text: '63°', + }, + { + id: 'temp-low-column', + component: 'Column', + align: 'center', + children: ['temp-low-label', 'temp-low'], + }, + { + id: 'temp-low-label', + component: 'Text', + variant: 'caption', + text: 'Low', + }, + { + id: 'temp-low', + component: 'Text', + variant: 'h1', + text: '48°', + }, + { id: 'tempDivider', component: 'Divider' }, + { + id: 'location', + component: 'Text', + variant: 'h3', + text: '22903 (Charlottesville, VA)', + }, + { + id: 'description', + component: 'Text', + variant: 'caption', + text: 'Partly cloudy · Humidity 76%, Wind 3 mph', + }, + { id: 'forecastDivider', component: 'Divider' }, + { + id: 'forecast-row', + component: 'Row', + align: 'center', + justify: 'spaceAround', + children: ['today', 'tomorrow', 'next'], + }, + { + id: 'today', + component: 'Column', + align: 'center', + weight: 1, + children: ['todayLabel', 'todayIcon', 'todayTemp'], + }, + { + id: 'todayLabel', + component: 'Text', + variant: 'caption', + text: 'Today', + }, + { id: 'todayIcon', component: 'Text', variant: 'h2', text: '🌧️' }, + { + id: 'todayTemp', + component: 'Text', + variant: 'caption', + text: '63°', + }, + { + id: 'tomorrow', + component: 'Column', + align: 'center', + weight: 1, + children: ['tomorrowLabel', 'tomorrowIcon', 'tomorrowTemp'], + }, + { + id: 'tomorrowLabel', + component: 'Text', + variant: 'caption', + text: 'Wed', + }, + { id: 'tomorrowIcon', component: 'Text', variant: 'h2', text: '☁️' }, + { + id: 'tomorrowTemp', + component: 'Text', + variant: 'caption', + text: '74°', + }, + { + id: 'next', + component: 'Column', + align: 'center', + weight: 1, + children: ['nextLabel', 'nextIcon', 'nextTemp'], + }, + { + id: 'nextLabel', + component: 'Text', + variant: 'caption', + text: 'Thu', + }, + { id: 'nextIcon', component: 'Text', variant: 'h2', text: '🌧️' }, + { + id: 'nextTemp', + component: 'Text', + variant: 'caption', + text: '75°', + }, + ], + }, + }, + ], +}; + +function makeApprovalA2UI({ + surfaceId, + eyebrow, + title, + metadata, + copy, + allowNote, + requestId, +}: { + surfaceId: string; + eyebrow: string; + title: string; + metadata: string[]; + copy?: string; + allowNote: string; + requestId: string; +}): A2UI.BlobEntry { + const metadataChildren = metadata.map((_, index) => `metadata-${index}`); + const bodyChildren = copy + ? [ + 'eyebrow', + 'title', + 'titleDivider', + ...metadataChildren, + 'copy', + 'divider', + 'details', + 'actions', + ] + : [ + 'eyebrow', + 'title', + 'titleDivider', + ...metadataChildren, + 'divider', + 'details', + 'actions', + ]; + + return { + type: 'a2ui', + version: 1, + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId, + catalogId: 'tlon.a2ui.basic.v1', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId, + root: 'root', + components: [ + { id: 'root', component: 'Card', child: 'body' }, + { + id: 'body', + component: 'Column', + children: bodyChildren, + }, + { + id: 'eyebrow', + component: 'Text', + variant: 'caption', + text: eyebrow, + }, + { + id: 'title', + component: 'Text', + variant: 'h3', + text: title, + }, + { id: 'titleDivider', component: 'Divider' }, + ...metadata.map( + (line, index) => + ({ + id: `metadata-${index}`, + component: 'Text', + variant: 'caption', + text: line, + }) as const + ), + ...(copy + ? [ + { + id: 'copy', + component: 'Text', + variant: 'caption', + text: copy, + } as const, + ] + : []), + { id: 'divider', component: 'Divider' }, + { + id: 'details', + component: 'Column', + children: ['allowNote'], + }, + { + id: 'allowNote', + component: 'Text', + variant: 'caption', + text: allowNote, + }, + { + id: 'actions', + component: 'Row', + children: ['allow', 'reject', 'ban'], + }, + { + id: 'allow', + component: 'Button', + variant: 'primary', + child: 'allowLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: `/allow ${requestId}` }, + }, + }, + }, + { id: 'allowLabel', component: 'Text', text: 'Allow' }, + { + id: 'reject', + component: 'Button', + child: 'rejectLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: `/reject ${requestId}` }, + }, + }, + }, + { id: 'rejectLabel', component: 'Text', text: 'Reject' }, + { + id: 'ban', + component: 'Button', + variant: 'borderless', + child: 'banLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: `/ban ${requestId}` }, + }, + }, + }, + { id: 'banLabel', component: 'Text', text: 'Block' }, + ], + }, + }, + ], + }; +} + +const confirmationA2UI = makeApprovalA2UI({ + surfaceId: 'confirm-dm-sampel', + eyebrow: 'DM access', + title: 'Allow Sam Palnet to DM the bot?', + metadata: ['Sender: Sam Palnet (~sampel-palnet)'], + copy: 'Message: "Hello, I would like to chat with your bot..."', + allowNote: + 'The bot will be able to read and reply to future DMs from this user.', + requestId: 'da1b2', +}); + +const channelApprovalA2UI = makeApprovalA2UI({ + surfaceId: 'confirm-channel-littel', + eyebrow: 'Channel access', + title: 'Let the bot reply to Littel Wolfur in Design?', + metadata: [ + 'Sender: Littel Wolfur (~littel-wolfur)', + 'Channel: Design', + 'Group: Garden Club', + ], + copy: 'Message: "@bearclawd can you review this build before I merge?"', + allowNote: + 'The bot will be able to read and reply to this user in this channel.', + requestId: 'c3d4e', +}); + +const groupApprovalA2UI = makeApprovalA2UI({ + surfaceId: 'confirm-group-robin', + eyebrow: 'Group invite', + title: 'Let the bot join Garden Club?', + metadata: ['Inviter: Robin Dasler (~robin-dasler)', 'Group: Garden Club'], + allowNote: 'The bot will be able to read and respond in channels it joins.', + requestId: 'g5f6a', +}); + +const basicComponentsA2UI: A2UI.BlobEntry = { + type: 'a2ui', + version: 1, + messages: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'basic-components', + catalogId: 'tlon.a2ui.basic.v1', + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'basic-components', + root: 'root', + components: [ + { id: 'root', component: 'Card', child: 'body' }, + { + id: 'body', + component: 'Column', + children: [ + 'title', + 'intro', + 'textDivider', + 'textVariants', + 'layoutDivider', + 'layoutCard', + 'buttonDivider', + 'buttonRows', + ], + }, + { + id: 'title', + component: 'Text', + variant: 'h3', + text: 'Basic A2UI components', + }, + { + id: 'intro', + component: 'Text', + variant: 'caption', + text: 'Supported presentation components in the initial Tlon renderer.', + }, + { id: 'textDivider', component: 'Divider' }, + { + id: 'textVariants', + component: 'Column', + children: [ + 'textH1', + 'textH2', + 'textH3', + 'textH4', + 'textH5', + 'textBody', + 'textCaption', + ], + }, + { id: 'textH1', component: 'Text', variant: 'h1', text: 'Heading 1' }, + { id: 'textH2', component: 'Text', variant: 'h2', text: 'Heading 2' }, + { id: 'textH3', component: 'Text', variant: 'h3', text: 'Heading 3' }, + { id: 'textH4', component: 'Text', variant: 'h4', text: 'Heading 4' }, + { id: 'textH5', component: 'Text', variant: 'h5', text: 'Heading 5' }, + { id: 'textBody', component: 'Text', text: 'Body text' }, + { + id: 'textCaption', + component: 'Text', + variant: 'caption', + text: 'Caption text', + }, + { id: 'layoutDivider', component: 'Divider' }, + { id: 'layoutCard', component: 'Card', child: 'layoutBody' }, + { + id: 'layoutBody', + component: 'Column', + children: ['layoutTitle', 'rowStart', 'rowCenter', 'rowEnd'], + }, + { + id: 'layoutTitle', + component: 'Text', + variant: 'caption', + text: 'Rows, columns, and nested cards', + }, + { + id: 'rowStart', + component: 'Row', + justify: 'spaceBetween', + children: ['startLabel', 'startValue'], + }, + { id: 'startLabel', component: 'Text', text: 'spaceBetween' }, + { + id: 'startValue', + component: 'Text', + variant: 'caption', + text: 'right edge', + }, + { + id: 'rowCenter', + component: 'Row', + align: 'center', + justify: 'center', + children: ['centerA', 'centerB'], + }, + { + id: 'centerA', + component: 'Text', + variant: 'caption', + text: 'center', + }, + { id: 'centerB', component: 'Text', text: 'aligned row' }, + { + id: 'rowEnd', + component: 'Row', + justify: 'end', + children: ['endLabel', 'endValue'], + }, + { + id: 'endLabel', + component: 'Text', + variant: 'caption', + text: 'end', + }, + { id: 'endValue', component: 'Text', text: 'aligned row' }, + { id: 'buttonDivider', component: 'Divider' }, + { + id: 'buttonRows', + component: 'Column', + children: ['buttonRowOne', 'buttonRowTwo'], + }, + { + id: 'buttonRowOne', + component: 'Row', + children: ['primaryButton', 'secondaryButton', 'defaultButton'], + }, + { + id: 'primaryButton', + component: 'Button', + variant: 'primary', + child: 'primaryLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: 'primary action' }, + }, + }, + }, + { id: 'primaryLabel', component: 'Text', text: 'Primary' }, + { + id: 'secondaryButton', + component: 'Button', + variant: 'secondary', + child: 'secondaryLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: 'secondary action' }, + }, + }, + }, + { id: 'secondaryLabel', component: 'Text', text: 'Secondary' }, + { + id: 'defaultButton', + component: 'Button', + variant: 'default', + child: 'defaultLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: 'default action' }, + }, + }, + }, + { id: 'defaultLabel', component: 'Text', text: 'Default' }, + { + id: 'buttonRowTwo', + component: 'Row', + children: ['borderlessButton', 'disabledButton'], + }, + { + id: 'borderlessButton', + component: 'Button', + variant: 'borderless', + child: 'borderlessLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: 'borderless action' }, + }, + }, + }, + { id: 'borderlessLabel', component: 'Text', text: 'Borderless' }, + { + id: 'disabledButton', + component: 'Button', + disabled: true, + child: 'disabledLabel', + action: { + event: { + name: 'tlon.sendMessage', + context: { text: 'disabled action' }, + }, + }, + }, + { id: 'disabledLabel', component: 'Text', text: 'Disabled' }, + ], + }, + }, + ], +}; + +const weatherPost = makePost( + exampleContacts.mark, + [verse.inline('Weather: 22903 is 57F and partly cloudy.')], + { + blob: appendToPostBlob(undefined, weatherA2UI), + channelId: dmChannelId, + replyCount: 0, + } +); + +const confirmationPost = makePost( + exampleContacts.groupAdmin, + [verse.inline('DM request from Sam Palnet (~sampel-palnet)')], + { + blob: appendToPostBlob(undefined, confirmationA2UI), + channelId: dmChannelId, + replyCount: 0, + } +); + +const channelApprovalPost = makePost( + exampleContacts.mark, + [verse.inline('Channel mention request from Littel Wolfur (~littel-wolfur)')], + { + blob: appendToPostBlob(undefined, channelApprovalA2UI), + channelId: dmChannelId, + replyCount: 0, + } +); + +const groupApprovalPost = makePost( + exampleContacts.groupAdmin, + [verse.inline('Group invite request from Robin Dasler (~robin-dasler)')], + { + blob: appendToPostBlob(undefined, groupApprovalA2UI), + channelId: dmChannelId, + replyCount: 0, + } +); + +const basicComponentsPost = makePost( + exampleContacts.mark, + [verse.inline('Basic A2UI component catalog.')], + { + blob: appendToPostBlob(undefined, basicComponentsA2UI), + channelId: dmChannelId, + replyCount: 0, + } +); + +const examplePosts = [ + weatherPost, + confirmationPost, + channelApprovalPost, + groupApprovalPost, + basicComponentsPost, +]; + +function A2UIDraftProvider({ children }: PropsWithChildren) { + const [shouldBlur, setShouldBlur] = useState(false); + const draftContext = useMemo( + () => ({ + canStartDraft: true, + channel: tlonLocalIntros, + clearDraft: async () => {}, + configuration: {} as Record, + getDraft: async () => null, + group, + sendPostFromDraft: async (draft) => { + alert( + draft.content + .map((item) => (typeof item === 'string' ? item : '')) + .join('') + ); + }, + setShouldBlur, + shouldBlur, + startDraft: () => {}, + storeDraft: async (_content: JSONContent) => {}, + }), + [shouldBlur] + ); + + return ( + + {children} + + ); +} + +function A2UIFixture({ post }: { post: typeof weatherPost }) { + return ( + + + + + + + + + + + + ); +} + +function A2UIExamplesFixture() { + return ( + + + + + {examplePosts.map((post, index) => ( + + + + ))} + + + + + ); +} + +export default { + BasicComponents: () => , + Weather: () => , + ConfirmationDialog: () => , + Examples: () => , +}; diff --git a/packages/app/ui/components/ChatMessage/StaticChatMessage.tsx b/packages/app/ui/components/ChatMessage/StaticChatMessage.tsx index f4a85aa4eb..a0cd1482aa 100644 --- a/packages/app/ui/components/ChatMessage/StaticChatMessage.tsx +++ b/packages/app/ui/components/ChatMessage/StaticChatMessage.tsx @@ -1,11 +1,14 @@ +import { isDmChannelId } from '@tloncorp/api/client'; import * as db from '@tloncorp/shared/db'; +import { A2UI } from '@tloncorp/shared/logic'; import { Text } from '@tloncorp/ui'; -import { ComponentProps, useCallback } from 'react'; +import { ComponentProps, useCallback, useMemo } from 'react'; import { View, XStack, YStack, isWeb } from 'tamagui'; import { CHAT_REF_LIKE_MAX_WIDTH } from '../../../constants'; import { getPostImageViewerId } from '../../../utils/mediaViewer'; import AuthorRow from '../AuthorRow'; +import { A2UIBlock } from '../PostContent/A2UIBlock'; import { DefaultRendererProps } from '../PostContent/BlockRenderer'; import { createContentRenderer } from '../PostContent/ContentRenderer'; import { @@ -13,6 +16,7 @@ import { usePostLastEditContent, } from '../PostContent/contentUtils'; import { SentTimeText } from '../SentTimeText'; +import { useDraftInputContext } from '../draftInputs/shared'; import { ChatMessageDeliveryStatus } from './ChatMessageDeliveryStatus'; import { ChatMessageHighlight } from './ChatMessageHighlight'; import { ChatMessageReplySummary } from './ChatMessageReplySummary'; @@ -55,6 +59,7 @@ export function StaticChatMessage({ showReplies?: boolean; }) { const isNotice = post.type === 'notice'; + const draftInputContext = useDraftInputContext(); if (isNotice) { showAuthor = false; @@ -88,8 +93,55 @@ export function StaticChatMessage({ } }, [onPressRetry, post]); - const content = usePostContent(post); - const lastEditContent = usePostLastEditContent(post); + const handleA2UIAction = useCallback( + async (action: A2UI.Button['action'], fallbackText: string) => { + if (action.event.name !== A2UI.action.sendMessage) { + return; + } + + if (!draftInputContext || draftInputContext.canStartDraft === false) { + return; + } + + const message = action.event.context?.text ?? fallbackText; + const text = message.trim(); + if (!text) { + return; + } + + await draftInputContext.sendPostFromDraft({ + channelId: draftInputContext.channel.id, + content: [text], + attachments: [], + channelType: draftInputContext.channel.type, + replyToPostId: null, + isEdit: false, + }); + }, + [draftInputContext] + ); + const canRenderA2UI = isDmChannelId(post.channelId); + const canHandleA2UIAction = + canRenderA2UI && + !!draftInputContext && + draftInputContext.canStartDraft !== false; + + const postContent = usePostContent(post); + const lastEditPostContent = usePostLastEditContent(post); + const content = useMemo( + () => + canRenderA2UI + ? postContent + : postContent.filter((block) => block.type !== 'a2ui'), + [canRenderA2UI, postContent] + ); + const lastEditContent = useMemo( + () => + canRenderA2UI + ? lastEditPostContent + : lastEditPostContent.filter((block) => block.type !== 'a2ui'), + [canRenderA2UI, lastEditPostContent] + ); const shouldRenderReplies = showReplies && post.replyCount && post.replyTime && post.replyContactIds; @@ -162,6 +214,7 @@ export function StaticChatMessage({ onPressImage={handleImagePressed} getImageViewerId={(src) => getPostImageViewerId(post.id, src)} onLongPress={handleLongPress} + onA2UIAction={canHandleA2UIAction ? handleA2UIAction : undefined} searchQuery={searchQuery} /> )} @@ -206,6 +259,9 @@ const WebChatVideoRenderer: DefaultRendererProps['video'] = { }; const ChatContentRenderer = createContentRenderer({ + blockRenderers: { + a2ui: A2UIBlock, + }, blockSettings: { blockWrapper: { paddingLeft: 0, diff --git a/packages/app/ui/components/PostContent/A2UIBlock.tsx b/packages/app/ui/components/PostContent/A2UIBlock.tsx new file mode 100644 index 0000000000..11492dacf4 --- /dev/null +++ b/packages/app/ui/components/PostContent/A2UIBlock.tsx @@ -0,0 +1,302 @@ +import { A2UI, type A2UIBlockData } from '@tloncorp/shared/logic'; +import { Button, Text } from '@tloncorp/ui'; +import React, { ComponentProps, useCallback, useMemo } from 'react'; +import { View, XStack, YStack } from 'tamagui'; + +import { useContentContext } from './contentUtils'; + +type RenderOptions = { + cardDepth?: number; + parentAlign?: A2UI.Container['align']; +}; + +function getTextSize(component: A2UI.Text) { + switch (component.variant) { + case 'h1': + return '$title/l'; + case 'h2': + return '$label/xl'; + case 'h3': + return '$label/xl'; + case 'caption': + return '$label/m'; + default: + return '$body'; + } +} + +function getTextColor(component: A2UI.Text) { + return component.variant === 'caption' ? '$secondaryText' : '$primaryText'; +} + +function getComponentGap( + component: A2UI.Container, + components: Map +) { + const isTextOnly = component.children.every( + (child) => components.get(child)?.component === 'Text' + ); + + if (isTextOnly) { + return component.component === 'Row' ? '$s' : '$xs'; + } + + return '$m'; +} + +function hasButtonChild( + component: A2UI.Container, + components: Map +) { + return component.children.some( + (child) => components.get(child)?.component === 'Button' + ); +} + +function getButtonTreatment(component: A2UI.Button) { + switch (component.variant) { + case 'primary': + return { fill: 'solid', intent: 'positive' } as const; + case 'secondary': + case 'borderless': + default: + return { fill: 'outline', intent: 'secondary' } as const; + } +} + +function getJustifyContent(justify?: A2UI.Container['justify']) { + switch (justify) { + case 'center': + return 'center'; + case 'end': + return 'flex-end'; + case 'spaceBetween': + return 'space-between'; + case 'spaceAround': + return 'space-around'; + default: + return 'flex-start'; + } +} + +function getAlignItems( + align?: A2UI.Container['align'], + fallback: 'center' | 'stretch' = 'center' +) { + switch (align) { + case 'start': + return 'flex-start'; + case 'center': + return 'center'; + case 'end': + return 'flex-end'; + case 'stretch': + return 'stretch'; + default: + return fallback; + } +} + +function getComponentFlex(component: A2UI.Component) { + return component.weight === undefined ? undefined : component.weight; +} + +function getTextAlign(align?: A2UI.Container['align']) { + return align === 'center' ? 'center' : undefined; +} + +function getComponentText( + component: A2UI.Component | undefined, + components: Map +): string { + if (!component) { + return ''; + } + switch (component.component) { + case 'Text': + return component.text; + case 'Button': + case 'Card': + return getComponentText(components.get(component.child), components); + case 'Row': + case 'Column': + return component.children + .map((child) => getComponentText(components.get(child), components)) + .filter(Boolean) + .join(' '); + case 'Divider': + return ''; + } +} + +export function A2UIBlock({ + block, + ...props +}: { block: A2UIBlockData } & ComponentProps) { + const context = useContentContext(); + const update = A2UI.getUpdateMessage(block.a2ui); + const root = A2UI.getRootComponentId(block.a2ui); + const components = useMemo(() => { + return new Map( + update?.updateComponents.components.map((component) => [ + component.id, + component, + ]) ?? [] + ); + }, [update]); + + const handleButtonPress = useCallback( + (component: A2UI.Button) => { + const fallbackText = + component.action.event.context?.text ?? + getComponentText(components.get(component.child), components); + + if (!fallbackText.trim()) { + return; + } + + context.onA2UIAction?.(component.action, fallbackText); + }, + [components, context] + ); + + const renderComponent = useCallback( + (id: string, options: RenderOptions = {}): React.ReactNode => { + const component = components.get(id); + if (!component) { + return null; + } + + switch (component.component) { + case 'Text': { + return ( + + {component.text} + + ); + } + case 'Row': + return ( + + {component.children.map((child) => + renderComponent(child, { + cardDepth: options.cardDepth, + parentAlign: component.align, + }) + )} + + ); + case 'Column': { + return ( + + {component.children.map((child) => + renderComponent(child, { + cardDepth: options.cardDepth, + parentAlign: component.align, + }) + )} + + ); + } + case 'Card': { + const isNestedCard = Boolean(options.cardDepth); + return ( + + {renderComponent(component.child, { + cardDepth: (options.cardDepth ?? 0) + 1, + })} + + ); + } + case 'Divider': + return ( + + ); + case 'Button': { + const disabled = component.disabled || !context.onA2UIAction; + const label = getComponentText( + components.get(component.child), + components + ); + const treatment = getButtonTreatment(component); + return ( + handleButtonPress(component) + } + > + {label} + + ); + } + } + }, + [components, context.onA2UIAction, handleButtonPress] + ); + + if (!root) { + return null; + } + + return ( + + {renderComponent(root)} + + ); +} diff --git a/packages/app/ui/components/PostContent/BlockRenderer.tsx b/packages/app/ui/components/PostContent/BlockRenderer.tsx index 6793ae7a72..819595895e 100644 --- a/packages/app/ui/components/PostContent/BlockRenderer.tsx +++ b/packages/app/ui/components/PostContent/BlockRenderer.tsx @@ -35,6 +35,7 @@ import { import { VideoEmbed } from '../Embed'; import { FileUploadPreview } from '../FileUploadPreview'; import { HighlightedCode } from '../HighlightedCode'; +import { A2UIBlock } from './A2UIBlock'; import { BlockquoteSideBorder } from './BlockquoteSideBorder'; import { InlineRenderer } from './InlineRenderer'; import { ContentContext, useContentContext } from './contentUtils'; @@ -698,6 +699,7 @@ export const defaultBlockRenderers: BlockRendererConfig = { lineText: LineText, blockquote: BlockquoteBlock, paragraph: ParagraphBlock, + a2ui: () => null, link: LinkBlock, image: ImageBlock, video: VideoBlock, @@ -720,6 +722,7 @@ export type DefaultRendererProps = { lineText: Partial>; blockquote: BlockSettings; paragraph: BlockSettings; + a2ui: BlockSettings; link: BlockSettings; image: BlockSettings; video: BlockSettings; diff --git a/packages/app/ui/components/PostContent/ContentRenderer.tsx b/packages/app/ui/components/PostContent/ContentRenderer.tsx index 53c8fc7091..d0eb28c4c0 100644 --- a/packages/app/ui/components/PostContent/ContentRenderer.tsx +++ b/packages/app/ui/components/PostContent/ContentRenderer.tsx @@ -56,6 +56,7 @@ function ContentRenderer({ onPressImage, getImageViewerId, onLongPress, + onA2UIAction, isNotice, searchQuery, ...rest @@ -67,6 +68,7 @@ function ContentRenderer({ onPressImage={onPressImage} getImageViewerId={getImageViewerId} onLongPress={onLongPress} + onA2UIAction={onA2UIAction} isNotice={isNotice} searchQuery={searchQuery} > diff --git a/packages/app/ui/components/PostContent/contentUtils.tsx b/packages/app/ui/components/PostContent/contentUtils.tsx index 86d2baaac8..5b6149e17e 100644 --- a/packages/app/ui/components/PostContent/contentUtils.tsx +++ b/packages/app/ui/components/PostContent/contentUtils.tsx @@ -1,5 +1,5 @@ import { Post } from '@tloncorp/shared/db'; -import { BlockData, convertContent } from '@tloncorp/shared/logic'; +import { type A2UI, BlockData, convertContent } from '@tloncorp/shared/logic'; import { useContext, useMemo } from 'react'; import { createStyledContext } from 'tamagui'; @@ -30,6 +30,10 @@ export interface ContentContextProps { onPressImage?: (src: string) => void; getImageViewerId?: (src: string) => string | undefined; onLongPress?: () => void; + onA2UIAction?: ( + action: A2UI.Button['action'], + fallbackText: string + ) => void | Promise; searchQuery?: string; }