about summary refs log tree commit diff
path: root/bskyembed/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'bskyembed/src/components')
-rw-r--r--bskyembed/src/components/container.tsx55
-rw-r--r--bskyembed/src/components/embed.tsx299
-rw-r--r--bskyembed/src/components/link.tsx21
-rw-r--r--bskyembed/src/components/post.tsx150
4 files changed, 525 insertions, 0 deletions
diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx
new file mode 100644
index 000000000..a96addc8c
--- /dev/null
+++ b/bskyembed/src/components/container.tsx
@@ -0,0 +1,55 @@
+import {ComponentChildren, h} from 'preact'
+import {useEffect, useRef} from 'preact/hooks'
+
+import {Link} from './link'
+
+export function Container({
+  children,
+  href,
+}: {
+  children: ComponentChildren
+  href: string
+}) {
+  const ref = useRef<HTMLDivElement>(null)
+  const prevHeight = useRef(0)
+
+  useEffect(() => {
+    if (ref.current) {
+      const observer = new ResizeObserver(entries => {
+        const entry = entries[0]
+        if (!entry) return
+
+        let {height} = entry.contentRect
+        height += 2 // border top and bottom
+        if (height !== prevHeight.current) {
+          prevHeight.current = height
+          window.parent.postMessage(
+            {height, id: new URLSearchParams(window.location.search).get('id')},
+            '*',
+          )
+        }
+      })
+      observer.observe(ref.current)
+      return () => observer.disconnect()
+    }
+  }, [])
+
+  return (
+    <div
+      ref={ref}
+      className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
+      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} />
+      <div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
+    </div>
+  )
+}
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>
+  )
+}
diff --git a/bskyembed/src/components/link.tsx b/bskyembed/src/components/link.tsx
new file mode 100644
index 000000000..7226ecf3d
--- /dev/null
+++ b/bskyembed/src/components/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/components/post.tsx b/bskyembed/src/components/post.tsx
new file mode 100644
index 000000000..dcbf3e336
--- /dev/null
+++ b/bskyembed/src/components/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 {getRkey, niceDate} from '../utils'
+import {Container} from './container'
+import {Embed} from './embed'
+import {Link} from './link'
+
+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" lang={record?.langs?.[0]}>
+        <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="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
+      {richText}
+    </p>
+  )
+}