[Embeds] Embed for single post (#3450)
* add bskyembed vite app * create build script (temp until embedr is ready) * add build output to web build * simplify post-build step by copying everything at once * add simple post viewer * add butterfly logo * add vite plugin legacy * proper error screen * add image embed * add url embed * record embed + embedwithmedia * add list+feed embeds * add labeller embed (just to be safe) * fix curatelist and modlist being the wrong way around * Add PWI opt-out * add favicon * improve wording of PWI * remove padding I used for screenshots * add disabled state to embed * improve PWI styles by adding an icon * remove unused prop * rm open proxy * [Embeds] Add CTA and add general polish - input needed! (#3454) * add CTA, colourful icons, and bigger logo * make hover effect smaller + add to cta * more responsive + preserve whitespace * add trailing newsline to deploy script * add repost indicator * Make butterfly link to content * More consistent error text wording --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>zio/stable
|
@ -0,0 +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>
|
After Width: | Height: | Size: 224 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.002 6a3 3 0 0 1 3-3h14a3 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-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 386 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>
|
After Width: | Height: | Size: 470 B |
|
@ -0,0 +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>
|
After Width: | Height: | Size: 235 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(66,87,108)" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 361 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ec4899" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>
|
After Width: | Height: | Size: 339 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 320 286"><path fill="rgb(10,122,255)" d="M69.364 19.146c36.687 27.806 76.147 84.186 90.636 114.439 14.489-30.253 53.948-86.633 90.636-114.439C277.107-.917 320-16.44 320 32.957c0 9.865-5.603 82.875-8.889 94.729-11.423 41.208-53.045 51.719-90.071 45.357 64.719 11.12 81.182 47.953 45.627 84.785-80 82.874-106.667-44.333-106.667-44.333s-26.667 127.207-106.667 44.333c-35.555-36.832-19.092-73.665 45.627-84.785-37.026 6.362-78.648-4.149-90.071-45.357C5.603 115.832 0 42.822 0 32.957 0-16.44 42.893-.917 69.364 19.147Z"/></svg>
|
After Width: | Height: | Size: 588 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#20bc07" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>
|
After Width: | Height: | Size: 473 B |
|
@ -3,7 +3,14 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
@ -1,21 +1,28 @@
|
||||||
{
|
{
|
||||||
"name": "bskyembed",
|
"name": "bskyembed",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.4.8"
|
"@atproto/api": "^0.12.2",
|
||||||
|
"@preact/preset-vite": "^2.8.2",
|
||||||
|
"@vitejs/plugin-legacy": "^5.3.2",
|
||||||
|
"preact": "^10.4.8",
|
||||||
|
"terser": "^5.30.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@prefresh/vite": "^1.2.1",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-config-preact": "^1.3.0",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^4.0.5",
|
"typescript": "^4.0.5",
|
||||||
"vite": "^1.0.0-rc.13",
|
"vite": "^5.2.8",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
|
@ -1,18 +0,0 @@
|
||||||
import {Fragment, h} from 'preact'
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p>Hello Vite + Preact!</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
className="link"
|
|
||||||
href="https://preactjs.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
Learn Preact
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
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,299 @@
|
||||||
|
import {
|
||||||
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedRecord,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {ComponentChildren, h} from 'preact'
|
||||||
|
|
||||||
|
import infoIcon from '../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||||
|
import {Link} from './link'
|
||||||
|
import {getRkey} from './utils'
|
||||||
|
|
||||||
|
export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) {
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Case 1: Image
|
||||||
|
if (AppBskyEmbedImages.isView(content)) {
|
||||||
|
return <ImageEmbed content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: External link
|
||||||
|
if (AppBskyEmbedExternal.isView(content)) {
|
||||||
|
return <ExternalEmbed content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Record (quote or linked post)
|
||||||
|
if (AppBskyEmbedRecord.isView(content)) {
|
||||||
|
const record = content.record
|
||||||
|
|
||||||
|
// Case 3.1: Post
|
||||||
|
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||||
|
const pwiOptOut = !!record.author.labels?.find(
|
||||||
|
label => label.val === '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
if (pwiOptOut) {
|
||||||
|
return (
|
||||||
|
<Info>
|
||||||
|
The author of the quoted post has requested their posts not be
|
||||||
|
displayed on external sites.
|
||||||
|
</Info>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let text
|
||||||
|
if (AppBskyFeedPost.isRecord(record.value)) {
|
||||||
|
text = record.value.text
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||||
|
className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<img
|
||||||
|
src={record.author.avatar}
|
||||||
|
className="w-4 h-4 rounded-full bg-neutral-300 shrink-0"
|
||||||
|
/>
|
||||||
|
<p className="line-clamp-1 text-sm">
|
||||||
|
<span className="font-bold">{record.author.displayName}</span>
|
||||||
|
<span className="text-textLight ml-1">
|
||||||
|
@{record.author.handle}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{text && <p className="text-sm">{text}</p>}
|
||||||
|
{record.embeds
|
||||||
|
?.filter(embed => {
|
||||||
|
if (AppBskyEmbedImages.isView(embed)) return true
|
||||||
|
if (AppBskyEmbedExternal.isView(embed)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.map(embed => (
|
||||||
|
<Embed key={embed.$type} content={embed} />
|
||||||
|
))}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3.2: List
|
||||||
|
if (AppBskyGraphDefs.isListView(record)) {
|
||||||
|
return (
|
||||||
|
<GenericWithImage
|
||||||
|
image={record.avatar}
|
||||||
|
title={record.name}
|
||||||
|
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||||
|
subtitle={
|
||||||
|
record.purpose === AppBskyGraphDefs.MODLIST
|
||||||
|
? `Moderation list by @${record.creator.handle}`
|
||||||
|
: `User list by @${record.creator.handle}`
|
||||||
|
}
|
||||||
|
description={record.description}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3.3: Feed
|
||||||
|
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||||
|
return (
|
||||||
|
<GenericWithImage
|
||||||
|
image={record.avatar}
|
||||||
|
title={record.displayName}
|
||||||
|
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||||
|
subtitle={`Feed by @${record.creator.handle}`}
|
||||||
|
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3.4: Labeler
|
||||||
|
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||||
|
return (
|
||||||
|
<GenericWithImage
|
||||||
|
image={record.creator.avatar}
|
||||||
|
title={record.creator.displayName || record.creator.handle}
|
||||||
|
href={`/profile/${record.creator.did}`}
|
||||||
|
subtitle="Labeler"
|
||||||
|
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3.5: Post not found
|
||||||
|
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||||
|
return <Info>Quoted post not found, it may have been deleted.</Info>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3.6: Post blocked
|
||||||
|
if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||||
|
return <Info>The quoted post is blocked.</Info>
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown embed type')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: Record with media
|
||||||
|
if (AppBskyEmbedRecordWithMedia.isView(content)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Embed content={content.media} />
|
||||||
|
<Embed
|
||||||
|
content={{
|
||||||
|
$type: 'app.bsky.embed.record#view',
|
||||||
|
record: content.record.record,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported embed type')
|
||||||
|
} catch (err) {
|
||||||
|
return (
|
||||||
|
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({children}: {children: ComponentChildren}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
|
||||||
|
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-textLight">{children}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageEmbed({content}: {content: AppBskyEmbedImages.View}) {
|
||||||
|
switch (content.images.length) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={content.images[0].thumb}
|
||||||
|
alt={content.images[0].alt}
|
||||||
|
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||||
|
{content.images.map((image, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
className="w-1/2 h-full object-cover rounded-sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||||
|
<img
|
||||||
|
src={content.images[0].thumb}
|
||||||
|
alt={content.images[0].alt}
|
||||||
|
className="flex-[3] object-cover rounded-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 flex-[2]">
|
||||||
|
{content.images.slice(1).map((image, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
className="w-full h-full object-cover rounded-sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
||||||
|
{content.images.map((image, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
className="aspect-square w-full object-cover rounded-sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalEmbed({content}: {content: AppBskyEmbedExternal.View}) {
|
||||||
|
function toNiceDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlp = new URL(url)
|
||||||
|
return urlp.host ? urlp.host : url
|
||||||
|
} catch (e) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={content.external.uri}
|
||||||
|
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
|
||||||
|
{content.external.thumb && (
|
||||||
|
<img
|
||||||
|
src={content.external.thumb}
|
||||||
|
className="aspect-[1.91/1] object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="py-3 px-4">
|
||||||
|
<p className="text-sm text-textLight line-clamp-1">
|
||||||
|
{toNiceDomain(content.external.uri)}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold line-clamp-3">{content.external.title}</p>
|
||||||
|
<p className="text-sm text-textLight line-clamp-2 mt-0.5">
|
||||||
|
{content.external.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericWithImage({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
href,
|
||||||
|
image,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
href: string
|
||||||
|
image?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2.5 items-center">
|
||||||
|
{image ? (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-sm">{title}</p>
|
||||||
|
<p className="text-textLight text-sm">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-textLight text-sm">{description}</p>}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,29 +1,7 @@
|
||||||
html, body {
|
@tailwind base;
|
||||||
height: 100%;
|
@tailwind components;
|
||||||
width: 100%;
|
@tailwind utilities;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
background: #FAFAFA;
|
|
||||||
font-family: 'Helvetica Neue', arial, sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #444;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
.break-word {
|
||||||
box-sizing: border-box;
|
word-break: break-word;
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
text-align: center;
|
|
||||||
background-color: #673ab8;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1.5em;
|
|
||||||
padding-top: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import {h} from 'preact'
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
className?: string
|
||||||
|
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href.startsWith('http') ? href : `https://bsky.app${href}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
onClick={evt => evt.stopPropagation()}
|
||||||
|
className={`cursor-pointer ${className || ''}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,9 +1,88 @@
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||||
import {h, render} from 'preact'
|
import {h, render} from 'preact'
|
||||||
|
|
||||||
import {App} from './app'
|
import logo from '../assets/logo.svg'
|
||||||
|
import {Container} from './container'
|
||||||
|
import {Link} from './link'
|
||||||
|
import {Post} from './post'
|
||||||
|
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')
|
||||||
render(<App />, root)
|
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
const agent = new BskyAgent({
|
||||||
|
service: 'https://public.api.bsky.app',
|
||||||
|
})
|
||||||
|
|
||||||
|
const uri = searchParams.get('uri')
|
||||||
|
|
||||||
|
if (!uri) {
|
||||||
|
throw new Error('No uri in query string')
|
||||||
|
}
|
||||||
|
|
||||||
|
agent
|
||||||
|
.getPostThread({
|
||||||
|
uri,
|
||||||
|
depth: 0,
|
||||||
|
parentHeight: 0,
|
||||||
|
})
|
||||||
|
.then(({data}) => {
|
||||||
|
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
||||||
|
throw new Error('Expected a ThreadViewPost')
|
||||||
|
}
|
||||||
|
const pwiOptOut = !!data.thread.post.author.labels?.find(
|
||||||
|
label => label.val === '!no-unauthenticated',
|
||||||
|
)
|
||||||
|
if (pwiOptOut) {
|
||||||
|
render(<PwiOptOut thread={data.thread} />, root)
|
||||||
|
} else {
|
||||||
|
render(<Post thread={data.thread} />, root)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
render(<ErrorMessage />, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||||
|
const href = `/profile/${thread.post.author.did}/post/${getRkey(thread.post)}`
|
||||||
|
return (
|
||||||
|
<Container href={href}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||||
|
<img src={logo as string} className="h-6" />
|
||||||
|
</Link>
|
||||||
|
<div className="w-full py-12 gap-4 flex flex-col items-center">
|
||||||
|
<p className="max-w-80 text-center w-full text-textLight">
|
||||||
|
The author of this post has requested their posts not be displayed on
|
||||||
|
external sites.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
|
||||||
|
View on Bluesky
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorMessage() {
|
||||||
|
return (
|
||||||
|
<Container href="https://bsky.app/">
|
||||||
|
<Link
|
||||||
|
href="https://bsky.app/"
|
||||||
|
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||||
|
<img src={logo as string} className="h-6" />
|
||||||
|
</Link>
|
||||||
|
<p className="my-16 text-center w-full text-textLight">
|
||||||
|
Post not found, it may have been deleted.
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
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 {Container} from './container'
|
||||||
|
import {Embed} from './embed'
|
||||||
|
import {Link} from './link'
|
||||||
|
import {getRkey, niceDate} from './utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
thread: AppBskyFeedDefs.ThreadViewPost
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Post({thread}: Props) {
|
||||||
|
const post = thread.post
|
||||||
|
|
||||||
|
let record: AppBskyFeedPost.Record | null = null
|
||||||
|
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||||
|
record = post.record
|
||||||
|
}
|
||||||
|
|
||||||
|
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 gap-2.5 items-center">
|
||||||
|
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
||||||
|
<img
|
||||||
|
src={post.author.avatar}
|
||||||
|
className="w-10 h-10 rounded-full bg-neutral-300 shrink-0"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/profile/${post.author.did}`}
|
||||||
|
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
|
||||||
|
<p>{post.author.displayName}</p>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/profile/${post.author.did}`}
|
||||||
|
className="text-[15px] text-textLight hover:underline line-clamp-1">
|
||||||
|
<p>@{post.author.handle}</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="transition-transform hover:scale-110 shrink-0 self-start">
|
||||||
|
<img src={logo as string} className="h-8" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<PostContent record={record} />
|
||||||
|
<Embed content={post.embed} />
|
||||||
|
<time
|
||||||
|
datetime={new Date(post.indexedAt).toISOString()}
|
||||||
|
className="text-textLight mt-1 text-sm">
|
||||||
|
{niceDate(post.indexedAt)}
|
||||||
|
</time>
|
||||||
|
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm">
|
||||||
|
{!!post.likeCount && (
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<img src={likeIcon as string} className="w-5 h-5" />
|
||||||
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
|
{post.likeCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!post.repostCount && (
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<img src={repostIcon as string} className="w-5 h-5" />
|
||||||
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
|
{post.repostCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<img src={replyIcon as string} className="w-5 h-5" />
|
||||||
|
<p className="font-bold text-neutral-500 mb-px">Reply</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||||
|
{post.replyCount
|
||||||
|
? `Read ${post.replyCount} ${
|
||||||
|
post.replyCount > 1 ? 'replies' : 'reply'
|
||||||
|
} on Bluesky`
|
||||||
|
: `View on Bluesky`}
|
||||||
|
</p>
|
||||||
|
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
|
||||||
|
<span className="hidden min-[380px]:inline">View on </span>Bluesky
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
const rt = new RichText({
|
||||||
|
text: record.text,
|
||||||
|
facets: record.facets,
|
||||||
|
})
|
||||||
|
|
||||||
|
const richText = []
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
for (const segment of rt.segments()) {
|
||||||
|
if (segment.isLink() && segment.link) {
|
||||||
|
richText.push(
|
||||||
|
<Link
|
||||||
|
key={counter}
|
||||||
|
href={segment.link.uri}
|
||||||
|
className="text-blue-400 hover:underline">
|
||||||
|
{segment.text}
|
||||||
|
</Link>,
|
||||||
|
)
|
||||||
|
} else if (segment.isMention() && segment.mention) {
|
||||||
|
richText.push(
|
||||||
|
<Link
|
||||||
|
key={counter}
|
||||||
|
href={`/profile/${segment.mention.did}`}
|
||||||
|
className="text-blue-500 hover:underline">
|
||||||
|
{segment.text}
|
||||||
|
</Link>,
|
||||||
|
)
|
||||||
|
} else if (segment.isTag() && segment.tag) {
|
||||||
|
richText.push(
|
||||||
|
<Link
|
||||||
|
key={counter}
|
||||||
|
href={`/tag/${segment.tag.tag}`}
|
||||||
|
className="text-blue-500 hover:underline">
|
||||||
|
{segment.text}
|
||||||
|
</Link>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
richText.push(segment.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-lg leading-6 break-word break-words whitespace-pre-wrap">
|
||||||
|
{richText}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function niceDate(date: number | string | Date) {
|
||||||
|
const d = new Date(date)
|
||||||
|
return `${d.toLocaleDateString('en-us', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})} at ${d.toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRkey({uri}: {uri: string}): string {
|
||||||
|
return uri.split('/').pop() as string
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: 'rgb(10,122,255)',
|
||||||
|
textLight: 'rgb(66,87,108)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
|
@ -3,8 +3,8 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES5",
|
"target": "ES5",
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
"types": [],
|
"types": ["vite/client"],
|
||||||
"allowJs": true,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
"esModuleInterop": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
@ -17,7 +17,8 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"jsxFactory": "h",
|
"jsxFactory": "h",
|
||||||
"jsxFragmentFactory": "Fragment"
|
"jsxFragmentFactory": "Fragment",
|
||||||
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
import {resolve} from 'node:path'
|
import preact from '@preact/preset-vite'
|
||||||
|
import legacy from '@vitejs/plugin-legacy'
|
||||||
// @ts-expect-error - not important
|
|
||||||
import preactRefresh from '@prefresh/vite'
|
|
||||||
import type {UserConfig} from 'vite'
|
import type {UserConfig} from 'vite'
|
||||||
import paths from 'vite-tsconfig-paths'
|
import paths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
const config: UserConfig = {
|
const config: UserConfig = {
|
||||||
jsx: {
|
plugins: [
|
||||||
factory: 'h',
|
preact(),
|
||||||
fragment: 'Fragment',
|
paths(),
|
||||||
},
|
legacy({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
targets: ['defaults', 'not IE 11'],
|
||||||
plugins: [preactRefresh(), paths()],
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
assetsDir: 'static/embed/assets',
|
assetsDir: 'static/embed/assets',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|