[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
Samuel Newman 2024-04-13 02:07:39 +01:00 committed by GitHub
parent 93731e6d6b
commit 8e29b1f633
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2371 additions and 1315 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [],
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff