diff --git a/docs/embed-sdk.md b/docs/embed-sdk.md new file mode 100644 index 0000000..fb37891 --- /dev/null +++ b/docs/embed-sdk.md @@ -0,0 +1,155 @@ +# atmo.social Embed SDK + +Build interactive embeds for your atproto app that render natively inside atmo.social. + +## How it works + +When a user posts a link to your app on Bluesky (e.g. `https://yourapp.com/p/actor/item/abc`), atmo.social renders it as an iframe embed instead of a generic link card. Your embed page receives the host app's theme and the logged-in user's DID, and can create/delete AT Protocol records on their behalf. + +## Setup + +### 1. Register your app + +Your app needs to be added to the embed registry in atmo.social. Contact us or submit a PR adding your app to `src/lib/components/embed/special/embed-registry.ts`: + +```ts +{ + domain: 'yourapp.com', + match: (href) => /^https?:\/\/(www\.)?yourapp\.com\/p\//.test(href), + embedUrl: (href) => { + const url = new URL(href); + url.pathname = url.pathname.replace(/\/$/, '') + '/embed'; + url.search = ''; + return url.toString(); + }, + allowedCollections: ['your.lexicon.collection'], + aspectRatio: { width: 2, height: 1 } +} +``` + +- **`domain`**: Your domain, used to validate `postMessage` origins +- **`match`**: Function that returns `true` for URLs that should be embedded +- **`embedUrl`**: Transforms the original URL into your embed endpoint URL +- **`allowedCollections`**: AT Protocol collections your embed can write to (scoped permissions) +- **`aspectRatio`**: Aspect ratio (`width`/`height`) — embed fills full width and maintains this ratio + +### 2. Create your embed page + +For a URL like `https://yourapp.com/p/actor/item/abc`, serve an embed page at `https://yourapp.com/p/actor/item/abc/embed`. + +Include the SDK: + +```html + +``` + +### 3. Read parameters + +The host app passes theme and auth info as URL search params: + +```js +const { base, accent, dark, did } = AtmoEmbed.getParams(); +``` + +| Param | Type | Description | +|-------|------|-------------| +| `base` | `string` | Base color name (e.g. `mauve`, `slate`, `zinc`) | +| `accent` | `string` | Accent color name (e.g. `fuchsia`, `blue`, `rose`) | +| `dark` | `boolean` | Whether dark mode is active | +| `did` | `string \| null` | The logged-in user's DID, or `null` if not logged in | + +Use these to match your embed's theme to the host app. If you use [@foxui/core](https://flo-bit.dev/ui-kit) or Tailwind with the same color system, just add the `base`, `accent`, and optionally `dark` classes to your `` element. + +### 4. Create records + +Create an AT Protocol record on behalf of the logged-in user: + +```js +const result = await AtmoEmbed.createRecord({ + collection: 'your.lexicon.collection', + rkey: 'optional-record-key', // omitted = auto-generated + record: { + $type: 'your.lexicon.collection', + // ... your record fields + createdAt: new Date().toISOString() + } +}); + +console.log(result.uri); // at://did:plc:.../your.lexicon.collection/... +``` + +### 5. Delete records + +```js +await AtmoEmbed.deleteRecord({ + collection: 'your.lexicon.collection', + rkey: 'record-key-to-delete' +}); +``` + +## Security + +- Your embed runs in a sandboxed iframe (`allow-scripts allow-same-origin`) +- `postMessage` origins are validated against your registered domain +- Record operations are scoped to your `allowedCollections` — attempts to write to other collections are rejected +- The user's auth session is never exposed to the iframe — all writes go through the host app's server + +## Example + +Here's a minimal embed page for an RSVP button: + +```html + + + + + + + + + + + +``` + +## Full URL example + +Original link posted on Bluesky: +``` +https://atmo.rsvp/p/did:plc:lysqukqdu6hsrhet5v2brjgo/e/3mhqxpdvmw25h +``` + +Embed URL loaded in iframe: +``` +https://atmo.rsvp/embed/p/did:plc:lysqukqdu6hsrhet5v2brjgo/e/3mhqxpdvmw25h?base=mauve&accent=fuchsia&dark=1&did=did:plc:abc123 +``` diff --git a/src/lib/components/embed/Embed.svelte b/src/lib/components/embed/Embed.svelte index 31e7dfa..300efca 100644 --- a/src/lib/components/embed/Embed.svelte +++ b/src/lib/components/embed/Embed.svelte @@ -21,7 +21,7 @@ {#if embed.type === 'images'} {:else if embed.type === 'external' && embed.external && specialEmbed} - + {:else if embed.type === 'external' && embed.external} {:else if embed.type === 'video' && embed.video} diff --git a/src/lib/components/embed/special/AppEmbed.svelte b/src/lib/components/embed/special/AppEmbed.svelte new file mode 100644 index 0000000..3346590 --- /dev/null +++ b/src/lib/components/embed/special/AppEmbed.svelte @@ -0,0 +1,9 @@ + + + diff --git a/src/lib/components/embed/special/IframeEmbed.svelte b/src/lib/components/embed/special/IframeEmbed.svelte new file mode 100644 index 0000000..2ee7485 --- /dev/null +++ b/src/lib/components/embed/special/IframeEmbed.svelte @@ -0,0 +1,118 @@ + + +
+ +
diff --git a/src/lib/components/embed/special/embed-registry.ts b/src/lib/components/embed/special/embed-registry.ts new file mode 100644 index 0000000..5e32e27 --- /dev/null +++ b/src/lib/components/embed/special/embed-registry.ts @@ -0,0 +1,47 @@ +/** + * Registry of third-party atproto apps that support embedding. + * + * Each entry defines: + * - match: which external links should use the embed + * - embedUrl: transforms the original URL into the embed URL + * - allowedCollections: which AT Protocol collections the embed can create/delete records in + * - dimensions: fixed aspect ratio to prevent layout shift + */ + +export interface EmbedAppConfig { + /** Domain identifier for postMessage origin validation */ + domain: string; + /** Match function: returns true if this external link should be embedded */ + match: (href: string) => boolean; + /** Transform the original URL into the embed URL */ + embedUrl: (href: string) => string; + /** Collections the embed is allowed to create/delete records in */ + allowedCollections: string[]; + /** Aspect ratio for the embed to prevent layout shift */ + aspectRatio: { width: number; height: number }; +} + +export const embedApps: EmbedAppConfig[] = [ + { + domain: 'atmo.rsvp', + match: (href) => /^https?:\/\/(www\.)?atmo\.rsvp\/p\/[^/]+\/e\/[^/]+\/?$/.test(href), + embedUrl: (href) => { + const url = new URL(href); + // /p/actor/e/rkey -> /embed/p/actor/e/rkey + url.pathname = '/embed' + url.pathname.replace(/\/$/, ''); + url.search = ''; + return url.toString(); + }, + allowedCollections: [ + 'community.lexicon.calendar.rsvp' + ], + aspectRatio: { width: 2, height: 1 } + } +]; + +/** + * Find an embed app config for a given URL. + */ +export function findEmbedApp(href: string): EmbedAppConfig | undefined { + return embedApps.find((app) => app.match(href)); +} diff --git a/src/lib/components/embed/special/index.ts b/src/lib/components/embed/special/index.ts index ed618cf..9e3777c 100644 --- a/src/lib/components/embed/special/index.ts +++ b/src/lib/components/embed/special/index.ts @@ -2,11 +2,14 @@ import type { Component } from 'svelte'; import type { EmbedExternalData } from '../types'; import YouTubeEmbed from './YouTubeEmbed.svelte'; import TenorEmbed from './TenorEmbed.svelte'; -import AtmoRsvpEmbed from './AtmoRsvpEmbed.svelte'; +import AppEmbed from './AppEmbed.svelte'; +import { findEmbedApp, type EmbedAppConfig } from './embed-registry'; export type SpecialEmbed = { match: (data: EmbedExternalData) => boolean; - component: Component<{ data: EmbedExternalData }>; + component: Component<{ data: EmbedExternalData; config?: EmbedAppConfig }>; + /** If set, this is an app embed with iframe + postMessage support */ + appConfig?: EmbedAppConfig; }; export const specialEmbeds: SpecialEmbed[] = [ @@ -23,14 +26,24 @@ export const specialEmbeds: SpecialEmbed[] = [ }, component: TenorEmbed }, - { - match: (data) => { - return /^https?:\/\/(www\.)?atmo\.rsvp\/p\//.test(data.external.href); - }, - component: AtmoRsvpEmbed - } ]; export function findSpecialEmbed(data: EmbedExternalData): SpecialEmbed | undefined { - return specialEmbeds.find((e) => e.match(data)); + // Check hardcoded special embeds first + const special = specialEmbeds.find((e) => e.match(data)); + if (special) return special; + + // Check app embed registry + const appConfig = findEmbedApp(data.external.href); + if (appConfig) { + return { + match: () => true, + component: AppEmbed, + appConfig + }; + } + + return undefined; } + +export { findEmbedApp, type EmbedAppConfig } from './embed-registry'; diff --git a/static/embed-sdk.js b/static/embed-sdk.js new file mode 100644 index 0000000..d7f303d --- /dev/null +++ b/static/embed-sdk.js @@ -0,0 +1,103 @@ +/** + * atmo.social Embed SDK + * + * Include this script in your embed page to communicate with the host app. + * + * Usage: + *