Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Hailey
Dan Abramov
and committed by
GitHub
aa7117ed a3d4fb65

+246 -41
+48 -1
src/components/Link.tsx
··· 1 1 import React from 'react' 2 - import {GestureResponderEvent} from 'react-native' 2 + import { 3 + GestureResponderEvent, 4 + Pressable, 5 + StyleProp, 6 + ViewStyle, 7 + } from 'react-native' 3 8 import {sanitizeUrl} from '@braintree/sanitize-url' 4 9 import {StackActions, useLinkProps} from '@react-navigation/native' 5 10 ··· 323 328 </Text> 324 329 ) 325 330 } 331 + 332 + /** 333 + * A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles 334 + * in Link are not desired. 335 + * @param displayText 336 + * @param style 337 + * @param children 338 + * @param rest 339 + * @constructor 340 + */ 341 + export function BaseLink({ 342 + displayText, 343 + onPress: onPressOuter, 344 + style, 345 + children, 346 + ...rest 347 + }: { 348 + style?: StyleProp<ViewStyle> 349 + children: React.ReactNode 350 + to: string 351 + action: 'push' | 'replace' | 'navigate' 352 + onPress?: () => false | void 353 + shareOnLongPress?: boolean 354 + label: string 355 + displayText?: string 356 + }) { 357 + const {onPress, ...btnProps} = useLink({ 358 + displayText: displayText ?? rest.to, 359 + ...rest, 360 + }) 361 + return ( 362 + <Pressable 363 + style={style} 364 + onPress={e => { 365 + onPressOuter?.() 366 + onPress(e) 367 + }} 368 + {...btnProps}> 369 + {children} 370 + </Pressable> 371 + ) 372 + }
+17 -4
src/components/StarterPack/Main/ProfilesList.tsx
··· 11 11 import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' 12 12 import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted' 13 13 import {isNative, isWeb} from 'platform/detection' 14 + import {useListMembersQuery} from 'state/queries/list-members' 14 15 import {useSession} from 'state/session' 15 16 import {List, ListRef} from 'view/com/util/List' 16 17 import {SectionRef} from '#/screens/Profile/Sections/types' 17 18 import {atoms as a, useTheme} from '#/alf' 19 + import {ListMaybePlaceholder} from '#/components/Lists' 18 20 import {Default as ProfileCard} from '#/components/ProfileCard' 19 21 20 22 function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { ··· 33 35 34 36 export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( 35 37 function ProfilesListImpl( 36 - {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, 38 + {listUri, moderationOpts, headerHeight, scrollElRef}, 37 39 ref, 38 40 ) { 39 41 const t = useTheme() 40 42 const [initialHeaderHeight] = React.useState(headerHeight) 41 43 const bottomBarOffset = useBottomBarOffset(20) 42 44 const {currentAccount} = useSession() 45 + const {data, refetch, isError} = useListMembersQuery(listUri, 50) 43 46 44 47 const [isPTRing, setIsPTRing] = React.useState(false) 45 - 46 - const {data, refetch} = listMembersQuery 47 48 48 49 // The server returns these sorted by descending creation date, so we want to invert 49 50 const profiles = data?.pages ··· 96 97 ) 97 98 } 98 99 99 - if (listMembersQuery) 100 + if (!data) { 101 + return ( 102 + <View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}> 103 + <ListMaybePlaceholder 104 + isLoading={true} 105 + isError={isError} 106 + onRetry={refetch} 107 + /> 108 + </View> 109 + ) 110 + } 111 + 112 + if (data) 100 113 return ( 101 114 <List 102 115 data={getSortedProfiles()}
+50 -10
src/components/StarterPack/StarterPackCard.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {Image} from 'expo-image' 3 4 import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' 4 5 import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' 8 + import {useQueryClient} from '@tanstack/react-query' 7 9 8 10 import {sanitizeHandle} from 'lib/strings/handles' 11 + import {getStarterPackOgCard} from 'lib/strings/starter-pack' 12 + import {precacheResolvedUri} from 'state/queries/resolve-uri' 13 + import {precacheStarterPack} from 'state/queries/starter-packs' 9 14 import {useSession} from 'state/session' 10 15 import {atoms as a, useTheme} from '#/alf' 11 16 import {StarterPack} from '#/components/icons/StarterPack' 12 - import {Link as InternalLink, LinkProps} from '#/components/Link' 17 + import {BaseLink} from '#/components/Link' 13 18 import {Text} from '#/components/Typography' 14 19 15 20 export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { ··· 88 93 export function Link({ 89 94 starterPack, 90 95 children, 91 - ...rest 92 96 }: { 93 97 starterPack: StarterPackViewBasic 94 - } & Omit<LinkProps, 'to'>) { 98 + onPress?: () => void 99 + children: React.ReactNode 100 + }) { 101 + const {_} = useLingui() 102 + const queryClient = useQueryClient() 95 103 const {record} = starterPack 96 104 const {rkey, handleOrDid} = React.useMemo(() => { 97 105 const rkey = new AtUri(starterPack.uri).rkey ··· 104 112 } 105 113 106 114 return ( 107 - <InternalLink 108 - label={record.name} 109 - {...rest} 110 - to={{ 111 - screen: 'StarterPack', 112 - params: {name: handleOrDid, rkey}, 115 + <BaseLink 116 + action="push" 117 + to={`/starter-pack/${handleOrDid}/${rkey}`} 118 + label={_(msg`Navigate to ${record.name}`)} 119 + onPress={() => { 120 + precacheResolvedUri( 121 + queryClient, 122 + starterPack.creator.handle, 123 + starterPack.creator.did, 124 + ) 125 + precacheStarterPack(queryClient, starterPack) 113 126 }}> 114 127 {children} 115 - </InternalLink> 128 + </BaseLink> 129 + ) 130 + } 131 + 132 + export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) { 133 + const t = useTheme() 134 + const imageUri = getStarterPackOgCard(starterPack) 135 + 136 + return ( 137 + <View 138 + style={[ 139 + a.mt_xs, 140 + a.border, 141 + a.rounded_sm, 142 + a.overflow_hidden, 143 + t.atoms.border_contrast_low, 144 + ]}> 145 + <Link starterPack={starterPack}> 146 + <Image 147 + source={imageUri} 148 + style={[a.w_full, {aspectRatio: 1.91}]} 149 + accessibilityIgnoresInvertColors={true} 150 + /> 151 + <View style={[a.px_sm, a.py_md]}> 152 + <Card starterPack={starterPack} /> 153 + </View> 154 + </Link> 155 + </View> 116 156 ) 117 157 }
+1 -1
src/lib/strings/starter-pack.ts
··· 96 96 }: { 97 97 did: string 98 98 rkey: string 99 - }): string | null { 99 + }): string { 100 100 return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString() 101 101 } 102 102
+24
src/lib/strings/url-helpers.ts
··· 152 152 return false 153 153 } 154 154 155 + export function isBskyStartUrl(url: string): boolean { 156 + if (isBskyAppUrl(url)) { 157 + try { 158 + const urlp = new URL(url) 159 + return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname) 160 + } catch { 161 + console.error('Unexpected error in isBskyStartUrl()', url) 162 + } 163 + } 164 + return false 165 + } 166 + 167 + export function isBskyStarterPackUrl(url: string): boolean { 168 + if (isBskyAppUrl(url)) { 169 + try { 170 + const urlp = new URL(url) 171 + return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname) 172 + } catch { 173 + console.error('Unexpected error in isBskyStartUrl()', url) 174 + } 175 + } 176 + return false 177 + } 178 + 155 179 export function isBskyDownloadUrl(url: string): boolean { 156 180 if (isExternalUrl(url)) { 157 181 return false
+2 -20
src/screens/StarterPack/StarterPackScreen.tsx
··· 3 3 import {Image} from 'expo-image' 4 4 import { 5 5 AppBskyGraphDefs, 6 - AppBskyGraphGetList, 7 6 AppBskyGraphStarterpack, 8 7 AtUri, 9 8 ModerationOpts, ··· 14 13 import {useLingui} from '@lingui/react' 15 14 import {useNavigation} from '@react-navigation/native' 16 15 import {NativeStackScreenProps} from '@react-navigation/native-stack' 17 - import { 18 - InfiniteData, 19 - UseInfiniteQueryResult, 20 - useQueryClient, 21 - } from '@tanstack/react-query' 16 + import {useQueryClient} from '@tanstack/react-query' 22 17 23 18 import {cleanError} from '#/lib/strings/errors' 24 19 import {logger} from '#/logger' ··· 33 28 import {isWeb} from 'platform/detection' 34 29 import {updateProfileShadow} from 'state/cache/profile-shadow' 35 30 import {useModerationOpts} from 'state/preferences/moderation-opts' 36 - import {useListMembersQuery} from 'state/queries/list-members' 37 31 import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link' 38 32 import {useResolveDidQuery} from 'state/queries/resolve-uri' 39 33 import {useShortenLink} from 'state/queries/shorten-link' ··· 123 117 isLoading: isLoadingStarterPack, 124 118 isError: isErrorStarterPack, 125 119 } = useStarterPackQuery({did, rkey}) 126 - const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50) 127 120 128 121 const isValid = 129 122 starterPack && ··· 134 127 if (!did || !starterPack || !isValid || !moderationOpts) { 135 128 return ( 136 129 <ListMaybePlaceholder 137 - isLoading={ 138 - isLoadingDid || 139 - isLoadingStarterPack || 140 - listMembersQuery.isLoading || 141 - !moderationOpts 142 - } 130 + isLoading={isLoadingDid || isLoadingStarterPack || !moderationOpts} 143 131 isError={isErrorDid || isErrorStarterPack || !isValid} 144 132 errorMessage={_(msg`That starter pack could not be found.`)} 145 133 emptyMessage={_(msg`That starter pack could not be found.`)} ··· 155 143 <StarterPackScreenLoaded 156 144 starterPack={starterPack} 157 145 routeParams={routeParams} 158 - listMembersQuery={listMembersQuery} 159 146 moderationOpts={moderationOpts} 160 147 /> 161 148 ) ··· 164 151 function StarterPackScreenLoaded({ 165 152 starterPack, 166 153 routeParams, 167 - listMembersQuery, 168 154 moderationOpts, 169 155 }: { 170 156 starterPack: AppBskyGraphDefs.StarterPackView 171 157 routeParams: StarterPackScreeProps['route']['params'] 172 - listMembersQuery: UseInfiniteQueryResult< 173 - InfiniteData<AppBskyGraphGetList.OutputSchema> 174 - > 175 158 moderationOpts: ModerationOpts 176 159 }) { 177 160 const showPeopleTab = Boolean(starterPack.list) ··· 242 225 headerHeight={headerHeight} 243 226 // @ts-expect-error 244 227 scrollElRef={scrollElRef} 245 - listMembersQuery={listMembersQuery} 246 228 moderationOpts={moderationOpts} 247 229 /> 248 230 )
+33
src/state/queries/starter-packs.ts
··· 347 347 () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), 348 348 ) 349 349 } 350 + 351 + export async function precacheStarterPack( 352 + queryClient: QueryClient, 353 + starterPack: 354 + | AppBskyGraphDefs.StarterPackViewBasic 355 + | AppBskyGraphDefs.StarterPackView, 356 + ) { 357 + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { 358 + return 359 + } 360 + 361 + let starterPackView: AppBskyGraphDefs.StarterPackView | undefined 362 + if (AppBskyGraphDefs.isStarterPackView(starterPack)) { 363 + starterPackView = starterPack 364 + } else if (AppBskyGraphDefs.isStarterPackViewBasic(starterPack)) { 365 + const listView: AppBskyGraphDefs.ListViewBasic = { 366 + uri: starterPack.record.list, 367 + // This will be populated once the data from server is fetched 368 + cid: '', 369 + name: starterPack.record.name, 370 + purpose: 'app.bsky.graph.defs#referencelist', 371 + } 372 + starterPackView = { 373 + ...starterPack, 374 + $type: 'app.bsky.graph.defs#starterPackView', 375 + list: listView, 376 + } 377 + } 378 + 379 + if (starterPackView) { 380 + queryClient.setQueryData(RQKEY({uri: starterPack.uri}), starterPackView) 381 + } 382 + }
+20
src/view/com/composer/useExternalLinkFetch.ts
··· 10 10 getFeedAsEmbed, 11 11 getListAsEmbed, 12 12 getPostAsQuote, 13 + getStarterPackAsEmbed, 13 14 } from 'lib/link-meta/bsky' 14 15 import {getLinkMeta} from 'lib/link-meta/link-meta' 15 16 import {resolveShortLink} from 'lib/link-meta/resolve-short-link' ··· 18 19 isBskyCustomFeedUrl, 19 20 isBskyListUrl, 20 21 isBskyPostUrl, 22 + isBskyStarterPackUrl, 23 + isBskyStartUrl, 21 24 isShortLink, 22 25 } from 'lib/strings/url-helpers' 23 26 import {ImageModel} from 'state/models/media/image' ··· 94 97 err => { 95 98 logger.error('Failed to fetch list for embedding', {message: err}) 96 99 setExtLink(undefined) 100 + }, 101 + ) 102 + } else if ( 103 + isBskyStartUrl(extLink.uri) || 104 + isBskyStarterPackUrl(extLink.uri) 105 + ) { 106 + getStarterPackAsEmbed(agent, fetchDid, extLink.uri).then( 107 + ({embed, meta}) => { 108 + if (aborted) { 109 + return 110 + } 111 + setExtLink({ 112 + uri: extLink.uri, 113 + isLoading: false, 114 + meta, 115 + embed, 116 + }) 97 117 }, 98 118 ) 99 119 } else if (isShortLink(extLink.uri)) {
+5
src/view/com/util/post-embeds/index.tsx
··· 30 30 import {MaybeQuoteEmbed} from './QuoteEmbed' 31 31 import hairlineWidth = StyleSheet.hairlineWidth 32 32 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 33 + import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' 33 34 34 35 type Embed = 35 36 | AppBskyEmbedRecord.View ··· 88 89 if (AppBskyGraphDefs.isListView(embed.record)) { 89 90 // TODO moderation 90 91 return <ListEmbed item={embed.record} /> 92 + } 93 + 94 + if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 95 + return <StarterPackCard starterPack={embed.record} /> 91 96 } 92 97 93 98 // quote post