about summary refs log tree commit diff
path: root/bskyembed/src/components/embed.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'bskyembed/src/components/embed.tsx')
-rw-r--r--bskyembed/src/components/embed.tsx299
1 files changed, 299 insertions, 0 deletions
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
new file mode 100644
index 000000000..2f9f6b3cd
--- /dev/null
+++ b/bskyembed/src/components/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 {getRkey} from '../utils'
+import {Link} from './link'
+
+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>
+  )
+}