diff --git a/assets/icons/bubble_filled_stroke2_corner2_rounded.svg b/assets/icons/bubble_filled_stroke2_corner2_rounded.svg index b759bd70..3dbc8ca6 100644 --- a/assets/icons/bubble_filled_stroke2_corner2_rounded.svg +++ b/assets/icons/bubble_filled_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg index 2e798cbe..daec6f55 100644 --- a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg b/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..afb8f245 --- /dev/null +++ b/bskyembed/assets/arrowBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg b/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg index 36db9a88..9962a20b 100644 --- a/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg +++ b/bskyembed/assets/bubble_filled_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/bskyembed/index.html b/bskyembed/index.html index aa9335e8..61d0c7d1 100644 --- a/bskyembed/index.html +++ b/bskyembed/index.html @@ -14,6 +14,6 @@
- + diff --git a/bskyembed/package.json b/bskyembed/package.json index 6fb919c9..f610e8c0 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build-snippet": "tsc --project tsconfig.snippet.json", "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" }, "dependencies": { diff --git a/bskyembed/post.html b/bskyembed/post.html new file mode 100644 index 00000000..5f550495 --- /dev/null +++ b/bskyembed/post.html @@ -0,0 +1,19 @@ + + + + + + Bluesky Embed + + + + + + + + + +
+ + + diff --git a/bskyembed/snippet/embed.ts b/bskyembed/snippet/embed.ts new file mode 100644 index 00000000..f2b9b442 --- /dev/null +++ b/bskyembed/snippet/embed.ts @@ -0,0 +1,90 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface Window { + bluesky: { + scan: (element?: Pick) => void + } +} + +const EMBED_URL = 'https://embed.bsky.app' + +window.bluesky = window.bluesky || { + scan, +} + +/** + * Listen for messages from the Bluesky embed iframe and adjust the height of + * the iframe accordingly. + */ +window.addEventListener('message', event => { + if (event.origin !== EMBED_URL) { + return + } + + const id = (event.data as {id: string}).id + if (!id) { + return + } + + const embed = document.querySelector( + `[data-bluesky-id="${id}"]`, + ) + + if (!embed) { + return + } + + const height = (event.data as {height: number}).height + if (height) { + embed.style.height = `${height}px` + } +}) + +/** + * Scan the document for all elements with the data-bluesky-aturi attribute, + * and initialize them as Bluesky embeds. + * + * @param element Only scan this specific element @default document @optional + * @returns + */ +function scan(node = document) { + const embeds = node.querySelectorAll('[data-bluesky-uri]') + + for (let i = 0; i < embeds.length; i++) { + const id = String(Math.random()).slice(2) + + const embed = embeds[i] + const aturi = embed.getAttribute('data-bluesky-uri') + + if (!aturi) { + continue + } + + const iframe = document.createElement('iframe') + iframe.setAttribute('data-bluesky-id', id) + iframe.src = `${EMBED_URL}/embed/${aturi.slice('at://'.length)}?id=${id}` + iframe.width = '100%' + iframe.style.border = 'none' + iframe.style.display = 'block' + iframe.style.flexGrow = '1' + iframe.frameBorder = '0' + iframe.scrolling = 'no' + + const container = document.createElement('div') + container.style.maxWidth = '600px' + container.style.width = '100%' + container.style.marginTop = '10px' + container.style.marginBottom = '10px' + container.style.display = 'flex' + container.className = 'bluesky-embed' + + container.appendChild(iframe) + + embed.replaceWith(container) + } +} + +if (['interactive', 'complete'].indexOf(document.readyState) !== -1) { + scan() +} else { + document.addEventListener('DOMContentLoaded', () => scan()) +} diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx new file mode 100644 index 00000000..a96addc8 --- /dev/null +++ b/bskyembed/src/components/container.tsx @@ -0,0 +1,55 @@ +import {ComponentChildren, h} from 'preact' +import {useEffect, useRef} from 'preact/hooks' + +import {Link} from './link' + +export function Container({ + children, + href, +}: { + children: ComponentChildren + href: string +}) { + const ref = useRef(null) + const prevHeight = useRef(0) + + useEffect(() => { + if (ref.current) { + const observer = new ResizeObserver(entries => { + const entry = entries[0] + if (!entry) return + + let {height} = entry.contentRect + height += 2 // border top and bottom + if (height !== prevHeight.current) { + prevHeight.current = height + window.parent.postMessage( + {height, id: new URLSearchParams(window.location.search).get('id')}, + '*', + ) + } + }) + observer.observe(ref.current) + return () => observer.disconnect() + } + }, []) + + return ( +
{ + if (ref.current) { + // forwardRef requires preact/compat - let's keep it simple + // to keep the bundle size down + const anchor = ref.current.querySelector('a') + if (anchor) { + anchor.click() + } + } + }}> + +
{children}
+
+ ) +} diff --git a/bskyembed/src/embed.tsx b/bskyembed/src/components/embed.tsx similarity index 98% rename from bskyembed/src/embed.tsx rename to bskyembed/src/components/embed.tsx index 0980c5e7..2f9f6b3c 100644 --- a/bskyembed/src/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -10,9 +10,9 @@ import { } from '@atproto/api' import {ComponentChildren, h} from 'preact' -import infoIcon from '../assets/circleInfo_stroke2_corner0_rounded.svg' +import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' +import {getRkey} from '../utils' import {Link} from './link' -import {getRkey} from './utils' export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) { if (!content) return null diff --git a/bskyembed/src/link.tsx b/bskyembed/src/components/link.tsx similarity index 100% rename from bskyembed/src/link.tsx rename to bskyembed/src/components/link.tsx diff --git a/bskyembed/src/post.tsx b/bskyembed/src/components/post.tsx similarity index 90% rename from bskyembed/src/post.tsx rename to bskyembed/src/components/post.tsx index e10a502d..dcbf3e33 100644 --- a/bskyembed/src/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -1,14 +1,14 @@ import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' import {h} from 'preact' -import replyIcon from '../assets/bubble_filled_stroke2_corner2_rounded.svg' -import likeIcon from '../assets/heart2_filled_stroke2_corner0_rounded.svg' -import logo from '../assets/logo.svg' -import repostIcon from '../assets/repost_stroke2_corner2_rounded.svg' +import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg' +import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' +import logo from '../../assets/logo.svg' +import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' +import {getRkey, niceDate} from '../utils' import {Container} from './container' import {Embed} from './embed' import {Link} from './link' -import {getRkey, niceDate} from './utils' interface Props { thread: AppBskyFeedDefs.ThreadViewPost @@ -25,7 +25,7 @@ export function Post({thread}: Props) { const href = `/profile/${post.author.did}/post/${getRkey(post)}` return ( -
+
+

{richText}

) diff --git a/bskyembed/src/container.tsx b/bskyembed/src/container.tsx deleted file mode 100644 index 0d120e1b..00000000 --- a/bskyembed/src/container.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {ComponentChildren, h} from 'preact' -import {useRef} from 'preact/hooks' - -import {Link} from './link' - -export function Container({ - children, - href, -}: { - children: ComponentChildren - href: string -}) { - const ref = useRef(null) - return ( -
{ - if (ref.current) { - // forwardRef requires preact/compat - let's keep it simple - // to keep the bundle size down - const anchor = ref.current.querySelector('a') - if (anchor) { - anchor.click() - } - } - }}> - - {children} -
- ) -} diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx new file mode 100644 index 00000000..88e84ffb --- /dev/null +++ b/bskyembed/src/screens/landing.tsx @@ -0,0 +1,266 @@ +import '../index.css' + +import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api' +import {Fragment, h, render} from 'preact' +import {useEffect, useMemo, useRef, useState} from 'preact/hooks' + +import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg' +import logo from '../../assets/logo.svg' +import {Container} from '../components/container' +import {Link} from '../components/link' +import {Post} from '../components/post' +import {niceDate} from '../utils' + +const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y' +const DEFAULT_URI = + 'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y' + +export const EMBED_SERVICE = 'https://embed.bsky.app' +export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` + +const root = document.getElementById('app') +if (!root) throw new Error('No root element') + +const agent = new BskyAgent({ + service: 'https://public.api.bsky.app', +}) + +render(, root) + +function LandingPage() { + const [uri, setUri] = useState('') + const [error, setError] = useState(null) + const [thread, setThread] = useState( + null, + ) + + useEffect(() => { + void (async () => { + setError(null) + try { + let atUri = DEFAULT_URI + + if (uri) { + if (uri.startsWith('at://')) { + atUri = uri + } else { + try { + const urlp = new URL(uri) + if (!urlp.hostname.endsWith('bsky.app')) { + throw new Error('Invalid hostname') + } + const split = urlp.pathname.slice(1).split('/') + if (split.length < 4) { + throw new Error('Invalid pathname') + } + const [profile, didOrHandle, type, rkey] = split + if (profile !== 'profile' || type !== 'post') { + throw new Error('Invalid profile or type') + } + + let did = didOrHandle + if (!didOrHandle.startsWith('did:')) { + const resolution = await agent.resolveHandle({ + handle: didOrHandle, + }) + if (!resolution.data.did) { + throw new Error('No DID found') + } + did = resolution.data.did + } + + atUri = `at://${did}/app.bsky.feed.post/${rkey}` + } catch (err) { + console.log(err) + throw new Error('Invalid Bluesky URL') + } + } + } + + const {data} = await agent.getPostThread({ + uri: atUri, + depth: 0, + parentHeight: 0, + }) + + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { + throw new Error('Post not found') + } + + setThread(data.thread) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Invalid Bluesky URL') + } + })() + }, [uri]) + + return ( +
+ + + + +

Embed a Bluesky Post

+ +
+ setUri(e.currentTarget.value)} + className="border rounded-lg py-3 w-full max-w-[600px] px-4" + placeholder={DEFAULT_POST} + /> +

{error}

+
+ + + +
+ {uri && !error && thread && } + + {thread ? ( + + ) : ( + + + + +
+ + )} +
+
+ ) +} + +function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) { + const ref = useRef(null) + const [copied, setCopied] = useState(false) + + // reset copied state after 2 seconds + useEffect(() => { + if (copied) { + const timeout = setTimeout(() => { + setCopied(false) + }, 2000) + return () => clearTimeout(timeout) + } + }, [copied]) + + const snippet = useMemo(() => { + const record = thread.post.record + + if (!AppBskyFeedPost.isRecord(record)) { + return '' + } + + const profileHref = toShareUrl( + ['/profile', thread.post.author.did].join('/'), + ) + const urip = new AtUri(thread.post.uri) + const href = toShareUrl( + ['/profile', thread.post.author.did, 'post', urip.rkey].join('/'), + ) + + const lang = record.langs ? record.langs[0] : '' + + // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x + // DO NOT ADD ANY NEW INTERPOLATIOONS BELOW WITHOUT ESCAPING THEM! + // x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x + return `

${escapeHtml(record.text)}${ + record.embed + ? `

[image or embed]` + : '' + }

— ${escapeHtml( + thread.post.author.displayName || thread.post.author.handle, + )} (@${escapeHtml( + thread.post.author.handle, + )}) ${escapeHtml( + niceDate(thread.post.indexedAt), + )}
` + }, [thread]) + + return ( +
+ + +
+ ) +} + +function toShareUrl(path: string) { + return `https://bsky.app${path}` +} + +/** + * Based on a snippet of code from React, which itself was based on the escape-html library. + * Copyright (c) Meta Platforms, Inc. and affiliates + * Copyright (c) 2012-2013 TJ Holowaychuk + * Copyright (c) 2015 Andreas Lubbe + * Copyright (c) 2015 Tiancheng "Timothy" Gu + * Licensed as MIT. + */ +const matchHtmlRegExp = /["'&<>]/ +function escapeHtml(string: string) { + const str = String(string) + const match = matchHtmlRegExp.exec(str) + if (!match) { + return str + } + let escape + let html = '' + let index + let lastIndex = 0 + for (index = match.index; index < str.length; index++) { + switch (str.charCodeAt(index)) { + case 34: // " + escape = '"' + break + case 38: // & + escape = '&' + break + case 39: // ' + escape = ''' + break + case 60: // < + escape = '<' + break + case 62: // > + escape = '>' + break + default: + continue + } + if (lastIndex !== index) { + html += str.slice(lastIndex, index) + } + lastIndex = index + 1 + html += escape + } + return lastIndex !== index ? html + str.slice(lastIndex, index) : html +} diff --git a/bskyembed/src/main.tsx b/bskyembed/src/screens/post.tsx similarity index 85% rename from bskyembed/src/main.tsx rename to bskyembed/src/screens/post.tsx index 89567543..76c92154 100644 --- a/bskyembed/src/main.tsx +++ b/bskyembed/src/screens/post.tsx @@ -1,27 +1,27 @@ -import './index.css' +import '../index.css' import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import {h, render} from 'preact' -import logo from '../assets/logo.svg' -import {Container} from './container' -import {Link} from './link' -import {Post} from './post' -import {getRkey} from './utils' +import logo from '../../assets/logo.svg' +import {Container} from '../components/container' +import {Link} from '../components/link' +import {Post} from '../components/post' +import {getRkey} from '../utils' const root = document.getElementById('app') if (!root) throw new Error('No root element') -const searchParams = new URLSearchParams(window.location.search) - const agent = new BskyAgent({ service: 'https://public.api.bsky.app', }) -const uri = searchParams.get('uri') +const uri = `at://${window.location.pathname.slice('/embed/'.length)}` + +console.log(uri) if (!uri) { - throw new Error('No uri in query string') + throw new Error('No uri in path') } agent diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts index 3408fcd9..1f6fd506 100644 --- a/bskyembed/src/utils.ts +++ b/bskyembed/src/utils.ts @@ -1,3 +1,5 @@ +import {AtUri} from '@atproto/api' + export function niceDate(date: number | string | Date) { const d = new Date(date) return `${d.toLocaleDateString('en-us', { @@ -11,5 +13,6 @@ export function niceDate(date: number | string | Date) { } export function getRkey({uri}: {uri: string}): string { - return uri.split('/').pop() as string + const at = new AtUri(uri) + return at.rkey } diff --git a/bskyembed/tsconfig.snippet.json b/bskyembed/tsconfig.snippet.json new file mode 100644 index 00000000..a6b6071d --- /dev/null +++ b/bskyembed/tsconfig.snippet.json @@ -0,0 +1,10 @@ + +{ + "compilerOptions": { + "target": "ES5", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "strict": true, + "outDir": "dist" + }, + "include": ["snippet"], +} diff --git a/bskyembed/vite.config.ts b/bskyembed/vite.config.ts index 8d0b9207..9acc9d5e 100644 --- a/bskyembed/vite.config.ts +++ b/bskyembed/vite.config.ts @@ -1,3 +1,5 @@ +import {resolve} from 'node:path' + import preact from '@preact/preset-vite' import legacy from '@vitejs/plugin-legacy' import type {UserConfig} from 'vite' @@ -12,7 +14,13 @@ const config: UserConfig = { }), ], build: { - assetsDir: 'static/embed/assets', + assetsDir: 'static', + rollupOptions: { + input: { + index: resolve(__dirname, 'index.html'), + post: resolve(__dirname, 'post.html'), + }, + }, }, } diff --git a/bskyweb/.gitignore b/bskyweb/.gitignore index 1d945e1d..ace9fbf5 100644 --- a/bskyweb/.gitignore +++ b/bskyweb/.gitignore @@ -9,6 +9,10 @@ static/js/*.js static/js/*.map static/js/*.js.LICENSE.txt templates/scripts.html +templates/*-embed.html +static/embed/*.html +static/embed/assets/*.js +static/embed/assets/*.css # Don't ignore this file !.gitignore