about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-10-08 09:02:58 +0900
committerGitHub <noreply@github.com>2024-10-07 17:02:58 -0700
commitc06040cc209338fc37980648b31d4d64cc0c5c09 (patch)
tree766e41a310b03bed2e927f468114ca8d14602e5f /src
parentdd8be2e939d2879e2bb23b2ccd843a034d19b8dd (diff)
downloadvoidsky-c06040cc209338fc37980648b31d4d64cc0c5c09.tar.zst
Fetch link previews from RQ (#5608)
Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx61
-rw-r--r--src/App.web.tsx57
-rw-r--r--src/lib/api/index.ts47
-rw-r--r--src/lib/api/resolve.ts49
-rw-r--r--src/state/queries/resolve-link.ts70
-rw-r--r--src/state/shell/composer/index.tsx23
-rw-r--r--src/state/shell/index.tsx16
-rw-r--r--src/view/com/composer/Composer.tsx176
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx119
-rw-r--r--src/view/com/composer/GifAltText.tsx95
-rw-r--r--src/view/com/composer/state/composer.ts3
-rw-r--r--src/view/com/composer/useExternalLinkFetch.e2e.ts47
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts187
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx4
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx12
-rw-r--r--src/view/com/util/post-embeds/GifEmbed.tsx21
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx19
17 files changed, 490 insertions, 516 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index c6334379f..96b493af4 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -51,6 +51,7 @@ import {
 } from '#/state/session'
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as ComposerProvider} from '#/state/shell/composer'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -125,35 +126,37 @@ function InnerApp() {
                 // Resets the entire tree below when it changes:
                 key={currentAccount?.did}>
                 <QueryProvider currentDid={currentAccount?.did}>
-                  <StatsigProvider>
-                    <MessagesProvider>
-                      {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                      <LabelDefsProvider>
-                        <ModerationOptsProvider>
-                          <LoggedOutViewProvider>
-                            <SelectedFeedProvider>
-                              <HiddenRepliesProvider>
-                                <UnreadNotifsProvider>
-                                  <BackgroundNotificationPreferencesProvider>
-                                    <MutedThreadsProvider>
-                                      <ProgressGuideProvider>
-                                        <GestureHandlerRootView
-                                          style={s.h100pct}>
-                                          <TestCtrls />
-                                          <Shell />
-                                          <NuxDialogs />
-                                        </GestureHandlerRootView>
-                                      </ProgressGuideProvider>
-                                    </MutedThreadsProvider>
-                                  </BackgroundNotificationPreferencesProvider>
-                                </UnreadNotifsProvider>
-                              </HiddenRepliesProvider>
-                            </SelectedFeedProvider>
-                          </LoggedOutViewProvider>
-                        </ModerationOptsProvider>
-                      </LabelDefsProvider>
-                    </MessagesProvider>
-                  </StatsigProvider>
+                  <ComposerProvider>
+                    <StatsigProvider>
+                      <MessagesProvider>
+                        {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                        <LabelDefsProvider>
+                          <ModerationOptsProvider>
+                            <LoggedOutViewProvider>
+                              <SelectedFeedProvider>
+                                <HiddenRepliesProvider>
+                                  <UnreadNotifsProvider>
+                                    <BackgroundNotificationPreferencesProvider>
+                                      <MutedThreadsProvider>
+                                        <ProgressGuideProvider>
+                                          <GestureHandlerRootView
+                                            style={s.h100pct}>
+                                            <TestCtrls />
+                                            <Shell />
+                                            <NuxDialogs />
+                                          </GestureHandlerRootView>
+                                        </ProgressGuideProvider>
+                                      </MutedThreadsProvider>
+                                    </BackgroundNotificationPreferencesProvider>
+                                  </UnreadNotifsProvider>
+                                </HiddenRepliesProvider>
+                              </SelectedFeedProvider>
+                            </LoggedOutViewProvider>
+                          </ModerationOptsProvider>
+                        </LabelDefsProvider>
+                      </MessagesProvider>
+                    </StatsigProvider>
+                  </ComposerProvider>
                 </QueryProvider>
               </React.Fragment>
             </VideoVolumeProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 1664812d0..0d500908f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -41,6 +41,7 @@ import {
 } from '#/state/session'
 import {readLastActiveAccount} from '#/state/session/util'
 import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as ComposerProvider} from '#/state/shell/composer'
 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
@@ -116,33 +117,35 @@ function InnerApp() {
                   // Resets the entire tree below when it changes:
                   key={currentAccount?.did}>
                   <QueryProvider currentDid={currentAccount?.did}>
-                    <StatsigProvider>
-                      <MessagesProvider>
-                        {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-                        <LabelDefsProvider>
-                          <ModerationOptsProvider>
-                            <LoggedOutViewProvider>
-                              <SelectedFeedProvider>
-                                <HiddenRepliesProvider>
-                                  <UnreadNotifsProvider>
-                                    <BackgroundNotificationPreferencesProvider>
-                                      <MutedThreadsProvider>
-                                        <SafeAreaProvider>
-                                          <ProgressGuideProvider>
-                                            <Shell />
-                                            <NuxDialogs />
-                                          </ProgressGuideProvider>
-                                        </SafeAreaProvider>
-                                      </MutedThreadsProvider>
-                                    </BackgroundNotificationPreferencesProvider>
-                                  </UnreadNotifsProvider>
-                                </HiddenRepliesProvider>
-                              </SelectedFeedProvider>
-                            </LoggedOutViewProvider>
-                          </ModerationOptsProvider>
-                        </LabelDefsProvider>
-                      </MessagesProvider>
-                    </StatsigProvider>
+                    <ComposerProvider>
+                      <StatsigProvider>
+                        <MessagesProvider>
+                          {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+                          <LabelDefsProvider>
+                            <ModerationOptsProvider>
+                              <LoggedOutViewProvider>
+                                <SelectedFeedProvider>
+                                  <HiddenRepliesProvider>
+                                    <UnreadNotifsProvider>
+                                      <BackgroundNotificationPreferencesProvider>
+                                        <MutedThreadsProvider>
+                                          <SafeAreaProvider>
+                                            <ProgressGuideProvider>
+                                              <Shell />
+                                              <NuxDialogs />
+                                            </ProgressGuideProvider>
+                                          </SafeAreaProvider>
+                                        </MutedThreadsProvider>
+                                      </BackgroundNotificationPreferencesProvider>
+                                    </UnreadNotifsProvider>
+                                  </HiddenRepliesProvider>
+                                </SelectedFeedProvider>
+                              </LoggedOutViewProvider>
+                            </ModerationOptsProvider>
+                          </LabelDefsProvider>
+                        </MessagesProvider>
+                      </StatsigProvider>
+                    </ComposerProvider>
                   </QueryProvider>
                   <ToastContainer />
                 </React.Fragment>
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index e6e8eea3d..6edb111e6 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -12,6 +12,7 @@ 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'
@@ -19,6 +20,10 @@ 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,
   threadgateAllowUISettingToAllowRecordValue,
@@ -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<ComAtprotoRepoStrongRef.Main> {
-  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 a97a3f31c..4f409e100 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -1,4 +1,4 @@
-import {ComAtprotoRepoStrongRef} from '@atproto/api'
+import {AppBskyActorDefs, ComAtprotoRepoStrongRef} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {BskyAgent} from '@atproto/api'
 
@@ -33,12 +33,32 @@ type ResolvedExternalLink = {
   thumb: ComposerImage | undefined
 }
 
-type ResolvedRecord = {
+type ResolvedPostRecord = {
   type: 'record'
   record: ComAtprotoRepoStrongRef.Main
+  kind: 'post'
+  meta: {
+    text: string
+    indexedAt: string
+    author: AppBskyActorDefs.ProfileViewBasic
+  }
 }
 
-type ResolvedLink = ResolvedExternalLink | ResolvedRecord
+type ResolvedOtherRecord = {
+  type: 'record'
+  record: ComAtprotoRepoStrongRef.Main
+  kind: 'other'
+  meta: {
+    // 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: string
+  }
+}
+
+export type ResolvedLink =
+  | ResolvedExternalLink
+  | ResolvedPostRecord
+  | ResolvedOtherRecord
 
 export async function resolveLink(
   agent: BskyAgent,
@@ -57,6 +77,8 @@ export async function resolveLink(
         cid: result.cid,
         uri: result.uri,
       },
+      kind: 'post',
+      meta: result,
     }
   }
   if (isBskyCustomFeedUrl(uri)) {
@@ -64,7 +86,12 @@ export async function resolveLink(
     const result = await getFeedAsEmbed(agent, fetchDid, uri)
     return {
       type: 'record',
-      record: result.embed!.record, // TODO: Fix types.
+      record: result.embed!.record,
+      kind: 'other',
+      meta: {
+        // TODO: Include hydrated content instead.
+        title: result.meta!.title!,
+      },
     }
   }
   if (isBskyListUrl(uri)) {
@@ -72,7 +99,12 @@ export async function resolveLink(
     const result = await getListAsEmbed(agent, fetchDid, uri)
     return {
       type: 'record',
-      record: result.embed!.record, // TODO: Fix types.
+      record: result.embed!.record,
+      kind: 'other',
+      meta: {
+        // TODO: Include hydrated content instead.
+        title: result.meta!.title!,
+      },
     }
   }
   if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
@@ -80,7 +112,12 @@ export async function resolveLink(
     const result = await getStarterPackAsEmbed(agent, fetchDid, uri)
     return {
       type: 'record',
-      record: result.embed!.record, // TODO: Fix types.
+      record: result.embed!.record,
+      kind: 'other',
+      meta: {
+        // TODO: Include hydrated content instead.
+        title: result.meta!.title!,
+      },
     }
   }
   return resolveExternal(agent, uri)
diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts
new file mode 100644
index 000000000..5856cfb5f
--- /dev/null
+++ b/src/state/queries/resolve-link.ts
@@ -0,0 +1,70 @@
+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 {ResolvedLink, 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 precacheResolveLinkQuery(
+  queryClient: QueryClient,
+  url: string,
+  resolvedLink: ResolvedLink,
+) {
+  queryClient.setQueryData(RQKEY_LINK(url), resolvedLink)
+}
+
+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/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index 770b0789e..096948506 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -7,9 +7,12 @@ import {
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
 import {purgeTemporaryImageFiles} from '#/state/gallery'
+import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
 import * as Toast from '#/view/com/util/Toast'
 
 export interface ComposerOptsPostRef {
@@ -58,8 +61,28 @@ const controlsContext = React.createContext<ControlsContext>({
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const {_} = useLingui()
   const [state, setState] = React.useState<StateContext>()
+  const queryClient = useQueryClient()
 
   const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
+    if (opts.quote) {
+      const path = postUriToRelativePath(opts.quote.uri)
+      if (path) {
+        const appUrl = toBskyAppUrl(path)
+        precacheResolveLinkQuery(queryClient, appUrl, {
+          type: 'record',
+          kind: 'post',
+          record: {
+            cid: opts.quote.cid,
+            uri: opts.quote.uri,
+          },
+          meta: {
+            author: opts.quote.author,
+            indexedAt: opts.quote.indexedAt,
+            text: opts.quote.text,
+          },
+        })
+      }
+    }
     const author = opts.replyTo?.author || opts.quote?.author
     const isBlocked = Boolean(
       author &&
diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx
index 07909c000..f61dc3c41 100644
--- a/src/state/shell/index.tsx
+++ b/src/state/shell/index.tsx
@@ -1,22 +1,22 @@
 import React from 'react'
-import {Provider as ShellLayoutProvder} from './shell-layout'
+
+import {Provider as ColorModeProvider} from './color-mode'
 import {Provider as DrawerOpenProvider} from './drawer-open'
 import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
 import {Provider as MinimalModeProvider} from './minimal-mode'
-import {Provider as ColorModeProvider} from './color-mode'
 import {Provider as OnboardingProvider} from './onboarding'
-import {Provider as ComposerProvider} from './composer'
+import {Provider as ShellLayoutProvder} from './shell-layout'
 import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
 
+export {useSetThemePrefs, useThemePrefs} from './color-mode'
+export {useComposerControls, useComposerState} from './composer'
 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
 export {
   useIsDrawerSwipeDisabled,
   useSetDrawerSwipeDisabled,
 } from './drawer-swipe-disabled'
 export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
-export {useThemePrefs, useSetThemePrefs} from './color-mode'
-export {useOnboardingState, useOnboardingDispatch} from './onboarding'
-export {useComposerState, useComposerControls} from './composer'
+export {useOnboardingDispatch, useOnboardingState} from './onboarding'
 export {useTickEveryMinute} from './tick-every-minute'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -27,9 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           <MinimalModeProvider>
             <ColorModeProvider>
               <OnboardingProvider>
-                <ComposerProvider>
-                  <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
-                </ComposerProvider>
+                <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
               </OnboardingProvider>
             </ColorModeProvider>
           </MinimalModeProvider>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index a1c4e7656..ecafea500 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -46,19 +46,15 @@ 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'
 import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
-import {
-  createGIFDescription,
-  parseAltFromGIFDescription,
-} 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'
@@ -87,8 +83,11 @@ 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 {GifAltText} from '#/view/com/composer/GifAltText'
+import {
+  ExternalEmbedGif,
+  ExternalEmbedLink,
+} from '#/view/com/composer/ExternalEmbed'
+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'
@@ -100,12 +99,11 @@ 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'
 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
-import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
+import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
 import {Text} from '#/view/com/util/text/Text'
 import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -117,13 +115,15 @@ 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,
+  MAX_IMAGES,
+} from './state/composer'
 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
 
 const Portal = createPortalGroup()
 
-const MAX_IMAGES = 4
-
 type CancelRef = {
   onPressCancel: () => void
 }
@@ -135,7 +135,7 @@ export const ComposePost = ({
   replyTo,
   onPost,
   quote: initQuote,
-  quoteCount,
+  quoteCount: initQuoteCount,
   mention: initMention,
   openEmojiPicker,
   text: initText,
@@ -147,6 +147,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()
@@ -183,9 +184,6 @@ export const ComposePost = ({
   const graphemeLength = useMemo(() => {
     return shortenLinks(richtext).graphemeLength
   }, [richtext])
-  const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
-    initQuote,
-  )
 
   // TODO: Move more state here.
   const [composerState, dispatch] = useReducer(
@@ -246,8 +244,6 @@ export const ComposePost = ({
 
   const [publishOnUpload, setPublishOnUpload] = useState(false)
 
-  const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
-  const [extGif, setExtGif] = useState<Gif>()
   const [labels, setLabels] = useState<string[]>([])
   const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
     useState<ThreadgateAllowUISetting[]>(
@@ -255,10 +251,24 @@ export const ComposePost = ({
     )
   const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
 
+  let quote: string | undefined
+  if (composerState.embed.quote) {
+    quote = composerState.embed.quote.uri
+  }
   let images = NO_IMAGES
   if (composerState.embed.media?.type === 'images') {
     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
+  }
+  let extLink: string | undefined
+  if (composerState.embed.link) {
+    extLink = composerState.embed.link.uri
+  }
 
   const onClose = useCallback(() => {
     closeComposer()
@@ -335,14 +345,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[]) => {
@@ -371,14 +376,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) => {
@@ -411,17 +412,13 @@ 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)
 
       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,
@@ -449,13 +446,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 = _(
@@ -481,13 +471,13 @@ export const ComposePost = ({
         emitPostCreated()
       }
       setLangPrefs.savePostLanguageToHistory()
-      if (quote) {
+      if (initQuote && initQuoteCount !== undefined) {
         // We want to wait for the quote count to update before we call `onPost`, which will refetch data
-        whenAppViewReady(agent, quote.uri, res => {
+        whenAppViewReady(agent, initQuote.uri, res => {
           const thread = res.data.thread
           if (
             AppBskyFeedDefs.isThreadViewPost(thread) &&
-            thread.post.quoteCount !== quoteCount
+            thread.post.quoteCount !== initQuoteCount
           ) {
             onPost?.(postUri)
             return true
@@ -519,14 +509,15 @@ export const ComposePost = ({
       onPost,
       postgate,
       quote,
-      quoteCount,
+      initQuote,
+      initQuoteCount,
       replyTo,
       richtext.text,
-      setExtLink,
       setLangPrefs,
       threadgateAllowUISettings,
       videoState.asset,
       videoState.status,
+      queryClient,
     ],
   )
 
@@ -549,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())
@@ -563,45 +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),
-        },
-      })
-      setExtGif(gif)
-    },
-    [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,
@@ -660,7 +617,7 @@ export const ComposePost = ({
                   <LabelsBtn
                     labels={labels}
                     onChange={setLabels}
-                    hasMedia={hasMedia}
+                    hasMedia={hasMedia || Boolean(extLink)}
                   />
                   {canPost ? (
                     <Button
@@ -759,29 +716,35 @@ export const ComposePost = ({
               dispatch={dispatch}
               Portal={Portal.Portal}
             />
-            {images.length === 0 && extLink && (
-              <View style={a.relative}>
-                <ExternalEmbed
-                  link={extLink}
+
+            {extGif && (
+              <View style={a.relative} key={extGif.url}>
+                <ExternalEmbedGif
                   gif={extGif}
                   onRemove={() => {
-                    if (extGif) {
-                      dispatch({type: 'embed_remove_gif'})
-                    } else {
-                      dispatch({type: 'embed_remove_link'})
-                    }
-                    setExtLink(undefined)
-                    setExtGif(undefined)
+                    dispatch({type: 'embed_remove_gif'})
                   }}
                 />
-                <GifAltText
-                  link={extLink}
+                <GifAltTextDialog
                   gif={extGif}
+                  altText={extGifAlt ?? ''}
                   onSubmit={handleChangeGifAltText}
                   Portal={Portal.Portal}
                 />
               </View>
             )}
+
+            {!composerState.embed.media && extLink && (
+              <View style={a.relative} key={extLink}>
+                <ExternalEmbedLink
+                  uri={extLink}
+                  onRemove={() => {
+                    dispatch({type: 'embed_remove_link'})
+                  }}
+                />
+              </View>
+            )}
+
             <LayoutAnimationConfig skipExiting>
               {hasVideo && (
                 <Animated.View
@@ -835,13 +798,12 @@ export const ComposePost = ({
               {quote ? (
                 <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
                   <View style={{pointerEvents: 'none'}}>
-                    <QuoteEmbed quote={quote} />
+                    <LazyQuoteEmbed uri={quote} />
                   </View>
-                  {quote.uri !== initQuote?.uri && (
+                  {!initQuote && (
                     <QuoteX
                       onRemove={() => {
                         dispatch({type: 'embed_remove_quote'})
-                        setQuote(undefined)
                       }}
                     />
                   )}
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index f61d410df..d7dc32f14 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -1,71 +1,112 @@
 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 {cleanError} from '#/lib/strings/errors'
+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'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 
-export const ExternalEmbed = ({
-  link,
+export const ExternalEmbedGif = ({
   onRemove,
   gif,
 }: {
-  link?: ExternalEmbedDraft
   onRemove: () => void
-  gif?: Gif
+  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],
   )
 
-  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 (
-    <View
-      style={[
-        !gif && a.mb_xl,
-        a.overflow_hidden,
-        t.atoms.border_contrast_medium,
-      ]}>
-      {link.isLoading ? (
+    <View style={[a.overflow_hidden, t.atoms.border_contrast_medium]}>
+      {linkInfo ? (
+        <View style={{pointerEvents: 'auto'}}>
+          <ExternalLinkEmbed link={linkInfo} hideAlt />
+        </View>
+      ) : error ? (
+        <Container style={[a.align_start, a.p_md, a.gap_xs]}>
+          <Text numberOfLines={1} style={t.atoms.text_contrast_high}>
+            {gif.url}
+          </Text>
+          <Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
+            {cleanError(error)}
+          </Text>
+        </Container>
+      ) : (
         <Container style={loadingStyle}>
           <Loader size="xl" />
         </Container>
-      ) : link.meta?.error ? (
+      )}
+      <ExternalEmbedRemoveBtn onRemove={onRemove} />
+    </View>
+  )
+}
+
+export const ExternalEmbedLink = ({
+  uri,
+  onRemove,
+}: {
+  uri: string
+  onRemove: () => void
+}) => {
+  const t = useTheme()
+  const {data, error} = useResolveLinkQuery(uri)
+  const linkInfo = React.useMemo(
+    () =>
+      data && {
+        title:
+          data.type === 'external'
+            ? data.title
+            : data.kind === 'other'
+            ? data.meta.title
+            : uri,
+        uri,
+        description: data.type === 'external' ? data.description : '',
+        thumb: data.type === 'external' ? data.thumb?.source.path : undefined,
+      },
+    [data, uri],
+  )
+  return (
+    <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
+      {linkInfo ? (
+        <View style={{pointerEvents: 'none'}}>
+          <ExternalLinkEmbed link={linkInfo} hideAlt />
+        </View>
+      ) : error ? (
         <Container style={[a.align_start, a.p_md, a.gap_xs]}>
           <Text numberOfLines={1} style={t.atoms.text_contrast_high}>
-            {link.uri}
+            {uri}
           </Text>
           <Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
-            {link.meta?.error}
+            {cleanError(error)}
           </Text>
         </Container>
-      ) : linkInfo ? (
-        <View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
-          <ExternalLinkEmbed link={linkInfo} hideAlt />
-        </View>
-      ) : null}
+      ) : (
+        <Container>
+          <Loader size="xl" />
+        </Container>
+      )}
       <ExternalEmbedRemoveBtn onRemove={onRemove} />
     </View>
   )
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index 90d20d94f..01778c381 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
+  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 (
+    <GifAltTextDialogLoaded
+      altText={altText}
+      vendorAltText={vendorAltText}
+      thumb={data.thumb?.source.path}
+      params={params}
+      onSubmit={onSubmit}
+      Portal={Portal}
+    />
+  )
+}
+
+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 (
     <>
       <TouchableOpacity
@@ -80,7 +95,7 @@ export function GifAltText({
           a.align_center,
           {backgroundColor: 'rgba(0, 0, 0, 0.75)'},
         ]}>
-        {parsedAlt.isPreferred ? (
+        {altText ? (
           <Check size="xs" fill={t.palette.white} style={a.ml_xs} />
         ) : (
           <Plus size="sm" fill={t.palette.white} />
@@ -97,17 +112,17 @@ export function GifAltText({
       <Dialog.Outer
         control={control}
         onClose={() => {
-          onSubmit(altText)
+          onSubmit(altTextDraft)
         }}
         Portal={Portal}>
         <Dialog.Handle />
         <AltTextInner
-          altText={altText}
-          setAltText={setAltText}
+          vendorAltText={vendorAltText}
+          altText={altTextDraft}
+          onChange={setAltTextDraft}
+          thumb={thumb}
           control={control}
-          link={link}
           params={params}
-          key={link.uri}
         />
       </Dialog.Outer>
     </>
@@ -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({
               <TextField.Root>
                 <Dialog.Input
                   label={_(msg`Alt text`)}
-                  placeholder={link.title}
-                  onChangeText={text => {
-                    setAltText(text)
-                  }}
+                  placeholder={vendorAltText}
+                  onChangeText={onChange}
                   defaultValue={altText}
                   multiline
                   numberOfLines={3}
@@ -200,7 +215,9 @@ function AltTextInner({
           </Text>
           <View style={[a.align_center]}>
             <GifEmbed
-              link={link}
+              thumb={thumb}
+              altText={altText}
+              isPreferredAltText={true}
               params={params}
               hideAlt
               style={[native({maxHeight: 225})]}
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 62d1bff49..6156d3cfa 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -64,7 +64,7 @@ export type ComposerAction =
   | {type: 'embed_update_gif'; alt: string}
   | {type: 'embed_remove_gif'}
 
-const MAX_IMAGES = 4
+export const MAX_IMAGES = 4
 
 export function composerReducer(
   state: ComposerState,
@@ -317,7 +317,6 @@ export function createComposerState({
       }
     }
   }
-  // TODO: Other initial content.
   return {
     embed: {
       quote,
diff --git a/src/view/com/composer/useExternalLinkFetch.e2e.ts b/src/view/com/composer/useExternalLinkFetch.e2e.ts
deleted file mode 100644
index 257a3e8e5..000000000
--- a/src/view/com/composer/useExternalLinkFetch.e2e.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import {useEffect, useState} from 'react'
-
-import {useAgent} from '#/state/session'
-import * as apilib from 'lib/api/index'
-import {getLinkMeta} from 'lib/link-meta/link-meta'
-import {ComposerOpts} from 'state/shell/composer'
-
-export function useExternalLinkFetch({}: {
-  setQuote: (opts: ComposerOpts['quote']) => void
-}) {
-  const agent = useAgent()
-  const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
-    undefined,
-  )
-
-  useEffect(() => {
-    let aborted = false
-    const cleanup = () => {
-      aborted = true
-    }
-    if (!extLink) {
-      return cleanup
-    }
-    if (!extLink.meta) {
-      getLinkMeta(agent, extLink.uri).then(meta => {
-        if (aborted) {
-          return
-        }
-        setExtLink({
-          uri: extLink.uri,
-          isLoading: !!meta.image,
-          meta,
-        })
-      })
-      return cleanup
-    }
-    if (extLink.isLoading) {
-      setExtLink({
-        ...extLink,
-        isLoading: false, // done
-      })
-    }
-    return cleanup
-  }, [extLink, agent])
-
-  return {extLink, setExtLink}
-}
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
deleted file mode 100644
index 60afadefe..000000000
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import {useEffect, useState} from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import * as apilib from '#/lib/api/index'
-import {POST_IMG_MAX} from '#/lib/constants'
-import {
-  EmbeddingDisabledError,
-  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 {logger} from '#/logger'
-import {createComposerImage} from '#/state/gallery'
-import {useFetchDid} from '#/state/queries/handle'
-import {useGetPost} from '#/state/queries/post'
-import {useAgent} from '#/state/session'
-import {ComposerOpts} from '#/state/shell/composer'
-
-export function useExternalLinkFetch({
-  setQuote,
-  setError,
-}: {
-  setQuote: (opts: ComposerOpts['quote']) => void
-  setError: (err: string) => void
-}) {
-  const {_} = useLingui()
-  const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
-    undefined,
-  )
-  const getPost = useGetPost()
-  const fetchDid = useFetchDid()
-  const agent = useAgent()
-
-  useEffect(() => {
-    let aborted = false
-    const cleanup = () => {
-      aborted = true
-    }
-    if (!extLink) {
-      return cleanup
-    }
-    if (!extLink.meta) {
-      if (isBskyPostUrl(extLink.uri)) {
-        getPostAsQuote(getPost, extLink.uri).then(
-          newQuote => {
-            if (aborted) {
-              return
-            }
-            setQuote(newQuote)
-            setExtLink(undefined)
-          },
-          err => {
-            if (err instanceof EmbeddingDisabledError) {
-              setError(_(msg`This post's author has disabled quote posts.`))
-            } else {
-              logger.error('Failed to fetch post for quote embedding', {
-                message: err.toString(),
-              })
-            }
-            setExtLink(undefined)
-          },
-        )
-      } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(agent, fetchDid, extLink.uri).then(
-          ({embed, meta}) => {
-            if (aborted) {
-              return
-            }
-            setExtLink({
-              uri: extLink.uri,
-              isLoading: false,
-              meta,
-              embed,
-            })
-          },
-          err => {
-            logger.error('Failed to fetch feed for embedding', {message: err})
-            setExtLink(undefined)
-          },
-        )
-      } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(agent, fetchDid, extLink.uri).then(
-          ({embed, meta}) => {
-            if (aborted) {
-              return
-            }
-            setExtLink({
-              uri: extLink.uri,
-              isLoading: false,
-              meta,
-              embed,
-            })
-          },
-          err => {
-            logger.error('Failed to fetch list for embedding', {message: err})
-            setExtLink(undefined)
-          },
-        )
-      } else if (
-        isBskyStartUrl(extLink.uri) ||
-        isBskyStarterPackUrl(extLink.uri)
-      ) {
-        getStarterPackAsEmbed(agent, fetchDid, extLink.uri).then(
-          ({embed, meta}) => {
-            if (aborted) {
-              return
-            }
-            setExtLink({
-              uri: extLink.uri,
-              isLoading: false,
-              meta,
-              embed,
-            })
-          },
-        )
-      } else if (isShortLink(extLink.uri)) {
-        if (isShortLink(extLink.uri)) {
-          resolveShortLink(extLink.uri).then(res => {
-            if (res && res !== extLink.uri) {
-              setExtLink({
-                uri: res,
-                isLoading: true,
-              })
-            }
-          })
-        }
-      } else {
-        getLinkMeta(agent, extLink.uri).then(meta => {
-          if (aborted) {
-            return
-          }
-          setExtLink({
-            uri: extLink.uri,
-            isLoading: !!meta.image,
-            meta,
-          })
-        })
-      }
-      return cleanup
-    }
-    if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
-      downloadAndResize({
-        uri: extLink.meta.image,
-        width: POST_IMG_MAX.width,
-        height: POST_IMG_MAX.height,
-        mode: 'contain',
-        maxSize: POST_IMG_MAX.size,
-        timeout: 15e3,
-      })
-        .catch(() => undefined)
-        .then(thumb => (thumb ? createComposerImage(thumb) : undefined))
-        .then(thumb => {
-          if (aborted) {
-            return
-          }
-          setExtLink({
-            ...extLink,
-            isLoading: false, // done
-            localThumb: thumb,
-          })
-        })
-      return cleanup
-    }
-    if (extLink.isLoading) {
-      setExtLink({
-        ...extLink,
-        isLoading: false, // done
-      })
-    }
-    return cleanup
-  }, [_, extLink, setQuote, getPost, fetchDid, agent, setError])
-
-  return {extLink, setExtLink}
-}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 1cad5e091..8f93538c6 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -18,6 +18,8 @@ import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {POST_CTRL_HITSLOP} from '#/lib/constants'
+import {CountWheel} from '#/lib/custom-animations/CountWheel'
+import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
 import {useHaptics} from '#/lib/haptics'
 import {makeProfileLink} from '#/lib/routes/links'
 import {shareUrl} from '#/lib/sharing'
@@ -35,8 +37,6 @@ import {
   ProgressGuideAction,
   useProgressGuideControls,
 } from '#/state/shell/progress-guide'
-import {CountWheel} from 'lib/custom-animations/CountWheel'
-import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
 import {atoms as a, useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 98332c33b..eb03385d0 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -5,6 +5,7 @@ import {AppBskyEmbedExternal} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {shareUrl} from '#/lib/sharing'
@@ -55,7 +56,16 @@ export const ExternalLinkEmbed = ({
   }, [link.uri, externalEmbedPrefs])
 
   if (embedPlayerParams?.source === 'tenor') {
-    return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} />
+    const parsedAlt = parseAltFromGIFDescription(link.description)
+    return (
+      <GifEmbed
+        params={embedPlayerParams}
+        thumb={link.thumb}
+        altText={parsedAlt.alt}
+        isPreferredAltText={parsedAlt.isPreferred}
+        hideAlt={hideAlt}
+      />
+    )
   }
 
   return (
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index a1af6ab26..fc66278c9 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<ViewStyle>
 }) {
@@ -111,11 +113,6 @@ export function GifEmbed({
     playerRef.current?.toggleAsync()
   }, [])
 
-  const parsedAlt = React.useMemo(
-    () => parseAltFromGIFDescription(link.description),
-    [link],
-  )
-
   return (
     <View style={[a.rounded_md, a.overflow_hidden, a.mt_sm, style]}>
       <View
@@ -131,13 +128,13 @@ export function GifEmbed({
         />
         <GifView
           source={params.playerUri}
-          placeholderSource={link.thumb}
+          placeholderSource={thumb}
           style={[a.flex_1, a.rounded_md]}
           autoplay={!autoplayDisabled}
           onPlayerStateChange={onPlayerStateChange}
           ref={playerRef}
           accessibilityHint={_(msg`Animated GIF`)}
-          accessibilityLabel={parsedAlt.alt}
+          accessibilityLabel={altText}
         />
         {!playerState.isPlaying && (
           <Fill
@@ -150,7 +147,7 @@ export function GifEmbed({
           />
         )}
         <MediaInsetBorder />
-        {!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
+        {!hideAlt && isPreferredAltText && <AltText text={altText} />}
       </View>
     </View>
   )
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 3b8152c8b..c44ec3b84 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -31,6 +31,7 @@ import {makeProfileLink} from '#/lib/routes/links'
 import {s} from '#/lib/styles'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {precacheProfile} from '#/state/queries/profile'
+import {useResolveLinkQuery} from '#/state/queries/resolve-link'
 import {useSession} from '#/state/session'
 import {ComposerOptsQuote} from '#/state/shell/composer'
 import {atoms as a, useTheme} from '#/alf'
@@ -286,6 +287,24 @@ export function QuoteX({onRemove}: {onRemove: () => void}) {
   )
 }
 
+export function LazyQuoteEmbed({uri}: {uri: string}) {
+  const {data} = useResolveLinkQuery(uri)
+  if (!data || data.type !== 'record' || data.kind !== 'post') {
+    return null
+  }
+  return (
+    <QuoteEmbed
+      quote={{
+        cid: data.record.cid,
+        uri: data.record.uri,
+        author: data.meta.author,
+        indexedAt: data.meta.indexedAt,
+        text: data.meta.text,
+      }}
+    />
+  )
+}
+
 function viewRecordToPostView(
   viewRecord: AppBskyEmbedRecord.ViewRecord,
 ): AppBskyFeedDefs.PostView {