Add starter pack embeds to posts (#4699)

* starter pack embeds

* revert test code

* Types

* add `BaseLink`

* precache on click

* rm log

* add a comment

* loading state

* top margin

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Hailey 2024-07-03 18:15:08 -07:00 committed by GitHub
parent a3d4fb652b
commit aa7117edb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 246 additions and 41 deletions

View File

@ -1,5 +1,10 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent} from 'react-native' import {
GestureResponderEvent,
Pressable,
StyleProp,
ViewStyle,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native' import {StackActions, useLinkProps} from '@react-navigation/native'
@ -323,3 +328,45 @@ export function InlineLinkText({
</Text> </Text>
) )
} }
/**
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
* in Link are not desired.
* @param displayText
* @param style
* @param children
* @param rest
* @constructor
*/
export function BaseLink({
displayText,
onPress: onPressOuter,
style,
children,
...rest
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
to: string
action: 'push' | 'replace' | 'navigate'
onPress?: () => false | void
shareOnLongPress?: boolean
label: string
displayText?: string
}) {
const {onPress, ...btnProps} = useLink({
displayText: displayText ?? rest.to,
...rest,
})
return (
<Pressable
style={style}
onPress={e => {
onPressOuter?.()
onPress(e)
}}
{...btnProps}>
{children}
</Pressable>
)
}

View File

