[Embeds] Embed subdomain landing page (#3501)

* add build output to web build

* simplify post-build step by copying everything at once

* make script that converts placeholder -> iframe

* dynamically resize iframe based on inner content

Requires the iframe content to `postMessage` its height back up to the parent

* add lang to embed

* svg explicit height -> viewBox

* add build output to web build

* simplify post-build step by copying everything at once

* attempt to fix go embed issue

* rm changes to bskyweb

* remove another bskyweb change

* embed landing page

* Drop xl breakpoint, too far down

* Remove pointer enter behavior

* Avoid button width jump

* Escape HTML

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Samuel Newman 2024-04-13 03:58:40 +01:00 committed by GitHub
parent 8e29b1f633
commit 4b3ec55732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 482 additions and 57 deletions

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 221 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 463 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

Before

Width:  |  Height:  |  Size: 235 B

After

Width:  |  Height:  |  Size: 232 B

View File

@ -14,6 +14,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/screens/landing.tsx"></script>
</body>
</html>

View File

@ -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": {

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Embed</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/screens/post.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,90 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Window {
bluesky: {
scan: (element?: Pick<Element, 'querySelectorAll'>) => 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<HTMLIFrameElement>(
`[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())
}

View File

@ -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<HTMLDivElement>(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 (
<div
ref={ref}
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
onClick={() => {
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()
}
}
}}>
<Link href={href} />
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
</div>
)
}

View File

@ -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

View File

@ -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 (
<Container href={href}>
<div className="flex-1 flex-col flex gap-2">
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center">
<Link href={`/profile/${post.author.did}`} className="rounded-full">
<img
@ -143,7 +143,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
}
return (
<p className="text-lg leading-6 break-word break-words whitespace-pre-wrap">
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)

View File

@ -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<HTMLDivElement>(null)
return (
<div
ref={ref}
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[550px] min-w-[300px] flex border rounded-xl px-4 pt-3 pb-2.5"
onClick={() => {
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()
}
}
}}>
<Link href={href} />
{children}
</div>
)
}

View File

@ -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(<LandingPage />, root)
function LandingPage() {
const [uri, setUri] = useState('')
const [error, setError] = useState<string | null>(null)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
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 (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
<img src={logo as string} className="h-10" />
</Link>
<h1 className="text-4xl font-bold">Embed a Bluesky Post</h1>
<div className="w-full max-w-[600px] flex flex-col gap-2">
<input
type="text"
value={uri}
onInput={e => setUri(e.currentTarget.value)}
className="border rounded-lg py-3 w-full max-w-[600px] px-4"
placeholder={DEFAULT_POST}
/>
<p className={`text-red-500 ${error ? '' : 'invisible'}`}>{error}</p>
</div>
<img src={arrowBottom as string} className="w-6" />
<div className="w-full max-w-[600px] gap-8 flex flex-col">
{uri && !error && thread && <Snippet thread={thread} />}
{thread ? (
<Post thread={thread} key={thread.post.uri} />
) : (
<Container href="https://bsky.social/about">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo as string} className="h-8" />
</Link>
<div className="h-32" />
</Container>
)}
</div>
</main>
)
}
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const ref = useRef<HTMLInputElement>(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 `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
thread.post.uri,
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
}</p>&mdash; ${escapeHtml(
thread.post.author.displayName || thread.post.author.handle,
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
thread.post.author.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(thread.post.indexedAt),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [thread])
return (
<div className="flex gap-2 w-full">
<input
ref={ref}
type="text"
value={snippet}
className="border rounded-lg py-3 w-full px-4"
readOnly
autoFocus
/>
<button
className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
onClick={() => {
ref.current?.focus()
ref.current?.select()
void navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? 'Copied!' : 'Copy code'}
</button>
</div>
)
}
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 = '&quot;'
break
case 38: // &
escape = '&amp;'
break
case 39: // '
escape = '&#x27;'
break
case 60: // <
escape = '&lt;'
break
case 62: // >
escape = '&gt;'
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
}

View File

@ -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

View File

@ -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
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
}

View File

@ -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'),
},
},
},
}

4
bskyweb/.gitignore vendored
View File

@ -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