diff options
Diffstat (limited to 'bskyembed/src')
-rw-r--r-- | bskyembed/src/app.tsx | 18 | ||||
-rw-r--r-- | bskyembed/src/container.tsx | 32 | ||||
-rw-r--r-- | bskyembed/src/embed.tsx | 299 | ||||
-rw-r--r-- | bskyembed/src/index.css | 34 | ||||
-rw-r--r-- | bskyembed/src/link.tsx | 21 | ||||
-rw-r--r-- | bskyembed/src/main.tsx | 83 | ||||
-rw-r--r-- | bskyembed/src/post.tsx | 150 | ||||
-rw-r--r-- | bskyembed/src/utils.ts | 15 |
8 files changed, 604 insertions, 48 deletions
diff --git a/bskyembed/src/app.tsx b/bskyembed/src/app.tsx deleted file mode 100644 index 4fba80d59..000000000 --- a/bskyembed/src/app.tsx +++ /dev/null @@ -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> - </> - ) -} diff --git a/bskyembed/src/container.tsx b/bskyembed/src/container.tsx new file mode 100644 index 000000000..0d120e1b7 --- /dev/null +++ b/bskyembed/src/container.tsx @@ -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> + ) +} diff --git a/bskyembed/src/embed.tsx b/bskyembed/src/embed.tsx new file mode 100644 index 000000000..0980c5e7f --- /dev/null +++ b/bskyembed/src/embed.tsx @@ -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> + ) +} diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css index b8c94dfb5..23457ec28 100644 --- a/bskyembed/src/index.css +++ b/bskyembed/src/index.css @@ -1,29 +1,7 @@ -html, body { - height: 100%; - width: 100%; - 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; -} +@tailwind base; +@tailwind components; +@tailwind utilities; -* { - box-sizing: border-box; -} - -#app { - height: 100%; - text-align: center; - background-color: #673ab8; - color: #fff; - font-size: 1.5em; - padding-top: 100px; -} - -.link { - color: #fff; -} +.break-word { + word-break: break-word; +} \ No newline at end of file diff --git a/bskyembed/src/link.tsx b/bskyembed/src/link.tsx new file mode 100644 index 000000000..7226ecf3d --- /dev/null +++ b/bskyembed/src/link.tsx @@ -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} + /> + ) +} diff --git a/bskyembed/src/main.tsx b/bskyembed/src/main.tsx index 349f0ee78..895675434 100644 --- a/bskyembed/src/main.tsx +++ b/bskyembed/src/main.tsx @@ -1,9 +1,88 @@ import './index.css' +import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' 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') 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> + ) +} diff --git a/bskyembed/src/post.tsx b/bskyembed/src/post.tsx new file mode 100644 index 000000000..e10a502d2 --- /dev/null +++ b/bskyembed/src/post.tsx @@ -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> + ) +} diff --git a/bskyembed/src/utils.ts b/bskyembed/src/utils.ts new file mode 100644 index 000000000..3408fcd97 --- /dev/null +++ b/bskyembed/src/utils.ts @@ -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 +} |