An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

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

Pinned feeds cards (#4526)

* Add lists support to FeedCard

* Add useSavedFeeds query, similar to usePinnedFeedInfos

* Integrate into Feeds screen

* Fix alignment on mobile

* Update usages

* Add placeholder loading state

* Handle no feeds state

* Reuse previous data for placeholder

* Staged loading

* Improve staged loading

* Use setQueryData approach to pre-caching

* Add types for a little more safety

* Fix precaching

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Eric Bailey
Dan Abramov
and committed by
GitHub
4d678700 cb376479

+447 -233
+108 -27
src/components/FeedCard.tsx
··· 1 1 import React from 'react' 2 2 import {GestureResponderEvent, View} from 'react-native' 3 - import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyFeedDefs, 6 + AppBskyGraphDefs, 7 + AtUri, 8 + } from '@atproto/api' 4 9 import {msg, plural, Trans} from '@lingui/macro' 5 10 import {useLingui} from '@lingui/react' 6 11 ··· 20 25 import {useRichText} from '#/components/hooks/useRichText' 21 26 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 22 27 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 23 - import {Link as InternalLink} from '#/components/Link' 28 + import {Link as InternalLink, LinkProps} from '#/components/Link' 24 29 import {Loader} from '#/components/Loader' 25 30 import * as Prompt from '#/components/Prompt' 26 31 import {RichText} from '#/components/RichText' 27 32 import {Text} from '#/components/Typography' 28 33 29 - export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { 34 + export function Default({ 35 + type, 36 + view, 37 + }: 38 + | { 39 + type: 'feed' 40 + view: AppBskyFeedDefs.GeneratorView 41 + } 42 + | { 43 + type: 'list' 44 + view: AppBskyGraphDefs.ListView 45 + }) { 46 + const displayName = type === 'feed' ? view.displayName : view.name 30 47 return ( 31 - <Link feed={feed}> 48 + <Link feed={view}> 32 49 <Outer> 33 50 <Header> 34 - <Avatar src={feed.avatar} /> 35 - <TitleAndByline title={feed.displayName} creator={feed.creator} /> 36 - <Action uri={feed.uri} pin /> 51 + <Avatar src={view.avatar} /> 52 + <TitleAndByline title={displayName} creator={view.creator} /> 53 + <Action uri={view.uri} pin /> 37 54 </Header> 38 - <Description description={feed.description} /> 39 - <Likes count={feed.likeCount || 0} /> 55 + <Description description={view.description} /> 56 + {type === 'feed' && <Likes count={view.likeCount || 0} />} 40 57 </Outer> 41 58 </Link> 42 59 ) ··· 46 63 children, 47 64 feed, 48 65 }: { 49 - children: React.ReactElement 50 - feed: AppBskyFeedDefs.GeneratorView 51 - }) { 66 + feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 67 + } & Omit<LinkProps, 'to'>) { 52 68 const href = React.useMemo(() => { 53 - const urip = new AtUri(feed.uri) 54 - const handleOrDid = feed.creator.handle || feed.creator.did 55 - return `/profile/${handleOrDid}/feed/${urip.rkey}` 69 + return createProfileFeedHref({feed}) 56 70 }, [feed]) 57 71 return <InternalLink to={href}>{children}</InternalLink> 58 72 } ··· 62 76 } 63 77 64 78 export function Header({children}: {children: React.ReactNode}) { 65 - return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> 79 + return ( 80 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}> 81 + {children} 82 + </View> 83 + ) 66 84 } 67 85 68 - export function Avatar({src}: {src: string | undefined}) { 69 - return <UserAvatar type="algo" size={40} avatar={src} /> 86 + export type AvatarProps = {src: string | undefined; size?: number} 87 + 88 + export function Avatar({src, size = 40}: AvatarProps) { 89 + return <UserAvatar type="algo" size={size} avatar={src} /> 90 + } 91 + 92 + export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) { 93 + const t = useTheme() 94 + return ( 95 + <View 96 + style={[ 97 + t.atoms.bg_contrast_25, 98 + { 99 + width: size, 100 + height: size, 101 + borderRadius: 8, 102 + }, 103 + ]} 104 + /> 105 + ) 70 106 } 71 107 72 108 export function TitleAndByline({ ··· 74 110 creator, 75 111 }: { 76 112 title: string 77 - creator: AppBskyActorDefs.ProfileViewBasic 113 + creator?: AppBskyActorDefs.ProfileViewBasic 78 114 }) { 79 115 const t = useTheme() 80 116 81 117 return ( 82 118 <View style={[a.flex_1]}> 83 - <Text 84 - style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} 85 - numberOfLines={1}> 119 + <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> 86 120 {title} 87 121 </Text> 88 - <Text 89 - style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} 90 - numberOfLines={1}> 91 - <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> 92 - </Text> 122 + {creator && ( 123 + <Text 124 + style={[a.leading_snug, t.atoms.text_contrast_medium]} 125 + numberOfLines={1}> 126 + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> 127 + </Text> 128 + )} 129 + </View> 130 + ) 131 + } 132 + 133 + export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) { 134 + const t = useTheme() 135 + 136 + return ( 137 + <View style={[a.flex_1, a.gap_xs]}> 138 + <View 139 + style={[ 140 + a.rounded_xs, 141 + t.atoms.bg_contrast_50, 142 + { 143 + width: '60%', 144 + height: 14, 145 + }, 146 + ]} 147 + /> 148 + 149 + {creator && ( 150 + <View 151 + style={[ 152 + a.rounded_xs, 153 + t.atoms.bg_contrast_25, 154 + { 155 + width: '40%', 156 + height: 10, 157 + }, 158 + ]} 159 + /> 160 + )} 93 161 </View> 94 162 ) 95 163 } ··· 203 271 </> 204 272 ) 205 273 } 274 + 275 + export function createProfileFeedHref({ 276 + feed, 277 + }: { 278 + feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 279 + }) { 280 + const urip = new AtUri(feed.uri) 281 + const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list' 282 + const handleOrDid = feed.creator.handle || feed.creator.did 283 + return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${ 284 + urip.rkey 285 + }` 286 + }
+139
src/state/queries/feed.ts
··· 9 9 } from '@atproto/api' 10 10 import { 11 11 InfiniteData, 12 + QueryClient, 12 13 QueryKey, 13 14 useInfiniteQuery, 14 15 useMutation, 15 16 useQuery, 17 + useQueryClient, 16 18 } from '@tanstack/react-query' 17 19 18 20 import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' 19 21 import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 22 import {sanitizeHandle} from '#/lib/strings/handles' 21 23 import {STALE} from '#/state/queries' 24 + import {RQKEY as listQueryKey} from '#/state/queries/list' 22 25 import {usePreferencesQuery} from '#/state/queries/preferences' 23 26 import {useAgent, useSession} from '#/state/session' 24 27 import {router} from '#/routes' 25 28 import {FeedDescriptor} from './post-feed' 29 + import {precacheResolvedUri} from './resolve-uri' 26 30 27 31 export type FeedSourceFeedInfo = { 28 32 type: 'feed' ··· 201 205 const agent = useAgent() 202 206 const limit = options?.limit || 10 203 207 const {data: preferences} = usePreferencesQuery() 208 + const queryClient = useQueryClient() 204 209 205 210 // Make sure this doesn't invalidate unless really needed. 206 211 const selectArgs = useMemo( ··· 225 230 limit, 226 231 cursor: pageParam, 227 232 }) 233 + 234 + // precache feeds 235 + for (const feed of res.data.feeds) { 236 + const hydratedFeed = hydrateFeedGenerator(feed) 237 + precacheFeed(queryClient, hydratedFeed) 238 + } 239 + 228 240 return res.data 229 241 }, 230 242 initialPageParam: undefined, ··· 449 461 }, 450 462 }) 451 463 } 464 + 465 + export type SavedFeedItem = 466 + | { 467 + type: 'feed' 468 + config: AppBskyActorDefs.SavedFeed 469 + view: AppBskyFeedDefs.GeneratorView 470 + } 471 + | { 472 + type: 'list' 473 + config: AppBskyActorDefs.SavedFeed 474 + view: AppBskyGraphDefs.ListView 475 + } 476 + | { 477 + type: 'timeline' 478 + config: AppBskyActorDefs.SavedFeed 479 + view: undefined 480 + } 481 + 482 + export function useSavedFeeds() { 483 + const agent = useAgent() 484 + const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() 485 + const savedItems = preferences?.savedFeeds ?? [] 486 + const queryClient = useQueryClient() 487 + 488 + return useQuery({ 489 + staleTime: STALE.INFINITY, 490 + enabled: !isLoadingPrefs, 491 + queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems], 492 + placeholderData: previousData => { 493 + return ( 494 + previousData || { 495 + count: savedItems.length, 496 + feeds: [], 497 + } 498 + ) 499 + }, 500 + queryFn: async () => { 501 + const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>() 502 + const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>() 503 + 504 + const savedFeeds = savedItems.filter(feed => feed.type === 'feed') 505 + const savedLists = savedItems.filter(feed => feed.type === 'list') 506 + 507 + let feedsPromise = Promise.resolve() 508 + if (savedFeeds.length > 0) { 509 + feedsPromise = agent.app.bsky.feed 510 + .getFeedGenerators({ 511 + feeds: savedFeeds.map(f => f.value), 512 + }) 513 + .then(res => { 514 + res.data.feeds.forEach(f => { 515 + resolvedFeeds.set(f.uri, f) 516 + }) 517 + }) 518 + } 519 + 520 + const listsPromises = savedLists.map(list => 521 + agent.app.bsky.graph 522 + .getList({ 523 + list: list.value, 524 + limit: 1, 525 + }) 526 + .then(res => { 527 + const listView = res.data.list 528 + resolvedLists.set(listView.uri, listView) 529 + }), 530 + ) 531 + 532 + await Promise.allSettled([feedsPromise, ...listsPromises]) 533 + 534 + resolvedFeeds.forEach(feed => { 535 + const hydratedFeed = hydrateFeedGenerator(feed) 536 + precacheFeed(queryClient, hydratedFeed) 537 + }) 538 + resolvedLists.forEach(list => { 539 + precacheList(queryClient, list) 540 + }) 541 + 542 + const res: SavedFeedItem[] = savedItems.map(s => { 543 + if (s.type === 'timeline') { 544 + return { 545 + type: 'timeline', 546 + config: s, 547 + view: undefined, 548 + } 549 + } 550 + 551 + return { 552 + type: s.type, 553 + config: s, 554 + view: 555 + s.type === 'feed' 556 + ? resolvedFeeds.get(s.value) 557 + : resolvedLists.get(s.value), 558 + } 559 + }) as SavedFeedItem[] 560 + 561 + return { 562 + count: savedItems.length, 563 + feeds: res, 564 + } 565 + }, 566 + }) 567 + } 568 + 569 + function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) { 570 + precacheResolvedUri( 571 + queryClient, 572 + hydratedFeed.creatorHandle, 573 + hydratedFeed.creatorDid, 574 + ) 575 + queryClient.setQueryData<FeedSourceInfo>( 576 + feedSourceInfoQueryKey({uri: hydratedFeed.uri}), 577 + hydratedFeed, 578 + ) 579 + } 580 + 581 + function precacheList( 582 + queryClient: QueryClient, 583 + list: AppBskyGraphDefs.ListView, 584 + ) { 585 + precacheResolvedUri(queryClient, list.creator.handle, list.creator.did) 586 + queryClient.setQueryData<AppBskyGraphDefs.ListView>( 587 + listQueryKey(list.uri), 588 + list, 589 + ) 590 + }
+14 -1
src/state/queries/resolve-uri.ts
··· 1 1 import {AppBskyActorDefs, AtUri} from '@atproto/api' 2 - import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' 2 + import { 3 + QueryClient, 4 + useQuery, 5 + useQueryClient, 6 + UseQueryResult, 7 + } from '@tanstack/react-query' 3 8 4 9 import {STALE} from '#/state/queries' 5 10 import {useAgent} from '#/state/session' ··· 50 55 enabled: !!didOrHandle, 51 56 }) 52 57 } 58 + 59 + export function precacheResolvedUri( 60 + queryClient: QueryClient, 61 + handle: string, 62 + did: string, 63 + ) { 64 + queryClient.setQueryData<string>(RQKEY(handle), did) 65 + }
+184 -203
src/view/screens/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' 3 - import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 3 + import {AppBskyFeedDefs} from '@atproto/api' 6 4 import {msg, Trans} from '@lingui/macro' 7 5 import {useLingui} from '@lingui/react' 8 6 import {useFocusEffect} from '@react-navigation/native' ··· 10 8 11 9 import {isNative, isWeb} from '#/platform/detection' 12 10 import { 13 - getAvatarTypeFromUri, 14 - useFeedSourceInfoQuery, 11 + SavedFeedItem, 15 12 useGetPopularFeedsQuery, 13 + useSavedFeeds, 16 14 useSearchPopularFeedsMutation, 17 15 } from '#/state/queries/feed' 18 - import {usePreferencesQuery} from '#/state/queries/preferences' 19 16 import {useSession} from '#/state/session' 20 17 import {useSetMinimalShellMode} from '#/state/shell' 21 18 import {useComposerControls} from '#/state/shell/composer' ··· 28 25 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 29 26 import {FAB} from 'view/com/util/fab/FAB' 30 27 import {SearchInput} from 'view/com/util/forms/SearchInput' 31 - import {Link, TextLink} from 'view/com/util/Link' 28 + import {TextLink} from 'view/com/util/Link' 32 29 import {List} from 'view/com/util/List' 33 - import { 34 - FeedFeedLoadingPlaceholder, 35 - LoadingPlaceholder, 36 - } from 'view/com/util/LoadingPlaceholder' 30 + import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 37 31 import {Text} from 'view/com/util/text/Text' 38 - import {UserAvatar} from 'view/com/util/UserAvatar' 39 32 import {ViewHeader} from 'view/com/util/ViewHeader' 40 33 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 41 34 import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' ··· 47 40 import hairlineWidth = StyleSheet.hairlineWidth 48 41 import {Divider} from '#/components/Divider' 49 42 import * as FeedCard from '#/components/FeedCard' 43 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 50 44 51 45 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> 52 46 ··· 61 55 key: string 62 56 } 63 57 | { 64 - type: 'savedFeedsLoading' 58 + type: 'savedFeedPlaceholder' 65 59 key: string 66 - // pendingItems: number, 67 60 } 68 61 | { 69 62 type: 'savedFeedNoResults' ··· 72 65 | { 73 66 type: 'savedFeed' 74 67 key: string 75 - feedUri: string 76 - savedFeedConfig: AppBskyActorDefs.SavedFeed 68 + savedFeed: SavedFeedItem 77 69 } 78 70 | { 79 71 type: 'savedFeedsLoadMore' ··· 113 105 const [query, setQuery] = React.useState('') 114 106 const [isPTR, setIsPTR] = React.useState(false) 115 107 const { 116 - data: preferences, 117 - isLoading: isPreferencesLoading, 118 - error: preferencesError, 119 - refetch: refetchPreferences, 120 - } = usePreferencesQuery() 108 + data: savedFeeds, 109 + isPlaceholderData: isSavedFeedsPlaceholder, 110 + error: savedFeedsError, 111 + refetch: refetchSavedFeeds, 112 + } = useSavedFeeds() 121 113 const { 122 114 data: popularFeeds, 123 115 isFetching: isPopularFeedsFetching, ··· 173 165 const onPullToRefresh = React.useCallback(async () => { 174 166 setIsPTR(true) 175 167 await Promise.all([ 176 - refetchPreferences().catch(_e => undefined), 168 + refetchSavedFeeds().catch(_e => undefined), 177 169 refetchPopularFeeds().catch(_e => undefined), 178 170 ]) 179 171 setIsPTR(false) 180 - }, [setIsPTR, refetchPreferences, refetchPopularFeeds]) 172 + }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) 181 173 const onEndReached = React.useCallback(() => { 182 174 if ( 183 175 isPopularFeedsFetching || ··· 203 195 204 196 const items = React.useMemo(() => { 205 197 let slices: FlatlistSlice[] = [] 198 + const hasActualSavedCount = 199 + !isSavedFeedsPlaceholder || 200 + (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) 201 + const canShowDiscoverSection = 202 + !hasSession || (hasSession && hasActualSavedCount) 206 203 207 204 if (hasSession) { 208 205 slices.push({ ··· 210 207 type: 'savedFeedsHeader', 211 208 }) 212 209 213 - if (preferencesError) { 210 + if (savedFeedsError) { 214 211 slices.push({ 215 212 key: 'savedFeedsError', 216 213 type: 'error', 217 - error: cleanError(preferencesError.toString()), 214 + error: cleanError(savedFeedsError.toString()), 218 215 }) 219 216 } else { 220 - if (isPreferencesLoading || !preferences?.savedFeeds) { 221 - slices.push({ 222 - key: 'savedFeedsLoading', 223 - type: 'savedFeedsLoading', 224 - // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, 225 - }) 217 + if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) { 218 + /* 219 + * Initial render in placeholder state is 0 on a cold page load, 220 + * because preferences haven't loaded yet. 221 + * 222 + * In practice, `savedFeeds` is always defined, but we check for TS 223 + * and for safety. 224 + * 225 + * In both cases, we show 4 as the the loading state. 226 + */ 227 + const min = 8 228 + const count = savedFeeds 229 + ? savedFeeds.count === 0 230 + ? min 231 + : savedFeeds.count 232 + : min 233 + Array(count) 234 + .fill(0) 235 + .forEach((_, i) => { 236 + slices.push({ 237 + key: 'savedFeedPlaceholder' + i, 238 + type: 'savedFeedPlaceholder', 239 + }) 240 + }) 226 241 } else { 227 - if (preferences.savedFeeds?.length) { 228 - const noFollowingFeed = preferences.savedFeeds.every( 242 + if (savedFeeds?.feeds?.length) { 243 + const noFollowingFeed = savedFeeds.feeds.every( 229 244 f => f.type !== 'timeline', 230 245 ) 231 246 232 247 slices = slices.concat( 233 - preferences.savedFeeds 234 - .filter(f => { 235 - return f.pinned 248 + savedFeeds.feeds 249 + .filter(s => { 250 + return s.config.pinned 236 251 }) 237 - .map(feed => ({ 238 - key: `savedFeed:${feed.value}:${feed.id}`, 252 + .map(s => ({ 253 + key: `savedFeed:${s.view?.uri}:${s.config.id}`, 239 254 type: 'savedFeed', 240 - feedUri: feed.value, 241 - savedFeedConfig: feed, 255 + savedFeed: s, 242 256 })), 243 257 ) 244 258 slices = slices.concat( 245 - preferences.savedFeeds 246 - .filter(f => { 247 - return !f.pinned 259 + savedFeeds.feeds 260 + .filter(s => { 261 + return !s.config.pinned 248 262 }) 249 - .map(feed => ({ 250 - key: `savedFeed:${feed.value}:${feed.id}`, 263 + .map(s => ({ 264 + key: `savedFeed:${s.view?.uri}:${s.config.id}`, 251 265 type: 'savedFeed', 252 - feedUri: feed.value, 253 - savedFeedConfig: feed, 266 + savedFeed: s, 254 267 })), 255 268 ) 256 269 ··· 270 283 } 271 284 } 272 285 273 - slices.push({ 274 - key: 'popularFeedsHeader', 275 - type: 'popularFeedsHeader', 276 - }) 277 - 278 - if (popularFeedsError || searchError) { 286 + if (!hasSession || (hasSession && canShowDiscoverSection)) { 279 287 slices.push({ 280 - key: 'popularFeedsError', 281 - type: 'error', 282 - error: cleanError( 283 - popularFeedsError?.toString() ?? searchError?.toString() ?? '', 284 - ), 288 + key: 'popularFeedsHeader', 289 + type: 'popularFeedsHeader', 285 290 }) 286 - } else { 287 - if (isUserSearching) { 288 - if (isSearchPending || !searchResults) { 289 - slices.push({ 290 - key: 'popularFeedsLoading', 291 - type: 'popularFeedsLoading', 292 - }) 293 - } else { 294 - if (!searchResults || searchResults?.length === 0) { 295 - slices.push({ 296 - key: 'popularFeedsNoResults', 297 - type: 'popularFeedsNoResults', 298 - }) 299 - } else { 300 - slices = slices.concat( 301 - searchResults.map(feed => ({ 302 - key: `popularFeed:${feed.uri}`, 303 - type: 'popularFeed', 304 - feedUri: feed.uri, 305 - feed, 306 - })), 307 - ) 308 - } 309 - } 291 + 292 + if (popularFeedsError || searchError) { 293 + slices.push({ 294 + key: 'popularFeedsError', 295 + type: 'error', 296 + error: cleanError( 297 + popularFeedsError?.toString() ?? searchError?.toString() ?? '', 298 + ), 299 + }) 310 300 } else { 311 - if (isPopularFeedsFetching && !popularFeeds?.pages) { 312 - slices.push({ 313 - key: 'popularFeedsLoading', 314 - type: 'popularFeedsLoading', 315 - }) 316 - } else { 317 - if (!popularFeeds?.pages) { 301 + if (isUserSearching) { 302 + if (isSearchPending || !searchResults) { 318 303 slices.push({ 319 - key: 'popularFeedsNoResults', 320 - type: 'popularFeedsNoResults', 304 + key: 'popularFeedsLoading', 305 + type: 'popularFeedsLoading', 321 306 }) 322 307 } else { 323 - for (const page of popularFeeds.pages || []) { 308 + if (!searchResults || searchResults?.length === 0) { 309 + slices.push({ 310 + key: 'popularFeedsNoResults', 311 + type: 'popularFeedsNoResults', 312 + }) 313 + } else { 324 314 slices = slices.concat( 325 - page.feeds.map(feed => ({ 315 + searchResults.map(feed => ({ 326 316 key: `popularFeed:${feed.uri}`, 327 317 type: 'popularFeed', 328 318 feedUri: feed.uri, ··· 330 320 })), 331 321 ) 332 322 } 333 - 334 - if (isPopularFeedsFetchingNextPage) { 323 + } 324 + } else { 325 + if (isPopularFeedsFetching && !popularFeeds?.pages) { 326 + slices.push({ 327 + key: 'popularFeedsLoading', 328 + type: 'popularFeedsLoading', 329 + }) 330 + } else { 331 + if (!popularFeeds?.pages) { 335 332 slices.push({ 336 - key: 'popularFeedsLoadingMore', 337 - type: 'popularFeedsLoadingMore', 333 + key: 'popularFeedsNoResults', 334 + type: 'popularFeedsNoResults', 338 335 }) 336 + } else { 337 + for (const page of popularFeeds.pages || []) { 338 + slices = slices.concat( 339 + page.feeds.map(feed => ({ 340 + key: `popularFeed:${feed.uri}`, 341 + type: 'popularFeed', 342 + feedUri: feed.uri, 343 + feed, 344 + })), 345 + ) 346 + } 347 + 348 + if (isPopularFeedsFetchingNextPage) { 349 + slices.push({ 350 + key: 'popularFeedsLoadingMore', 351 + type: 'popularFeedsLoadingMore', 352 + }) 353 + } 339 354 } 340 355 } 341 356 } ··· 345 360 return slices 346 361 }, [ 347 362 hasSession, 348 - preferences, 349 - isPreferencesLoading, 350 - preferencesError, 363 + savedFeeds, 364 + isSavedFeedsPlaceholder, 365 + savedFeedsError, 351 366 popularFeeds, 352 367 isPopularFeedsFetching, 353 368 popularFeedsError, ··· 407 422 ({item}: {item: FlatlistSlice}) => { 408 423 if (item.type === 'error') { 409 424 return <ErrorMessage message={item.error} /> 410 - } else if ( 411 - item.type === 'popularFeedsLoadingMore' || 412 - item.type === 'savedFeedsLoading' 413 - ) { 425 + } else if (item.type === 'popularFeedsLoadingMore') { 414 426 return ( 415 427 <View style={s.p10}> 416 428 <ActivityIndicator size="large" /> ··· 459 471 <NoSavedFeedsOfAnyType /> 460 472 </View> 461 473 ) 474 + } else if (item.type === 'savedFeedPlaceholder') { 475 + return <SavedFeedPlaceholder /> 462 476 } else if (item.type === 'savedFeed') { 463 - return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> 477 + return <FeedOrFollowing savedFeed={item.savedFeed} /> 464 478 } else if (item.type === 'popularFeedsHeader') { 465 479 return ( 466 480 <> ··· 481 495 } else if (item.type === 'popularFeed') { 482 496 return ( 483 497 <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> 484 - <FeedCard.Default feed={item.feed} /> 498 + <FeedCard.Default type="feed" view={item.feed} /> 485 499 <Divider /> 486 500 </View> 487 501 ) ··· 571 585 ) 572 586 } 573 587 574 - function FeedOrFollowing({ 575 - savedFeedConfig: feed, 576 - }: { 577 - savedFeedConfig: AppBskyActorDefs.SavedFeed 578 - }) { 579 - return feed.type === 'timeline' ? ( 588 + function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { 589 + return savedFeed.type === 'timeline' ? ( 580 590 <FollowingFeed /> 581 591 ) : ( 582 - <SavedFeed savedFeedConfig={feed} /> 592 + <SavedFeed savedFeed={savedFeed} /> 583 593 ) 584 594 } 585 595 586 596 function FollowingFeed() { 587 - const pal = usePalette('default') 588 597 const t = useTheme() 589 - const {isMobile} = useWebMediaQueries() 598 + const {_} = useLingui() 590 599 return ( 591 600 <View 592 - testID={`saved-feed-timeline`} 593 601 style={[ 594 - pal.border, 595 - styles.savedFeed, 596 - isMobile && styles.savedFeedMobile, 602 + a.flex_1, 603 + a.px_lg, 604 + a.py_md, 605 + a.border_b, 606 + t.atoms.border_contrast_low, 597 607 ]}> 598 - <View 599 - style={[ 600 - a.align_center, 601 - a.justify_center, 602 - { 603 - width: 28, 604 - height: 28, 605 - borderRadius: 3, 606 - backgroundColor: t.palette.primary_500, 607 - }, 608 - ]}> 609 - <FilterTimeline 608 + <FeedCard.Header> 609 + <View 610 610 style={[ 611 + a.align_center, 612 + a.justify_center, 611 613 { 612 - width: 18, 613 - height: 18, 614 + width: 28, 615 + height: 28, 616 + borderRadius: 3, 617 + backgroundColor: t.palette.primary_500, 614 618 }, 615 - ]} 616 - fill={t.palette.white} 617 - /> 618 - </View> 619 - <View 620 - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 621 - <Text type="lg-medium" style={pal.text} numberOfLines={1}> 622 - <Trans>Following</Trans> 623 - </Text> 624 - </View> 619 + ]}> 620 + <FilterTimeline 621 + style={[ 622 + { 623 + width: 18, 624 + height: 18, 625 + }, 626 + ]} 627 + fill={t.palette.white} 628 + /> 629 + </View> 630 + <FeedCard.TitleAndByline title={_(msg`Following`)} /> 631 + </FeedCard.Header> 625 632 </View> 626 633 ) 627 634 } 628 635 629 636 function SavedFeed({ 630 - savedFeedConfig: feed, 637 + savedFeed, 631 638 }: { 632 - savedFeedConfig: AppBskyActorDefs.SavedFeed 639 + savedFeed: SavedFeedItem & {type: 'feed' | 'list'} 633 640 }) { 634 - const pal = usePalette('default') 635 - const {isMobile} = useWebMediaQueries() 636 - const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) 637 - const typeAvatar = getAvatarTypeFromUri(feed.value) 638 - 639 - if (!info) 640 - return ( 641 - <SavedFeedLoadingPlaceholder 642 - key={`savedFeedLoadingPlaceholder:${feed.value}`} 643 - /> 644 - ) 641 + const t = useTheme() 642 + const {view: feed} = savedFeed 643 + const displayName = 644 + savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name 645 645 646 646 return ( 647 - <Link 648 - testID={`saved-feed-${info.displayName}`} 649 - href={info.route.href} 650 - style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} 651 - hoverStyle={pal.viewLight} 652 - accessibilityLabel={info.displayName} 653 - accessibilityHint="" 654 - asAnchor 655 - anchorNoUnderline> 656 - {error ? ( 647 + <FeedCard.Link testID={`saved-feed-${feed.displayName}`} feed={feed}> 648 + {({hovered, pressed}) => ( 657 649 <View 658 - style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> 659 - <FontAwesomeIcon 660 - icon="exclamation-circle" 661 - color={pal.colors.textLight} 662 - /> 663 - </View> 664 - ) : ( 665 - <UserAvatar type={typeAvatar} size={28} avatar={info.avatar} /> 666 - )} 667 - <View 668 - style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> 669 - <Text type="lg-medium" style={pal.text} numberOfLines={1}> 670 - {info.displayName} 671 - </Text> 672 - {error ? ( 673 - <View style={[styles.offlineSlug, pal.borderDark]}> 674 - <Text type="xs" style={pal.textLight}> 675 - <Trans>Feed offline</Trans> 676 - </Text> 677 - </View> 678 - ) : null} 679 - </View> 650 + style={[ 651 + a.flex_1, 652 + a.px_lg, 653 + a.py_md, 654 + a.border_b, 655 + t.atoms.border_contrast_low, 656 + (hovered || pressed) && t.atoms.bg_contrast_25, 657 + ]}> 658 + <FeedCard.Header> 659 + <FeedCard.Avatar src={feed.avatar} size={28} /> 660 + <FeedCard.TitleAndByline title={displayName} /> 680 661 681 - {isMobile && ( 682 - <FontAwesomeIcon 683 - icon="chevron-right" 684 - size={14} 685 - style={pal.textLight as FontAwesomeIconStyle} 686 - /> 662 + <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} /> 663 + </FeedCard.Header> 664 + </View> 687 665 )} 688 - </Link> 666 + </FeedCard.Link> 689 667 ) 690 668 } 691 669 692 - function SavedFeedLoadingPlaceholder() { 693 - const pal = usePalette('default') 694 - const {isMobile} = useWebMediaQueries() 670 + function SavedFeedPlaceholder() { 671 + const t = useTheme() 695 672 return ( 696 673 <View 697 674 style={[ 698 - pal.border, 699 - styles.savedFeed, 700 - isMobile && styles.savedFeedMobile, 675 + a.flex_1, 676 + a.px_lg, 677 + a.py_md, 678 + a.border_b, 679 + t.atoms.border_contrast_low, 701 680 ]}> 702 - <LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} /> 703 - <LoadingPlaceholder width={140} height={12} /> 681 + <FeedCard.Header> 682 + <FeedCard.AvatarPlaceholder size={28} /> 683 + <FeedCard.TitleAndBylinePlaceholder /> 684 + </FeedCard.Header> 704 685 </View> 705 686 ) 706 687 }
+1 -1
src/view/screens/Search/Explore.tsx
··· 505 505 a.px_lg, 506 506 a.py_lg, 507 507 ]}> 508 - <FeedCard.Default feed={item.feed} /> 508 + <FeedCard.Default type="feed" view={item.feed} /> 509 509 </View> 510 510 ) 511 511 }
+1 -1
src/view/screens/Search/Search.tsx
··· 306 306 a.px_lg, 307 307 a.py_lg, 308 308 ]}> 309 - <FeedCard.Default feed={item} /> 309 + <FeedCard.Default type="feed" view={item} /> 310 310 </View> 311 311 )} 312 312 keyExtractor={item => item.uri}