migrate runtime call sites to lex clients and sdk actions

Phase 3 tasks 3-7 (parallel wave): composer/post pipeline on
pdsClient/appviewClient with structural+instance blob guards and a golden
CID fixture test; chat Convo/EventBus/queries on the dedicated chat client;
preferences sugar to SDK actions on the PDS client; remaining state/queries
producers (usePostThread unspecced flip, video scoped-token clients,
notifications, starter packs, lists) to client.call; UI runtime sweep
(AtUri from @atproto/syntax, moderation fns from @bsky.app/sdk/moderation,
SDK RichText, ozone reason tokens, guard rewrites via #/types/bsky).

Intermediate checkpoint (hooks skipped): ~114 typecheck errors remain in
cross-boundary consumer files, resolved by the type-only codemod next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-16 21:04:44 +03:00
parent b1a4e1cd16
commit 300a50b69a
296 changed files with 4507 additions and 3903 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import {createContext, useContext, useMemo} from 'react'
import {hasMutedWord} from '@atproto/api'
import {hasMutedWord} from '@bsky.app/sdk/moderation'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {useOnAppStateChange} from '#/lib/appState'
+20 -15
View File
@@ -1,12 +1,12 @@
import {useEffect} from 'react'
import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api'
import {getPreferences, updateLiveEventPreferences} from '@bsky.app/sdk'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {
preferencesQueryKey,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {usePdsClient} from '#/state/session'
import {useAnalytics} from '#/analytics'
import * as env from '#/env'
import {IS_WEB} from '#/env'
@@ -14,10 +14,11 @@ import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
import {app} from '#/lexicons'
export type LiveEventPreferencesAction = Parameters<
Agent['updateLiveEventPreferences']
>[0] & {
typeof updateLiveEventPreferences
>[1] & {
/**
* Flag that is internal to this hook, do not set when updating prefs
*/
@@ -38,7 +39,7 @@ export function useLiveEventPreferences() {
function useWebOnlyDebugLiveEventPreferences() {
const queryClient = useQueryClient()
const agent = useAgent()
const pdsClient = usePdsClient()
useEffect(() => {
if (env.IS_DEV && IS_WEB && typeof window !== 'undefined') {
@@ -46,14 +47,14 @@ function useWebOnlyDebugLiveEventPreferences() {
window.__updateLiveEventPreferences = async (
action: LiveEventPreferencesAction,
) => {
await agent.updateLiveEventPreferences(action)
await pdsClient.call(updateLiveEventPreferences, action)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
}
}
}, [agent, queryClient])
}, [pdsClient, queryClient])
}
export function useUpdateLiveEventPreferences(props: {
@@ -65,10 +66,10 @@ export function useUpdateLiveEventPreferences(props: {
}) {
const ax = useAnalytics()
const queryClient = useQueryClient()
const agent = useAgent()
const pdsClient = usePdsClient()
return useMutation<
AppBskyActorDefs.LiveEventPreferences,
app.bsky.actor.defs.LiveEventPreferences,
Error,
LiveEventPreferencesAction,
{undoAction: LiveEventPreferencesAction | null}
@@ -108,10 +109,14 @@ export function useUpdateLiveEventPreferences(props: {
}
},
mutationFn: async action => {
const updated = await agent.updateLiveEventPreferences(action)
const prefs = updated.find(p =>
asPredicate(AppBskyActorDefs.validateLiveEventPreferences)(p),
)
/*
* The SDK action returns void, so after applying the update we read the
* fresh, interpreted preferences back to obtain the updated
* `liveEventPreferences` (the SDK extracts it from the raw prefs array for
* us, replacing the old `asPredicate(...).find(...)` lookup).
*/
await pdsClient.call(updateLiveEventPreferences, action)
const {liveEventPreferences: prefs} = await pdsClient.call(getPreferences)
switch (action.type) {
case 'hideFeed':
@@ -138,7 +143,7 @@ export function useUpdateLiveEventPreferences(props: {
break
}
case 'toggleHideAllFeeds': {
if (prefs!.hideAllFeeds) {
if (prefs.hideAllFeeds) {
ax.metric('liveEvents:hideAllFeedBanners', {
context: props.metricContext,
})
@@ -156,7 +161,7 @@ export function useUpdateLiveEventPreferences(props: {
queryKey: preferencesQueryKey,
})
return prefs!
return prefs
},
})
}
@@ -1,6 +1,5 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -26,6 +25,7 @@ import {
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
import {type app} from '#/lexicons'
export function EditLiveDialog({
control,
@@ -33,8 +33,8 @@ export function EditLiveDialog({
embed,
}: {
control: Dialog.DialogControlProps
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
status: app.bsky.actor.defs.StatusView
embed: app.bsky.embed.external.View
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
@@ -48,14 +48,14 @@ function DialogInner({
status,
embed,
}: {
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
status: app.bsky.actor.defs.StatusView
embed: app.bsky.embed.external.View
}) {
const control = Dialog.useDialogContext()
const {_, i18n} = useLingui()
const t = useTheme()
const [liveLink, setLiveLink] = useState(embed.external.uri)
const [liveLink, setLiveLink] = useState<string>(embed.external.uri)
const [liveLinkError, setLiveLinkError] = useState('')
const tick = useTickEveryMinute()
@@ -1,20 +1,22 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {api} from '@bsky.app/sdk'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useMutation} from '@tanstack/react-query'
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {useMaybePdsClient, useSession} from '#/state/session'
import {atoms as a, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {com, tools} from '#/lexicons'
import {toLex} from '#/types/bsky'
export function GoLiveDisabledDialog({
control,
@@ -39,12 +41,13 @@ export function DialogInner({
status: AppBskyActorDefs.StatusView
}) {
const {_} = useLingui()
const agent = useAgent()
const {currentAccount} = useSession()
const pdsClient = useMaybePdsClient()
const [details, setDetails] = useState('')
const {mutate, isPending} = useMutation({
mutationFn: async () => {
if (!agent.session?.did) {
if (!currentAccount?.did || !pdsClient) {
throw new Error('Not logged in')
}
if (!status.uri || !status.cid) {
@@ -56,19 +59,19 @@ export function DialogInner({
details,
})
} else {
await agent.createModerationReport(
{
reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
await pdsClient.call(
com.atproto.moderation.createReport,
toLex<com.atproto.moderation.createReport.$InputBody>({
reasonType: tools.ozone.report.defs.reasonAppeal.value,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: status.uri,
cid: status.cid,
},
reason: details,
},
}),
{
encoding: 'application/json',
headers: BLUESKY_MOD_SERVICE_HEADERS,
service: api.moderation.service,
},
)
}
@@ -1,11 +1,7 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyEmbedExternal,
moderateStatus,
} from '@atproto/api'
import {moderateStatus} from '@bsky.app/sdk/moderation'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -30,7 +26,8 @@ import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import type * as bsky from '#/types/bsky'
import {type app} from '#/lexicons'
import * as bsky from '#/types/bsky'
export function LiveStatusDialog({
control,
@@ -40,8 +37,8 @@ export function LiveStatusDialog({
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
status: app.bsky.actor.defs.StatusView
embed: app.bsky.embed.external.View
}) {
const navigation = useNavigation<NavigationProp>()
return (
@@ -64,9 +61,9 @@ function DialogInner({
status,
}: {
profile: bsky.profile.AnyProfileView
embed: AppBskyEmbedExternal.View
embed: app.bsky.embed.external.View
navigation: NavigationProp
status: AppBskyActorDefs.StatusView
status: app.bsky.actor.defs.StatusView
}) {
const {t: l} = useLingui()
const control = Dialog.useDialogContext()
@@ -102,9 +99,9 @@ export function LiveStatus({
padding = 'xl',
onPressOpenProfile,
}: {
status: AppBskyActorDefs.StatusView
status: app.bsky.actor.defs.StatusView
profile: bsky.profile.AnyProfileView
embed: AppBskyEmbedExternal.View
embed: app.bsky.embed.external.View
padding?: 'lg' | 'xl'
onPressOpenProfile: () => void
}) {
@@ -118,7 +115,8 @@ export function LiveStatus({
const dialogContext = Dialog.useDialogContext()
const moderation = useMemo(() => {
if (!moderationOpts) return undefined
return moderateStatus(profile, moderationOpts)
// TODO(phase4): drop toLex once profile producers emit #/lexicons views
return moderateStatus(bsky.toLex(profile), moderationOpts)
}, [profile, moderationOpts])
return (
+54 -46
View File
@@ -1,14 +1,13 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus,
AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord,
moderateStatus,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {type $Typed} from '@atproto/lex'
import {
type AtIdentifierString,
AtUri,
type DatetimeString,
type UriString,
} from '@atproto/syntax'
import {moderateStatus} from '@bsky.app/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -17,19 +16,21 @@ import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {getErrorName} from '#/lib/xrpc-error'
import {useAppConfig} from '#/state/appConfig'
import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {useAgent, usePdsClient, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {useDialogContext} from '#/components/Dialog'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
import type * as bsky from '#/types/bsky'
import {app, com} from '#/lexicons'
import * as bsky from '#/types/bsky'
export * from '#/features/liveNow/utils'
@@ -47,7 +48,7 @@ const DEFAULT_STATE = {
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
} satisfies app.bsky.actor.defs.StatusView
export type LiveNowConfig = {
canGoLive: boolean
@@ -104,7 +105,8 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const moderation = useMemo(() => {
if (!actor || !('status' in actor && actor.status)) return undefined
if (!moderationOpts) return undefined
return moderateStatus(actor, moderationOpts)
// TODO(phase4): drop toLex once profile producers emit #/lexicons views
return moderateStatus(bsky.toLex(actor), moderationOpts)
}, [actor, moderationOpts])
return useMemo(() => {
@@ -118,31 +120,37 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
}
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
/*
* TODO(phase4): drop toLex once profile-shadow emits #/lexicons views.
* The shadow store still holds old `@atproto/api` StatusViews, so brand
* it once here to feed the new-world validation and returned view.
*/
const status = bsky.toLex<app.bsky.actor.defs.StatusView>(shadowed.status)
const isValid = isStatusValidForViewers(status, config)
const isDisabled = status.isDisabled || false
const isActive = isStatusStillActive(status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
uri: status.uri,
cid: status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
embed: status.embed as $Typed<app.bsky.embed.external.View>, // temp_isStatusValid asserts this
expiresAt: status.expiresAt!, // isStatusStillActive asserts this
record: status.record,
} satisfies app.bsky.actor.defs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
uri: status.uri,
cid: status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
embed: status.embed as $Typed<app.bsky.embed.external.View>, // temp_isStatusValid asserts this
expiresAt: status.expiresAt!, // isStatusStillActive asserts this
record: status.record,
} satisfies app.bsky.actor.defs.StatusView
} else {
return DEFAULT_STATE
}
@@ -162,14 +170,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
status: app.bsky.actor.defs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
if (bsky.isType(app.bsky.embed.external.view, status.embed)) {
const host = getLiveNowHost(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(host) : false
@@ -217,7 +225,7 @@ export function useUpsertLiveStatusMutation(
) {
const ax = useAnalytics()
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
@@ -226,7 +234,7 @@ export function useUpsertLiveStatusMutation(
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
let embed: $Typed<app.bsky.embed.external.Main> | undefined
if (linkMeta) {
let thumb
@@ -236,11 +244,11 @@ export function useUpsertLiveStatusMutation(
const img = await imageToThumb(linkMeta.image)
if (img) {
const blob = await uploadBlob(
agent,
pdsClient,
img.source.path,
img.source.mime,
)
thumb = blob.data.blob
thumb = blob.blob
}
} catch (e: any) {
ax.logger.error(`Failed to upload thumbnail for live status`, {
@@ -257,7 +265,7 @@ export function useUpsertLiveStatusMutation(
$type: 'app.bsky.embed.external#external',
title: linkMeta.title ?? '',
description: linkMeta.description ?? '',
uri: linkMeta.url,
uri: linkMeta.url as UriString,
thumb,
},
}
@@ -265,32 +273,32 @@ export function useUpsertLiveStatusMutation(
const record = {
$type: 'app.bsky.actor.status',
createdAt: createdAt ?? new Date().toISOString(),
createdAt: (createdAt ?? new Date().toISOString()) as DatetimeString,
status: 'app.bsky.actor.status#live',
durationMinutes: duration,
embed,
} satisfies AppBskyActorStatus.Record
} satisfies app.bsky.actor.status.Main
const upsert = async () => {
const repo = currentAccount.did
const repo = currentAccount.did as AtIdentifierString
const collection = 'app.bsky.actor.status'
const existing = await agent.com.atproto.repo
.getRecord({repo, collection, rkey: 'self'})
const existing = await pdsClient
.call(com.atproto.repo.getRecord, {repo, collection, rkey: 'self'})
.catch(_e => undefined)
await agent.com.atproto.repo.putRecord({
await pdsClient.call(com.atproto.repo.putRecord, {
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.data.cid || null,
swapRecord: existing?.cid || null,
})
}
await retry(upsert, {
maxRetries: 5,
retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
retryable: e => getErrorName(e) === 'InvalidSwap',
})
return {
@@ -347,7 +355,7 @@ export function useUpsertLiveStatusMutation(
export function useRemoveLiveStatusMutation() {
const ax = useAnalytics()
const {currentAccount} = useSession()
const agent = useAgent()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
const control = useDialogContext()
const {_} = useLingui()
@@ -356,8 +364,8 @@ export function useRemoveLiveStatusMutation() {
mutationFn: async () => {
if (!currentAccount) throw new Error('Not logged in')
await agent.app.bsky.actor.status.delete({
repo: currentAccount.did,
await pdsClient.delete(app.bsky.actor.status, {
repo: currentAccount.did as AtIdentifierString,
rkey: 'self',
})
},
+7 -7
View File
@@ -1,19 +1,19 @@
import {AppBskyActorStatus, AppBskyEmbedExternal} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/core/macro'
import psl from 'psl'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
/**
* Validates a raw status record and returns the typed record, or null if the
* value is not a valid `app.bsky.actor.status` record.
*/
export function getValidLiveStatusRecord(
statusRecord: unknown,
): AppBskyActorStatus.Record | null {
if (!AppBskyActorStatus.isRecord(statusRecord)) return null
const validation = AppBskyActorStatus.validateRecord(statusRecord)
if (!validation.success) return null
return validation.value
): app.bsky.actor.status.Main | null {
if (!bsky.matches(app.bsky.actor.status, statusRecord)) return null
return statusRecord
}
/**
@@ -23,7 +23,7 @@ export function getValidLiveStatusRecord(
export function getLiveLinkFromStatusRecord(statusRecord: unknown): string {
const record = getValidLiveStatusRecord(statusRecord)
if (!record) return ''
if (!AppBskyEmbedExternal.isMain(record.embed)) return ''
if (!bsky.isType(app.bsky.embed.external, record.embed)) return ''
return record.embed.external.uri
}