From 3677c9887360bb06f4d5652a7b654c3ef498231f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 13 Aug 2026 22:26:22 +0300 Subject: [PATCH] [SDK] Validate and brand the persisted account did (#11387) Co-authored-by: Claude Fable 5 --- .../ageAssurance/AgeAssuranceAppealDialog.tsx | 4 +- .../lists/CreateListFromStarterPackDialog.tsx | 9 +- .../moderation/ReportDialog/action.ts | 2 +- src/env/common.ts | 6 +- src/features/liveNow/index.tsx | 7 +- src/lib/api/feed/custom.ts | 131 +++++++++++------- src/lib/api/index.ts | 10 -- src/lib/strings/url-helpers.ts | 6 +- .../Messages/components/ChatDisabled.tsx | 4 +- .../Messages/components/MessagesList.tsx | 4 - .../ModerationInteractionSettings/index.tsx | 4 - .../StarterPackCard.tsx | 1 - src/screens/Onboarding/util.ts | 2 +- src/screens/Profile/components/GermButton.tsx | 2 +- .../components/DeleteAccountDialog.tsx | 4 +- .../Settings/components/ExportCarDialog.tsx | 3 +- src/screens/StarterPack/StarterPackScreen.tsx | 1 - src/screens/Takendown.tsx | 4 +- src/state/persisted/schema.ts | 13 +- src/state/preferences/moderation-opts.tsx | 10 +- src/state/queries/activity-subscriptions.ts | 6 +- .../queries/bookmarks/useBookmarksQuery.ts | 13 +- src/state/queries/list-memberships.ts | 6 +- src/state/queries/list.ts | 12 +- src/state/queries/lists-with-membership.ts | 13 +- .../queries/messages/actor-declaration.ts | 5 +- src/state/queries/my-lists.ts | 3 +- src/state/queries/nuxs/index.ts | 4 - src/state/queries/pinned-post.ts | 5 +- src/state/queries/postgate/util.ts | 4 +- src/state/queries/profile-followers.ts | 3 +- src/state/queries/profile-follows.ts | 3 +- src/state/queries/profile.ts | 4 +- src/state/queries/starter-packs.ts | 6 +- src/state/queries/threadgate/util.ts | 6 +- .../useVerificationCreateMutation.tsx | 1 - .../useVerificationsRemoveMutation.tsx | 1 - .../provider-session-events-test.tsx | 2 +- src/state/session/session-data.ts | 2 +- src/state/unstable-post-source.tsx | 6 +- src/view/com/auth/LoggedOut.tsx | 3 +- src/view/com/composer/drafts/state/queries.ts | 4 - src/view/com/composer/state/composer.ts | 8 -- .../com/composer/threadgate/ThreadgateBtn.tsx | 4 - .../notifications/NotificationFeedItem.tsx | 6 +- src/view/screens/DebugMod.tsx | 2 +- 46 files changed, 154 insertions(+), 205 deletions(-) diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx index e3a9dd41a7..10426d0ca6 100644 --- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -1,6 +1,5 @@ import {useState} from 'react' import {View} from 'react-native' -import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -62,8 +61,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) { reasonType: tools.ozone.report.defs.reasonAppeal.value, subject: { $type: 'com.atproto.admin.defs#repoRef', - // the persisted account did is already resolved - did: currentAccount.did as DidString, + did: currentAccount.did, }, reason: `AGE_ASSURANCE_INQUIRY: ` + details, }, diff --git a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx index b9979554cd..0a528adedb 100644 --- a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx +++ b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx @@ -1,12 +1,7 @@ import {View} from 'react-native' import {TID} from '@atproto/common-web' import {type $Typed} from '@atproto/lex' -import { - type AtIdentifierString, - AtUri, - type AtUriString, - toDatetimeString, -} from '@atproto/syntax' +import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -99,7 +94,7 @@ export function CreateListFromStarterPackDialog({ const chunks = chunk(listitemWrites, 50) for (const c of chunks) { await pdsClient.call(com.atproto.repo.applyWrites, { - repo: currentAccount.did as AtIdentifierString, + repo: currentAccount.did, writes: c, }) } diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index c22a93fce9..86a0c8cca6 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -67,7 +67,7 @@ export function useSubmitReportMutation() { reason: state.details, subject: { $type: 'com.atproto.admin.defs#repoRef', - // the parsed subject carries an already-resolved did + // the parsed subject holds the did as a plain string did: subject.did as DidString, }, } diff --git a/src/env/common.ts b/src/env/common.ts index bcda2e4982..0490859898 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -76,15 +76,13 @@ export const LOG_DEBUG: string = process.env.EXPO_PUBLIC_LOG_DEBUG || '' * The DID of the Bluesky appview to proxy to */ export const BLUESKY_PROXY_DID: DidString = - (process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID as DidString) || - 'did:web:api.bsky.app' + process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID || 'did:web:api.bsky.app' /** * The DID of the chat service to proxy to */ export const CHAT_PROXY_DID: DidString = - (process.env.EXPO_PUBLIC_CHAT_PROXY_DID as DidString) || - 'did:web:api.bsky.chat' + process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' /** * Metrics API host diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 88daad4df7..cc9dd21fbb 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -1,7 +1,7 @@ import {useMemo} from 'react' import {retry} from '@atproto/common-web' import {type $Typed, type l, type UriString} from '@atproto/lex' -import {type AtIdentifierString, AtUri, toDatetimeString} from '@atproto/syntax' +import {AtUri, toDatetimeString} from '@atproto/syntax' import {moderateStatus} from '@bsky.app/sdk/moderation' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -276,8 +276,7 @@ export function useUpsertLiveStatusMutation( } satisfies app.bsky.actor.status.Main const upsert = async () => { - // the session account is still legacy-typed, so its did is unbranded - const repo = currentAccount.did as AtIdentifierString + const repo = currentAccount.did const collection = 'app.bsky.actor.status' const existing = await pdsClient @@ -369,7 +368,7 @@ export function useRemoveLiveStatusMutation() { if (!currentAccount) throw new Error('Not logged in') await pdsClient.delete(app.bsky.actor.status, { - repo: currentAccount.did as AtIdentifierString, + repo: currentAccount.did, rkey: 'self', }) }, diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 6185f788df..f4a5d4862f 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,5 +1,11 @@ -import {Client, lexParse, type XrpcRequestParams} from '@atproto/lex' +import { + type Client, + type XrpcRequestParams, + XrpcResponseError, +} from '@atproto/lex' +import {PUBLIC_APPVIEW} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' import { getAppLanguageAsContentLanguage, getContentLanguages, @@ -96,68 +102,87 @@ export class CustomFeedAPI implements FeedAPI { } } -// HACK -// we want feeds to give language-specific results immediately when a -// logged-out user changes their language. this comes with two problems: -// 1. not all languages have content, and -// 2. our public caching layer isnt correctly busting against the accept-language header -// for now we handle both of these with a manual workaround -// -prf -async function loggedOutFetch({ - feed, - limit, - cursor, -}: { - feed: string - limit: number - cursor?: string -}): Promise { - let contentLangs = getAppLanguageAsContentLanguage() +let loggedOutAppviewClient: Client | undefined - /* - * This request is hand-rolled rather than issued through a client, so it has - * to reproduce the header lex would have emitted from the global static. - */ - const labelersHeader = { - 'atproto-accept-labelers': Client.appLabelers - .map(l => `${l};redact`) - .join(', '), - } +/** + * The unauthenticated {@link Client} for logged-out feed reads, pointed at the + * direct appview ({@link PUBLIC_APPVIEW}, `api.bsky.app`). + * + * Deliberately NOT the public appview client (`public.api.bsky.app`): that host + * fronts a cache which does not vary on `Accept-Language`, so it would answer a + * language-filtered read from another language's cached body. The direct + * appview respects the header (verified 2026-08-04), at the cost of not being + * cached. See {@link loggedOutFetch}. + * + * A single module-level instance, because there is no session to scope it to. + * Like the public chat client, it uses plain `fetch` rather than + * `networkAwareFetch`, matching the ad-hoc fetch it replaces: this read has its + * own failure handling and should not move the app-wide network signal. + */ +function getLoggedOutAppviewClient(): Client { + return (loggedOutAppviewClient ??= createLexClient({ + service: PUBLIC_APPVIEW, + })) +} - // manually construct fetch call so we can add the `lang` cache-busting param - let res = await fetch( - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ - cursor ? `&cursor=${cursor}` : '' - }&limit=${limit}&lang=${contentLangs}`, - { - method: 'GET', - headers: {'Accept-Language': contentLangs, ...labelersHeader}, - }, - ) - /* - * The response is hand-decoded rather than validated, so the lex output shape - * is asserted here just as the old-world one was. - */ - let data = res.ok - ? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody) - : null +/* + * HACK + * We want feeds to give language-specific results immediately when a logged-out + * user changes their language. That comes with two problems: + * 1. not all languages have content, and + * 2. our public caching layer does not bust against the `Accept-Language` + * header. + * -prf + * + * Problem 2 is why this uses its own client rather than the app's public + * appview one: it talks to the direct appview, which honors the header, instead + * of the cached `public.api.bsky.app`, which does not vary on it. That trades + * CDN caching for language correctness on logged-out feed traffic. + * + * Problem 1 is host-independent, so it is still handled here: an empty + * language-filtered feed is retried once with the language constraint removed. + */ +async function loggedOutFetch( + params: GetCustomFeedParams, +): Promise { + const contentLangs = getAppLanguageAsContentLanguage() + + let data = await getFeedOrNull(params, contentLangs) if (data?.feed?.length) { return data } // no data, try again with language headers removed - res = await fetch( - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ - cursor ? `&cursor=${cursor}` : '' - }&limit=${limit}`, - {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}}, - ) - data = res.ok - ? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody) - : null + data = await getFeedOrNull(params, '') if (data?.feed?.length) { return data } return null } + +/** + * A logged-out `getFeed` read that resolves to null on a response error. + * + * The pre-client code only guarded `res.ok`, so a failed RESPONSE fell through + * to the next attempt while a failed REQUEST rejected. Catching + * `XrpcResponseError` preserves that split: every other lex error - the fetch + * and validation ones - still propagates. + */ +async function getFeedOrNull( + params: GetCustomFeedParams, + contentLangs: string, +): Promise { + try { + return await getLoggedOutAppviewClient().call( + app.bsky.feed.getFeed, + params, + {headers: {'Accept-Language': contentLangs}}, + ) + } catch (e) { + if (e instanceof XrpcResponseError) { + return null + } + throw e + } +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index af2d54e65a..990d41c8d1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -307,11 +307,6 @@ async function resolveEmbed( if (resolvedLink.type === 'record') { return { $type: 'app.bsky.embed.record', - /* - * `resolve.ts` is still legacy-typed - its strong refs and URIs carry - * unbranded strings. Assert at the boundary until it moves to the - * clients. - */ record: resolvedLink.record, } } @@ -495,11 +490,6 @@ async function resolveMedia( return undefined } -/* - * `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( clients: LinkResolvers, queryClient: QueryClient, diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index c90f1a6fb9..08ecf4b566 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,4 +1,4 @@ -import {AtUri} from '@atproto/syntax' +import {type AtIdentifierString, AtUri} from '@atproto/syntax' import {parse} from 'psl' import TLDs from 'tlds' @@ -43,8 +43,8 @@ export function makeRecordUri( rkey: string, ) { const urip = new AtUri('at://placeholder.placeholder/') - // @ts-expect-error TODO new-sdk-migration - urip.host = didOrName + // the helper takes the did or handle as a plain string + urip.host = didOrName as AtIdentifierString urip.collection = collection urip.rkey = rkey return urip.toString() diff --git a/src/screens/Messages/components/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx index 31bdebc4d2..b330113d19 100644 --- a/src/screens/Messages/components/ChatDisabled.tsx +++ b/src/screens/Messages/components/ChatDisabled.tsx @@ -1,6 +1,5 @@ import {useCallback, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' -import {type DidString} from '@atproto/syntax' import {Trans, useLingui} from '@lingui/react/macro' import {useMutation} from '@tanstack/react-query' @@ -106,8 +105,7 @@ function DialogInner() { reasonType: tools.ozone.report.defs.reasonAppeal.value, subject: { $type: 'com.atproto.admin.defs#repoRef', - // the persisted account did is already resolved - did: currentAccount.did as DidString, + did: currentAccount.did, }, reason: details, }, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 1ef81f006d..586809c7a2 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -567,10 +567,6 @@ export function MessagesList({ if (post) { embed = { $type: 'app.bsky.embed.record', - /* - * `getPost` hands back `uri` and `cid` as plain strings rather - * than the branded syntax types the lexicon input declares. - */ record: { uri: post.uri, cid: post.cid, diff --git a/src/screens/ModerationInteractionSettings/index.tsx b/src/screens/ModerationInteractionSettings/index.tsx index 9ce833a8e5..c6ea9db622 100644 --- a/src/screens/ModerationInteractionSettings/index.tsx +++ b/src/screens/ModerationInteractionSettings/index.tsx @@ -71,10 +71,6 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { $type: 'app.bsky.feed.threadgate', post: '' as AtUriString, createdAt: toDatetimeString(new Date()), - /* - * Preferences are still typed against the legacy client, so the stored - * rules arrive unbranded. Wave B migrates `getPreferences`. - */ allow: preferences.postInteractionSettings.threadgateAllowRules, }) }, [preferences.postInteractionSettings.threadgateAllowRules]) diff --git a/src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx b/src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx index 450bf6da15..ecbc7a9c4d 100644 --- a/src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx +++ b/src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx @@ -75,7 +75,6 @@ export function StarterPackCard({ let followUris: Map try { followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, { - // the starter pack view is still legacy-typed uri: view.uri, cid: view.cid, }) diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index af8a40f092..07ccc2f17a 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -21,7 +21,7 @@ export async function bulkWriteFollows( const followRecords: $Typed[] = dids.map(did => { return { $type: 'app.bsky.graph.follow', - // callers hold plain dids read off legacy-typed views + // the helper takes the dids as plain strings subject: did as DidString, createdAt: toDatetimeString(new Date()), via, diff --git a/src/screens/Profile/components/GermButton.tsx b/src/screens/Profile/components/GermButton.tsx index f79c503251..59d3d5d6bb 100644 --- a/src/screens/Profile/components/GermButton.tsx +++ b/src/screens/Profile/components/GermButton.tsx @@ -124,7 +124,7 @@ function GermSelfButton({did}: {did: string}) { const {mutate: deleteDeclaration, isPending} = useMutation({ mutationFn: async () => { const previousRecord = await pdsClient - // the profile view is still legacy-typed, so its did is unbranded + // the component takes the did as a plain string prop .get(com.germnetwork.declaration, { repo: did as DidString, rkey: 'self', diff --git a/src/screens/Settings/components/DeleteAccountDialog.tsx b/src/screens/Settings/components/DeleteAccountDialog.tsx index 0df008280b..d2d687bc79 100644 --- a/src/screens/Settings/components/DeleteAccountDialog.tsx +++ b/src/screens/Settings/components/DeleteAccountDialog.tsx @@ -1,6 +1,5 @@ import {useCallback, useRef, useState} from 'react' import {type TextInput, View} from 'react-native' -import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -126,8 +125,7 @@ function DeleteAccountDialogInner({ */ await chatClient.call(chat.bsky.actor.deleteAccount) await client.call(com.atproto.server.deleteAccount, { - // the persisted account did is already resolved - did: currentAccount.did as DidString, + did: currentAccount.did, password, token, }) diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx index 6f28aa340f..290f380157 100644 --- a/src/screens/Settings/components/ExportCarDialog.tsx +++ b/src/screens/Settings/components/ExportCarDialog.tsx @@ -1,6 +1,5 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' -import {type DidString} from '@atproto/syntax' import {Trans, useLingui} from '@lingui/react/macro' import {saveBytesToDisk} from '#/lib/media/manip' @@ -34,7 +33,7 @@ export function ExportCarDialog({ } try { setLoading('repo') - const did = currentAccount.did as DidString + const did = currentAccount.did const data = await pdsClient.call(com.atproto.sync.getRepo, {did}) /* * getRepo declares `application/vnd.ipld.car`, so lex-client hands back diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index 658241c660..b023889300 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -376,7 +376,6 @@ function Header({ let followUris: Map try { followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, { - // the starter pack view is still legacy-typed uri: starterPack.uri, cid: starterPack.cid, }) diff --git a/src/screens/Takendown.tsx b/src/screens/Takendown.tsx index 9843e9337d..60517295f5 100644 --- a/src/screens/Takendown.tsx +++ b/src/screens/Takendown.tsx @@ -2,7 +2,6 @@ import {useState} from 'react' import {View} from 'react-native' import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -57,8 +56,7 @@ export function Takendown() { reasonType: tools.ozone.report.defs.reasonAppeal.value, subject: { $type: 'com.atproto.admin.defs#repoRef', - // the persisted account did is already resolved - did: currentAccount.did as DidString, + did: currentAccount.did, }, reason: appealText, }, diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 9b86360765..58229b3deb 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,3 +1,4 @@ +import {isDidString} from '@atproto/lex' import {z} from 'zod' import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' @@ -13,7 +14,17 @@ const externalEmbedOptions = ['show', 'hide'] as const */ const accountSchema = z.object({ service: z.string(), - did: z.string(), + /** + * Genuinely validated, not just branded: the refinement rejects malformed + * values at runtime and narrows the inferred type to `DidString`. + * + * Weigh any further tightening of this field carefully. One failing field + * fails the whole root schema, and {@link tryParse} then discards the ENTIRE + * persisted state - every account and every preference - so the app boots + * logged out with defaults. Persisted dids come from com.atproto.server + * responses and are always canonical, so this particular check is safe. + */ + did: z.string().refine(isDidString), handle: z.string(), email: z.string().optional(), emailConfirmed: z.boolean().optional(), diff --git a/src/state/preferences/moderation-opts.tsx b/src/state/preferences/moderation-opts.tsx index 77ef89f283..a2575c9a50 100644 --- a/src/state/preferences/moderation-opts.tsx +++ b/src/state/preferences/moderation-opts.tsx @@ -39,11 +39,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return undefined } return { - /* - * `did`/`hiddenPosts` come from persisted storage typed as plain - * `string`, so brand them to the SDK's `DidString`/`AtUriString` slots. - */ - userDid: userDid as ModerationOpts['userDid'], + userDid, prefs: { ...moderationPrefs, labelers: moderationPrefs.labelers.length @@ -52,6 +48,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { did, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, })), + /* + * `hiddenPosts` comes from persisted storage typed as plain `string`, + * so brand it to the SDK's `AtUriString` slot. + */ hiddenPosts: (hiddenPosts || []) as ModerationOpts['prefs']['hiddenPosts'], }, diff --git a/src/state/queries/activity-subscriptions.ts b/src/state/queries/activity-subscriptions.ts index 31f542bda8..5b52fc7e16 100644 --- a/src/state/queries/activity-subscriptions.ts +++ b/src/state/queries/activity-subscriptions.ts @@ -1,4 +1,3 @@ -import {type AtIdentifierString} from '@atproto/syntax' import {t} from '@lingui/core/macro' import { type InfiniteData, @@ -41,8 +40,7 @@ export function useNotificationDeclarationQuery() { queryFn: async () => { try { const response = await client.get(app.bsky.notification.declaration, { - // the session account is still legacy-typed, so its did is unbranded - repo: currentAccount!.did as AtIdentifierString, + repo: currentAccount!.did, rkey: 'self', }) return response @@ -72,7 +70,7 @@ export function useNotificationDeclarationMutation() { app.bsky.notification.declaration, record, { - repo: currentAccount!.did as AtIdentifierString, + repo: currentAccount!.did, rkey: 'self', }, ) diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts index adb12a47ce..2eeab4c213 100644 --- a/src/state/queries/bookmarks/useBookmarksQuery.ts +++ b/src/state/queries/bookmarks/useBookmarksQuery.ts @@ -1,5 +1,5 @@ import {type $Typed} from '@atproto/lex' -import {AtUri} from '@atproto/syntax' +import {AtUri, toDatetimeString} from '@atproto/syntax' import { type InfiniteData, type QueryClient, @@ -71,19 +71,14 @@ export async function optimisticallySaveBookmark( pages: data.pages.map((page, index) => { if (index === 0) { post.$type = 'app.bsky.feed.defs#postView' - /* - * The optimistic entry is synthesized with unbranded string - * fields, so it is asserted to the generated view type the query - * data is keyed on. - */ - const bookmark = { - createdAt: new Date().toISOString(), + const bookmark: app.bsky.bookmark.defs.BookmarkView = { + createdAt: toDatetimeString(new Date()), subject: { uri: post.uri, cid: post.cid, }, item: post as $Typed, - } as unknown as app.bsky.bookmark.defs.BookmarkView + } return { ...page, bookmarks: [bookmark, ...page.bookmarks], diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index 2bc62f3712..01bc795a31 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -44,8 +44,8 @@ export function useListMembershipAddMutation({ throw new Error('Not signed in') } /* - * The mutation's inputs are plain strings held by legacy-typed views, so - * they are asserted to their branded forms here. + * The mutation takes the list uri and actor did as plain strings, so they + * are asserted to their branded forms here. */ const res = await pdsClient.create(app.bsky.graph.listitem, { subject: actorDid as DidString, @@ -145,7 +145,7 @@ export function useListMembershipRemoveMutation({ } const membershipUrip = new AtUri(membershipUri) await pdsClient.delete(app.bsky.graph.listitem, { - repo: currentAccount.did as DidString, + repo: currentAccount.did, rkey: membershipUrip.rkeySafe, }) }, diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 4a9499117c..85efd85b15 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -1,10 +1,5 @@ import {type $Typed, type Client} from '@atproto/lex' -import { - type AtIdentifierString, - AtUri, - type AtUriString, - toDatetimeString, -} from '@atproto/syntax' +import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax' import { blockActorList, muteActorList, @@ -194,8 +189,7 @@ export function useListDeleteMutation() { let listitemRecordUris: string[] = [] for (let i = 0; i < 100; i++) { const res = await pdsClient.list(app.bsky.graph.listitem, { - // the session account is still legacy-typed, so its did is unbranded - repo: currentAccount.did as AtIdentifierString, + repo: currentAccount.did, cursor, limit: 100, }) @@ -228,7 +222,7 @@ export function useListDeleteMutation() { // apply in chunks for (const writesChunk of chunk(writes, 10)) { await pdsClient.call(com.atproto.repo.applyWrites, { - repo: currentAccount.did as AtIdentifierString, + repo: currentAccount.did, writes: writesChunk, }) } diff --git a/src/state/queries/lists-with-membership.ts b/src/state/queries/lists-with-membership.ts index 9aac39197c..1ef035c24a 100644 --- a/src/state/queries/lists-with-membership.ts +++ b/src/state/queries/lists-with-membership.ts @@ -1,4 +1,4 @@ -import {type AtIdentifierString} from '@atproto/syntax' +import {type AtIdentifierString, type AtUriString} from '@atproto/syntax' import { type InfiniteData, type QueryClient, @@ -72,18 +72,13 @@ export function updateListMembershipOptimistically({ ...page, listsWithMembership: page.listsWithMembership.map(lwm => { if (lwm.list.uri === listUri) { - /* - * Callers hand over a plain at-uri and an old-world ProfileView, - * whose `uri`/`did` are unbranded strings. They are runtime - * identical to the generated branded forms, so the synthesised - * listItem is asserted rather than re-validated. - */ return { ...lwm, listItem: { - uri: membershipUri, + // callers hand over the membership uri as a plain string + uri: membershipUri as AtUriString, subject, - } as app.bsky.graph.defs.ListItemView, + }, } } return lwm diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts index 7f0f363c82..e581040ce9 100644 --- a/src/state/queries/messages/actor-declaration.ts +++ b/src/state/queries/messages/actor-declaration.ts @@ -40,8 +40,7 @@ export function useUpdateActorDeclaration({ current?.associated?.chat?.allowGroupInvites, }) const result = await pdsClient.call(com.atproto.repo.putRecord, { - // the session account is still legacy-typed, so its did is unbranded - repo: currentAccount.did as DidString, + repo: currentAccount.did, collection: 'chat.bsky.actor.declaration', rkey: 'self', record: { @@ -106,7 +105,7 @@ export function useDeleteActorDeclaration() { mutationFn: async () => { if (!currentAccount) throw new Error('Not signed in') const result = await pdsClient.call(com.atproto.repo.deleteRecord, { - repo: currentAccount.did as DidString, + repo: currentAccount.did, collection: 'chat.bsky.actor.declaration', rkey: 'self', }) diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts index d529b1e2ad..1150fa74ec 100644 --- a/src/state/queries/my-lists.ts +++ b/src/state/queries/my-lists.ts @@ -1,4 +1,3 @@ -import {type DidString} from '@atproto/syntax' import {type QueryClient, useQuery} from '@tanstack/react-query' import {accumulate} from '#/lib/async/accumulate' @@ -27,7 +26,7 @@ export function useMyListsQuery(filter: MyListsFilter) { accumulate(cursor => client .call(app.bsky.graph.getLists, { - actor: currentAccount!.did as DidString, + actor: currentAccount!.did, cursor, limit: 50, }) diff --git a/src/state/queries/nuxs/index.ts b/src/state/queries/nuxs/index.ts index dc69b1fcde..d8c9e71924 100644 --- a/src/state/queries/nuxs/index.ts +++ b/src/state/queries/nuxs/index.ts @@ -103,10 +103,6 @@ export function useSaveNux() { return useMutation({ retry: 3, mutationFn: async (nux: AppNux) => { - /* - * `serializeAppNux` still returns the legacy `Nux`, whose strings are - * unbranded; it is validated against the same schema the action expects. - */ await pdsClient.call(upsertNux, serializeAppNux(nux)) // triggers a refetch await queryClient.invalidateQueries({ diff --git a/src/state/queries/pinned-post.ts b/src/state/queries/pinned-post.ts index e53cefcbfe..b1364725ca 100644 --- a/src/state/queries/pinned-post.ts +++ b/src/state/queries/pinned-post.ts @@ -1,4 +1,3 @@ -import {type DidString} from '@atproto/syntax' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useMutation, useQueryClient} from '@tanstack/react-query' @@ -36,7 +35,7 @@ export function usePinnedPostMutation() { // get the currently pinned post so we can optimistically remove the pin from it if (!currentAccount) throw new Error('Not signed in') const profile = await client.call(app.bsky.actor.getProfile, { - actor: currentAccount.did as DidString, + actor: currentAccount.did, }) prevPinnedPost = profile.pinnedPost?.uri if (prevPinnedPost && prevPinnedPost !== postUri) { @@ -47,7 +46,7 @@ export function usePinnedPostMutation() { profile, updates: existing => { existing.pinnedPost = pinCurrentPost - ? // the caller's uri/cid are unbranded strings + ? // the mutation takes the uri/cid as plain strings ({ uri: postUri, cid: postCid, diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts index b9a3218b2c..f57fe0479d 100644 --- a/src/state/queries/postgate/util.ts +++ b/src/state/queries/postgate/util.ts @@ -8,8 +8,8 @@ export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' /** * Create a new {@link app.bsky.feed.postgate.Main}. URIs are accepted as plain - * strings (callers hold raw AT-URIs, often from legacy-typed views) and - * asserted to the branded `AtUriString` here. + * strings (callers hold raw AT-URIs) and asserted to the branded `AtUriString` + * here. */ export function createPostgateRecord( postgate: Omit< diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts index 5e79b5719c..59f3c4e842 100644 --- a/src/state/queries/profile-followers.ts +++ b/src/state/queries/profile-followers.ts @@ -1,4 +1,3 @@ -import {type DidString} from '@atproto/syntax' import { type InfiniteData, type QueryClient, @@ -51,7 +50,7 @@ export function useProfileFollowersQuery( * is `undefined`, hence the conditional spread. */ return await client.call(app.bsky.graph.getFollowers, { - actor: (did || '') as DidString, + actor: did || '', limit: PAGE_SIZE, cursor: pageParam, ...(sortParam ? {sort: sortParam} : {}), diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts index 340184ef2c..0f684054b9 100644 --- a/src/state/queries/profile-follows.ts +++ b/src/state/queries/profile-follows.ts @@ -1,4 +1,3 @@ -import {type DidString} from '@atproto/syntax' import { type InfiniteData, type QueryClient, @@ -56,7 +55,7 @@ export function useProfileFollowsQuery( * is `undefined`, hence the conditional spread. */ return await client.call(app.bsky.graph.getFollows, { - actor: (did || '') as DidString, + actor: did || '', limit: limit || PAGE_SIZE, cursor: pageParam, ...(sortParam ? {sort: sortParam} : {}), diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index faf4dbc3fe..190f4a3998 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -643,7 +643,7 @@ function useProfileBlockMutation() { throw new Error('Not signed in') } return await pdsClient.create(app.bsky.graph.block, { - // the profile view is still legacy-typed, so its did is unbranded + // the mutation takes the did as a plain string subject: did as DidString, createdAt: toDatetimeString(new Date()), }) @@ -666,7 +666,7 @@ function useProfileUnblockMutation() { } const {rkeySafe: rkey} = new AtUri(blockUri) await pdsClient.delete(app.bsky.graph.block, { - repo: currentAccount.did as AtIdentifierString, + repo: currentAccount.did, rkey, }) }, diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 3a30b30caa..dd3ff1a9fe 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -253,9 +253,9 @@ export function useEditStarterPackMutation({ list: currentStarterPack.list?.uri, /* * Pre-existing quirk preserved verbatim: the edit path writes whole - * `GeneratorView`s where the lexicon declares `feedItem` refs. lex - * types the raw `putRecord` body as a `LexValue`, which the legacy - * view interface does not structurally satisfy, hence the cast. + * `GeneratorView`s where the lexicon declares `feedItem` refs. The + * raw `putRecord` body is typed as a lex `LexValue`, which the view + * structurally satisfies, so this passes through unchanged. */ feeds: feeds, createdAt: currentStarterPack.record.createdAt, diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts index 4e64835f4d..5a9fa4aea9 100644 --- a/src/state/queries/threadgate/util.ts +++ b/src/state/queries/threadgate/util.ts @@ -5,10 +5,8 @@ import {app} from '#/lexicons' import * as bsky from '#/types/bsky' /* - * Threadgate VIEWS stay on the legacy client types: they are read-only inputs - * from the appview, and branding them here would ripple through every post - * component. Only the threadgate RECORD is migrated, because it is written - * through `com.atproto.repo.putRecord`, whose body is typed as a lex `LexMap`. + * The view's `record` is typed as an opaque lex `LexMap`, so it is validated + * against the threadgate record schema before its `allow` rules are read. */ export function threadgateViewToAllowUISetting( threadgateView: app.bsky.feed.defs.ThreadgateView | undefined, diff --git a/src/state/queries/verification/useVerificationCreateMutation.tsx b/src/state/queries/verification/useVerificationCreateMutation.tsx index cd52a62a1f..50510bac9e 100644 --- a/src/state/queries/verification/useVerificationCreateMutation.tsx +++ b/src/state/queries/verification/useVerificationCreateMutation.tsx @@ -22,7 +22,6 @@ export function useVerificationCreateMutation() { } const {uri} = await pdsClient.create(app.bsky.graph.verification, { - // the profile view is still legacy-typed, so its strings are unbranded subject: profile.did, createdAt: toDatetimeString(new Date()), handle: profile.handle, diff --git a/src/state/queries/verification/useVerificationsRemoveMutation.tsx b/src/state/queries/verification/useVerificationsRemoveMutation.tsx index 0de675f0d2..e0e0fe5e5a 100644 --- a/src/state/queries/verification/useVerificationsRemoveMutation.tsx +++ b/src/state/queries/verification/useVerificationsRemoveMutation.tsx @@ -50,7 +50,6 @@ export function useVerificationsRemoveMutation() { }, () => { return appviewClient.call(app.bsky.actor.getProfile, { - // the profile view is still legacy-typed, so its did is unbranded actor: profile.did ?? '', }) }, diff --git a/src/state/session/__tests__/provider-session-events-test.tsx b/src/state/session/__tests__/provider-session-events-test.tsx index e6a51a5414..cd7873b1ba 100644 --- a/src/state/session/__tests__/provider-session-events-test.tsx +++ b/src/state/session/__tests__/provider-session-events-test.tsx @@ -173,7 +173,7 @@ function makeBundle(account: SessionAccount): FakeBundle { refreshJwt: account.refreshJwt ?? '', /* SessionData types these as branded strings; the values are fixtures */ handle: account.handle as `${string}.${string}`, - did: account.did as `did:${string}:${string}`, + did: account.did, active: true, service: account.service, }, diff --git a/src/state/session/session-data.ts b/src/state/session/session-data.ts index 3ead91c173..711f6ffe9b 100644 --- a/src/state/session/session-data.ts +++ b/src/state/session/session-data.ts @@ -69,7 +69,7 @@ export function sessionAccountToSessionData( return { accessJwt: account.accessJwt ?? '', active: account.active ?? true, - did: account.did as SessionData['did'], + did: account.did, email: account.email, emailAuthFactor: account.emailAuthFactor, emailConfirmed: account.emailConfirmed, diff --git a/src/state/unstable-post-source.tsx b/src/state/unstable-post-source.tsx index e374cbbeae..4386dec5e7 100644 --- a/src/state/unstable-post-source.tsx +++ b/src/state/unstable-post-source.tsx @@ -1,5 +1,5 @@ import {useEffect, useId, useState} from 'react' -import {AtUri} from '@atproto/syntax' +import {type AtIdentifierString, AtUri} from '@atproto/syntax' import {Logger} from '#/logger' import {type FeedSourceInfo} from '#/state/queries/feed' @@ -82,8 +82,8 @@ export function useUnstablePostSource(key: string) { */ export function buildPostSourceKey(key: string, handle: string) { const urip = new AtUri(key) - // @ts-expect-error TODO new-sdk-migration - urip.host = handle + // the handle arrives as a plain string + urip.host = handle as AtIdentifierString return urip.toString() } diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 5becdd45d3..b9d0f722c8 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,7 +1,6 @@ import {useCallback, useEffect, useState} from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {type DidString} from '@atproto/syntax' import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' @@ -69,7 +68,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const {accounts} = useSession() const client = useAppviewClient() useEffect(() => { - const actors = accounts.map(acc => acc.did as DidString) + const actors = accounts.map(acc => acc.did) if (actors.length === 0) return void queryClient.prefetchQuery({ queryKey: profilesQueryKey(actors), diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index 7f4840c7bf..ec786239a4 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -143,10 +143,6 @@ export function useSaveDraftMutation() { {appviewClient: client, chatClient}, composerState, ) - /* - * `composerStateToDraft` builds the draft with unbranded string fields, - * so it is asserted once here to the generated input type. - */ const draft = apiDraft logger.debug('saving draft', { diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 228f9a62ce..ad3e5f4df6 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -321,10 +321,6 @@ export function composerReducer( posts, postgate: createPostgateRecord({ post: '', - /* - * Draft records are still typed against the legacy client, so the - * stored rules arrive unbranded. Wave B migrates the draft types. - */ embeddingRules: postgateEmbeddingRules, }), threadgate: threadgateRecordToAllowUISetting({ @@ -750,10 +746,6 @@ export function createComposerState({ ], postgate: createPostgateRecord({ post: '', - /* - * Preferences are still typed against the legacy client, so the stored - * rules arrive unbranded. Wave B migrates `getPreferences`. - */ embeddingRules: initInteractionSettings?.postgateEmbeddingRules || [], }), threadgate: threadgateRecordToAllowUISetting({ diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 9e730b48d8..388c5e2be0 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -86,10 +86,6 @@ export function ThreadgateBtn({ $type: 'app.bsky.feed.threadgate', post: '' as AtUriString, createdAt: toDatetimeString(new Date()), - /* - * Preferences are still typed against the legacy client, so the stored - * rules arrive unbranded. Wave B migrates `getPreferences`. - */ allow: preferences?.postInteractionSettings.threadgateAllowRules, }) const prefPostgate = createPostgateRecord({ diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index e60640c9d6..d4d4fe52b3 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {TID} from '@atproto/common-web' -import {AtUri, type DidString} from '@atproto/syntax' +import {AtUri} from '@atproto/syntax' import { moderateProfile, type ModerationDecision, @@ -913,9 +913,7 @@ function SayHelloBtn({profile}: {profile: app.bsky.actor.defs.ProfileView}) { try { setIsLoading(true) const data = await client.call(chat.bsky.convo.getConvoForMembers, { - // both dids are already resolved - one from the profile view, one from - // the active session - members: [profile.did, currentAccount!.did] as DidString[], + members: [profile.did, currentAccount!.did], }) navigation.navigate('MessagesConversation', { conversation: data.convo.id, diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index c58edac3c9..359d61ef85 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -341,7 +341,7 @@ export const DebugModScreen = ({}: NativeStackScreenProps< blockingByList: undefined, }), }) - mockedProfile.did = did as app.bsky.actor.defs.ProfileViewBasic['did'] + mockedProfile.did = did mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' // @ts-expect-error ProfileViewBasic is close enough -esb mockedProfile.banner =