[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
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -14,6 +14,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/screens/landing.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -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>
|
|
@ -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())
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -10,9 +10,9 @@ import {
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {ComponentChildren, h} from 'preact'
|
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 {Link} from './link'
|
||||||
import {getRkey} from './utils'
|
|
||||||
|
|
||||||
export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) {
|
export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) {
|
||||||
if (!content) return null
|
if (!content) return null
|
|
@ -1,14 +1,14 @@
|
||||||
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
|
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
|
||||||
import {h} from 'preact'
|
import {h} from 'preact'
|
||||||
|
|
||||||
import replyIcon from '../assets/bubble_filled_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 likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
||||||
import logo from '../assets/logo.svg'
|
import logo from '../../assets/logo.svg'
|
||||||
import repostIcon from '../assets/repost_stroke2_corner2_rounded.svg'
|
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||||
|
import {getRkey, niceDate} from '../utils'
|
||||||
import {Container} from './container'
|
import {Container} from './container'
|
||||||
import {Embed} from './embed'
|
import {Embed} from './embed'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
import {getRkey, niceDate} from './utils'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
thread: AppBskyFeedDefs.ThreadViewPost
|
thread: AppBskyFeedDefs.ThreadViewPost
|
||||||
|
@ -25,7 +25,7 @@ export function Post({thread}: Props) {
|
||||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||||
return (
|
return (
|
||||||
<Container href={href}>
|
<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">
|
<div className="flex gap-2.5 items-center">
|
||||||
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
||||||
<img
|
<img
|
||||||
|
@ -143,7 +143,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{richText}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>— ${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 = '"'
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,27 +1,27 @@
|
||||||
import './index.css'
|
import '../index.css'
|
||||||
|
|
||||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||||
import {h, render} from 'preact'
|
import {h, render} from 'preact'
|
||||||
|
|
||||||
import logo from '../assets/logo.svg'
|
import logo from '../../assets/logo.svg'
|
||||||
import {Container} from './container'
|
import {Container} from '../components/container'
|
||||||
import {Link} from './link'
|
import {Link} from '../components/link'
|
||||||
import {Post} from './post'
|
import {Post} from '../components/post'
|
||||||
import {getRkey} from './utils'
|
import {getRkey} from '../utils'
|
||||||
|
|
||||||
const root = document.getElementById('app')
|
const root = document.getElementById('app')
|
||||||
if (!root) throw new Error('No root element')
|
if (!root) throw new Error('No root element')
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search)
|
|
||||||
|
|
||||||
const agent = new BskyAgent({
|
const agent = new BskyAgent({
|
||||||
service: 'https://public.api.bsky.app',
|
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) {
|
if (!uri) {
|
||||||
throw new Error('No uri in query string')
|
throw new Error('No uri in path')
|
||||||
}
|
}
|
||||||
|
|
||||||
agent
|
agent
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {AtUri} from '@atproto/api'
|
||||||
|
|
||||||
export function niceDate(date: number | string | Date) {
|
export function niceDate(date: number | string | Date) {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
return `${d.toLocaleDateString('en-us', {
|
return `${d.toLocaleDateString('en-us', {
|
||||||
|
@ -11,5 +13,6 @@ export function niceDate(date: number | string | Date) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRkey({uri}: {uri: string}): string {
|
export function getRkey({uri}: {uri: string}): string {
|
||||||
return uri.split('/').pop() as string
|
const at = new AtUri(uri)
|
||||||
|
return at.rkey
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES5",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["snippet"],
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {resolve} from 'node:path'
|
||||||
|
|
||||||
import preact from '@preact/preset-vite'
|
import preact from '@preact/preset-vite'
|
||||||
import legacy from '@vitejs/plugin-legacy'
|
import legacy from '@vitejs/plugin-legacy'
|
||||||
import type {UserConfig} from 'vite'
|
import type {UserConfig} from 'vite'
|
||||||
|
@ -12,7 +14,13 @@ const config: UserConfig = {
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
assetsDir: 'static/embed/assets',
|
assetsDir: 'static',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'index.html'),
|
||||||
|
post: resolve(__dirname, 'post.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,10 @@ static/js/*.js
|
||||||
static/js/*.map
|
static/js/*.map
|
||||||
static/js/*.js.LICENSE.txt
|
static/js/*.js.LICENSE.txt
|
||||||
templates/scripts.html
|
templates/scripts.html
|
||||||
|
templates/*-embed.html
|
||||||
|
static/embed/*.html
|
||||||
|
static/embed/assets/*.js
|
||||||
|
static/embed/assets/*.css
|
||||||
|
|
||||||
# Don't ignore this file
|
# Don't ignore this file
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|