migrate link resolution to the appview and chat clients

`resolveLink` took an agent and used it for four appview reads plus the chat
invite preview, so it now takes both clients as a `LinkResolvers` pair - the
caller cannot know which branch a URL will take until it is parsed. That was
the last DM_SERVICE_HEADERS site, so the constant is deleted.

`resolveGif` never touched the agent at all (it is pure URL metadata work on
what the picker already returned), so its parameter is dropped rather than
replaced, along with the one on `fetchResolveGifQuery`.

With the resolvers on clients, `apilib.post` loses the agent parameter the
previous slice kept solely for them, and `composerStateToDraft` takes the
resolver pair instead of minting a throwaway public agent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 03:23:37 +03:00
parent 9bbc3befdd
commit 92408c62a6
7 changed files with 132 additions and 101 deletions
+37 -27
View File
@@ -1,4 +1,4 @@
import {type AtpAgent, ChatBskyGroupDefs} from '@atproto/api' import {ChatBskyGroupDefs} from '@atproto/api'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import {type $Typed, type Client} from '@atproto/lex' import {type $Typed, type Client} from '@atproto/lex'
import { import {
@@ -10,6 +10,7 @@ import {RichText} from '@bsky.app/sdk/richtext'
import {t} from '@lingui/core/macro' import {t} from '@lingui/core/macro'
import {type QueryClient} from '@tanstack/react-query' import {type QueryClient} from '@tanstack/react-query'
import {type LinkResolvers} from '#/lib/api/resolve'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
@@ -47,6 +48,11 @@ interface PostOpts {
* fallback keeps facet detection working when logged out. * fallback keeps facet detection working when logged out.
*/ */
appviewClient: Client appviewClient: Client
/*
* A quoted chat invite link resolves its preview through the chat service, so
* the embed resolver needs the chat client alongside the appview one.
*/
chatClient: Client
/* /*
* The repo write itself (applyWrites) plus every record blob (images, * The repo write itself (applyWrites) plus every record blob (images,
* gallery items, link thumbnails, video captions) goes to the account's own * gallery items, link thumbnails, video captions) goes to the account's own
@@ -55,15 +61,7 @@ interface PostOpts {
pdsClient: Client pdsClient: Client
} }
/** export async function post(queryClient: QueryClient, opts: PostOpts) {
* The `agent` is only still here for the link/gif resolvers in `resolve.ts`,
* which read through it; drop the parameter when those move to the clients.
*/
export async function post(
agent: AtpAgent,
queryClient: QueryClient,
opts: PostOpts,
) {
const thread = opts.thread const thread = opts.thread
opts.onStateChange?.(t`Processing...`) opts.onStateChange?.(t`Processing...`)
@@ -95,7 +93,8 @@ export async function post(
// Not awaited to avoid waterfalls. // Not awaited to avoid waterfalls.
const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const rtPromise = resolveRT(opts.appviewClient, draft.richtext)
const embedPromise = resolveEmbed( const embedPromise = resolveEmbed(
agent, opts.appviewClient,
opts.chatClient,
opts.pdsClient, opts.pdsClient,
queryClient, queryClient,
draft, draft,
@@ -251,7 +250,8 @@ async function resolveReply(appviewClient: Client, replyTo: string) {
} }
async function resolveEmbed( async function resolveEmbed(
agent: AtpAgent, appviewClient: Client,
chatClient: Client,
pdsClient: Client, pdsClient: Client,
queryClient: QueryClient, queryClient: QueryClient,
draft: PostDraft, draft: PostDraft,
@@ -259,8 +259,19 @@ async function resolveEmbed(
): Promise<app.bsky.feed.post.Main['embed']> { ): Promise<app.bsky.feed.post.Main['embed']> {
if (draft.embed.quote) { if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([ const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), resolveMedia(
resolveRecord(agent, queryClient, draft.embed.quote.uri), appviewClient,
chatClient,
pdsClient,
queryClient,
draft.embed,
onStateChange,
),
resolveRecord(
{appviewClient, chatClient},
queryClient,
draft.embed.quote.uri,
),
]) ])
if (resolvedMedia) { if (resolvedMedia) {
return { return {
@@ -278,7 +289,8 @@ async function resolveEmbed(
} }
} }
const resolvedMedia = await resolveMedia( const resolvedMedia = await resolveMedia(
agent, appviewClient,
chatClient,
pdsClient, pdsClient,
queryClient, queryClient,
draft.embed, draft.embed,
@@ -290,7 +302,7 @@ async function resolveEmbed(
if (draft.embed.link) { if (draft.embed.link) {
const resolvedLink = await fetchResolveLinkQuery( const resolvedLink = await fetchResolveLinkQuery(
queryClient, queryClient,
agent, {appviewClient, chatClient},
draft.embed.link.uri, draft.embed.link.uri,
) )
if (resolvedLink.type === 'record') { if (resolvedLink.type === 'record') {
@@ -309,7 +321,8 @@ async function resolveEmbed(
} }
async function resolveMedia( async function resolveMedia(
agent: AtpAgent, appviewClient: Client,
chatClient: Client,
pdsClient: Client, pdsClient: Client,
queryClient: QueryClient, queryClient: QueryClient,
embedDraft: EmbedDraft, embedDraft: EmbedDraft,
@@ -423,11 +436,7 @@ async function resolveMedia(
} }
if (embedDraft.media?.type === 'gif') { if (embedDraft.media?.type === 'gif') {
const gifDraft = embedDraft.media const gifDraft = embedDraft.media
const resolvedGif = await fetchResolveGifQuery( const resolvedGif = await fetchResolveGifQuery(queryClient, gifDraft.gif)
queryClient,
agent,
gifDraft.gif,
)
let blob: app.bsky.embed.external.External['thumb'] let blob: app.bsky.embed.external.External['thumb']
if (resolvedGif.thumb) { if (resolvedGif.thumb) {
onStateChange?.(t`Uploading link thumbnail...`) onStateChange?.(t`Uploading link thumbnail...`)
@@ -448,7 +457,7 @@ async function resolveMedia(
if (embedDraft.link) { if (embedDraft.link) {
const resolvedLink = await fetchResolveLinkQuery( const resolvedLink = await fetchResolveLinkQuery(
queryClient, queryClient,
agent, {appviewClient, chatClient},
embedDraft.link.uri, embedDraft.link.uri,
) )
if (resolvedLink.type === 'external') { if (resolvedLink.type === 'external') {
@@ -490,15 +499,16 @@ async function resolveMedia(
} }
/* /*
* `resolve.ts` still resolves through the agent and returns legacy-typed refs; * `resolve.ts` still returns legacy-typed views, so its strong refs carry plain
* assert at the boundary until it moves to the clients. * strings where the record write wants the branded syntax types; assert at the
* boundary until those views come from the generated lexicons.
*/ */
async function resolveRecord( async function resolveRecord(
agent: AtpAgent, clients: LinkResolvers,
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
): Promise<com.atproto.repo.strongRef.Main> { ): Promise<com.atproto.repo.strongRef.Main> {
const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) const resolvedLink = await fetchResolveLinkQuery(queryClient, clients, uri)
if (resolvedLink.type !== 'record') { if (resolvedLink.type !== 'record') {
throw Error(t`Expected uri to resolve to a record`) throw Error(t`Expected uri to resolve to a record`)
} }
+54 -34
View File
@@ -1,12 +1,13 @@
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
type AtpAgent,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type AtUriString, type HandleString} from '@atproto/syntax'
import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
import {downloadAndResize} from '#/lib/media/manip' import {downloadAndResize} from '#/lib/media/manip'
@@ -29,6 +30,7 @@ import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links' import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
import {app, chat, com} from '#/lexicons'
import {createGIFDescription} from '../gif-alt-text' import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = { type ResolvedExternalLink = {
@@ -94,8 +96,21 @@ export class EmbeddingDisabledError extends Error {
} }
} }
/**
* The clients a link resolution may need.
*
* Everything but the chat-invite branch is an appview read, and the chat invite
* preview goes through the chat client so it is proxied to the chat service.
* Both are passed together because the caller cannot know which branch a URL
* will take until it is parsed.
*/
export type LinkResolvers = {
appviewClient: Client
chatClient: Client
}
export async function resolveLink( export async function resolveLink(
agent: AtpAgent, {appviewClient, chatClient}: LinkResolvers,
uri: string, uri: string,
): Promise<ResolvedLink> { ): Promise<ResolvedLink> {
if (isShortLink(uri)) { if (isShortLink(uri)) {
@@ -124,15 +139,17 @@ export async function resolveLink(
const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
const did = await fetchDid(handleOrDid) const did = await fetchDid(handleOrDid)
const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
const res = await agent.app.bsky.feed.getFeedGenerator({feed}) const data = await appviewClient.call(app.bsky.feed.getFeedGenerator, {
feed: feed,
})
return { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.view.uri, uri: data.view.uri,
cid: res.data.view.cid, cid: data.view.cid,
}, },
kind: 'feed', kind: 'feed',
view: res.data.view, view: data.view,
} }
} }
if (isBskyListUrl(uri)) { if (isBskyListUrl(uri)) {
@@ -140,28 +157,29 @@ export async function resolveLink(
const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
const did = await fetchDid(handleOrDid) const did = await fetchDid(handleOrDid)
const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
const res = await agent.app.bsky.graph.getList({list}) const data = await appviewClient.call(app.bsky.graph.getList, {
list: list,
})
return { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.list.uri, uri: data.list.uri,
cid: res.data.list.cid, cid: data.list.cid,
}, },
kind: 'list', kind: 'list',
view: res.data.list, view: data.list,
} }
} }
const chatInviteCode = getChatInviteCodeFromUrl(uri) const chatInviteCode = getChatInviteCodeFromUrl(uri)
if (chatInviteCode) { if (chatInviteCode) {
const res = await agent.chat.bsky.group.getJoinLinkPreviews( const data = await chatClient.call(chat.bsky.group.getJoinLinkPreviews, {
{codes: [chatInviteCode]}, codes: [chatInviteCode],
{headers: DM_SERVICE_HEADERS}, })
)
return { return {
type: 'chat-invite', type: 'chat-invite',
uri, uri,
code: chatInviteCode, code: chatInviteCode,
view: res.data.joinLinkPreviews[0], view: data.joinLinkPreviews[0],
} }
} }
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
@@ -173,15 +191,17 @@ export async function resolveLink(
} }
const did = await fetchDid(parsed.name) const did = await fetchDid(parsed.name)
const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
const res = await agent.app.bsky.graph.getStarterPack({starterPack}) const data = await appviewClient.call(app.bsky.graph.getStarterPack, {
starterPack: starterPack as AtUriString,
})
return { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.starterPack.uri, uri: data.starterPack.uri,
cid: res.data.starterPack.cid, cid: data.starterPack.cid,
}, },
kind: 'starter-pack', kind: 'starter-pack',
view: res.data.starterPack, view: data.starterPack,
} }
} }
return resolveExternal(uri) return resolveExternal(uri)
@@ -190,17 +210,17 @@ export async function resolveLink(
async function getPost({uri}: {uri: string}) { async function getPost({uri}: {uri: string}) {
const urip = new AtUri(uri) const urip = new AtUri(uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({ const data = await appviewClient.call(
handle: urip.host, com.atproto.identity.resolveHandle,
}) {handle: urip.host as HandleString},
// @ts-expect-error TODO new-sdk-migration )
urip.host = res.data.did urip.host = data.did
} }
const res = await agent.getPosts({ const data = await appviewClient.call(app.bsky.feed.getPosts, {
uris: [urip.toString()], uris: [urip.toString()],
}) })
if (res.success && res.data.posts[0]) { if (data.posts[0]) {
return res.data.posts[0] return data.posts[0]
} }
throw new Error('getPost: post not found') throw new Error('getPost: post not found')
} }
@@ -209,17 +229,17 @@ export async function resolveLink(
async function fetchDid(handleOrDid: string) { async function fetchDid(handleOrDid: string) {
let identifier = handleOrDid let identifier = handleOrDid
if (!identifier.startsWith('did:')) { if (!identifier.startsWith('did:')) {
const res = await agent.resolveHandle({handle: identifier}) const data = await appviewClient.call(
identifier = res.data.did com.atproto.identity.resolveHandle,
{handle: identifier as HandleString},
)
identifier = data.did
} }
return identifier return identifier
} }
} }
export async function resolveGif( export async function resolveGif(gif: Gif): Promise<ResolvedExternalLink> {
agent: AtpAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url const gifUrl = gif.media_formats.gif.url
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('hh', String(gif.media_formats.gif.dims[1])) params.set('hh', String(gif.media_formats.gif.dims[1]))
-7
View File
@@ -257,9 +257,6 @@ export const BLUESKY_PROXY_HEADER = {
* The DID comes from the env-configurable `CHAT_PROXY_DID` (via * The DID comes from the env-configurable `CHAT_PROXY_DID` (via
* `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the * `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the
* target can be retargeted per environment. * target can be retargeted per environment.
*
* This is the client-level equivalent of {@link DM_SERVICE_HEADERS}, which
* carries the same value as a per-call header.
*/ */
export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat` export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat`
@@ -275,10 +272,6 @@ export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat`
*/ */
export const MOD_PROXY_SERVICE: Service = `${BSKY_LABELER_DID}#atproto_labeler` export const MOD_PROXY_SERVICE: Service = `${BSKY_LABELER_DID}#atproto_labeler`
export const DM_SERVICE_HEADERS = {
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
}
/** /**
* The notification service's proxy target, in the `did#service_id` form a lex * The notification service's proxy target, in the `did#service_id` form a lex
* client's per-call `service` option takes. Passing it emits `atproto-proxy: * client's per-call `service` option takes. Passing it emits `atproto-proxy:
+21 -17
View File
@@ -1,9 +1,13 @@
import {type AtpAgent} from '@atproto/api'
import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query'
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve' import {
type LinkResolvers,
type ResolvedLink,
resolveGif,
resolveLink,
} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index' import {STALE} from '#/state/queries/index'
import {useAgent} from '#/state/session' import {useAppviewClient, useChatClient} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link' export const RQKEY_LINK_ROOT = 'resolve-link'
@@ -12,24 +16,25 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
export const RQKEY_GIF_ROOT = 'resolve-gif' export const RQKEY_GIF_ROOT = 'resolve-gif'
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url] export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
export function resolveLinkQueryOptions(agent: AtpAgent, url: string) { export function resolveLinkQueryOptions(clients: LinkResolvers, url: string) {
return queryOptions({ return queryOptions({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url), queryKey: RQKEY_LINK(url),
queryFn: () => resolveLink(agent, url), queryFn: () => resolveLink(clients, url),
}) })
} }
export function useResolveLinkQuery(url: string) { export function useResolveLinkQuery(url: string) {
const agent = useAgent() const appviewClient = useAppviewClient()
return useQuery(resolveLinkQueryOptions(agent, url)) const chatClient = useChatClient()
return useQuery(resolveLinkQueryOptions({appviewClient, chatClient}, url))
} }
export function fetchResolveLinkQuery( export function fetchResolveLinkQuery(
queryClient: QueryClient, queryClient: QueryClient,
agent: AtpAgent, clients: LinkResolvers,
url: string, url: string,
) { ) {
return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url)) return queryClient.fetchQuery(resolveLinkQueryOptions(clients, url))
} }
export function precacheResolveLinkQuery( export function precacheResolveLinkQuery(
queryClient: QueryClient, queryClient: QueryClient,
@@ -39,26 +44,25 @@ export function precacheResolveLinkQuery(
queryClient.setQueryData(RQKEY_LINK(url), resolvedLink) queryClient.setQueryData(RQKEY_LINK(url), resolvedLink)
} }
/*
* GIF resolution is pure metadata work on a URL the picker already returned -
* it makes no atproto request - so it takes no client.
*/
export function useResolveGifQuery(gif: Gif) { export function useResolveGifQuery(gif: Gif) {
const agent = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url), queryKey: RQKEY_GIF(gif.url),
queryFn: async () => { queryFn: async () => {
return await resolveGif(agent, gif) return await resolveGif(gif)
}, },
}) })
} }
export function fetchResolveGifQuery( export function fetchResolveGifQuery(queryClient: QueryClient, gif: Gif) {
queryClient: QueryClient,
agent: AtpAgent,
gif: Gif,
) {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url), queryKey: RQKEY_GIF(gif.url),
queryFn: async () => { queryFn: async () => {
return await resolveGif(agent, gif) return await resolveGif(gif)
}, },
}) })
} }
+6 -5
View File
@@ -97,8 +97,8 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
import { import {
useAgent,
useAppviewClient, useAppviewClient,
useChatClient,
usePdsClient, usePdsClient,
useSession, useSession,
} from '#/state/session' } from '#/state/session'
@@ -280,8 +280,8 @@ export const ComposePost = ({
const videoMaxDurationMs = allow10MinuteVideos const videoMaxDurationMs = allow10MinuteVideos
? VIDEO_10_MINUTE_MAX_DURATION_MS ? VIDEO_10_MINUTE_MAX_DURATION_MS
: VIDEO_MAX_DURATION_MS : VIDEO_MAX_DURATION_MS
const agent = useAgent()
const client = useAppviewClient() const client = useAppviewClient()
const chatClient = useChatClient()
const pdsClient = usePdsClient() const pdsClient = usePdsClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did const currentDid = currentAccount!.did
@@ -1003,7 +1003,7 @@ export const ComposePost = ({
.map(post => post.embed.link!.uri) .map(post => post.embed.link!.uri)
const linkQueries = useQueries({ const linkQueries = useQueries({
queries: linkUris.map(uri => ({ queries: linkUris.map(uri => ({
...resolveLinkQueryOptions(agent, uri), ...resolveLinkQueryOptions({appviewClient: client, chatClient}, uri),
enabled: false, enabled: false,
})), })),
}) })
@@ -1094,12 +1094,13 @@ export const ComposePost = ({
try { try {
logger.info(`composer: posting...`) logger.info(`composer: posting...`)
postUri = ( postUri = (
await apilib.post(agent, queryClient, { await apilib.post(queryClient, {
thread: filteredThread, thread: filteredThread,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
onStateChange: setPublishingStage, onStateChange: setPublishingStage,
langs: currentLanguages, langs: currentLanguages,
appviewClient: client, appviewClient: client,
chatClient,
pdsClient, pdsClient,
}) })
).uris[0] ).uris[0]
@@ -1294,8 +1295,8 @@ export const ComposePost = ({
}, [ }, [
l, l,
ax, ax,
agent,
client, client,
chatClient,
pdsClient, pdsClient,
canPost, canPost,
isPublishing, isPublishing,
+8 -8
View File
@@ -5,14 +5,13 @@ import {AppBskyDraftDefs, AtUri} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext' import {RichText} from '@bsky.app/sdk/richtext'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {resolveLink} from '#/lib/api/resolve' import {type LinkResolvers, resolveLink} from '#/lib/api/resolve'
import {getDeviceName} from '#/lib/deviceName' import {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip' import {getImageDim} from '#/lib/media/manip'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {shortenLinks} from '#/lib/strings/rich-text-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/bridge-agent'
import { import {
type ComposerState, type ComposerState,
type EmbedDraft, type EmbedDraft,
@@ -60,7 +59,10 @@ function parseVideoMimeType(localRefPath: string): string {
* Convert ComposerState to server Draft format for saving. * Convert ComposerState to server Draft format for saving.
* Returns both the draft and a map of localRef paths to their source paths. * Returns both the draft and a map of localRef paths to their source paths.
*/ */
export async function composerStateToDraft(state: ComposerState): Promise<{ export async function composerStateToDraft(
clients: LinkResolvers,
state: ComposerState,
): Promise<{
draft: AppBskyDraftDefs.Draft draft: AppBskyDraftDefs.Draft
localRefPaths: Map<string, string> localRefPaths: Map<string, string>
}> { }> {
@@ -68,7 +70,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all( const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all(
state.thread.posts.map(post => { state.thread.posts.map(post => {
return postDraftToServerPost(post, localRefPaths) return postDraftToServerPost(clients, post, localRefPaths)
}), }),
) )
@@ -94,6 +96,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
* Convert a single PostDraft to server DraftPost format. * Convert a single PostDraft to server DraftPost format.
*/ */
async function postDraftToServerPost( async function postDraftToServerPost(
clients: LinkResolvers,
post: PostDraft, post: PostDraft,
localRefPaths: Map<string, string>, localRefPaths: Map<string, string>,
): Promise<AppBskyDraftDefs.DraftPost> { ): Promise<AppBskyDraftDefs.DraftPost> {
@@ -138,10 +141,7 @@ async function postDraftToServerPost(
// Add quote record embed // Add quote record embed
if (post.embed.quote) { if (post.embed.quote) {
const resolved = await resolveLink( const resolved = await resolveLink(clients, post.embed.quote.uri)
createPublicAgent(),
post.embed.quote.uri,
)
if (resolved && resolved.type === 'record') { if (resolved && resolved.type === 'record') {
draftPost.embedRecords = [ draftPost.embedRecords = [
{ {
@@ -7,7 +7,7 @@ import {
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {matchXrpcError} from '#/lib/xrpc-error' import {matchXrpcError} from '#/lib/xrpc-error'
import {useAppviewClient} from '#/state/session' import {useAppviewClient, useChatClient} from '#/state/session'
import {type ComposerState} from '#/view/com/composer/state/composer' import {type ComposerState} from '#/view/com/composer/state/composer'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers' import {getDeviceId} from '#/analytics/identifiers'
@@ -121,6 +121,7 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
*/ */
export function useSaveDraftMutation() { export function useSaveDraftMutation() {
const client = useAppviewClient() const client = useAppviewClient()
const chatClient = useChatClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
@@ -136,8 +137,10 @@ export function useSaveDraftMutation() {
originalLocalRefs: Set<string> | undefined originalLocalRefs: Set<string> | undefined
}> => { }> => {
// Convert composer state to server draft format // Convert composer state to server draft format
const {draft: apiDraft, localRefPaths} = const {draft: apiDraft, localRefPaths} = await composerStateToDraft(
await composerStateToDraft(composerState) {appviewClient: client, chatClient},
composerState,
)
/* /*
* `composerStateToDraft` builds the draft against the `@atproto/api` * `composerStateToDraft` builds the draft against the `@atproto/api`
* types, whose string fields are unbranded, so it is asserted once here * types, whose string fields are unbranded, so it is asserted once here