about summary refs log tree commit diff
path: root/bskyembed/src
diff options
context:
space:
mode:
Diffstat (limited to 'bskyembed/src')
-rw-r--r--bskyembed/src/app.tsx18
-rw-r--r--bskyembed/src/container.tsx32
-rw-r--r--bskyembed/src/embed.tsx299
-rw-r--r--bskyembed/src/index.css34
-rw-r--r--bskyembed/src/link.tsx21
-rw-r--r--bskyembed/src/main.tsx83
-rw-r--r--bskyembed/src/post.tsx150
-rw-r--r--bskyembed/src/utils.ts15
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
+}