2beb63fb2d
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.
388 lines
9.9 KiB
TypeScript
388 lines
9.9 KiB
TypeScript
import {useCallback, useEffect, useMemo} from 'react'
|
|
import {type GestureResponderEvent, View} from 'react-native'
|
|
import {type AppBskyFeedDefs, type AppBskyGraphDefs, AtUri} from '@atproto/api'
|
|
import {RichText as RichTextApi} from '@bsky.app/sdk/richtext'
|
|
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
|
import {useQueryClient} from '@tanstack/react-query'
|
|
|
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
import {logger} from '#/logger'
|
|
import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
|
|
import {
|
|
useAddSavedFeedsMutation,
|
|
usePreferencesQuery,
|
|
useRemoveFeedMutation,
|
|
} from '#/state/queries/preferences'
|
|
import {useSession} from '#/state/session'
|
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
import {atoms as a, select, useTheme} from '#/alf'
|
|
import {
|
|
Button,
|
|
ButtonIcon,
|
|
type ButtonProps,
|
|
ButtonText,
|
|
} from '#/components/Button'
|
|
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
|
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
|
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
|
import {Loader} from '#/components/Loader'
|
|
import * as Prompt from '#/components/Prompt'
|
|
import {RichText, type RichTextProps} from '#/components/RichText'
|
|
import * as Toast from '#/components/Toast'
|
|
import {Text} from '#/components/Typography'
|
|
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
|
|
import type * as bsky from '#/types/bsky'
|
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
|
|
|
|
type Props = {
|
|
view: AppBskyFeedDefs.GeneratorView
|
|
onPress?: () => void
|
|
}
|
|
|
|
export function Default(props: Props) {
|
|
const {view} = props
|
|
return (
|
|
<Link {...props}>
|
|
<Outer>
|
|
<Header>
|
|
<Avatar src={view.avatar} />
|
|
<TitleAndByline
|
|
title={view.displayName}
|
|
creator={view.creator}
|
|
uri={view.uri}
|
|
/>
|
|
<SaveButton view={view} pin />
|
|
</Header>
|
|
<Description description={view.description} />
|
|
<Likes count={view.likeCount || 0} />
|
|
</Outer>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function Link({
|
|
view,
|
|
children,
|
|
...props
|
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
|
const queryClient = useQueryClient()
|
|
|
|
const href = useMemo(() => {
|
|
return createProfileFeedHref({feed: view})
|
|
}, [view])
|
|
|
|
useEffect(() => {
|
|
precacheFeedFromGeneratorView(queryClient, view)
|
|
}, [view, queryClient])
|
|
|
|
return (
|
|
<InternalLink
|
|
label={view.displayName}
|
|
to={href}
|
|
style={[a.flex_col]}
|
|
{...props}>
|
|
{children}
|
|
</InternalLink>
|
|
)
|
|
}
|
|
|
|
export function Outer({children}: {children: React.ReactNode}) {
|
|
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
|
|
}
|
|
|
|
export function Header({children}: {children: React.ReactNode}) {
|
|
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
|
}
|
|
|
|
export type AvatarProps = {src: string | undefined; size?: number}
|
|
|
|
export function Avatar({src, size = 40}: AvatarProps) {
|
|
return <UserAvatar type="algo" size={size} avatar={src} />
|
|
}
|
|
|
|
export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
|
const t = useTheme()
|
|
return (
|
|
<View
|
|
style={[
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: 8,
|
|
},
|
|
]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function TitleAndByline({
|
|
title,
|
|
creator,
|
|
uri,
|
|
}: {
|
|
title: string
|
|
creator?: bsky.profile.AnyProfileView
|
|
uri?: string
|
|
}) {
|
|
const t = useTheme()
|
|
const activeLiveEvents = useActiveLiveEventFeedUris()
|
|
const liveColor = useMemo(
|
|
() =>
|
|
select(t.name, {
|
|
dark: t.palette.negative_600,
|
|
dim: t.palette.negative_600,
|
|
light: t.palette.negative_500,
|
|
}),
|
|
[t],
|
|
)
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
{uri && activeLiveEvents.has(uri) && (
|
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
|
<LiveIcon size="xs" fill={liveColor} />
|
|
<Text
|
|
style={[
|
|
a.text_2xs,
|
|
a.font_medium,
|
|
a.leading_snug,
|
|
{color: liveColor},
|
|
]}>
|
|
<Trans>Happening now</Trans>
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<Text
|
|
emoji
|
|
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
|
|
numberOfLines={1}>
|
|
{title}
|
|
</Text>
|
|
{creator && (
|
|
<Text
|
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
|
numberOfLines={1}>
|
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View style={[a.flex_1, a.gap_xs]}>
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_50,
|
|
{
|
|
width: '60%',
|
|
height: 14,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{creator && (
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
width: '40%',
|
|
height: 10,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Description({
|
|
description,
|
|
...rest
|
|
}: {description?: string} & Partial<RichTextProps>) {
|
|
const rt = useMemo(() => {
|
|
if (!description) return
|
|
const rt = new RichTextApi({text: description || ''})
|
|
rt.detectFacetsWithoutResolution()
|
|
return rt
|
|
}, [description])
|
|
if (!rt) return null
|
|
return <RichText value={rt} disableLinks {...rest} />
|
|
}
|
|
|
|
export function DescriptionPlaceholder() {
|
|
const t = useTheme()
|
|
return (
|
|
<View style={[a.gap_xs]}>
|
|
<View
|
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
|
/>
|
|
<View
|
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
|
/>
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
a.w_full,
|
|
t.atoms.bg_contrast_50,
|
|
{height: 12, width: 100},
|
|
]}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Likes({count}: {count: number}) {
|
|
const t = useTheme()
|
|
return (
|
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
|
<Trans>
|
|
Liked by <Plural value={count || 0} one="# user" other="# users" />
|
|
</Trans>
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function SaveButton({
|
|
view,
|
|
pin,
|
|
...props
|
|
}: {
|
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
|
pin?: boolean
|
|
text?: boolean
|
|
} & Partial<ButtonProps>) {
|
|
const {hasSession} = useSession()
|
|
if (!hasSession) return null
|
|
return <SaveButtonInner view={view} pin={pin} {...props} />
|
|
}
|
|
|
|
function SaveButtonInner({
|
|
view,
|
|
pin,
|
|
text = true,
|
|
...buttonProps
|
|
}: {
|
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
|
pin?: boolean
|
|
text?: boolean
|
|
} & Partial<ButtonProps>) {
|
|
const {t: l} = useLingui()
|
|
const {data: preferences} = usePreferencesQuery()
|
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
|
useAddSavedFeedsMutation()
|
|
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
|
useRemoveFeedMutation()
|
|
|
|
const uri = view.uri
|
|
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
|
|
|
const savedFeedConfig = useMemo(() => {
|
|
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
|
}, [preferences?.savedFeeds, uri])
|
|
const removePromptControl = Prompt.usePromptControl()
|
|
const isPending = isAddSavedFeedPending || isRemovePending
|
|
|
|
const toggleSave = useCallback(
|
|
async (e: GestureResponderEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
try {
|
|
if (savedFeedConfig) {
|
|
await removeFeed(savedFeedConfig)
|
|
} else {
|
|
await saveFeeds([
|
|
{
|
|
type,
|
|
value: uri,
|
|
pinned: pin || false,
|
|
},
|
|
])
|
|
}
|
|
Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
|
|
} catch (err: any) {
|
|
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
|
Toast.show(l`Failed to update feeds`, {
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
|
)
|
|
|
|
const onPromptRemoveFeed = useCallback(
|
|
(e: GestureResponderEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
removePromptControl.open()
|
|
},
|
|
[removePromptControl],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
disabled={isPending}
|
|
label={l`Add this feed to your feeds`}
|
|
size="small"
|
|
variant="solid"
|
|
color={savedFeedConfig ? 'secondary' : 'primary'}
|
|
onPress={(e: GestureResponderEvent) =>
|
|
savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e)
|
|
}
|
|
{...buttonProps}>
|
|
{savedFeedConfig ? (
|
|
<>
|
|
{isPending ? (
|
|
<ButtonIcon size="md" icon={Loader} />
|
|
) : (
|
|
!text && <ButtonIcon size="md" icon={TrashIcon} />
|
|
)}
|
|
{text && (
|
|
<ButtonText>
|
|
<Trans>Unpin feed</Trans>
|
|
</ButtonText>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
|
{text && (
|
|
<ButtonText>
|
|
<Trans>Pin feed</Trans>
|
|
</ButtonText>
|
|
)}
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
<Prompt.Basic
|
|
control={removePromptControl}
|
|
title={l`Remove from your feeds?`}
|
|
description={l`Are you sure you want to remove this from your feeds?`}
|
|
onConfirm={(e: GestureResponderEvent) => void toggleSave(e)}
|
|
confirmButtonCta={l`Remove`}
|
|
confirmButtonColor="negative"
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function createProfileFeedHref({
|
|
feed,
|
|
}: {
|
|
feed: AppBskyFeedDefs.GeneratorView
|
|
}) {
|
|
const urip = new AtUri(feed.uri)
|
|
const handleOrDid = feed.creator.handle || feed.creator.did
|
|
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
|
}
|