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
@@ -1,12 +1,7 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, View} from 'react-native'
import {
AppBskyFeedPost,
AppBskyRichtextFacet,
AtUri,
moderatePost,
RichText as RichTextAPI,
} from '@atproto/api'
import {AppBskyFeedPost, AtUri, moderatePost} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {Trans, useLingui} from '@lingui/react/macro'
import {type RouteProp, useNavigation, useRoute} from '@react-navigation/native'
@@ -16,6 +11,7 @@ import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
@@ -36,6 +32,7 @@ import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
/**
@@ -106,7 +103,7 @@ export function useExtractEmbedFromFacets(
for (const facet of rt.facets ?? []) {
for (const feature of facet.features) {
if (
AppBskyRichtextFacet.isLink(feature) &&
bsky.isType(app.bsky.richtext.facet.link, feature) &&
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
) {
uriFromFacet = feature.uri
@@ -176,7 +173,7 @@ function MessageInputPostEmbed({
return {
rt: new RichTextAPI({
text: post.record.text,
facets: post.record.facets,
facets: asSdkFacets(post.record.facets),
}),
record: post.record,
}
@@ -25,13 +25,12 @@ import {scheduleOnRN} from 'react-native-worklets'
import {
type $Typed,
type AppBskyEmbedRecord,
AppBskyRichtextFacet,
ChatBskyConvoDefs,
type ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
RichText,
} from '@atproto/api'
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
import {RichText} from '@bsky.app/sdk/richtext'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {mergeRefs} from '#/lib/merge-refs'
@@ -52,7 +51,7 @@ import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
import {useGetPost} from '#/state/queries/post'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useAgent, useSession} from '#/state/session'
import {useAppviewClient, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
@@ -71,6 +70,8 @@ import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {ChatStatusInfo} from './ChatStatusInfo'
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
import {InviteLinkDialogProvider} from './InviteLinkDialogProvider'
@@ -144,7 +145,11 @@ export function MessagesList({
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
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 appviewClient = useAppviewClient()
const {hasSession, currentAccount} = useSession()
const getPost = useGetPost()
const getJoinLinkPreview = useGetJoinLinkPreview()
@@ -550,7 +555,8 @@ export function MessagesList({
const linkFacet = rt.facets?.find(facet =>
facet.features.find(
feature =>
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
bsky.isType(app.bsky.richtext.facet.link, feature) &&
predicate(feature.uri),
),
)
if (linkFacet) {
@@ -617,7 +623,7 @@ export function MessagesList({
replyTo = {messageId: reply.id}
}
await rt.detectFacets(agent)
await rt.detectFacets(appviewClient)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
@@ -667,7 +673,7 @@ export function MessagesList({
}
},
[
agent,
appviewClient,
convoState,
getPost,
getJoinLinkPreview,
@@ -5,8 +5,8 @@ import {
AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -14,6 +14,7 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {niceDate} from '#/lib/strings/time'
import {
POST_TOMBSTONE,
@@ -199,7 +200,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
facets: asSdkFacets(record.facets),
}),
[record],
)
@@ -4,14 +4,15 @@ import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {Trans} from '@lingui/react/macro'
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,
@@ -211,7 +212,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
facets: asSdkFacets(record.facets),
}),
[record],
)
@@ -4,14 +4,15 @@ import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {Trans} from '@lingui/react/macro'
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,
@@ -271,7 +272,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
facets: asSdkFacets(record.facets),
}),
[record],
)
@@ -5,8 +5,8 @@ import {
type AppBskyLabelerDefs,
moderateProfile,
type ModerationOpts,
type RichText as RichTextAPI,
} from '@atproto/api'
import {type RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
@@ -5,8 +5,8 @@ import {
moderateProfile,
type ModerationDecision,
type ModerationOpts,
type RichText as RichTextAPI,
} from '@atproto/api'
import {type 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'
+1 -1
View File
@@ -12,8 +12,8 @@ import {
type AppBskyLabelerDefs,
moderateProfile,
type ModerationOpts,
type RichText as RichTextAPI,
} from '@atproto/api'
import {type RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {useIsFocused} from '@react-navigation/native'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -1,12 +1,14 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
import {AppBskyGraphDefs} 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'
import {useHaptics} from '#/lib/haptics'
import {makeListLink} from '#/lib/routes/links'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {logger} from '#/logger'
import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list'
import {
@@ -128,7 +130,7 @@ export function Header({
list.description
? new RichTextAPI({
text: list.description,
facets: list.descriptionFacets,
facets: asSdkFacets(list.descriptionFacets),
})
: undefined,
[list],
@@ -1,11 +1,8 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyUnspeccedDefs,
moderateProfile,
RichText as RichTextApi,
} from '@atproto/api'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {RichText as RichTextApi} from '@bsky.app/sdk/richtext'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -6,8 +6,8 @@ import {
AppBskyGraphStarterpack,
AtUri,
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 {Plural, Trans} from '@lingui/react/macro'
@@ -24,6 +24,7 @@ import {
type NavigationProp,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
@@ -418,7 +419,7 @@ function Header({
const richText = record.description
? new RichTextAPI({
text: record.description,
facets: record.descriptionFacets,
facets: asSdkFacets(record.descriptionFacets),
})
: undefined
+3 -2
View File
@@ -31,8 +31,8 @@ import {
AppBskyFeedPost,
AtUri,
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {Trans, useLingui} from '@lingui/react/macro'
import {
type RouteProp,
@@ -58,6 +58,7 @@ import {
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
import {
@@ -834,7 +835,7 @@ function Overlay({
: undefined
const richText = new RichTextAPI({
text: record?.text || '',
facets: record?.facets,
facets: asSdkFacets(record?.facets),
})
const handle = sanitizeHandle(post.author.handle, '@')