collapse the dual-world type layer and delete the widening shims

The types/bsky post/profile/starterPack unions drop their @atproto/api arms,
and dangerousIsType/validate go with the old-world guards they wrapped. The
moderation subjects.ts widening shim and rich-text-helpers' asSdkFacets both
existed only to bridge branded and unbranded views, so their 55 and 14 callers
now go straight to @bsky.app/sdk/moderation and the raw facets.

Boundary fallout: lexicon token defs are camelCase schema objects needing
.value, and the branded string slots that the widening used to absorb are now
cast or branded at their producers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 04:53:44 +03:00
parent e9a67f2dea
commit ae0750099f
114 changed files with 591 additions and 899 deletions
+22 -5
View File
@@ -1,3 +1,4 @@
import {type AtUriString} from '@atproto/syntax'
import {useEffect, useMemo, useState} from 'react'
import {type QueryClient} from '@tanstack/react-query'
import {EventEmitter} from 'eventemitter3'
@@ -15,9 +16,13 @@ import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/stat
import {castAsShadow, type Shadow} from './types'
export type {Shadow} from './types'
/**
* `'pending'` is the optimistic sentinel written before the real record uri
* comes back, so the two uri slots admit it alongside a real at-uri.
*/
export interface PostShadow {
likeUri: string | undefined
repostUri: string | undefined
likeUri: AtUriString | 'pending' | undefined
repostUri: AtUriString | 'pending' | undefined
isDeleted: boolean
embed:
| app.bsky.embed.record.View
@@ -130,7 +135,12 @@ function mergeShadow(
(bsky.isType(app.bsky.embed.recordWithMedia.view, post.embed) &&
bsky.isType(app.bsky.embed.recordWithMedia.view, shadow.embed))
) {
embed = shadow.embed
/*
* `isType` asserts a present, matching `$type` at runtime but narrows to
* the schema's input type, whose `$type` is optional - so the value does
* not satisfy the `$Typed` arm of `PostView['embed']` without this cast.
*/
embed = shadow.embed as typeof post.embed
}
}
@@ -143,8 +153,15 @@ function mergeShadow(
bookmarkCount: bookmarkCount,
viewer: {
...(post.viewer || {}),
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
/*
* The optimistic `'pending'` sentinel is not a real at-uri; consumers only
* test these for presence while the write is in flight.
*/
like: (('likeUri' in shadow ? shadow.likeUri : post.viewer?.like) ??
undefined) as AtUriString | undefined,
repost: (('repostUri' in shadow
? shadow.repostUri
: post.viewer?.repost) ?? undefined) as AtUriString | undefined,
pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned,
bookmarked:
'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked,
+5 -4
View File
@@ -1,3 +1,4 @@
import {type DidString, toDatetimeString} from '@atproto/syntax'
import {type Client, XrpcResponseError, type $Typed} from '@atproto/lex'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
@@ -97,7 +98,7 @@ export class Convo {
private chatClient: Client
private events: MessagesEventBus
private senderUserDid: string
private senderUserDid: DidString
private status: ConvoStatus = ConvoStatus.Uninitialized
private error: ConvoError | undefined
@@ -1420,7 +1421,7 @@ export class Convo {
$type: 'chat.bsky.convo.defs#messageView',
id: nanoid(),
rev: '__fake__',
sentAt: new Date().toISOString(),
sentAt: toDatetimeString(new Date()),
sender: {
$type: 'chat.bsky.convo.defs#messageViewSender',
did: this.senderUserDid,
@@ -1462,10 +1463,10 @@ export class Convo {
* @param emoji - must be one grapheme
*/
async addReaction(messageId: string, emoji: string) {
const optimisticReaction = {
const optimisticReaction: chat.bsky.convo.defs.ReactionView = {
value: emoji,
sender: {did: this.senderUserDid},
createdAt: new Date().toISOString(),
createdAt: toDatetimeString(new Date()),
}
let restore: null | (() => void) = null
if (this.pastMessages.has(messageId)) {
+1 -2
View File
@@ -1,9 +1,8 @@
import {useCallback} from 'react'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {moderateProfile, type ModerationOpts} from '@bsky.app/sdk/moderation'
import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query'
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
import {moderateProfile} from '#/lib/moderation/subjects'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useAppviewClient} from '#/state/session'
+1 -1
View File
@@ -1,5 +1,6 @@
import {useMemo, useRef} from 'react'
import {type AtUriString, AtUri} from '@atproto/syntax'
import {moderatePost} from '@bsky.app/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {
@@ -13,7 +14,6 @@ import {app} from '#/lexicons'
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner} from '#/lib/api/feed-manip'
import {moderatePost} from '#/lib/moderation/subjects'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
+3 -4
View File
@@ -1,6 +1,7 @@
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {type AtUriString, AtUri} from '@atproto/syntax'
import {RichText} from '@bsky.app/sdk/richtext'
import {moderateFeedGenerator} from '@bsky.app/sdk/moderation'
import {t} from '@lingui/core/macro'
import {
type InfiniteData,
@@ -13,10 +14,8 @@ import {
} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {moderateFeedGenerator} from '#/lib/moderation/subjects'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {asSdkFacets} from '#/lib/strings/rich-text-helpers'
import {GCTIME, STALE} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -99,7 +98,7 @@ export function hydrateFeedGenerator(
const description = new RichText({
text: view.description || '',
facets: asSdkFacets((view.descriptionFacets || [])?.slice()),
facets: (view.descriptionFacets || [])?.slice(),
})
if (!view.descriptionFacets) {
@@ -142,7 +141,7 @@ export function hydrateList(
const description = new RichText({
text: view.description || '',
facets: asSdkFacets((view.descriptionFacets || [])?.slice()),
facets: (view.descriptionFacets || [])?.slice(),
})
if (!view.descriptionFacets) {
+8 -5
View File
@@ -1,5 +1,6 @@
import {toDatetimeString} from '@atproto/syntax'
import {useCallback} from 'react'
import {type Client, type $Typed} from '@atproto/lex'
import {type $Typed, type Client, type Unknown$TypedObject} from '@atproto/lex'
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import * as bsky from '#/types/bsky'
@@ -22,10 +23,10 @@ export type KnownChatInvitePreview =
| $Typed<chat.bsky.group.defs.InvalidJoinLinkPreviewView>
/**
* The full open-union shape, including the `{$type: string}` fallback for
* future variants.
* The full open-union shape, including lex's `Unknown$TypedObject` fallback for
* unrecognized future variants.
*/
export type ChatInvitePreview = KnownChatInvitePreview | {$type: string}
export type ChatInvitePreview = KnownChatInvitePreview | Unknown$TypedObject
/**
* Narrows a preview to one of the three known variants, filtering out the
@@ -112,7 +113,9 @@ export function setJoinLinkPreviewRequestedForCode(
...preview,
viewer: {
...preview.viewer,
requestedAt: requested ? new Date().toISOString() : undefined,
requestedAt: requested
? toDatetimeString(new Date())
: undefined,
},
}
}
+2 -2
View File
@@ -28,14 +28,14 @@ export function useListMembershipAddMutation({
* Needed for optimistic update of starter pack query
*/
subject?: bsky.profile.AnyProfileView
onSuccess?: (data: {uri: string; cid: string}) => void
onSuccess?: (data: {uri: AtUriString; cid: string}) => void
onError?: (error: Error) => void
} = {}) {
const {currentAccount} = useSession()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
return useMutation<
{uri: string; cid: string},
{uri: AtUriString; cid: string},
Error,
{listUri: string; actorDid: string}
>({
@@ -1,3 +1,4 @@
import {toDatetimeString} from '@atproto/syntax'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import * as bsky from '#/types/bsky'
@@ -52,7 +53,7 @@ export function useCreateJoinLink(
enabledStatus: 'enabled',
joinRule,
requireApproval,
createdAt: new Date().toISOString(),
createdAt: toDatetimeString(new Date()),
},
},
}
@@ -742,11 +742,18 @@ export function ListConvosProviderInner({
return {
...convo,
members: [...convo.members, ...relatedProfilesSansMembers],
/*
* `logAddReaction.message` is the wider message union (it can
* carry a deleted message too), while the reaction view's
* `message` slot is only the live `messageView` - the server
* never pairs a reaction with a deleted message, so the arm is
* asserted rather than re-narrowed.
*/
lastReaction: {
$type: 'chat.bsky.convo.defs#messageAndReactionView',
reaction: log.reaction,
message: log.message,
},
} as chat.bsky.convo.defs.ConvoView['lastReaction'],
rev: log.rev,
}
}),
@@ -1,14 +1,19 @@
import {type DidString} from '@atproto/syntax'
import {describe, expect, it, jest} from '@jest/globals'
import {groupNotifications} from '../util'
import {app} from '#/lexicons'
import {type Notification} from '../types'
import {groupNotifications} from '../util'
jest.mock('#/state/queries/profile', () => ({precacheProfile: jest.fn()}))
type Notification = app.bsky.notification.listNotifications.Notification
/*
* Fixture builder. The generated view brands `did`/`uri`/`cid`/`indexedAt`, and
* these are plain test strings, so the whole literal is asserted once - the
* grouping logic under test only compares them as strings.
*/
function makeFollowNotification(
did: string,
did: DidString,
starterPackUri?: string,
): Notification {
return {
@@ -31,7 +36,7 @@ function makeFollowNotification(
: undefined,
isRead: false,
indexedAt: '2026-07-28T12:00:00.000Z',
}
} as Notification
}
describe('groupNotifications', () => {
+1 -1
View File
@@ -26,8 +26,8 @@ import {
} from '@tanstack/react-query'
import {AtUri} from '@atproto/syntax'
import {moderatePost} from '@bsky.app/sdk/moderation'
import {app} from '#/lexicons'
import {moderatePost} from '#/lib/moderation/subjects'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {useAppviewClient} from '#/state/session'
+13 -2
View File
@@ -1,5 +1,16 @@
import {app} from '#/lexicons'
/**
* A `listNotifications` notification, widened with the `starterPack` the server
* attaches to a `follow` notification sourced from a starter pack. The pinned
* lexicon does not declare the field yet, so it is added here rather than read
* off an `unknown`. Drop this alias once the generated type carries it.
*/
export type Notification =
app.bsky.notification.listNotifications.Notification & {
starterPack?: app.bsky.graph.defs.StarterPackViewBasic
}
export type NotificationType =
| StarterPackNotificationType
| OtherNotificationType
@@ -50,8 +61,8 @@ type OtherNotificationType =
type FeedNotificationBase = {
_reactKey: string
notification: app.bsky.notification.listNotifications.Notification
additional?: app.bsky.notification.listNotifications.Notification[]
notification: Notification
additional?: Notification[]
subjectUri?: string
subject?:
| app.bsky.feed.defs.PostView
+10 -10
View File
@@ -1,17 +1,21 @@
import {type Client} from '@atproto/lex'
import {type AtUriString} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {
hasMutedWord,
moderateNotification,
type ModerationOpts,
} from '@bsky.app/sdk/moderation'
import {type QueryClient} from '@tanstack/react-query'
import chunk from 'lodash.chunk'
import {labelIsHideableOffense} from '#/lib/moderation'
import {hasMutedWord, moderateNotification} from '#/lib/moderation/subjects'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {precacheProfile} from '../profile'
import {
type FeedNotification,
type FeedPage,
type Notification,
type NotificationType,
} from './types'
@@ -108,7 +112,7 @@ export async function fetchPage({
// =
export function shouldFilterNotif(
notif: app.bsky.notification.listNotifications.Notification,
notif: Notification,
moderationOpts: ModerationOpts | undefined,
): boolean {
const containsImperative = !!notif.author.labels?.some(labelIsHideableOffense)
@@ -138,9 +142,7 @@ export function shouldFilterNotif(
return moderateNotification(notif, moderationOpts).ui('contentList').filter
}
export function groupNotifications(
notifs: app.bsky.notification.listNotifications.Notification[],
): FeedNotification[] {
export function groupNotifications(notifs: Notification[]): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
@@ -249,9 +251,7 @@ async function fetchSubjects(
}
}
function toKnownType(
notif: app.bsky.notification.listNotifications.Notification,
): NotificationType {
function toKnownType(notif: Notification): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
@@ -279,7 +279,7 @@ function toKnownType(
function getSubjectUri(
type: NotificationType,
notif: app.bsky.notification.listNotifications.Notification,
notif: Notification,
): string | undefined {
if (
type === 'reply' ||
+3 -3
View File
@@ -55,10 +55,10 @@ export function usePinnedPostMutation() {
: undefined
return existing
},
checkCommitted: res =>
checkCommitted: profile =>
pinCurrentPost
? res.data.pinnedPost?.uri === postUri
: !res.data.pinnedPost,
? profile.pinnedPost?.uri === postUri
: !profile.pinnedPost,
})
if (pinCurrentPost) {
+2 -2
View File
@@ -3,6 +3,7 @@ import {AppState} from 'react-native'
import {type Client} from '@atproto/lex'
import {type AtIdentifierString, type AtUriString, AtUri} from '@atproto/syntax'
import {
moderatePost,
type ModerationDecision,
type ModerationPrefs,
} from '@bsky.app/sdk/moderation'
@@ -28,7 +29,6 @@ import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {moderatePost} from '#/lib/moderation/subjects'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
@@ -78,7 +78,7 @@ export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
export interface FeedPostSliceItem {
_reactKey: string
uri: string
uri: AtUriString
post: app.bsky.feed.defs.PostView
record: app.bsky.feed.post.Main
moderation: ModerationDecision
+2 -2
View File
@@ -190,7 +190,7 @@ function usePostLikeMutation(
const pdsClient = usePdsClient()
const ax = useAnalytics()
return useMutation<
{uri: string}, // responds with the uri of the like
{uri: AtUriString}, // responds with the uri of the like
Error,
{uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
>({
@@ -319,7 +319,7 @@ function usePostRepostMutation(
const pdsClient = usePdsClient()
const ax = useAnalytics()
return useMutation<
{uri: string}, // responds with the uri of the repost
{uri: AtUriString}, // responds with the uri of the repost
Error,
{uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
>({
+2 -2
View File
@@ -1,6 +1,6 @@
import {useRef} from 'react'
import {type Client} from '@atproto/lex'
import {AtUri, type HandleString} from '@atproto/syntax'
import {AtUri, type AtUriString, type HandleString} from '@atproto/syntax'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {networkRetry, retry} from '#/lib/async/retry'
@@ -177,7 +177,7 @@ export function useToggleQuoteDetachmentMutation() {
action,
}: {
post: app.bsky.feed.defs.PostView
quoteUri: string
quoteUri: AtUriString
action: 'detach' | 'reattach'
}) => {
// cache here since post shadow mutates original object
+2 -2
View File
@@ -58,7 +58,7 @@ export function mergePostgateRecords(
export function createEmbedViewDetachedRecord({
uri,
}: {
uri: string
uri: AtUriString
}): $Typed<app.bsky.embed.record.View> {
const record: $Typed<app.bsky.embed.record.ViewDetached> = {
$type: 'app.bsky.embed.record#viewDetached',
@@ -86,7 +86,7 @@ export function createMaybeDetachedQuoteEmbed({
| {
post: app.bsky.feed.defs.PostView
quote: undefined
quoteUri: string
quoteUri: AtUriString
detached: true
}):
| app.bsky.embed.record.View
+1 -1
View File
@@ -5,7 +5,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {moderateFeedGenerator} from '#/lib/moderation/subjects'
import {moderateFeedGenerator} from '@bsky.app/sdk/moderation'
import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
import {useModerationOpts} from '../preferences/moderation-opts'
+1 -1
View File
@@ -5,7 +5,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
import {moderateUserList} from '#/lib/moderation/subjects'
import {moderateUserList} from '@bsky.app/sdk/moderation'
import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
import {useModerationOpts} from '../preferences/moderation-opts'
+13 -20
View File
@@ -146,7 +146,7 @@ interface ProfileUpdateParams {
) => Un$Typed<app.bsky.actor.profile.Main>)
newUserAvatar?: ImageMeta | undefined | null
newUserBanner?: ImageMeta | undefined | null
checkCommitted?: (res: app.bsky.actor.getProfile.Response) => boolean
checkCommitted?: (profile: app.bsky.actor.getProfile.$OutputBody) => boolean
}
export function useProfileUpdateMutation() {
const queryClient = useQueryClient()
@@ -206,21 +206,21 @@ export function useProfileUpdateMutation() {
appviewClient,
profile.did,
checkCommitted ||
(res => {
(profile => {
if (typeof newUserAvatar !== 'undefined') {
if (newUserAvatar === null && res.data.avatar) {
if (newUserAvatar === null && profile.avatar) {
// url hasn't cleared yet
return false
} else if (res.data.avatar === profile.avatar) {
} else if (profile.avatar === profile.avatar) {
// url hasn't changed yet
return false
}
}
if (typeof newUserBanner !== 'undefined') {
if (newUserBanner === null && res.data.banner) {
if (newUserBanner === null && profile.banner) {
// url hasn't cleared yet
return false
} else if (res.data.banner === profile.banner) {
} else if (profile.banner === profile.banner) {
// url hasn't changed yet
return false
}
@@ -229,8 +229,8 @@ export function useProfileUpdateMutation() {
return true
}
return (
res.data.displayName === updates.displayName &&
res.data.description === updates.description
profile.displayName === updates.displayName &&
profile.description === updates.description
)
}),
)
@@ -381,7 +381,7 @@ function useProfileFollowMutation(
const queryClient = useQueryClient()
const {captureAction} = useProgressGuideControls()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
return useMutation<{uri: AtUriString; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
let ownProfile: app.bsky.actor.defs.ProfileViewDetailed | undefined
if (currentAccount) {
@@ -637,7 +637,7 @@ function useProfileBlockMutation() {
const {currentAccount} = useSession()
const pdsClient = usePdsClient()
const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
return useMutation<{uri: AtUriString; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
if (!currentAccount) {
throw new Error('Not signed in')
@@ -679,23 +679,16 @@ function useProfileUnblockMutation() {
async function whenAppViewReady(
client: Client,
actor: string,
fn: (res: app.bsky.actor.getProfile.Response) => boolean,
fn: (profile: app.bsky.actor.getProfile.$OutputBody) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
/*
* `checkCommitted` callbacks are still written against the legacy
* `Response` envelope, so wrap the lex output until those consumers move.
*/
async () => ({
success: true,
headers: {},
data: await client.call(app.bsky.actor.getProfile, {
async () =>
await client.call(app.bsky.actor.getProfile, {
actor: actor as AtIdentifierString,
}),
}),
)
}
+21 -2
View File
@@ -200,12 +200,31 @@ function mergeList(a?: string[], b?: string[]): string[] | undefined {
* operator. v2 renames v1's singular operators to plural arrays, and `lang` to
* `language`.
*/
/**
* The filter subset of the `searchPostsV2` params, with the format-constrained
* strings (uri, at-identifier, language, datetime) left unbranded: every value
* here is parsed from user input, so the brand is asserted by the caller rather
* than re-validated here. `q`, `limit`, `cursor` and `sort` are the caller's.
*/
type SearchPostsV2FilterParams = {
[K in Exclude<
keyof app.bsky.feed.searchPostsV2.$Params,
'query' | 'limit' | 'cursor' | 'sort'
>]?: app.bsky.feed.searchPostsV2.$Params[K] extends
| readonly (infer _E)[]
| undefined
? string[]
: app.bsky.feed.searchPostsV2.$Params[K] extends string | undefined
? string
: app.bsky.feed.searchPostsV2.$Params[K]
}
export function buildSearchPostsV2Filters(
embedded: Omit<ExtractedSearchParams, 'q'>,
filters?: SearchFilters,
): app.bsky.feed.searchPostsV2.$Params {
): SearchPostsV2FilterParams {
const apiFilters = filters ? filtersToApiParams(filters) : {}
const params: app.bsky.feed.searchPostsV2.$Params = {}
const params: SearchPostsV2FilterParams = {}
const authors = mergeList(
embedded.author ? [embedded.author] : undefined,
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query'
import {AtUri} from '@atproto/syntax'
import {moderatePost} from '#/lib/moderation/subjects'
import {moderatePost} from '@bsky.app/sdk/moderation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAppviewClient} from '#/state/session'
import {type SearchFilters} from '#/screens/Search/searchParams'
+1 -2
View File
@@ -1,9 +1,8 @@
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {moderatePost, type ModerationOpts} from '@bsky.app/sdk/moderation'
import {type $Typed} from '@atproto/lex'
import {AtUri} from '@atproto/syntax'
import {app} from '#/lexicons'
import {moderatePost} from '#/lib/moderation/subjects'
import {makeProfileLink} from '#/lib/routes/links'
import {
type ApiThreadItem,