about summary refs log tree commit diff
path: root/src/lib/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/api')
-rw-r--r--src/lib/api/index.ts155
-rw-r--r--src/lib/api/resolve.ts161
2 files changed, 261 insertions, 55 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index c7608ae55..e6e8eea3d 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -6,8 +6,10 @@ import {
   AppBskyEmbedVideo,
   AppBskyFeedPostgate,
   AtUri,
+  BlobRef,
   BskyAgent,
   ComAtprotoLabelDefs,
+  ComAtprotoRepoStrongRef,
   RichText,
 } from '@atproto/api'
 
@@ -22,8 +24,10 @@ import {
   threadgateAllowUISettingToAllowRecordValue,
   writeThreadgateRecord,
 } from '#/state/queries/threadgate'
-import {ComposerState} from '#/view/com/composer/state/composer'
+import {ComposerState, EmbedDraft} from '#/view/com/composer/state/composer'
+import {createGIFDescription} from '../gif-alt-text'
 import {LinkMeta} from '../link-meta/link-meta'
+import {resolveGif, resolveLink} from './resolve'
 import {uploadBlob} from './upload-blob'
 
 export {uploadBlob}
@@ -40,11 +44,6 @@ interface PostOpts {
   composerState: ComposerState // TODO: Not used yet.
   rawText: string
   replyTo?: string
-  quote?: {
-    uri: string
-    cid: string
-  }
-  extLink?: ExternalEmbedDraft
   labels?: string[]
   threadgate: ThreadgateAllowUISetting[]
   postgate: AppBskyFeedPostgate.Record
@@ -63,7 +62,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
   rt = shortenLinks(rt)
   rt = stripInvalidMentions(rt)
 
-  const embed = await resolveEmbed(agent, opts)
+  const embed = await resolveEmbed(
+    agent,
+    opts.composerState,
+    opts.onStateChange,
+  )
 
   // add replyTo if post is a reply to another post
   if (opts.replyTo) {
@@ -175,7 +178,8 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
 
 async function resolveEmbed(
   agent: BskyAgent,
-  opts: PostOpts,
+  draft: ComposerState,
+  onStateChange: ((state: string) => void) | undefined,
 ): Promise<
   | AppBskyEmbedImages.Main
   | AppBskyEmbedVideo.Main
@@ -184,52 +188,60 @@ async function resolveEmbed(
   | AppBskyEmbedRecordWithMedia.Main
   | undefined
 > {
-  const media = await resolveMedia(agent, opts)
-  if (opts.quote) {
-    const quoteRecord = {
-      $type: 'app.bsky.embed.record',
-      record: {
-        uri: opts.quote.uri,
-        cid: opts.quote.cid,
-      },
-    }
-    if (media) {
+  if (draft.embed.quote) {
+    const [resolvedMedia, resolvedQuote] = await Promise.all([
+      resolveMedia(agent, draft.embed, onStateChange),
+      resolveRecord(agent, draft.embed.quote.uri),
+    ])
+    if (resolvedMedia) {
       return {
         $type: 'app.bsky.embed.recordWithMedia',
-        record: quoteRecord,
-        media,
+        record: {
+          $type: 'app.bsky.embed.record',
+          record: resolvedQuote,
+        },
+        media: resolvedMedia,
       }
-    } else {
-      return quoteRecord
+    }
+    return {
+      $type: 'app.bsky.embed.record',
+      record: resolvedQuote,
     }
   }
-  if (media) {
-    return media
+  const resolvedMedia = await resolveMedia(agent, draft.embed, onStateChange)
+  if (resolvedMedia) {
+    return resolvedMedia
   }
-  if (opts.extLink?.embed) {
-    return opts.extLink.embed
+  if (draft.embed.link) {
+    const resolvedLink = await resolveLink(agent, draft.embed.link.uri)
+    if (resolvedLink.type === 'record') {
+      return {
+        $type: 'app.bsky.embed.record',
+        record: resolvedLink.record,
+      }
+    }
   }
   return undefined
 }
 
 async function resolveMedia(
   agent: BskyAgent,
-  opts: PostOpts,
+  embedDraft: EmbedDraft,
+  onStateChange: ((state: string) => void) | undefined,
 ): Promise<
   | AppBskyEmbedExternal.Main
   | AppBskyEmbedImages.Main
   | AppBskyEmbedVideo.Main
   | undefined
 > {
-  const state = opts.composerState
-  const media = state.embed.media
-  if (media?.type === 'images') {
+  if (embedDraft.media?.type === 'images') {
+    const imagesDraft = embedDraft.media.images
     logger.debug(`Uploading images`, {
-      count: media.images.length,
+      count: imagesDraft.length,
     })
-    opts.onStateChange?.(`Uploading images...`)
+    onStateChange?.(`Uploading images...`)
     const images: AppBskyEmbedImages.Image[] = await Promise.all(
-      media.images.map(async (image, i) => {
+      imagesDraft.map(async (image, i) => {
         logger.debug(`Compressing image #${i}`)
         const {path, width, height, mime} = await compressImage(image)
         logger.debug(`Uploading image #${i}`)
@@ -246,10 +258,13 @@ async function resolveMedia(
       images,
     }
   }
-  if (media?.type === 'video' && media.video.status === 'done') {
-    const video = media.video
+  if (
+    embedDraft.media?.type === 'video' &&
+    embedDraft.media.video.status === 'done'
+  ) {
+    const videoDraft = embedDraft.media.video
     const captions = await Promise.all(
-      video.captions
+      videoDraft.captions
         .filter(caption => caption.lang !== '')
         .map(async caption => {
           const {data} = await agent.uploadBlob(caption.file, {
@@ -260,36 +275,66 @@ async function resolveMedia(
     )
     return {
       $type: 'app.bsky.embed.video',
-      video: video.pendingPublish.blobRef,
-      alt: video.altText || undefined,
+      video: videoDraft.pendingPublish.blobRef,
+      alt: videoDraft.altText || undefined,
       captions: captions.length === 0 ? undefined : captions,
       aspectRatio: {
-        width: video.asset.width,
-        height: video.asset.height,
+        width: videoDraft.asset.width,
+        height: videoDraft.asset.height,
       },
     }
   }
-  if (opts.extLink) {
-    // TODO: Read this from composer state as well.
-    if (opts.extLink.embed) {
-      return undefined
-    }
-    let thumb
-    if (opts.extLink.localThumb) {
-      opts.onStateChange?.('Uploading link thumbnail...')
-      const {path, mime} = opts.extLink.localThumb.source
-      const res = await uploadBlob(agent, path, mime)
-      thumb = res.data.blob
+  if (embedDraft.media?.type === 'gif') {
+    const gifDraft = embedDraft.media
+    const resolvedGif = await resolveGif(agent, gifDraft.gif)
+    let blob: BlobRef | undefined
+    if (resolvedGif.thumb) {
+      onStateChange?.('Uploading link thumbnail...')
+      const {path, mime} = resolvedGif.thumb.source
+      const response = await uploadBlob(agent, path, mime)
+      blob = response.data.blob
     }
     return {
       $type: 'app.bsky.embed.external',
       external: {
-        uri: opts.extLink.uri,
-        title: opts.extLink.meta?.title || '',
-        description: opts.extLink.meta?.description || '',
-        thumb,
+        uri: resolvedGif.uri,
+        title: resolvedGif.title,
+        description: createGIFDescription(resolvedGif.title, gifDraft.alt),
+        thumb: blob,
       },
     }
   }
+  if (embedDraft.link) {
+    const resolvedLink = await resolveLink(agent, embedDraft.link.uri)
+    if (resolvedLink.type === 'external') {
+      let blob: BlobRef | undefined
+      if (resolvedLink.thumb) {
+        onStateChange?.('Uploading link thumbnail...')
+        const {path, mime} = resolvedLink.thumb.source
+        const response = await uploadBlob(agent, path, mime)
+        blob = response.data.blob
+      }
+      return {
+        $type: 'app.bsky.embed.external',
+        external: {
+          uri: resolvedLink.uri,
+          title: resolvedLink.title,
+          description: resolvedLink.description,
+          thumb: blob,
+        },
+      }
+    }
+  }
   return undefined
 }
+
+async function resolveRecord(
+  agent: BskyAgent,
+  uri: string,
+): Promise<ComAtprotoRepoStrongRef.Main> {
+  const resolvedLink = await resolveLink(agent, uri)
+  if (resolvedLink.type !== 'record') {
+    throw Error('Expected uri to resolve to a record')
+  }
+  return resolvedLink.record
+}
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
new file mode 100644
index 000000000..a97a3f31c
--- /dev/null
+++ b/src/lib/api/resolve.ts
@@ -0,0 +1,161 @@
+import {ComAtprotoRepoStrongRef} from '@atproto/api'
+import {AtUri} from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
+
+import {POST_IMG_MAX} from '#/lib/constants'
+import {
+  getFeedAsEmbed,
+  getListAsEmbed,
+  getPostAsQuote,
+  getStarterPackAsEmbed,
+} from '#/lib/link-meta/bsky'
+import {getLinkMeta} from '#/lib/link-meta/link-meta'
+import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
+import {downloadAndResize} from '#/lib/media/manip'
+import {
+  isBskyCustomFeedUrl,
+  isBskyListUrl,
+  isBskyPostUrl,
+  isBskyStarterPackUrl,
+  isBskyStartUrl,
+  isShortLink,
+} from '#/lib/strings/url-helpers'
+import {ComposerImage} from '#/state/gallery'
+import {createComposerImage} from '#/state/gallery'
+import {Gif} from '#/state/queries/tenor'
+import {createGIFDescription} from '../gif-alt-text'
+
+type ResolvedExternalLink = {
+  type: 'external'
+  uri: string
+  title: string
+  description: string
+  thumb: ComposerImage | undefined
+}
+
+type ResolvedRecord = {
+  type: 'record'
+  record: ComAtprotoRepoStrongRef.Main
+}
+
+type ResolvedLink = ResolvedExternalLink | ResolvedRecord
+
+export async function resolveLink(
+  agent: BskyAgent,
+  uri: string,
+): Promise<ResolvedLink> {
+  if (isShortLink(uri)) {
+    uri = await resolveShortLink(uri)
+  }
+  if (isBskyPostUrl(uri)) {
+    // TODO: Remove this abstraction.
+    // TODO: Nice error messages (e.g. EmbeddingDisabledError).
+    const result = await getPostAsQuote(getPost, uri)
+    return {
+      type: 'record',
+      record: {
+        cid: result.cid,
+        uri: result.uri,
+      },
+    }
+  }
+  if (isBskyCustomFeedUrl(uri)) {
+    // TODO: Remove this abstraction.
+    const result = await getFeedAsEmbed(agent, fetchDid, uri)
+    return {
+      type: 'record',
+      record: result.embed!.record, // TODO: Fix types.
+    }
+  }
+  if (isBskyListUrl(uri)) {
+    // TODO: Remove this abstraction.
+    const result = await getListAsEmbed(agent, fetchDid, uri)
+    return {
+      type: 'record',
+      record: result.embed!.record, // TODO: Fix types.
+    }
+  }
+  if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
+    // TODO: Remove this abstraction.
+    const result = await getStarterPackAsEmbed(agent, fetchDid, uri)
+    return {
+      type: 'record',
+      record: result.embed!.record, // TODO: Fix types.
+    }
+  }
+  return resolveExternal(agent, uri)
+
+  // Forked from useGetPost. TODO: move into RQ.
+  async function getPost({uri}: {uri: string}) {
+    const urip = new AtUri(uri)
+    if (!urip.host.startsWith('did:')) {
+      const res = await agent.resolveHandle({
+        handle: urip.host,
+      })
+      urip.host = res.data.did
+    }
+    const res = await agent.getPosts({
+      uris: [urip.toString()],
+    })
+    if (res.success && res.data.posts[0]) {
+      return res.data.posts[0]
+    }
+    throw new Error('getPost: post not found')
+  }
+
+  // Forked from useFetchDid. TODO: move into RQ.
+  async function fetchDid(handleOrDid: string) {
+    let identifier = handleOrDid
+    if (!identifier.startsWith('did:')) {
+      const res = await agent.resolveHandle({handle: identifier})
+      identifier = res.data.did
+    }
+    return identifier
+  }
+}
+
+export async function resolveGif(
+  agent: BskyAgent,
+  gif: Gif,
+): Promise<ResolvedExternalLink> {
+  const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
+  return {
+    type: 'external',
+    uri,
+    title: gif.content_description,
+    description: createGIFDescription(gif.content_description),
+    thumb: await imageToThumb(gif.media_formats.preview.url),
+  }
+}
+
+async function resolveExternal(
+  agent: BskyAgent,
+  uri: string,
+): Promise<ResolvedExternalLink> {
+  const result = await getLinkMeta(agent, uri)
+  return {
+    type: 'external',
+    uri: result.url,
+    title: result.title ?? '',
+    description: result.description ?? '',
+    thumb: result.image ? await imageToThumb(result.image) : undefined,
+  }
+}
+
+async function imageToThumb(
+  imageUri: string,
+): Promise<ComposerImage | undefined> {
+  try {
+    const img = await downloadAndResize({
+      uri: imageUri,
+      width: POST_IMG_MAX.width,
+      height: POST_IMG_MAX.height,
+      mode: 'contain',
+      maxSize: POST_IMG_MAX.size,
+      timeout: 15e3,
+    })
+    if (img) {
+      return await createComposerImage(img)
+    }
+  } catch {}
+}