From 3bd0cf296279f98930c8905b10bc3e3d76bf1502 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 4 Oct 2024 23:13:16 +0900 Subject: [PATCH 01/28] Scaffold async record/media resolution when posting Co-authored-by: Mary --- src/lib/api/index.ts | 136 +++++++++++++++--------- src/lib/api/resolve.ts | 31 ++++++ src/view/com/composer/state/composer.ts | 2 +- 3 files changed, 118 insertions(+), 51 deletions(-) create mode 100644 src/lib/api/resolve.ts diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index c7608ae557..6919e82e2e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -6,6 +6,7 @@ import { AppBskyEmbedVideo, AppBskyFeedPostgate, AtUri, + BlobRef, BskyAgent, ComAtprotoLabelDefs, RichText, @@ -22,8 +23,9 @@ 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 {LinkMeta} from '../link-meta/link-meta' +import {resolveGif,resolveLink, resolveRecord} from './resolve' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -63,7 +65,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 +181,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 +191,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(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(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 +261,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 +278,54 @@ 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 resolvedGif = await resolveGif(embedDraft.media.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: resolvedGif.description, + thumb: blob, }, } } + if (embedDraft.link) { + const resolvedLink = await resolveLink(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 } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts new file mode 100644 index 0000000000..f52f2d8195 --- /dev/null +++ b/src/lib/api/resolve.ts @@ -0,0 +1,31 @@ +import {ComposerImage} from '#/state/gallery' +import {ComAtprotoRepoStrongRef} from '@atproto/api' + +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(uri: string): Promise { + // TODO +} + +export async function resolveRecord( + uri: string, +): Promise { + // TODO +} + +export async function resolveGif(gif: Gif): Promise { + // TODO +} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 769a0521d4..0f6155dd05 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -29,7 +29,7 @@ type Link = { // This structure doesn't exactly correspond to the data model. // Instead, it maps to how the UI is organized, and how we present a post. -type EmbedDraft = { +export type EmbedDraft = { // We'll always submit quote and actual media (images, video, gifs) chosen by the user. quote: Link | undefined media: ImagesMedia | VideoMedia | GifMedia | undefined From 73bc85c74670557018efd9b9cfb0708edab38a88 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 00:05:01 +0900 Subject: [PATCH 02/28] Implement resolving for records and links Co-authored-by: Mary --- src/lib/api/index.ts | 14 ++-- src/lib/api/resolve.ts | 157 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 6919e82e2e..9f58e55733 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -24,8 +24,9 @@ import { writeThreadgateRecord, } from '#/state/queries/threadgate' 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, resolveRecord} from './resolve' +import {resolveGif, resolveLink, resolveRecord} from './resolve' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -194,7 +195,7 @@ async function resolveEmbed( if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ resolveMedia(agent, draft.embed, onStateChange), - resolveRecord(draft.embed.quote.uri), + resolveRecord(agent, draft.embed.quote.uri), ]) if (resolvedMedia) { return { @@ -216,7 +217,7 @@ async function resolveEmbed( return resolvedMedia } if (draft.embed.link) { - const resolvedLink = await resolveLink(draft.embed.link.uri) + const resolvedLink = await resolveLink(agent, draft.embed.link.uri) if (resolvedLink.type === 'record') { return { $type: 'app.bsky.embed.record', @@ -288,7 +289,8 @@ async function resolveMedia( } } if (embedDraft.media?.type === 'gif') { - const resolvedGif = await resolveGif(embedDraft.media.gif) + const gifDraft = embedDraft.media + const resolvedGif = await resolveGif(agent, gifDraft.gif) let blob: BlobRef | undefined if (resolvedGif.thumb) { onStateChange?.('Uploading link thumbnail...') @@ -301,13 +303,13 @@ async function resolveMedia( external: { uri: resolvedGif.uri, title: resolvedGif.title, - description: resolvedGif.description, + description: createGIFDescription(resolvedGif.title, gifDraft.alt), thumb: blob, }, } } if (embedDraft.link) { - const resolvedLink = await resolveLink(embedDraft.link.uri) + const resolvedLink = await resolveLink(agent, embedDraft.link.uri) if (resolvedLink.type === 'external') { let blob: BlobRef | undefined if (resolvedLink.thumb) { diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index f52f2d8195..9cc7b2fd2c 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,5 +1,29 @@ -import {ComposerImage} from '#/state/gallery' 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' @@ -16,16 +40,133 @@ type ResolvedRecord = { type ResolvedLink = ResolvedExternalLink | ResolvedRecord -export async function resolveLink(uri: string): Promise { - // TODO -} - export async function resolveRecord( + agent: BskyAgent, uri: string, ): Promise { - // TODO + const resolvedLink = await resolveLink(agent, uri) + if (resolvedLink.type !== 'record') { + throw Error('Expected uri to resolve to a record') + } + return resolvedLink.record +} + +export async function resolveLink( + agent: BskyAgent, + uri: string, +): Promise { + 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 { + 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 { + 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, + } } -export async function resolveGif(gif: Gif): Promise { - // TODO +async function imageToThumb( + imageUri: string, +): Promise { + 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 {} } From 9aca51cf0bdfb15f2bfa52cfba2465d0cfbefa68 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 00:34:27 +0900 Subject: [PATCH 03/28] Remove now-unused publish options --- src/lib/api/index.ts | 5 ----- src/view/com/composer/Composer.tsx | 2 -- 2 files changed, 7 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9f58e55733..2673b5c0a1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -43,11 +43,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 diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 94c02767ea..80f01d4531 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -425,8 +425,6 @@ export const ComposePost = ({ composerState, // TODO: move more state here. rawText: richtext.text, replyTo: replyTo?.uri, - quote, - extLink, labels, threadgate: threadgateAllowUISettings, postgate, From fb9c9e4d6cff28a83075302840570ff326adb1fc Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 01:06:54 +0900 Subject: [PATCH 04/28] Move function --- src/lib/api/index.ts | 14 +++++++++++++- src/lib/api/resolve.ts | 11 ----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 2673b5c0a1..e6e8eea3da 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -9,6 +9,7 @@ import { BlobRef, BskyAgent, ComAtprotoLabelDefs, + ComAtprotoRepoStrongRef, RichText, } from '@atproto/api' @@ -26,7 +27,7 @@ import { 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, resolveRecord} from './resolve' +import {resolveGif, resolveLink} from './resolve' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -326,3 +327,14 @@ async function resolveMedia( } return undefined } + +async function resolveRecord( + agent: BskyAgent, + uri: string, +): Promise { + 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 index 9cc7b2fd2c..a97a3f31c4 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -40,17 +40,6 @@ type ResolvedRecord = { type ResolvedLink = ResolvedExternalLink | ResolvedRecord -export async function resolveRecord( - agent: BskyAgent, - uri: string, -): Promise { - const resolvedLink = await resolveLink(agent, uri) - if (resolvedLink.type !== 'record') { - throw Error('Expected uri to resolve to a record') - } - return resolvedLink.record -} - export async function resolveLink( agent: BskyAgent, uri: string, From bf00e098157e46ca9edb2f520653335ce2de374b Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 04:10:28 +0900 Subject: [PATCH 05/28] Fix quotes --- src/view/com/composer/state/composer.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 0f6155dd05..62d1bff493 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -1,6 +1,10 @@ import {ImagePickerAsset} from 'expo-image-picker' -import {isBskyPostUrl} from '#/lib/strings/url-helpers' +import { + isBskyPostUrl, + postUriToRelativePath, + toBskyAppUrl, +} from '#/lib/strings/url-helpers' import {ComposerImage, createInitialImages} from '#/state/gallery' import {Gif} from '#/state/queries/tenor' import {ComposerOpts} from '#/state/shell/composer' @@ -304,9 +308,13 @@ export function createComposerState({ } let quote: Link | undefined if (initQuoteUri) { - quote = { - type: 'link', - uri: initQuoteUri, + // TODO: Consider passing the app url directly. + const path = postUriToRelativePath(initQuoteUri) + if (path) { + quote = { + type: 'link', + uri: toBskyAppUrl(path), + } } } // TODO: Other initial content. From 1cab152c70e0e967ee5374e7f003cebc55df48ac Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 01:06:54 +0900 Subject: [PATCH 06/28] Cache link/embed resolution in RQ --- src/lib/api/index.ts | 47 +++++++++++++++++----- src/lib/api/resolve.ts | 2 - src/state/queries/resolve-link.ts | 63 ++++++++++++++++++++++++++++++ src/view/com/composer/Composer.tsx | 5 ++- 4 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 src/state/queries/resolve-link.ts diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index e6e8eea3da..6edb111e6b 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -12,12 +12,17 @@ import { ComAtprotoRepoStrongRef, RichText, } from '@atproto/api' +import {QueryClient} from '@tanstack/react-query' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {logger} from '#/logger' import {ComposerImage, compressImage} from '#/state/gallery' import {writePostgateRecord} from '#/state/queries/postgate' +import { + fetchResolveGifQuery, + fetchResolveLinkQuery, +} from '#/state/queries/resolve-link' import { createThreadgateRecord, ThreadgateAllowUISetting, @@ -27,7 +32,6 @@ import { 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} @@ -51,7 +55,11 @@ interface PostOpts { langs?: string[] } -export async function post(agent: BskyAgent, opts: PostOpts) { +export async function post( + agent: BskyAgent, + queryClient: QueryClient, + opts: PostOpts, +) { let reply let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) @@ -64,6 +72,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { const embed = await resolveEmbed( agent, + queryClient, opts.composerState, opts.onStateChange, ) @@ -178,6 +187,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { async function resolveEmbed( agent: BskyAgent, + queryClient: QueryClient, draft: ComposerState, onStateChange: ((state: string) => void) | undefined, ): Promise< @@ -190,8 +200,8 @@ async function resolveEmbed( > { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ - resolveMedia(agent, draft.embed, onStateChange), - resolveRecord(agent, draft.embed.quote.uri), + resolveMedia(agent, queryClient, draft.embed, onStateChange), + resolveRecord(agent, queryClient, draft.embed.quote.uri), ]) if (resolvedMedia) { return { @@ -208,12 +218,21 @@ async function resolveEmbed( record: resolvedQuote, } } - const resolvedMedia = await resolveMedia(agent, draft.embed, onStateChange) + const resolvedMedia = await resolveMedia( + agent, + queryClient, + draft.embed, + onStateChange, + ) if (resolvedMedia) { return resolvedMedia } if (draft.embed.link) { - const resolvedLink = await resolveLink(agent, draft.embed.link.uri) + const resolvedLink = await fetchResolveLinkQuery( + queryClient, + agent, + draft.embed.link.uri, + ) if (resolvedLink.type === 'record') { return { $type: 'app.bsky.embed.record', @@ -226,6 +245,7 @@ async function resolveEmbed( async function resolveMedia( agent: BskyAgent, + queryClient: QueryClient, embedDraft: EmbedDraft, onStateChange: ((state: string) => void) | undefined, ): Promise< @@ -286,7 +306,11 @@ async function resolveMedia( } if (embedDraft.media?.type === 'gif') { const gifDraft = embedDraft.media - const resolvedGif = await resolveGif(agent, gifDraft.gif) + const resolvedGif = await fetchResolveGifQuery( + queryClient, + agent, + gifDraft.gif, + ) let blob: BlobRef | undefined if (resolvedGif.thumb) { onStateChange?.('Uploading link thumbnail...') @@ -305,7 +329,11 @@ async function resolveMedia( } } if (embedDraft.link) { - const resolvedLink = await resolveLink(agent, embedDraft.link.uri) + const resolvedLink = await fetchResolveLinkQuery( + queryClient, + agent, + embedDraft.link.uri, + ) if (resolvedLink.type === 'external') { let blob: BlobRef | undefined if (resolvedLink.thumb) { @@ -330,9 +358,10 @@ async function resolveMedia( async function resolveRecord( agent: BskyAgent, + queryClient: QueryClient, uri: string, ): Promise { - const resolvedLink = await resolveLink(agent, uri) + const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) if (resolvedLink.type !== 'record') { throw Error('Expected uri to resolve to a record') } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index a97a3f31c4..2d55a27c86 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -85,7 +85,6 @@ export async function resolveLink( } 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:')) { @@ -103,7 +102,6 @@ export async function resolveLink( 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:')) { diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts new file mode 100644 index 0000000000..a58f35f677 --- /dev/null +++ b/src/state/queries/resolve-link.ts @@ -0,0 +1,63 @@ +import {QueryClient, useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries/index' +import {useAgent} from '../session' + +const RQKEY_LINK_ROOT = 'resolve-link' +export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url] + +const RQKEY_GIF_ROOT = 'resolve-gif' +export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url] + +import {BskyAgent} from '@atproto/api' + +import {resolveGif, resolveLink} from '#/lib/api/resolve' +import {Gif} from './tenor' + +export function useResolveLinkQuery(url: string) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY_LINK(url), + queryFn: async () => { + return await resolveLink(agent, url) + }, + }) +} +export function fetchResolveLinkQuery( + queryClient: QueryClient, + agent: BskyAgent, + url: string, +) { + return queryClient.fetchQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY_LINK(url), + queryFn: async () => { + return await resolveLink(agent, url) + }, + }) +} + +export function useResolveGifQuery(gif: Gif) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY_GIF(gif.url), + queryFn: async () => { + return await resolveGif(agent, gif) + }, + }) +} +export function fetchResolveGifQuery( + queryClient: QueryClient, + agent: BskyAgent, + gif: Gif, +) { + return queryClient.fetchQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY_GIF(gif.url), + queryFn: async () => { + return await resolveGif(agent, gif) + }, + }) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 80f01d4531..dfdc18195b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -46,6 +46,7 @@ import {RichText} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {until} from '#/lib/async/until' @@ -147,6 +148,7 @@ export const ComposePost = ({ }) => { const {currentAccount} = useSession() const agent = useAgent() + const queryClient = useQueryClient() const currentDid = currentAccount!.did const {data: currentProfile} = useProfileQuery({did: currentDid}) const {isModalActive} = useModals() @@ -421,7 +423,7 @@ export const ComposePost = ({ let postUri try { postUri = ( - await apilib.post(agent, { + await apilib.post(agent, queryClient, { composerState, // TODO: move more state here. rawText: richtext.text, replyTo: replyTo?.uri, @@ -527,6 +529,7 @@ export const ComposePost = ({ threadgateAllowUISettings, videoState.asset, videoState.status, + queryClient, ], ) From 98297d397335f28182aec45e783f493a9517181d Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 22:54:48 +0900 Subject: [PATCH 07/28] Copy-paste ExternalEmbed into a condition forked by extGif --- src/view/com/composer/Composer.tsx | 64 +++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dfdc18195b..dad4ebdb42 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -764,25 +764,51 @@ export const ComposePost = ({ /> {images.length === 0 && extLink && ( - { - if (extGif) { - dispatch({type: 'embed_remove_gif'}) - } else { - dispatch({type: 'embed_remove_link'}) - } - setExtLink(undefined) - setExtGif(undefined) - }} - /> - + {extGif ? ( + <> + { + if (extGif) { + dispatch({type: 'embed_remove_gif'}) + } else { + dispatch({type: 'embed_remove_link'}) + } + setExtLink(undefined) + setExtGif(undefined) + }} + /> + + + ) : ( + <> + { + if (extGif) { + dispatch({type: 'embed_remove_gif'}) + } else { + dispatch({type: 'embed_remove_link'}) + } + setExtLink(undefined) + setExtGif(undefined) + }} + /> + + + )} )} From 73b59e7442e8728e5a71d99654ce6012aba288d4 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 22:55:59 +0900 Subject: [PATCH 08/28] Prune dead branches --- src/view/com/composer/Composer.tsx | 35 +++++++--------------------- src/view/com/composer/GifAltText.tsx | 2 +- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dad4ebdb42..4b5248f188 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -770,11 +770,7 @@ export const ComposePost = ({ link={extLink} gif={extGif} onRemove={() => { - if (extGif) { - dispatch({type: 'embed_remove_gif'}) - } else { - dispatch({type: 'embed_remove_link'}) - } + dispatch({type: 'embed_remove_gif'}) setExtLink(undefined) setExtGif(undefined) }} @@ -787,27 +783,14 @@ export const ComposePost = ({ /> ) : ( - <> - { - if (extGif) { - dispatch({type: 'embed_remove_gif'}) - } else { - dispatch({type: 'embed_remove_link'}) - } - setExtLink(undefined) - setExtGif(undefined) - }} - /> - - + { + dispatch({type: 'embed_remove_link'}) + setExtLink(undefined) + setExtGif(undefined) + }} + /> )} )} diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index 90d20d94f7..c9cb6ed978 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -34,7 +34,7 @@ export function GifAltText({ Portal, }: { link: ExternalEmbedDraft - gif?: Gif + gif: Gif onSubmit: (alt: string) => void Portal: PortalComponent }) { From e5e28277b700decc45fbd62a7218f37a52807a87 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 22:56:52 +0900 Subject: [PATCH 09/28] Copypaste ExternalEmbed into ExternalEmbedGif/Link --- src/view/com/composer/Composer.tsx | 9 ++-- src/view/com/composer/ExternalEmbed.tsx | 72 +++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 4b5248f188..38e6516222 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -88,7 +88,10 @@ import {useComposerControls} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' -import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed' +import { + ExternalEmbedGif, + ExternalEmbedLink, +} from '#/view/com/composer/ExternalEmbed' import {GifAltText} from '#/view/com/composer/GifAltText' import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' @@ -766,7 +769,7 @@ export const ComposePost = ({ {extGif ? ( <> - { @@ -783,7 +786,7 @@ export const ComposePost = ({ /> ) : ( - { dispatch({type: 'embed_remove_link'}) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index f61d410dfc..52d1fdf8ac 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -1,15 +1,77 @@ import React from 'react' import {StyleProp, View, ViewStyle} from 'react-native' -import {ExternalEmbedDraft} from 'lib/api/index' -import {Gif} from 'state/queries/tenor' -import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' -import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed' +import {ExternalEmbedDraft} from '#/lib/api/index' +import {Gif} from '#/state/queries/tenor' +import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' +import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -export const ExternalEmbed = ({ +export const ExternalEmbedGif = ({ + link, + onRemove, + gif, +}: { + link?: ExternalEmbedDraft + onRemove: () => void + gif?: Gif +}) => { + const t = useTheme() + + const linkInfo = React.useMemo( + () => + link && { + title: link.meta?.title ?? link.uri, + uri: link.uri, + description: link.meta?.description ?? '', + thumb: link.localThumb?.source.path, + }, + [link], + ) + + if (!link) return null + + const loadingStyle: ViewStyle | undefined = gif + ? { + aspectRatio: + gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1], + width: '100%', + } + : undefined + + return ( + + {link.isLoading ? ( + + + + ) : link.meta?.error ? ( + + + {link.uri} + + + {link.meta?.error} + + + ) : linkInfo ? ( + + + + ) : null} + + + ) +} + +export const ExternalEmbedLink = ({ link, onRemove, gif, From 9c054b799656edbf429f1f97f8fc4cc1aa5c8c3d Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 23:00:45 +0900 Subject: [PATCH 10/28] Prune dead branches --- src/view/com/composer/ExternalEmbed.tsx | 51 ++++++------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 52d1fdf8ac..3ee3679cac 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -14,9 +14,9 @@ export const ExternalEmbedGif = ({ onRemove, gif, }: { - link?: ExternalEmbedDraft + link: ExternalEmbedDraft onRemove: () => void - gif?: Gif + gif: Gif }) => { const t = useTheme() @@ -31,23 +31,13 @@ export const ExternalEmbedGif = ({ [link], ) - if (!link) return null - - const loadingStyle: ViewStyle | undefined = gif - ? { - aspectRatio: - gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1], - width: '100%', - } - : undefined + const loadingStyle: ViewStyle = { + aspectRatio: gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1], + width: '100%', + } return ( - + {link.isLoading ? ( @@ -62,7 +52,7 @@ export const ExternalEmbedGif = ({ ) : linkInfo ? ( - + ) : null} @@ -74,11 +64,9 @@ export const ExternalEmbedGif = ({ export const ExternalEmbedLink = ({ link, onRemove, - gif, }: { - link?: ExternalEmbedDraft + link: ExternalEmbedDraft onRemove: () => void - gif?: Gif }) => { const t = useTheme() @@ -93,25 +81,10 @@ export const ExternalEmbedLink = ({ [link], ) - if (!link) return null - - const loadingStyle: ViewStyle | undefined = gif - ? { - aspectRatio: - gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1], - width: '100%', - } - : undefined - return ( - + {link.isLoading ? ( - + ) : link.meta?.error ? ( @@ -124,7 +97,7 @@ export const ExternalEmbedLink = ({ ) : linkInfo ? ( - + ) : null} From f17d0d9f5553b9c3eb6557b4b3bb8e6e87c84d7b Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 23:04:01 +0900 Subject: [PATCH 11/28] Populate ExternalEmbedGif from RQ --- src/view/com/composer/Composer.tsx | 1 - src/view/com/composer/ExternalEmbed.tsx | 42 ++++++++++++------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 38e6516222..02a17cc9e5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -770,7 +770,6 @@ export const ComposePost = ({ {extGif ? ( <> { dispatch({type: 'embed_remove_gif'}) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 3ee3679cac..e524d1cd21 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -2,6 +2,8 @@ import React from 'react' import {StyleProp, View, ViewStyle} from 'react-native' import {ExternalEmbedDraft} from '#/lib/api/index' +import {cleanError} from '#/lib/strings/errors' +import {useResolveGifQuery} from '#/state/queries/resolve-link' import {Gif} from '#/state/queries/tenor' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' @@ -10,25 +12,23 @@ import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' export const ExternalEmbedGif = ({ - link, onRemove, gif, }: { - link: ExternalEmbedDraft onRemove: () => void gif: Gif }) => { const t = useTheme() - + const {data, error} = useResolveGifQuery(gif) const linkInfo = React.useMemo( () => - link && { - title: link.meta?.title ?? link.uri, - uri: link.uri, - description: link.meta?.description ?? '', - thumb: link.localThumb?.source.path, + data && { + title: data.title ?? data.uri, + uri: data.uri, + description: data.description ?? '', + thumb: data.thumb?.source.path, }, - [link], + [data], ) const loadingStyle: ViewStyle = { @@ -38,24 +38,24 @@ export const ExternalEmbedGif = ({ return ( - {link.isLoading ? ( - - - - ) : link.meta?.error ? ( + {linkInfo ? ( + + + + ) : error ? ( - {link.uri} + {gif.url} - {link.meta?.error} + {cleanError(error)} - ) : linkInfo ? ( - - - - ) : null} + ) : ( + + + + )} ) From 6480db80a3c040b9285ab9eb7a74c35c4d4a2f81 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 23:05:03 +0900 Subject: [PATCH 12/28] Populate ExternalEmbedLink from RQ --- src/view/com/composer/ExternalEmbed.tsx | 46 +++++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index e524d1cd21..e17fda6e34 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -3,7 +3,10 @@ import {StyleProp, View, ViewStyle} from 'react-native' import {ExternalEmbedDraft} from '#/lib/api/index' import {cleanError} from '#/lib/strings/errors' -import {useResolveGifQuery} from '#/state/queries/resolve-link' +import { + useResolveGifQuery, + useResolveLinkQuery, +} from '#/state/queries/resolve-link' import {Gif} from '#/state/queries/tenor' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' @@ -69,38 +72,43 @@ export const ExternalEmbedLink = ({ onRemove: () => void }) => { const t = useTheme() - + const {data, error} = useResolveLinkQuery(link.uri) + const externalData = data?.type === 'external' ? data : null const linkInfo = React.useMemo( () => - link && { - title: link.meta?.title ?? link.uri, - uri: link.uri, - description: link.meta?.description ?? '', - thumb: link.localThumb?.source.path, + externalData && { + title: externalData.title ?? externalData.uri, + uri: externalData.uri, + description: externalData.description ?? '', + thumb: externalData.thumb?.source.path, }, - [link], + [externalData], ) + if (data?.type === 'record') { + return null // TODO: Display record embeds. + } + return ( - {link.isLoading ? ( - - - - ) : link.meta?.error ? ( + {linkInfo ? ( + + + + ) : error ? ( {link.uri} - {link.meta?.error} + {cleanError(error)} - ) : linkInfo ? ( - - - - ) : null} + ) : ( + + + + )} ) From fad5ee76ba24b5580ccdb02c41fae7a527d2bc44 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 02:39:05 +0900 Subject: [PATCH 13/28] Handle record embeds like before They don't get fancy previews but at least we show something. --- src/lib/api/resolve.ts | 22 ++++++++++++------- src/view/com/composer/Composer.tsx | 2 +- src/view/com/composer/ExternalEmbed.tsx | 29 ++++++++++--------------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 2d55a27c86..838b58ae87 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -36,6 +36,9 @@ type ResolvedExternalLink = { type ResolvedRecord = { type: 'record' record: ComAtprotoRepoStrongRef.Main + // We should replace this with a hydrated record (e.g. feed, list, starter pack) + // and change the composer preview to use the actual post embed components: + title_deprecated: string | undefined } type ResolvedLink = ResolvedExternalLink | ResolvedRecord @@ -48,8 +51,6 @@ export async function resolveLink( 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', @@ -57,30 +58,35 @@ export async function resolveLink( cid: result.cid, uri: result.uri, }, + // TODO: Include hydrated content instead. + title_deprecated: undefined, } } if (isBskyCustomFeedUrl(uri)) { - // TODO: Remove this abstraction. const result = await getFeedAsEmbed(agent, fetchDid, uri) return { type: 'record', - record: result.embed!.record, // TODO: Fix types. + record: result.embed!.record, + // TODO: Include hydrated content instead. + title_deprecated: result.meta!.title, } } if (isBskyListUrl(uri)) { - // TODO: Remove this abstraction. const result = await getListAsEmbed(agent, fetchDid, uri) return { type: 'record', - record: result.embed!.record, // TODO: Fix types. + record: result.embed!.record, + // TODO: Include hydrated content instead. + title_deprecated: result.meta!.title, } } 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. + record: result.embed!.record, + // TODO: Include hydrated content instead. + title_deprecated: result.meta!.title, } } return resolveExternal(agent, uri) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 02a17cc9e5..7dfc1e385f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -786,7 +786,7 @@ export const ComposePost = ({ ) : ( { dispatch({type: 'embed_remove_link'}) setExtLink(undefined) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index e17fda6e34..89889b69f7 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleProp, View, ViewStyle} from 'react-native' -import {ExternalEmbedDraft} from '#/lib/api/index' import {cleanError} from '#/lib/strings/errors' import { useResolveGifQuery, @@ -65,30 +64,26 @@ export const ExternalEmbedGif = ({ } export const ExternalEmbedLink = ({ - link, + uri, onRemove, }: { - link: ExternalEmbedDraft + uri: string onRemove: () => void }) => { const t = useTheme() - const {data, error} = useResolveLinkQuery(link.uri) - const externalData = data?.type === 'external' ? data : null + const {data, error} = useResolveLinkQuery(uri) const linkInfo = React.useMemo( () => - externalData && { - title: externalData.title ?? externalData.uri, - uri: externalData.uri, - description: externalData.description ?? '', - thumb: externalData.thumb?.source.path, + data && { + title: + (data.type === 'external' ? data.title : data.title_deprecated) ?? + uri, + uri, + description: data.type === 'external' ? data.description : '', + thumb: data.type === 'external' ? data.thumb?.source.path : undefined, }, - [externalData], + [data, uri], ) - - if (data?.type === 'record') { - return null // TODO: Display record embeds. - } - return ( {linkInfo ? ( @@ -98,7 +93,7 @@ export const ExternalEmbedLink = ({ ) : error ? ( - {link.uri} + {uri} {cleanError(error)} From 1f70c6768f8b90b849d25e511537f175774c79db Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 23:09:11 +0900 Subject: [PATCH 14/28] Derive extGif from composer state --- src/view/com/composer/Composer.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7dfc1e385f..80618cddb9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -252,7 +252,6 @@ export const ComposePost = ({ const [publishOnUpload, setPublishOnUpload] = useState(false) const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) - const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = useState( @@ -264,6 +263,10 @@ export const ComposePost = ({ if (composerState.embed.media?.type === 'images') { images = composerState.embed.media.images } + let extGif: Gif | undefined + if (composerState.embed.media?.type === 'gif') { + extGif = composerState.embed.media.gif + } const onClose = useCallback(() => { closeComposer() @@ -583,7 +586,6 @@ export const ComposePost = ({ description: createGIFDescription(gif.content_description), }, }) - setExtGif(gif) }, [setExtLink], ) @@ -774,7 +776,6 @@ export const ComposePost = ({ onRemove={() => { dispatch({type: 'embed_remove_gif'}) setExtLink(undefined) - setExtGif(undefined) }} /> { dispatch({type: 'embed_remove_link'}) setExtLink(undefined) - setExtGif(undefined) }} /> )} From de97ecdc395ed00fbeedcd9483aaa00ec70b273d Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 23:10:51 +0900 Subject: [PATCH 15/28] Use composer state for gif alt check --- src/view/com/composer/Composer.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 80618cddb9..71b80091ed 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -51,10 +51,7 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {until} from '#/lib/async/until' import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' -import { - createGIFDescription, - parseAltFromGIFDescription, -} from '#/lib/gif-alt-text' +import {createGIFDescription} from '#/lib/gif-alt-text' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {usePalette} from '#/lib/hooks/usePalette' @@ -264,8 +261,10 @@ export const ComposePost = ({ images = composerState.embed.media.images } let extGif: Gif | undefined + let extGifAlt: string | undefined if (composerState.embed.media?.type === 'gif') { extGif = composerState.embed.media.gif + extGifAlt = composerState.embed.media.alt } const onClose = useCallback(() => { @@ -379,14 +378,10 @@ export const ComposePost = ({ if (images.some(img => img.alt === '')) return true - if (extGif) { - if (!extLink?.meta?.description) return true + if (extGif && !extGifAlt) return true - const parsedAlt = parseAltFromGIFDescription(extLink.meta.description) - if (!parsedAlt.isPreferred) return true - } return false - }, [images, extLink, extGif, requireAltTextEnabled]) + }, [images, extGifAlt, extGif, requireAltTextEnabled]) const onPressPublish = React.useCallback( async (finishedUploading?: boolean) => { From 54a21510a692d2371aa4222f33b7abb33670111a Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 5 Oct 2024 20:04:53 +0900 Subject: [PATCH 16/28] Split GifEmbed props --- src/view/com/composer/GifAltText.tsx | 4 +++- .../util/post-embeds/ExternalLinkEmbed.tsx | 12 ++++++++++- src/view/com/util/post-embeds/GifEmbed.tsx | 21 ++++++++----------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index c9cb6ed978..fc75295de2 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -200,7 +200,9 @@ function AltTextInner({ + const parsedAlt = parseAltFromGIFDescription(link.description) + return ( + + ) } return ( diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index a1af6ab26b..fc66278c95 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -7,12 +7,10 @@ import { View, ViewStyle, } from 'react-native' -import {AppBskyEmbedExternal} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_20} from '#/lib/constants' -import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' import {EmbedPlayerParams} from '#/lib/strings/embed-player' import {isWeb} from '#/platform/detection' import {useAutoplayDisabled} from '#/state/preferences' @@ -77,12 +75,16 @@ function PlaybackControls({ export function GifEmbed({ params, - link, + thumb, + altText, + isPreferredAltText, hideAlt, style = {width: '100%'}, }: { params: EmbedPlayerParams - link: AppBskyEmbedExternal.ViewExternal + thumb: string | undefined + altText: string + isPreferredAltText: boolean hideAlt?: boolean style?: StyleProp }) { @@ -111,11 +113,6 @@ export function GifEmbed({ playerRef.current?.toggleAsync() }, []) - const parsedAlt = React.useMemo( - () => parseAltFromGIFDescription(link.description), - [link], - ) - return ( {!playerState.isPlaying && ( )} - {!hideAlt && parsedAlt.isPreferred && } + {!hideAlt && isPreferredAltText && } ) From 79947b75bb2facccc02539b29d67626865645b89 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sun, 6 Oct 2024 02:18:46 +0900 Subject: [PATCH 17/28] Populate GifAltText from RQ --- src/view/com/composer/Composer.tsx | 8 +-- src/view/com/composer/GifAltText.tsx | 91 ++++++++++++++++------------ 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 71b80091ed..dbe04d639d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -89,7 +89,7 @@ import { ExternalEmbedGif, ExternalEmbedLink, } from '#/view/com/composer/ExternalEmbed' -import {GifAltText} from '#/view/com/composer/GifAltText' +import {GifAltTextDialog} from '#/view/com/composer/GifAltText' import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' @@ -763,7 +763,7 @@ export const ComposePost = ({ Portal={Portal.Portal} /> {images.length === 0 && extLink && ( - + {extGif ? ( <> - diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index fc75295de2..01778c3817 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -1,10 +1,8 @@ import React, {useState} from 'react' import {TouchableOpacity, View} from 'react-native' -import {AppBskyEmbedExternal} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {ExternalEmbedDraft} from '#/lib/api' import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' import { @@ -12,6 +10,7 @@ import { parseEmbedPlayerFromUrl, } from '#/lib/strings/embed-player' import {isAndroid} from '#/platform/detection' +import {useResolveGifQuery} from '#/state/queries/resolve-link' import {Gif} from '#/state/queries/tenor' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, native, useTheme} from '#/alf' @@ -27,38 +26,54 @@ import {Text} from '#/components/Typography' import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' -export function GifAltText({ - link: linkProp, +export function GifAltTextDialog({ gif, + altText, onSubmit, Portal, }: { - link: ExternalEmbedDraft gif: Gif + altText: string onSubmit: (alt: string) => void Portal: PortalComponent +}) { + const {data} = useResolveGifQuery(gif) + const vendorAltText = parseAltFromGIFDescription(data?.description ?? '').alt + const params = data ? parseEmbedPlayerFromUrl(data.uri) : undefined + if (!data || !params) { + return null + } + return ( + + ) +} + +export function GifAltTextDialogLoaded({ + vendorAltText, + altText, + onSubmit, + params, + thumb, + Portal, +}: { + vendorAltText: string + altText: string + onSubmit: (alt: string) => void + params: EmbedPlayerParams + thumb: string | undefined + Portal: PortalComponent }) { const control = Dialog.useDialogControl() const {_} = useLingui() const t = useTheme() - - const {link, params} = React.useMemo(() => { - return { - link: { - title: linkProp.meta?.title ?? linkProp.uri, - uri: linkProp.uri, - description: linkProp.meta?.description ?? '', - thumb: linkProp.localThumb?.source.path, - }, - params: parseEmbedPlayerFromUrl(linkProp.uri), - } - }, [linkProp]) - - const parsedAlt = parseAltFromGIFDescription(link.description) - const [altText, setAltText] = useState(parsedAlt.alt) - - if (!gif || !params) return null - + const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText) return ( <> - {parsedAlt.isPreferred ? ( + {altText ? ( ) : ( @@ -97,17 +112,17 @@ export function GifAltText({ { - onSubmit(altText) + onSubmit(altTextDraft) }} Portal={Portal}> @@ -115,17 +130,19 @@ export function GifAltText({ } function AltTextInner({ + vendorAltText, altText, - setAltText, + onChange, control, - link, params, + thumb, }: { + vendorAltText: string altText: string - setAltText: (text: string) => void + onChange: (text: string) => void control: DialogControlProps - link: AppBskyEmbedExternal.ViewExternal params: EmbedPlayerParams + thumb: string | undefined }) { const t = useTheme() const {_, i18n} = useLingui() @@ -142,10 +159,8 @@ function AltTextInner({ { - setAltText(text) - }} + placeholder={vendorAltText} + onChangeText={onChange} defaultValue={altText} multiline numberOfLines={3} @@ -200,7 +215,7 @@ function AltTextInner({ Date: Sun, 6 Oct 2024 02:22:43 +0900 Subject: [PATCH 18/28] Delete extLink and useExternalLinkFetch --- src/view/com/composer/Composer.tsx | 88 ++------- src/view/com/composer/state/composer.ts | 2 +- .../com/composer/useExternalLinkFetch.e2e.ts | 47 ----- src/view/com/composer/useExternalLinkFetch.ts | 187 ------------------ 4 files changed, 21 insertions(+), 303 deletions(-) delete mode 100644 src/view/com/composer/useExternalLinkFetch.e2e.ts delete mode 100644 src/view/com/composer/useExternalLinkFetch.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dbe04d639d..2c0432d263 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -51,12 +51,10 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {until} from '#/lib/async/until' import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' -import {createGIFDescription} from '#/lib/gif-alt-text' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {LikelyType} from '#/lib/link-meta/link-meta' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {insertMentionAt} from '#/lib/strings/mention-manip' @@ -101,7 +99,6 @@ import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLa // due to linting false positives import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' -import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch' import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' @@ -118,7 +115,11 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' -import {composerReducer, createComposerState} from './state/composer' +import { + composerReducer, + createComposerState, + Link as ComposerLink, +} from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' const Portal = createPortalGroup() @@ -248,7 +249,6 @@ export const ComposePost = ({ const [publishOnUpload, setPublishOnUpload] = useState(false) - const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) const [labels, setLabels] = useState([]) const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = useState( @@ -266,6 +266,10 @@ export const ComposePost = ({ extGif = composerState.embed.media.gif extGifAlt = composerState.embed.media.alt } + let extLink: ComposerLink | undefined + if (composerState.embed.link) { + extLink = composerState.embed.link + } const onClose = useCallback(() => { closeComposer() @@ -342,14 +346,9 @@ export const ComposePost = ({ } }, [onEscape, isModalActive]) - const onNewLink = useCallback( - (uri: string) => { - dispatch({type: 'embed_add_uri', uri}) - if (extLink != null) return - setExtLink({uri, isLoading: true}) - }, - [extLink, setExtLink], - ) + const onNewLink = useCallback((uri: string) => { + dispatch({type: 'embed_add_uri', uri}) + }, []) const onImageAdd = useCallback( (next: ComposerImage[]) => { @@ -414,10 +413,6 @@ export const ComposePost = ({ setError(_(msg`Did you want to say anything?`)) return } - if (extLink?.isLoading) { - setError(_(msg`Please wait for your link card to finish loading`)) - return - } setIsProcessing(true) @@ -452,13 +447,6 @@ export const ComposePost = ({ hasImages: images.length > 0, }) - if (extLink) { - setExtLink({ - ...extLink, - isLoading: true, - localThumb: undefined, - } as apilib.ExternalEmbedDraft) - } let err = cleanError(e.message) if (err.includes('not locate record')) { err = _( @@ -525,7 +513,6 @@ export const ComposePost = ({ quoteCount, replyTo, richtext.text, - setExtLink, setLangPrefs, threadgateAllowUISettings, videoState.asset, @@ -553,11 +540,9 @@ export const ComposePost = ({ const canSelectImages = images.length < MAX_IMAGES && - !extLink && videoState.status === 'idle' && !videoState.video - const hasMedia = - images.length > 0 || Boolean(extLink) || Boolean(videoState.video) + const hasMedia = images.length > 0 || Boolean(videoState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -567,44 +552,13 @@ export const ComposePost = ({ textInput.current?.focus() }, []) - const onSelectGif = useCallback( - (gif: Gif) => { - dispatch({type: 'embed_add_gif', gif}) - setExtLink({ - uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`, - isLoading: true, - meta: { - url: gif.media_formats.gif.url, - image: gif.media_formats.preview.url, - likelyType: LikelyType.HTML, - title: gif.content_description, - description: createGIFDescription(gif.content_description), - }, - }) - }, - [setExtLink], - ) + const onSelectGif = useCallback((gif: Gif) => { + dispatch({type: 'embed_add_gif', gif}) + }, []) - const handleChangeGifAltText = useCallback( - (altText: string) => { - dispatch({type: 'embed_update_gif', alt: altText}) - setExtLink(ext => - ext && ext.meta - ? { - ...ext, - meta: { - ...ext.meta, - description: createGIFDescription( - ext.meta.title ?? '', - altText, - ), - }, - } - : ext, - ) - }, - [setExtLink], - ) + const handleChangeGifAltText = useCallback((altText: string) => { + dispatch({type: 'embed_update_gif', alt: altText}) + }, []) const { scrollHandler, @@ -663,7 +617,7 @@ export const ComposePost = ({ {canPost ? (