diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index f13c227857..e03a40c391 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,4 +1,4 @@ -import {type AtpAgent, ChatBskyGroupDefs} from '@atproto/api' +import {ChatBskyGroupDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {type $Typed, type Client} from '@atproto/lex' import { @@ -10,6 +10,7 @@ import {RichText} from '@bsky.app/sdk/richtext' import {t} from '@lingui/core/macro' import {type QueryClient} from '@tanstack/react-query' +import {type LinkResolvers} from '#/lib/api/resolve' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -47,6 +48,11 @@ interface PostOpts { * fallback keeps facet detection working when logged out. */ 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, * gallery items, link thumbnails, video captions) goes to the account's own @@ -55,15 +61,7 @@ interface PostOpts { pdsClient: Client } -/** - * 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, -) { +export async function post(queryClient: QueryClient, opts: PostOpts) { const thread = opts.thread opts.onStateChange?.(t`Processing...`) @@ -95,7 +93,8 @@ export async function post( // Not awaited to avoid waterfalls. const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const embedPromise = resolveEmbed( - agent, + opts.appviewClient, + opts.chatClient, opts.pdsClient, queryClient, draft, @@ -251,7 +250,8 @@ async function resolveReply(appviewClient: Client, replyTo: string) { } async function resolveEmbed( - agent: AtpAgent, + appviewClient: Client, + chatClient: Client, pdsClient: Client, queryClient: QueryClient, draft: PostDraft, @@ -259,8 +259,19 @@ async function resolveEmbed( ): Promise { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ - resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), - resolveRecord(agent, queryClient, draft.embed.quote.uri), + resolveMedia( + appviewClient, + chatClient, + pdsClient, + queryClient, + draft.embed, + onStateChange, + ), + resolveRecord( + {appviewClient, chatClient}, + queryClient, + draft.embed.quote.uri, + ), ]) if (resolvedMedia) { return { @@ -278,7 +289,8 @@ async function resolveEmbed( } } const resolvedMedia = await resolveMedia( - agent, + appviewClient, + chatClient, pdsClient, queryClient, draft.embed, @@ -290,7 +302,7 @@ async function resolveEmbed( if (draft.embed.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, - agent, + {appviewClient, chatClient}, draft.embed.link.uri, ) if (resolvedLink.type === 'record') { @@ -309,7 +321,8 @@ async function resolveEmbed( } async function resolveMedia( - agent: AtpAgent, + appviewClient: Client, + chatClient: Client, pdsClient: Client, queryClient: QueryClient, embedDraft: EmbedDraft, @@ -423,11 +436,7 @@ async function resolveMedia( } if (embedDraft.media?.type === 'gif') { const gifDraft = embedDraft.media - const resolvedGif = await fetchResolveGifQuery( - queryClient, - agent, - gifDraft.gif, - ) + const resolvedGif = await fetchResolveGifQuery(queryClient, gifDraft.gif) let blob: app.bsky.embed.external.External['thumb'] if (resolvedGif.thumb) { onStateChange?.(t`Uploading link thumbnail...`) @@ -448,7 +457,7 @@ async function resolveMedia( if (embedDraft.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, - agent, + {appviewClient, chatClient}, embedDraft.link.uri, ) if (resolvedLink.type === 'external') { @@ -490,15 +499,16 @@ async function resolveMedia( } /* - * `resolve.ts` still resolves through the agent and returns legacy-typed refs; - * assert at the boundary until it moves to the clients. + * `resolve.ts` still returns legacy-typed views, so its strong refs carry plain + * 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( - agent: AtpAgent, + clients: LinkResolvers, queryClient: QueryClient, uri: string, ): Promise { - const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) + const resolvedLink = await fetchResolveLinkQuery(queryClient, clients, uri) if (resolvedLink.type !== 'record') { throw Error(t`Expected uri to resolve to a record`) } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 67bc46284a..297388edb0 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,12 +1,13 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, - type AtpAgent, type ComAtprotoRepoStrongRef, } 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 {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -29,6 +30,7 @@ import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' import {type ChatInvitePreview} from '#/state/queries/join-links' import {type Gif} from '#/features/gifPicker/types' +import {app, chat, com} from '#/lexicons' import {createGIFDescription} from '../gif-alt-text' 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( - agent: AtpAgent, + {appviewClient, chatClient}: LinkResolvers, uri: string, ): Promise { if (isShortLink(uri)) { @@ -124,15 +139,17 @@ export async function resolveLink( const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) 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 { type: 'record', record: { - uri: res.data.view.uri, - cid: res.data.view.cid, + uri: data.view.uri, + cid: data.view.cid, }, kind: 'feed', - view: res.data.view, + view: data.view, } } if (isBskyListUrl(uri)) { @@ -140,28 +157,29 @@ export async function resolveLink( const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) 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 { type: 'record', record: { - uri: res.data.list.uri, - cid: res.data.list.cid, + uri: data.list.uri, + cid: data.list.cid, }, kind: 'list', - view: res.data.list, + view: data.list, } } const chatInviteCode = getChatInviteCodeFromUrl(uri) if (chatInviteCode) { - const res = await agent.chat.bsky.group.getJoinLinkPreviews( - {codes: [chatInviteCode]}, - {headers: DM_SERVICE_HEADERS}, - ) + const data = await chatClient.call(chat.bsky.group.getJoinLinkPreviews, { + codes: [chatInviteCode], + }) return { type: 'chat-invite', uri, code: chatInviteCode, - view: res.data.joinLinkPreviews[0], + view: data.joinLinkPreviews[0], } } if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { @@ -173,15 +191,17 @@ export async function resolveLink( } const did = await fetchDid(parsed.name) 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 { type: 'record', record: { - uri: res.data.starterPack.uri, - cid: res.data.starterPack.cid, + uri: data.starterPack.uri, + cid: data.starterPack.cid, }, kind: 'starter-pack', - view: res.data.starterPack, + view: data.starterPack, } } return resolveExternal(uri) @@ -190,17 +210,17 @@ export async function resolveLink( async function getPost({uri}: {uri: string}) { const urip = new AtUri(uri) if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - // @ts-expect-error TODO new-sdk-migration - urip.host = res.data.did + const data = await appviewClient.call( + com.atproto.identity.resolveHandle, + {handle: urip.host as HandleString}, + ) + urip.host = data.did } - const res = await agent.getPosts({ + const data = await appviewClient.call(app.bsky.feed.getPosts, { uris: [urip.toString()], }) - if (res.success && res.data.posts[0]) { - return res.data.posts[0] + if (data.posts[0]) { + return data.posts[0] } throw new Error('getPost: post not found') } @@ -209,17 +229,17 @@ export async function resolveLink( async function fetchDid(handleOrDid: string) { let identifier = handleOrDid if (!identifier.startsWith('did:')) { - const res = await agent.resolveHandle({handle: identifier}) - identifier = res.data.did + const data = await appviewClient.call( + com.atproto.identity.resolveHandle, + {handle: identifier as HandleString}, + ) + identifier = data.did } return identifier } } -export async function resolveGif( - agent: AtpAgent, - gif: Gif, -): Promise { +export async function resolveGif(gif: Gif): Promise { const gifUrl = gif.media_formats.gif.url const params = new URLSearchParams() params.set('hh', String(gif.media_formats.gif.dims[1])) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9fd7d12b47..1eecb92ee3 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -257,9 +257,6 @@ export const BLUESKY_PROXY_HEADER = { * The DID comes from the env-configurable `CHAT_PROXY_DID` (via * `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the * 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` @@ -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 DM_SERVICE_HEADERS = { - 'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, -} - /** * 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: diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts index 2ccd0af863..aac9bacb12 100644 --- a/src/state/queries/resolve-link.ts +++ b/src/state/queries/resolve-link.ts @@ -1,9 +1,13 @@ -import {type AtpAgent} from '@atproto/api' 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 {useAgent} from '#/state/session' +import {useAppviewClient, useChatClient} from '#/state/session' import {type Gif} from '#/features/gifPicker/types' 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 = (url: string) => [RQKEY_GIF_ROOT, url] -export function resolveLinkQueryOptions(agent: AtpAgent, url: string) { +export function resolveLinkQueryOptions(clients: LinkResolvers, url: string) { return queryOptions({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_LINK(url), - queryFn: () => resolveLink(agent, url), + queryFn: () => resolveLink(clients, url), }) } export function useResolveLinkQuery(url: string) { - const agent = useAgent() - return useQuery(resolveLinkQueryOptions(agent, url)) + const appviewClient = useAppviewClient() + const chatClient = useChatClient() + return useQuery(resolveLinkQueryOptions({appviewClient, chatClient}, url)) } export function fetchResolveLinkQuery( queryClient: QueryClient, - agent: AtpAgent, + clients: LinkResolvers, url: string, ) { - return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url)) + return queryClient.fetchQuery(resolveLinkQueryOptions(clients, url)) } export function precacheResolveLinkQuery( queryClient: QueryClient, @@ -39,26 +44,25 @@ export function precacheResolveLinkQuery( 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) { - const agent = useAgent() return useQuery({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_GIF(gif.url), queryFn: async () => { - return await resolveGif(agent, gif) + return await resolveGif(gif) }, }) } -export function fetchResolveGifQuery( - queryClient: QueryClient, - agent: AtpAgent, - gif: Gif, -) { +export function fetchResolveGifQuery(queryClient: QueryClient, gif: Gif) { return queryClient.fetchQuery({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_GIF(gif.url), queryFn: async () => { - return await resolveGif(agent, gif) + return await resolveGif(gif) }, }) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 3f44040444..8108c41cd1 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -97,8 +97,8 @@ import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' import { - useAgent, useAppviewClient, + useChatClient, usePdsClient, useSession, } from '#/state/session' @@ -280,8 +280,8 @@ export const ComposePost = ({ const videoMaxDurationMs = allow10MinuteVideos ? VIDEO_10_MINUTE_MAX_DURATION_MS : VIDEO_MAX_DURATION_MS - const agent = useAgent() const client = useAppviewClient() + const chatClient = useChatClient() const pdsClient = usePdsClient() const queryClient = useQueryClient() const currentDid = currentAccount!.did @@ -1003,7 +1003,7 @@ export const ComposePost = ({ .map(post => post.embed.link!.uri) const linkQueries = useQueries({ queries: linkUris.map(uri => ({ - ...resolveLinkQueryOptions(agent, uri), + ...resolveLinkQueryOptions({appviewClient: client, chatClient}, uri), enabled: false, })), }) @@ -1094,12 +1094,13 @@ export const ComposePost = ({ try { logger.info(`composer: posting...`) postUri = ( - await apilib.post(agent, queryClient, { + await apilib.post(queryClient, { thread: filteredThread, replyTo: replyTo?.uri, onStateChange: setPublishingStage, langs: currentLanguages, appviewClient: client, + chatClient, pdsClient, }) ).uris[0] @@ -1294,8 +1295,8 @@ export const ComposePost = ({ }, [ l, ax, - agent, client, + chatClient, pdsClient, canPost, isPublishing, diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 3b5ff9cdf2..34fa4f6db4 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -5,14 +5,13 @@ import {AppBskyDraftDefs, AtUri} from '@atproto/api' import {RichText} from '@bsky.app/sdk/richtext' 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 {getImageDim} from '#/lib/media/manip' import {mimeToExt} from '#/lib/media/video/util' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {type ComposerImage} from '#/state/gallery' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' -import {createPublicAgent} from '#/state/session/bridge-agent' import { type ComposerState, type EmbedDraft, @@ -60,7 +59,10 @@ function parseVideoMimeType(localRefPath: string): string { * Convert ComposerState to server Draft format for saving. * 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 localRefPaths: Map }> { @@ -68,7 +70,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{ const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all( 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. */ async function postDraftToServerPost( + clients: LinkResolvers, post: PostDraft, localRefPaths: Map, ): Promise { @@ -138,10 +141,7 @@ async function postDraftToServerPost( // Add quote record embed if (post.embed.quote) { - const resolved = await resolveLink( - createPublicAgent(), - post.embed.quote.uri, - ) + const resolved = await resolveLink(clients, post.embed.quote.uri) if (resolved && resolved.type === 'record') { draftPost.embedRecords = [ { diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index 0108dbbbff..a620f0ec0f 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -7,7 +7,7 @@ import { import {isNetworkError} from '#/lib/strings/errors' 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 {useAnalytics} from '#/analytics' import {getDeviceId} from '#/analytics/identifiers' @@ -121,6 +121,7 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ */ export function useSaveDraftMutation() { const client = useAppviewClient() + const chatClient = useChatClient() const queryClient = useQueryClient() return useMutation({ @@ -136,8 +137,10 @@ export function useSaveDraftMutation() { originalLocalRefs: Set | undefined }> => { // Convert composer state to server draft format - const {draft: apiDraft, localRefPaths} = - await composerStateToDraft(composerState) + const {draft: apiDraft, localRefPaths} = await composerStateToDraft( + {appviewClient: client, chatClient}, + composerState, + ) /* * `composerStateToDraft` builds the draft against the `@atproto/api` * types, whose string fields are unbranded, so it is asserted once here