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

View file

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

View file

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