diff options
Diffstat (limited to 'src/lib/api')
-rw-r--r-- | src/lib/api/index.ts | 155 | ||||
-rw-r--r-- | src/lib/api/resolve.ts | 161 |
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 {} +} |