migrate RichText to the SDK and resolve facets via the appview

The two RichText classes are not mutually assignable - `UnicodeString` has a
private field and the SDK brands `did`/`uri` as template literal types - so
every producer and consumer of a RichText instance has to move in one step.

`detectFacets` now takes a lex client instead of the legacy agent, which is
what removes the last hard agent dependency from these files. Handle
resolution is an appview job, so the appview client is threaded in: through
`useAppviewClient` in the hooks and dialogs, and through a new
`appviewClient` option on `apilib.post` (Composer already had the client to
hand). The rest of the post pipeline still writes through the agent.

Facet feature checks move from the `AppBskyRichtextFacet` validators to the
generated `#/lexicons` schemas, matching how the rest of the app narrows
lexicon types.

Display sinks still read facets off `@atproto/api` view types, which are the
same lexicon but typed with plain strings. `asSdkFacets` widens them at those
call sites and goes away once the view types come from the SDK too.
This commit is contained in:
Samuel Newman
2026-08-03 22:33:04 +03:00
parent dbffcce821
commit 2beb63fb2d
44 changed files with 200 additions and 123 deletions
+2 -1
View File
@@ -49,10 +49,10 @@ import {
AppBskyUnspeccedDefs,
AtUri,
ChatBskyGroupDefs,
type RichText,
} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type AtUriString} from '@atproto/syntax'
import {type RichText} from '@bsky.app/sdk/richtext'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
@@ -1083,6 +1083,7 @@ export const ComposePost = ({
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: currentLanguages,
appviewClient: client,
})
).uris[0]
+2 -1
View File
@@ -1,7 +1,8 @@
/**
* Type converters for Draft API - convert between ComposerState and server Draft types.
*/
import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
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'
@@ -1,11 +1,11 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {Platform, Text as RNText, View} from 'react-native'
import {RichText} from '@atproto/api'
import {parseLanguageString} from '@atproto/syntax'
import {
guessLanguageAsync,
type LanguageResult,
} from '@bsky.app/expo-guess-language'
import {RichText} from '@bsky.app/sdk/richtext'
import {Trans, useLingui} from '@lingui/react/macro'
import debounce from 'lodash.debounce'
+4 -3
View File
@@ -3,9 +3,8 @@ import {
type AppBskyActorDefs,
type AppBskyDraftDefs,
type AppBskyFeedPostgate,
AppBskyRichtextFacet,
RichText,
} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext'
import {nanoid} from 'nanoid/non-secure'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
@@ -28,6 +27,8 @@ import {
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
import {type Gif} from '#/features/gifPicker/types'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {
createVideoState,
type VideoAction,
@@ -677,7 +678,7 @@ export function createComposerState({
if (initRichText.facets) {
for (const facet of initRichText.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
if (bsky.isType(app.bsky.richtext.facet.link, feature)) {
if (isBskyPostUrl(feature.uri)) {
detectedPostUris.set(feature.uri, {facet, rt: initRichText})
} else {
@@ -12,7 +12,7 @@ import {
View,
} from 'react-native'
import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext'
import {useLingui} from '@lingui/react/macro'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
@@ -27,6 +27,8 @@ import {
import {atoms as a, useAlf} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID} from '#/env'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {Autocomplete} from './mobile/Autocomplete'
import {type TextInputProps} from './TextInput.types'
@@ -88,7 +90,7 @@ export function TextInput({
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
if (bsky.isType(app.bsky.richtext.facet.link, feature)) {
if (isUriImage(feature.uri)) {
const res = await downloadAndResize({
uri: feature.uri,
@@ -1,5 +1,5 @@
import {type TextInput} from 'react-native'
import {type RichText} from '@atproto/api'
import {type RichText} from '@bsky.app/sdk/richtext'
export type TextInputRef = {
focus: () => void
@@ -8,7 +8,7 @@ import {
} from 'react'
import {StyleSheet, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext'
import {Trans} from '@lingui/react/macro'
import {getSchema} from '@tiptap/core'
import {Document} from '@tiptap/extension-document'
@@ -40,6 +40,8 @@ import {normalizeTextStyles} from '#/alf/typography'
import {type Emoji} from '#/components/EmojiPicker'
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {type TextInputProps} from './TextInput.types'
import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
import {LinkDecorator} from './web/LinkDecorator'
@@ -265,7 +267,7 @@ export function TextInput({
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
if (bsky.isType(app.bsky.richtext.facet.link, feature)) {
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
}
}
@@ -1,8 +1,10 @@
import {type AppBskyRichtextFacet, type RichText} from '@atproto/api'
import {type RichText} from '@bsky.app/sdk/richtext'
import {type app} from '#/lexicons'
export type LinkFacetMatch = {
rt: RichText
facet: AppBskyRichtextFacet.Main
facet: app.bsky.richtext.facet.Main
}
export function suggestLinkCardUri(
+3 -2
View File
@@ -6,14 +6,15 @@ import {
AtUri,
moderatePost,
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {useQueryClient} from '@tanstack/react-query'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {
POST_TOMBSTONE,
type Shadow,
@@ -68,7 +69,7 @@ export function Post({
record
? new RichTextAPI({
text: record.text,
facets: record.facets,
facets: asSdkFacets(record.facets),
})
: undefined,
[record],
+1 -1
View File
@@ -25,8 +25,8 @@ import {
AppBskyEmbedImages,
AppBskyEmbedVideo,
type AppBskyFeedDefs,
type RichText as RichTextType,
} from '@atproto/api'
import {type RichText as RichTextType} from '@bsky.app/sdk/richtext'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
+3 -2
View File
@@ -7,8 +7,8 @@ import {
AppBskyFeedThreadgate,
AtUri,
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {useQueryClient} from '@tanstack/react-query'
import {type ReasonFeedSource} from '#/lib/api/feed/types'
@@ -17,6 +17,7 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {
POST_TOMBSTONE,
type Shadow,
@@ -104,7 +105,7 @@ export function PostFeedItem({
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
facets: asSdkFacets(record.facets),
}),
[record],
)
+1 -1
View File
@@ -15,8 +15,8 @@ import {
type ModerationBehavior,
type ModerationDecision,
type ModerationOpts,
RichText,
} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
+9 -5
View File
@@ -6,8 +6,8 @@ import {
type AppBskyActorDefs,
moderateProfile,
type ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -33,7 +33,7 @@ import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useProfileQuery} from '#/state/queries/profile'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useAgent, useSession} from '#/state/session'
import {useAppviewClient, useSession} from '#/state/session'
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
import {ProfileLists} from '#/view/com/lists/ProfileLists'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
@@ -613,7 +613,11 @@ function ProfileScreenLoaded({
}
function useRichText(text: string): [RichTextAPI, boolean] {
const agent = useAgent()
/*
* Facet/mention resolution is an appview job - it resolves handles through
* the appview, and the public fallback keeps it working when logged out.
*/
const client = useAppviewClient()
const [prevText, setPrevText] = useState(text)
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)
@@ -628,7 +632,7 @@ function useRichText(text: string): [RichTextAPI, boolean] {
async function resolveRTFacets() {
// new each time
const resolvedRT = new RichTextAPI({text})
await resolvedRT.detectFacets(agent)
await resolvedRT.detectFacets(client)
if (!ignore) {
setResolvedRT(resolvedRT)
}
@@ -637,7 +641,7 @@ function useRichText(text: string): [RichTextAPI, boolean] {
return () => {
ignore = true
}
}, [text, agent])
}, [text, client])
const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving]
}