@ -11,10 +11,12 @@ import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted' import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List' import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types' import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard' import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@ -33,18 +35,17 @@ interface ProfilesListProps {
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
function ProfilesListImpl( function ProfilesListImpl(
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, {listUri, moderationOpts, headerHeight, scrollElRef},
ref, ref,
) { ) {
const t = useTheme() const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight) const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20) const bottomBarOffset = useBottomBarOffset(20)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const {data, refetch} = listMembersQuery
// The server returns these sorted by descending creation date, so we want to invert // The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject)) .flatMap(p => p.items.map(i => i.subject))
@ -96,7 +97,19 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
) )
} }
if (listMembersQuery) if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
onRetry={refetch}
/>
</View>
)
}
if (data)
return ( return (
<List <List
data={getSortedProfiles()} data={getSortedProfiles()}

View File

@ -1,15 +1,20 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {precacheResolvedUri} from 'state/queries/resolve-uri'
import {precacheStarterPack} from 'state/queries/starter-packs'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack' import {StarterPack} from '#/components/icons/StarterPack'
import {Link as InternalLink, LinkProps} from '#/components/Link' import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
@ -88,10 +93,13 @@ export function Card({
export function Link({ export function Link({
starterPack, starterPack,
children, children,
...rest
}: { }: {
starterPack: StarterPackViewBasic starterPack: StarterPackViewBasic
} & Omit<LinkProps, 'to'>) { onPress?: () => void
children: React.ReactNode
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {record} = starterPack const {record} = starterPack
const {rkey, handleOrDid} = React.useMemo(() => { const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(starterPack.uri).rkey const rkey = new AtUri(starterPack.uri).rkey
@ -104,14 +112,46 @@ export function Link({
} }
return ( return (
<InternalLink <BaseLink
label={record.name} action="push"
{...rest} to={`/starter-pack/${handleOrDid}/${rkey}`}
to={{ label={_(msg`Navigate to ${record.name}`)}
screen: 'StarterPack', onPress={() => {
params: {name: handleOrDid, rkey}, precacheResolvedUri(
queryClient,
starterPack.creator.handle,
starterPack.creator.did,
)
precacheStarterPack(queryClient, starterPack)
}}> }}>
{children} {children}
</InternalLink> </BaseLink>
)
}
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
return (
<View
style={[
a.mt_xs,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<Link starterPack={starterPack}>
<Image
source={imageUri}
style={[a.w_full, {aspectRatio: 1.91}]}
accessibilityIgnoresInvertColors={true}
/>
<View style={[a.px_sm, a.py_md]}>
<Card starterPack={starterPack} />
</View>
</Link>
</View>
) )
} }

View File

@ -1,11 +1,16 @@
import {AppBskyFeedPost, BskyAgent} from '@atproto/api' import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {LikelyType, LinkMeta} from './link-meta' import {
createStarterPackUri,
parseStarterPackUri,
} from 'lib/strings/starter-pack'
import {ComposerOptsQuote} from 'state/shell/composer'
// import {match as matchRoute} from 'view/routes' // import {match as matchRoute} from 'view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
import {ComposerOptsQuote} from 'state/shell/composer' import {LikelyType, LinkMeta} from './link-meta'
import {useGetPost} from '#/state/queries/post'
import {useFetchDid} from '#/state/queries/handle'
// TODO // TODO
// import {Home} from 'view/screens/Home' // import {Home} from 'view/screens/Home'
@ -174,3 +179,39 @@ export async function getListAsEmbed(
}, },
} }
} }
export async function getStarterPackAsEmbed(
agent: BskyAgent,
fetchDid: ReturnType<typeof useFetchDid>,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
const parsed = parseStarterPackUri(url)
if (!parsed) {
throw new Error(
'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
)
}
const did = await fetchDid(parsed.name)
const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
const res = await agent.app.bsky.graph.getStarterPack({starterPack})
const record = res.data.starterPack.record
return {
isLoading: false,
uri: starterPack,
meta: {
url: starterPack,
likelyType: LikelyType.AtpData,
// Validation here should never fail
title: AppBskyGraphStarterpack.isRecord(record)
? record.name
: 'Starter Pack',
},
embed: {
$type: 'app.bsky.embed.record',
record: {
uri: res.data.starterPack.uri,
cid: res.data.starterPack.cid,
},
},
}
}

View File

@ -96,7 +96,7 @@ export function createStarterPackUri({
}: { }: {
did: string did: string
rkey: string rkey: string
}): string | null { }): string {
return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString() return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
} }

View File

@ -152,6 +152,30 @@ export function isBskyListUrl(url: string): boolean {
return false return false
} }
export function isBskyStartUrl(url: string): boolean {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
} catch {
console.error('Unexpected error in isBskyStartUrl()', url)
}
}
return false
}
export function isBskyStarterPackUrl(url: string): boolean {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
} catch {
console.error('Unexpected error in isBskyStartUrl()', url)
}
}
return false
}
export function isBskyDownloadUrl(url: string): boolean { export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) { if (isExternalUrl(url)) {
return false return false

View File

@ -3,7 +3,6 @@ import {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import { import {
AppBskyGraphDefs, AppBskyGraphDefs,
AppBskyGraphGetList,
AppBskyGraphStarterpack, AppBskyGraphStarterpack,
AtUri, AtUri,
ModerationOpts, ModerationOpts,
@ -14,11 +13,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import { import {useQueryClient} from '@tanstack/react-query'
InfiniteData,
UseInfiniteQueryResult,
useQueryClient,
} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
@ -33,7 +28,6 @@ import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {updateProfileShadow} from 'state/cache/profile-shadow' import {updateProfileShadow} from 'state/cache/profile-shadow'
import {useModerationOpts} from 'state/preferences/moderation-opts' import {useModerationOpts} from 'state/preferences/moderation-opts'
import {useListMembersQuery} from 'state/queries/list-members'
import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link' import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
import {useResolveDidQuery} from 'state/queries/resolve-uri' import {useResolveDidQuery} from 'state/queries/resolve-uri'
import {useShortenLink} from 'state/queries/shorten-link' import {useShortenLink} from 'state/queries/shorten-link'
@ -123,7 +117,6 @@ export function StarterPackScreenInner({
isLoading: isLoadingStarterPack, isLoading: isLoadingStarterPack,
isError: isErrorStarterPack, isError: isErrorStarterPack,
} = useStarterPackQuery({did, rkey}) } = useStarterPackQuery({did, rkey})
const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50)
const isValid = const isValid =
starterPack && starterPack &&
@ -134,12 +127,7 @@ export function StarterPackScreenInner({
if (!did || !starterPack || !isValid || !moderationOpts) { if (!did || !starterPack || !isValid || !moderationOpts) {
return ( return (
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={ isLoading={isLoadingDid || isLoadingStarterPack || !moderationOpts}
isLoadingDid ||
isLoadingStarterPack ||
listMembersQuery.isLoading ||
!moderationOpts
}
isError={isErrorDid || isErrorStarterPack || !isValid} isError={isErrorDid || isErrorStarterPack || !isValid}
errorMessage={_(msg`That starter pack could not be found.`)} errorMessage={_(msg`That starter pack could not be found.`)}
emptyMessage={_(msg`That starter pack could not be found.`)} emptyMessage={_(msg`That starter pack could not be found.`)}
@ -155,7 +143,6 @@ export function StarterPackScreenInner({
<StarterPackScreenLoaded <StarterPackScreenLoaded
starterPack={starterPack} starterPack={starterPack}
routeParams={routeParams} routeParams={routeParams}
listMembersQuery={listMembersQuery}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
) )
@ -164,14 +151,10 @@ export function StarterPackScreenInner({
function StarterPackScreenLoaded({ function StarterPackScreenLoaded({
starterPack, starterPack,
routeParams, routeParams,
listMembersQuery,
moderationOpts, moderationOpts,
}: { }: {
starterPack: AppBskyGraphDefs.StarterPackView starterPack: AppBskyGraphDefs.StarterPackView
routeParams: StarterPackScreeProps['route']['params'] routeParams: StarterPackScreeProps['route']['params']
listMembersQuery: UseInfiniteQueryResult<
InfiniteData<AppBskyGraphGetList.OutputSchema>
>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const showPeopleTab = Boolean(starterPack.list) const showPeopleTab = Boolean(starterPack.list)
@ -242,7 +225,6 @@ function StarterPackScreenLoaded({
headerHeight={headerHeight} headerHeight={headerHeight}
// @ts-expect-error // @ts-expect-error
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
listMembersQuery={listMembersQuery}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
) )

View File

@ -347,3 +347,36 @@ async function whenAppViewReady(
() => agent.app.bsky.graph.getStarterPack({starterPack: uri}), () => agent.app.bsky.graph.getStarterPack({starterPack: uri}),
) )
} }
export async function precacheStarterPack(
queryClient: QueryClient,
starterPack:
| AppBskyGraphDefs.StarterPackViewBasic
| AppBskyGraphDefs.StarterPackView,
) {
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
return
}
let starterPackView: AppBskyGraphDefs.StarterPackView | undefined
if (AppBskyGraphDefs.isStarterPackView(starterPack)) {
starterPackView = starterPack
} else if (AppBskyGraphDefs.isStarterPackViewBasic(starterPack)) {
const listView: AppBskyGraphDefs.ListViewBasic = {
uri: starterPack.record.list,
// This will be populated once the data from server is fetched
cid: '',
name: starterPack.record.name,
purpose: 'app.bsky.graph.defs#referencelist',
}
starterPackView = {
...starterPack,
$type: 'app.bsky.graph.defs#starterPackView',
list: listView,
}
}
if (starterPackView) {
queryClient.setQueryData(RQKEY({uri: starterPack.uri}), starterPackView)
}
}

View File

@ -10,6 +10,7 @@ import {
getFeedAsEmbed, getFeedAsEmbed,
getListAsEmbed, getListAsEmbed,
getPostAsQuote, getPostAsQuote,
getStarterPackAsEmbed,
} from 'lib/link-meta/bsky' } from 'lib/link-meta/bsky'
import {getLinkMeta} from 'lib/link-meta/link-meta' import {getLinkMeta} from 'lib/link-meta/link-meta'
import {resolveShortLink} from 'lib/link-meta/resolve-short-link' import {resolveShortLink} from 'lib/link-meta/resolve-short-link'
@ -18,6 +19,8 @@ import {
isBskyCustomFeedUrl, isBskyCustomFeedUrl,
isBskyListUrl, isBskyListUrl,
isBskyPostUrl, isBskyPostUrl,
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink, isShortLink,
} from 'lib/strings/url-helpers' } from 'lib/strings/url-helpers'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
@ -96,6 +99,23 @@ export function useExternalLinkFetch({
setExtLink(undefined) setExtLink(undefined)
}, },
) )
} else if (
isBskyStartUrl(extLink.uri) ||
isBskyStarterPackUrl(extLink.uri)
) {
getStarterPackAsEmbed(agent, fetchDid, extLink.uri).then(
({embed, meta}) => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: false,
meta,
embed,
})
},
)
} else if (isShortLink(extLink.uri)) { } else if (isShortLink(extLink.uri)) {
if (isShortLink(extLink.uri)) { if (isShortLink(extLink.uri)) {
resolveShortLink(extLink.uri).then(res => { resolveShortLink(extLink.uri).then(res => {

View File

@ -30,6 +30,7 @@ import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
type Embed = type Embed =
| AppBskyEmbedRecord.View | AppBskyEmbedRecord.View
@ -90,6 +91,10 @@ export function PostEmbeds({
return <ListEmbed item={embed.record} /> return <ListEmbed item={embed.record} />
} }
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
return <StarterPackCard starterPack={embed.record} />
}
// quote post // quote post
// = // =
return ( return (