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>
zio/stable
Eric Bailey 2024-06-21 16:50:23 -05:00 committed by GitHub
parent cb37647949
commit 4d6787009c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 447 additions and 233 deletions

View File

@ -1,6 +1,11 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent, View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro' import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -20,23 +25,35 @@ import {Button, ButtonIcon} from '#/components/Button'
import {useRichText} from '#/components/hooks/useRichText' import {useRichText} from '#/components/hooks/useRichText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink} from '#/components/Link' import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { export function Default({
type,
view,
}:
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}) {
const displayName = type === 'feed' ? view.displayName : view.name
return ( return (
<Link feed={feed}> <Link feed={view}>
<Outer> <Outer>
<Header> <Header>
<Avatar src={feed.avatar} /> <Avatar src={view.avatar} />
<TitleAndByline title={feed.displayName} creator={feed.creator} /> <TitleAndByline title={displayName} creator={view.creator} />
<Action uri={feed.uri} pin /> <Action uri={view.uri} pin />
</Header> </Header>
<Description description={feed.description} /> <Description description={view.description} />
<Likes count={feed.likeCount || 0} /> {type === 'feed' && <Likes count={view.likeCount || 0} />}
</Outer> </Outer>
</Link> </Link>
) )
@ -46,13 +63,10 @@ export function Link({
children, children,
feed, feed,
}: { }: {
children: React.ReactElement feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
feed: AppBskyFeedDefs.GeneratorView } & Omit<LinkProps, 'to'>) {
}) {
const href = React.useMemo(() => { const href = React.useMemo(() => {
const urip = new AtUri(feed.uri) return createProfileFeedHref({feed})
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}, [feed]) }, [feed])
return <InternalLink to={href}>{children}</InternalLink> return <InternalLink to={href}>{children}</InternalLink>
} }
@ -62,11 +76,33 @@ export function Outer({children}: {children: React.ReactNode}) {
} }
export function Header({children}: {children: React.ReactNode}) { export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}>
{children}
</View>
)
} }
export function Avatar({src}: {src: string | undefined}) { export type AvatarProps = {src: string | undefined; size?: number}
return <UserAvatar type="algo" size={40} avatar={src} />
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({ export function TitleAndByline({
@ -74,22 +110,54 @@ export function TitleAndByline({
creator, creator,
}: { }: {
title: string title: string
creator: AppBskyActorDefs.ProfileViewBasic creator?: AppBskyActorDefs.ProfileViewBasic
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Text <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{title} {title}
</Text> </Text>
<Text {creator && (
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} <Text
numberOfLines={1}> style={[a.leading_snug, t.atoms.text_contrast_medium]}
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> numberOfLines={1}>
</Text> <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> </View>
) )
} }
@ -203,3 +271,16 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
</> </>
) )
} }
export function createProfileFeedHref({
feed,
}: {
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(feed.uri)
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
urip.rkey
}`
}

View File

@ -9,20 +9,24 @@ import {
} from '@atproto/api' } from '@atproto/api'
import { import {
InfiniteData, InfiniteData,
QueryClient,
QueryKey, QueryKey,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants' import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes' import {router} from '#/routes'
import {FeedDescriptor} from './post-feed' import {FeedDescriptor} from './post-feed'
import {precacheResolvedUri} from './resolve-uri'
export type FeedSourceFeedInfo = { export type FeedSourceFeedInfo = {
type: 'feed' type: 'feed'
@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
const agent = useAgent() const agent = useAgent()
const limit = options?.limit || 10 const limit = options?.limit || 10
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const queryClient = useQueryClient()
// Make sure this doesn't invalidate unless really needed. // Make sure this doesn't invalidate unless really needed.
const selectArgs = useMemo( const selectArgs = useMemo(
@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
limit, limit,
cursor: pageParam, cursor: pageParam,
}) })
// precache feeds
for (const feed of res.data.feeds) {
const hydratedFeed = hydrateFeedGenerator(feed)
precacheFeed(queryClient, hydratedFeed)
}
return res.data return res.data
}, },
initialPageParam: undefined, initialPageParam: undefined,
@ -449,3 +461,130 @@ export function usePinnedFeedsInfos() {
}, },
}) })
} }
export type SavedFeedItem =
| {
type: 'feed'
config: AppBskyActorDefs.SavedFeed
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
config: AppBskyActorDefs.SavedFeed
view: AppBskyGraphDefs.ListView
}
| {
type: 'timeline'
config: AppBskyActorDefs.SavedFeed
view: undefined
}
export function useSavedFeeds() {
const agent = useAgent()
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
const savedItems = preferences?.savedFeeds ?? []
const queryClient = useQueryClient()
return useQuery({
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
placeholderData: previousData => {
return (
previousData || {
count: savedItems.length,
feeds: [],
}
)
},
queryFn: async () => {
const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>()
const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>()
const savedFeeds = savedItems.filter(feed => feed.type === 'feed')
const savedLists = savedItems.filter(feed => feed.type === 'list')
let feedsPromise = Promise.resolve()
if (savedFeeds.length > 0) {
feedsPromise = agent.app.bsky.feed
.getFeedGenerators({
feeds: savedFeeds.map(f => f.value),
})
.then(res => {
res.data.feeds.forEach(f => {
resolvedFeeds.set(f.uri, f)
})
})
}
const listsPromises = savedLists.map(list =>
agent.app.bsky.graph
.getList({
list: list.value,
limit: 1,
})
.then(res => {
const listView = res.data.list
resolvedLists.set(listView.uri, listView)
}),
)
await Promise.allSettled([feedsPromise, ...listsPromises])
resolvedFeeds.forEach(feed => {
const hydratedFeed = hydrateFeedGenerator(feed)
precacheFeed(queryClient, hydratedFeed)
})
resolvedLists.forEach(list => {
precacheList(queryClient, list)
})
const res: SavedFeedItem[] = savedItems.map(s => {
if (s.type === 'timeline') {
return {
type: 'timeline',
config: s,
view: undefined,
}
}
return {
type: s.type,
config: s,
view:
s.type === 'feed'
? resolvedFeeds.get(s.value)
: resolvedLists.get(s.value),
}
}) as SavedFeedItem[]
return {
count: savedItems.length,
feeds: res,
}
},
})
}
function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) {
precacheResolvedUri(
queryClient,
hydratedFeed.creatorHandle,
hydratedFeed.creatorDid,
)
queryClient.setQueryData<FeedSourceInfo>(
feedSourceInfoQueryKey({uri: hydratedFeed.uri}),
hydratedFeed,
)
}
function precacheList(
queryClient: QueryClient,
list: AppBskyGraphDefs.ListView,
) {
precacheResolvedUri(queryClient, list.creator.handle, list.creator.did)
queryClient.setQueryData<AppBskyGraphDefs.ListView>(
listQueryKey(list.uri),
list,
)
}

View File

@ -1,5 +1,10 @@
import {AppBskyActorDefs, AtUri} from '@atproto/api' import {AppBskyActorDefs, AtUri} from '@atproto/api'
import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' import {
QueryClient,
useQuery,
useQueryClient,
UseQueryResult,
} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) {
enabled: !!didOrHandle, enabled: !!didOrHandle,
}) })
} }
export function precacheResolvedUri(
queryClient: QueryClient,
handle: string,
did: string,
) {
queryClient.setQueryData<string>(RQKEY(handle), did)
}

View File

@ -1,8 +1,6 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
@ -10,12 +8,11 @@ import debounce from 'lodash.debounce'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import { import {
getAvatarTypeFromUri, SavedFeedItem,
useFeedSourceInfoQuery,
useGetPopularFeedsQuery, useGetPopularFeedsQuery,
useSavedFeeds,
useSearchPopularFeedsMutation, useSearchPopularFeedsMutation,
} from '#/state/queries/feed' } from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
@ -28,14 +25,10 @@ import {s} from 'lib/styles'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {FAB} from 'view/com/util/fab/FAB' import {FAB} from 'view/com/util/fab/FAB'
import {SearchInput} from 'view/com/util/forms/SearchInput' import {SearchInput} from 'view/com/util/forms/SearchInput'
import {Link, TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import { import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
FeedFeedLoadingPlaceholder,
LoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ViewHeader} from 'view/com/util/ViewHeader' import {ViewHeader} from 'view/com/util/ViewHeader'
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@ -61,9 +55,8 @@ type FlatlistSlice =
key: string key: string
} }
| { | {
type: 'savedFeedsLoading' type: 'savedFeedPlaceholder'
key: string key: string
// pendingItems: number,
} }
| { | {
type: 'savedFeedNoResults' type: 'savedFeedNoResults'
@ -72,8 +65,7 @@ type FlatlistSlice =
| { | {
type: 'savedFeed' type: 'savedFeed'
key: string key: string
feedUri: string savedFeed: SavedFeedItem
savedFeedConfig: AppBskyActorDefs.SavedFeed
} }
| { | {
type: 'savedFeedsLoadMore' type: 'savedFeedsLoadMore'
@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) {
const [query, setQuery] = React.useState('') const [query, setQuery] = React.useState('')
const [isPTR, setIsPTR] = React.useState(false) const [isPTR, setIsPTR] = React.useState(false)
const { const {
data: preferences, data: savedFeeds,
isLoading: isPreferencesLoading, isPlaceholderData: isSavedFeedsPlaceholder,
error: preferencesError, error: savedFeedsError,
refetch: refetchPreferences, refetch: refetchSavedFeeds,
} = usePreferencesQuery() } = useSavedFeeds()
const { const {
data: popularFeeds, data: popularFeeds,
isFetching: isPopularFeedsFetching, isFetching: isPopularFeedsFetching,
@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) {
const onPullToRefresh = React.useCallback(async () => { const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true) setIsPTR(true)
await Promise.all([ await Promise.all([
refetchPreferences().catch(_e => undefined), refetchSavedFeeds().catch(_e => undefined),
refetchPopularFeeds().catch(_e => undefined), refetchPopularFeeds().catch(_e => undefined),
]) ])
setIsPTR(false) setIsPTR(false)
}, [setIsPTR, refetchPreferences, refetchPopularFeeds]) }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
const onEndReached = React.useCallback(() => { const onEndReached = React.useCallback(() => {
if ( if (
isPopularFeedsFetching || isPopularFeedsFetching ||
@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) {
const items = React.useMemo(() => { const items = React.useMemo(() => {
let slices: FlatlistSlice[] = [] let slices: FlatlistSlice[] = []
const hasActualSavedCount =
!isSavedFeedsPlaceholder ||
(isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
const canShowDiscoverSection =
!hasSession || (hasSession && hasActualSavedCount)
if (hasSession) { if (hasSession) {
slices.push({ slices.push({
@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) {
type: 'savedFeedsHeader', type: 'savedFeedsHeader',
}) })
if (preferencesError) { if (savedFeedsError) {
slices.push({ slices.push({
key: 'savedFeedsError', key: 'savedFeedsError',
type: 'error', type: 'error',
error: cleanError(preferencesError.toString()), error: cleanError(savedFeedsError.toString()),
}) })
} else { } else {
if (isPreferencesLoading || !preferences?.savedFeeds) { if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) {
slices.push({ /*
key: 'savedFeedsLoading', * Initial render in placeholder state is 0 on a cold page load,
type: 'savedFeedsLoading', * because preferences haven't loaded yet.
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3, *
}) * In practice, `savedFeeds` is always defined, but we check for TS
* and for safety.
*
* In both cases, we show 4 as the the loading state.
*/
const min = 8
const count = savedFeeds
? savedFeeds.count === 0
? min
: savedFeeds.count
: min
Array(count)
.fill(0)
.forEach((_, i) => {
slices.push({
key: 'savedFeedPlaceholder' + i,
type: 'savedFeedPlaceholder',
})
})
} else { } else {
if (preferences.savedFeeds?.length) { if (savedFeeds?.feeds?.length) {
const noFollowingFeed = preferences.savedFeeds.every( const noFollowingFeed = savedFeeds.feeds.every(
f => f.type !== 'timeline', f => f.type !== 'timeline',
) )
slices = slices.concat( slices = slices.concat(
preferences.savedFeeds savedFeeds.feeds
.filter(f => { .filter(s => {
return f.pinned return s.config.pinned
}) })
.map(feed => ({ .map(s => ({
key: `savedFeed:${feed.value}:${feed.id}`, key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed', type: 'savedFeed',
feedUri: feed.value, savedFeed: s,
savedFeedConfig: feed,
})), })),
) )
slices = slices.concat( slices = slices.concat(
preferences.savedFeeds savedFeeds.feeds
.filter(f => { .filter(s => {
return !f.pinned return !s.config.pinned
}) })
.map(feed => ({ .map(s => ({
key: `savedFeed:${feed.value}:${feed.id}`, key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed', type: 'savedFeed',
feedUri: feed.value, savedFeed: s,
savedFeedConfig: feed,
})), })),
) )
@ -270,59 +283,36 @@ export function FeedsScreen(_props: Props) {
} }
} }
slices.push({ if (!hasSession || (hasSession && canShowDiscoverSection)) {
key: 'popularFeedsHeader',
type: 'popularFeedsHeader',
})
if (popularFeedsError || searchError) {
slices.push({ slices.push({
key: 'popularFeedsError', key: 'popularFeedsHeader',
type: 'error', type: 'popularFeedsHeader',
error: cleanError(
popularFeedsError?.toString() ?? searchError?.toString() ?? '',
),
}) })
} else {
if (isUserSearching) { if (popularFeedsError || searchError) {
if (isSearchPending || !searchResults) { slices.push({
slices.push({ key: 'popularFeedsError',
key: 'popularFeedsLoading', type: 'error',
type: 'popularFeedsLoading', error: cleanError(
}) popularFeedsError?.toString() ?? searchError?.toString() ?? '',
} else { ),
if (!searchResults || searchResults?.length === 0) { })
slices.push({
key: 'popularFeedsNoResults',
type: 'popularFeedsNoResults',
})
} else {
slices = slices.concat(
searchResults.map(feed => ({
key: `popularFeed:${feed.uri}`,
type: 'popularFeed',
feedUri: feed.uri,
feed,
})),
)
}
}
} else { } else {
if (isPopularFeedsFetching && !popularFeeds?.pages) { if (isUserSearching) {
slices.push({ if (isSearchPending || !searchResults) {
key: 'popularFeedsLoading',
type: 'popularFeedsLoading',
})
} else {
if (!popularFeeds?.pages) {
slices.push({ slices.push({
key: 'popularFeedsNoResults', key: 'popularFeedsLoading',
type: 'popularFeedsNoResults', type: 'popularFeedsLoading',
}) })
} else { } else {
for (const page of popularFeeds.pages || []) { if (!searchResults || searchResults?.length === 0) {
slices.push({
key: 'popularFeedsNoResults',
type: 'popularFeedsNoResults',
})
} else {
slices = slices.concat( slices = slices.concat(
page.feeds.map(feed => ({ searchResults.map(feed => ({
key: `popularFeed:${feed.uri}`, key: `popularFeed:${feed.uri}`,
type: 'popularFeed', type: 'popularFeed',
feedUri: feed.uri, feedUri: feed.uri,
@ -330,12 +320,37 @@ export function FeedsScreen(_props: Props) {
})), })),
) )
} }
}
if (isPopularFeedsFetchingNextPage) { } else {
if (isPopularFeedsFetching && !popularFeeds?.pages) {
slices.push({
key: 'popularFeedsLoading',
type: 'popularFeedsLoading',
})
} else {
if (!popularFeeds?.pages) {
slices.push({ slices.push({
key: 'popularFeedsLoadingMore', key: 'popularFeedsNoResults',
type: 'popularFeedsLoadingMore', type: 'popularFeedsNoResults',
}) })
} else {
for (const page of popularFeeds.pages || []) {
slices = slices.concat(
page.feeds.map(feed => ({
key: `popularFeed:${feed.uri}`,
type: 'popularFeed',
feedUri: feed.uri,
feed,
})),
)
}
if (isPopularFeedsFetchingNextPage) {
slices.push({
key: 'popularFeedsLoadingMore',
type: 'popularFeedsLoadingMore',
})
}
} }
} }
} }
@ -345,9 +360,9 @@ export function FeedsScreen(_props: Props) {
return slices return slices
}, [ }, [
hasSession, hasSession,
preferences, savedFeeds,
isPreferencesLoading, isSavedFeedsPlaceholder,
preferencesError, savedFeedsError,
popularFeeds, popularFeeds,
isPopularFeedsFetching, isPopularFeedsFetching,
popularFeedsError, popularFeedsError,
@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) {
({item}: {item: FlatlistSlice}) => { ({item}: {item: FlatlistSlice}) => {
if (item.type === 'error') { if (item.type === 'error') {
return <ErrorMessage message={item.error} /> return <ErrorMessage message={item.error} />
} else if ( } else if (item.type === 'popularFeedsLoadingMore') {
item.type === 'popularFeedsLoadingMore' ||
item.type === 'savedFeedsLoading'
) {
return ( return (
<View style={s.p10}> <View style={s.p10}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) {
<NoSavedFeedsOfAnyType /> <NoSavedFeedsOfAnyType />
</View> </View>
) )
} else if (item.type === 'savedFeedPlaceholder') {
return <SavedFeedPlaceholder />
} else if (item.type === 'savedFeed') { } else if (item.type === 'savedFeed') {
return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} /> return <FeedOrFollowing savedFeed={item.savedFeed} />
} else if (item.type === 'popularFeedsHeader') { } else if (item.type === 'popularFeedsHeader') {
return ( return (
<> <>
@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) {
} else if (item.type === 'popularFeed') { } else if (item.type === 'popularFeed') {
return ( return (
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}> <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
<FeedCard.Default feed={item.feed} /> <FeedCard.Default type="feed" view={item.feed} />
<Divider /> <Divider />
</View> </View>
) )
@ -571,136 +585,103 @@ export function FeedsScreen(_props: Props) {
) )
} }
function FeedOrFollowing({ function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
savedFeedConfig: feed, return savedFeed.type === 'timeline' ? (
}: {
savedFeedConfig: AppBskyActorDefs.SavedFeed
}) {
return feed.type === 'timeline' ? (
<FollowingFeed /> <FollowingFeed />
) : ( ) : (
<SavedFeed savedFeedConfig={feed} /> <SavedFeed savedFeed={savedFeed} />
) )
} }
function FollowingFeed() { function FollowingFeed() {
const pal = usePalette('default')
const t = useTheme() const t = useTheme()
const {isMobile} = useWebMediaQueries() const {_} = useLingui()
return ( return (
<View <View
testID={`saved-feed-timeline`}
style={[ style={[
pal.border, a.flex_1,
styles.savedFeed, a.px_lg,
isMobile && styles.savedFeedMobile, a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}> ]}>
<View <FeedCard.Header>
style={[ <View
a.align_center,
a.justify_center,
{
width: 28,
height: 28,
borderRadius: 3,
backgroundColor: t.palette.primary_500,
},
]}>
<FilterTimeline
style={[ style={[
a.align_center,
a.justify_center,
{ {
width: 18, width: 28,
height: 18, height: 28,
borderRadius: 3,
backgroundColor: t.palette.primary_500,
}, },
]} ]}>
fill={t.palette.white} <FilterTimeline
/> style={[
</View> {
<View width: 18,
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> height: 18,
<Text type="lg-medium" style={pal.text} numberOfLines={1}> },
<Trans>Following</Trans> ]}
</Text> fill={t.palette.white}
</View> />
</View>
<FeedCard.TitleAndByline title={_(msg`Following`)} />
</FeedCard.Header>
</View> </View>
) )
} }
function SavedFeed({ function SavedFeed({
savedFeedConfig: feed, savedFeed,
}: { }: {
savedFeedConfig: AppBskyActorDefs.SavedFeed savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
}) { }) {
const pal = usePalette('default') const t = useTheme()
const {isMobile} = useWebMediaQueries() const {view: feed} = savedFeed
const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value}) const displayName =
const typeAvatar = getAvatarTypeFromUri(feed.value) savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
if (!info)
return (
<SavedFeedLoadingPlaceholder
key={`savedFeedLoadingPlaceholder:${feed.value}`}
/>
)
return ( return (
<Link <FeedCard.Link testID={`saved-feed-${feed.displayName}`} feed={feed}>
testID={`saved-feed-${info.displayName}`} {({hovered, pressed}) => (
href={info.route.href}
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
hoverStyle={pal.viewLight}
accessibilityLabel={info.displayName}
accessibilityHint=""
asAnchor
anchorNoUnderline>
{error ? (
<View <View
style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> style={[
<FontAwesomeIcon a.flex_1,
icon="exclamation-circle" a.px_lg,
color={pal.colors.textLight} a.py_md,
/> a.border_b,
</View> t.atoms.border_contrast_low,
) : ( (hovered || pressed) && t.atoms.bg_contrast_25,
<UserAvatar type={typeAvatar} size={28} avatar={info.avatar} /> ]}>
)} <FeedCard.Header>
<View <FeedCard.Avatar src={feed.avatar} size={28} />
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <FeedCard.TitleAndByline title={displayName} />
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
{info.displayName}
</Text>
{error ? (
<View style={[styles.offlineSlug, pal.borderDark]}>
<Text type="xs" style={pal.textLight}>
<Trans>Feed offline</Trans>
</Text>
</View>
) : null}
</View>
{isMobile && ( <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
<FontAwesomeIcon </FeedCard.Header>
icon="chevron-right" </View>
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
)} )}
</Link> </FeedCard.Link>
) )
} }
function SavedFeedLoadingPlaceholder() { function SavedFeedPlaceholder() {
const pal = usePalette('default') const t = useTheme()
const {isMobile} = useWebMediaQueries()
return ( return (
<View <View
style={[ style={[
pal.border, a.flex_1,
styles.savedFeed, a.px_lg,
isMobile && styles.savedFeedMobile, a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}> ]}>
<LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} /> <FeedCard.Header>
<LoadingPlaceholder width={140} height={12} /> <FeedCard.AvatarPlaceholder size={28} />
<FeedCard.TitleAndBylinePlaceholder />
</FeedCard.Header>
</View> </View>
) )
} }

View File

@ -505,7 +505,7 @@ export function Explore() {
a.px_lg, a.px_lg,
a.py_lg, a.py_lg,
]}> ]}>
<FeedCard.Default feed={item.feed} /> <FeedCard.Default type="feed" view={item.feed} />
</View> </View>
) )
} }

View File

@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({
a.px_lg, a.px_lg,
a.py_lg, a.py_lg,
]}> ]}>
<FeedCard.Default feed={item} /> <FeedCard.Default type="feed" view={item} />
</View> </View>
)} )}
keyExtractor={item => item.uri} keyExtractor={item => item.uri}