bsky-app/src/view/screens/Feeds.tsx
Eric Bailey 4d6787009c
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>
2024-06-22 00:50:23 +03:00

796 lines
22 KiB
TypeScript

import React from 'react'
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import debounce from 'lodash.debounce'
import {isNative, isWeb} from '#/platform/detection'
import {
SavedFeedItem,
useGetPopularFeedsQuery,
useSavedFeeds,
useSearchPopularFeedsMutation,
} from '#/state/queries/feed'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from 'lib/icons'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {cleanError} from 'lib/strings/errors'
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 {TextLink} from 'view/com/util/Link'
import {List} from 'view/com/util/List'
import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {Text} from 'view/com/util/text/Text'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
import {atoms as a, useTheme} from '#/alf'
import {IconCircle} from '#/components/IconCircle'
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
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'>
type FlatlistSlice =
| {
type: 'error'
key: string
error: string
}
| {
type: 'savedFeedsHeader'
key: string
}
| {
type: 'savedFeedPlaceholder'
key: string
}
| {
type: 'savedFeedNoResults'
key: string
}
| {
type: 'savedFeed'
key: string
savedFeed: SavedFeedItem
}
| {
type: 'savedFeedsLoadMore'
key: string
}
| {
type: 'popularFeedsHeader'
key: string
}
| {
type: 'popularFeedsLoading'
key: string
}
| {
type: 'popularFeedsNoResults'
key: string
}
| {
type: 'popularFeed'
key: string
feedUri: string
feed: AppBskyFeedDefs.GeneratorView
}
| {
type: 'popularFeedsLoadingMore'
key: string
}
| {
type: 'noFollowingFeed'
key: string
}
export function FeedsScreen(_props: Props) {
const pal = usePalette('default')
const {openComposer} = useComposerControls()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const [query, setQuery] = React.useState('')
const [isPTR, setIsPTR] = React.useState(false)
const {
data: savedFeeds,
isPlaceholderData: isSavedFeedsPlaceholder,
error: savedFeedsError,
refetch: refetchSavedFeeds,
} = useSavedFeeds()
const {
data: popularFeeds,
isFetching: isPopularFeedsFetching,
error: popularFeedsError,
refetch: refetchPopularFeeds,
fetchNextPage: fetchNextPopularFeedsPage,
isFetchingNextPage: isPopularFeedsFetchingNextPage,
hasNextPage: hasNextPopularFeedsPage,
} = useGetPopularFeedsQuery()
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {
data: searchResults,
mutate: search,
reset: resetSearch,
isPending: isSearchPending,
error: searchError,
} = useSearchPopularFeedsMutation()
const {hasSession} = useSession()
const listRef = React.useRef<FlatList>(null)
/**
* A search query is present. We may not have search results yet.
*/
const isUserSearching = query.length > 1
const debouncedSearch = React.useMemo(
() => debounce(q => search(q), 500), // debounce for 500ms
[search],
)
const onPressCompose = React.useCallback(() => {
openComposer({})
}, [openComposer])
const onChangeQuery = React.useCallback(
(text: string) => {
setQuery(text)
if (text.length > 1) {
debouncedSearch(text)
} else {
refetchPopularFeeds()
resetSearch()
}
},
[setQuery, refetchPopularFeeds, debouncedSearch, resetSearch],
)
const onPressCancelSearch = React.useCallback(() => {
setQuery('')
refetchPopularFeeds()
resetSearch()
}, [refetchPopularFeeds, setQuery, resetSearch])
const onSubmitQuery = React.useCallback(() => {
debouncedSearch(query)
}, [query, debouncedSearch])
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
await Promise.all([
refetchSavedFeeds().catch(_e => undefined),
refetchPopularFeeds().catch(_e => undefined),
])
setIsPTR(false)
}, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
const onEndReached = React.useCallback(() => {
if (
isPopularFeedsFetching ||
isUserSearching ||
!hasNextPopularFeedsPage ||
popularFeedsError
)
return
fetchNextPopularFeedsPage()
}, [
isPopularFeedsFetching,
isUserSearching,
popularFeedsError,
hasNextPopularFeedsPage,
fetchNextPopularFeedsPage,
])
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const items = React.useMemo(() => {
let slices: FlatlistSlice[] = []
const hasActualSavedCount =
!isSavedFeedsPlaceholder ||
(isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
const canShowDiscoverSection =
!hasSession || (hasSession && hasActualSavedCount)
if (hasSession) {
slices.push({
key: 'savedFeedsHeader',
type: 'savedFeedsHeader',
})
if (savedFeedsError) {
slices.push({
key: 'savedFeedsError',
type: 'error',
error: cleanError(savedFeedsError.toString()),
})
} else {
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 (savedFeeds?.feeds?.length) {
const noFollowingFeed = savedFeeds.feeds.every(
f => f.type !== 'timeline',
)
slices = slices.concat(
savedFeeds.feeds
.filter(s => {
return s.config.pinned
})
.map(s => ({
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed',
savedFeed: s,
})),
)
slices = slices.concat(
savedFeeds.feeds
.filter(s => {
return !s.config.pinned
})
.map(s => ({
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed',
savedFeed: s,
})),
)
if (noFollowingFeed) {
slices.push({
key: 'noFollowingFeed',
type: 'noFollowingFeed',
})
}
} else {
slices.push({
key: 'savedFeedNoResults',
type: 'savedFeedNoResults',
})
}
}
}
}
if (!hasSession || (hasSession && canShowDiscoverSection)) {
slices.push({
key: 'popularFeedsHeader',
type: 'popularFeedsHeader',
})
if (popularFeedsError || searchError) {
slices.push({
key: 'popularFeedsError',
type: 'error',
error: cleanError(
popularFeedsError?.toString() ?? searchError?.toString() ?? '',
),
})
} 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,
})),
)
}
}
} else {
if (isPopularFeedsFetching && !popularFeeds?.pages) {
slices.push({
key: 'popularFeedsLoading',
type: 'popularFeedsLoading',
})
} else {
if (!popularFeeds?.pages) {
slices.push({
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',
})
}
}
}
}
}
}
return slices
}, [
hasSession,
savedFeeds,
isSavedFeedsPlaceholder,
savedFeedsError,
popularFeeds,
isPopularFeedsFetching,
popularFeedsError,
isPopularFeedsFetchingNextPage,
searchResults,
isSearchPending,
searchError,
isUserSearching,
])
const renderHeaderBtn = React.useCallback(() => {
return (
<View style={styles.headerBtnGroup}>
<TextLink
testID="editFeedsBtn"
type="lg-medium"
href="/settings/saved-feeds"
accessibilityLabel={_(msg`Edit My Feeds`)}
accessibilityHint=""
text={_(msg`Edit`)}
style={[pal.link, a.pr_xs]}
/>
</View>
)
}, [pal, _])
const searchBarIndex = items.findIndex(
item => item.type === 'popularFeedsHeader',
)
const onChangeSearchFocus = React.useCallback(
(focus: boolean) => {
if (focus && searchBarIndex > -1) {
if (isNative) {
// scrollToIndex scrolls the exact right amount, so use if available
listRef.current?.scrollToIndex({
index: searchBarIndex,
animated: true,
})
} else {
// web implementation only supports scrollToOffset
// thus, we calculate the offset based on the index
// pixel values are estimates, I wasn't able to get it pixel perfect :(
const headerHeight = isMobile ? 43 : 53
const feedItemHeight = isMobile ? 49 : 58
listRef.current?.scrollToOffset({
offset: searchBarIndex * feedItemHeight - headerHeight,
animated: true,
})
}
}
},
[searchBarIndex, isMobile],
)
const renderItem = React.useCallback(
({item}: {item: FlatlistSlice}) => {
if (item.type === 'error') {
return <ErrorMessage message={item.error} />
} else if (item.type === 'popularFeedsLoadingMore') {
return (
<View style={s.p10}>
<ActivityIndicator size="large" />
</View>
)
} else if (item.type === 'savedFeedsHeader') {
return (
<>
{!isMobile && (
<View
style={[
pal.view,
styles.header,
pal.border,
{
borderBottomWidth: 1,
},
]}>
<Text type="title-lg" style={[pal.text, s.bold]}>
<Trans>Feeds</Trans>
</Text>
<View style={styles.headerBtnGroup}>
<TextLink
type="lg"
href="/settings/saved-feeds"
accessibilityLabel={_(msg`Edit My Feeds`)}
accessibilityHint=""
text={_(msg`Edit`)}
style={[pal.link]}
/>
</View>
</View>
)}
<FeedsSavedHeader />
</>
)
} else if (item.type === 'savedFeedNoResults') {
return (
<View
style={[
pal.border,
{
borderBottomWidth: 1,
},
]}>
<NoSavedFeedsOfAnyType />
</View>
)
} else if (item.type === 'savedFeedPlaceholder') {
return <SavedFeedPlaceholder />
} else if (item.type === 'savedFeed') {
return <FeedOrFollowing savedFeed={item.savedFeed} />
} else if (item.type === 'popularFeedsHeader') {
return (
<>
<FeedsAboutHeader />
<View style={{paddingHorizontal: 12, paddingBottom: 4}}>
<SearchInput
query={query}
onChangeQuery={onChangeQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
setIsInputFocused={onChangeSearchFocus}
/>
</View>
</>
)
} else if (item.type === 'popularFeedsLoading') {
return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') {
return (
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
<FeedCard.Default type="feed" view={item.feed} />
<Divider />
</View>
)
} else if (item.type === 'popularFeedsNoResults') {
return (
<View
style={{
paddingHorizontal: 16,
paddingTop: 10,
paddingBottom: '150%',
}}>
<Text type="lg" style={pal.textLight}>
<Trans>No results found for "{query}"</Trans>
</Text>
</View>
)
} else if (item.type === 'noFollowingFeed') {
return (
<View
style={[
pal.border,
{
borderBottomWidth: 1,
},
]}>
<NoFollowingFeed />
</View>
)
}
return null
},
[
isMobile,
pal.view,
pal.border,
pal.text,
pal.textLight,
pal.link,
_,
query,
onChangeQuery,
onPressCancelSearch,
onSubmitQuery,
onChangeSearchFocus,
],
)
return (
<View style={[pal.view, styles.container]}>
{isMobile && (
<ViewHeader
title={_(msg`Feeds`)}
renderButton={renderHeaderBtn}
showBorder
/>
)}
<List
ref={listRef}
style={[!isTabletOrDesktop && s.flex1, styles.list]}
data={items}
keyExtractor={item => item.key}
contentContainerStyle={styles.contentContainer}
renderItem={renderItem}
refreshing={isPTR}
onRefresh={isUserSearching ? undefined : onPullToRefresh}
initialNumToRender={10}
onEndReached={onEndReached}
// @ts-ignore our .web version only -prf
desktopFixedHeight
scrollIndicatorInsets={{right: 1}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
{hasSession && (
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
)}
</View>
)
}
function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
return savedFeed.type === 'timeline' ? (
<FollowingFeed />
) : (
<SavedFeed savedFeed={savedFeed} />
)
}
function FollowingFeed() {
const t = useTheme()
const {_} = useLingui()
return (
<View
style={[
a.flex_1,
a.px_lg,
a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<FeedCard.Header>
<View
style={[
a.align_center,
a.justify_center,
{
width: 28,
height: 28,
borderRadius: 3,
backgroundColor: t.palette.primary_500,
},
]}>
<FilterTimeline
style={[
{
width: 18,
height: 18,
},
]}
fill={t.palette.white}
/>
</View>
<FeedCard.TitleAndByline title={_(msg`Following`)} />
</FeedCard.Header>
</View>
)
}
function SavedFeed({
savedFeed,
}: {
savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
}) {
const t = useTheme()
const {view: feed} = savedFeed
const displayName =
savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
return (
<FeedCard.Link testID={`saved-feed-${feed.displayName}`} feed={feed}>
{({hovered, pressed}) => (
<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} />
<ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
</FeedCard.Header>
</View>
)}
</FeedCard.Link>
)
}
function SavedFeedPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.px_lg,
a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<FeedCard.Header>
<FeedCard.AvatarPlaceholder size={28} />
<FeedCard.TitleAndBylinePlaceholder />
</FeedCard.Header>
</View>
)
}
function FeedsSavedHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [
a.flex_row,
a.px_md,
a.py_lg,
a.gap_md,
a.border_b,
t.atoms.border_contrast_low,
]
: [
{flexDirection: 'row-reverse'},
a.p_lg,
a.gap_md,
a.border_b,
t.atoms.border_contrast_low,
]
}>
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>My Feeds</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>All the feeds you've saved, right in one place.</Trans>
</Text>
</View>
</View>
)
}
function FeedsAboutHeader() {
const t = useTheme()
return (
<View
style={
isWeb
? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
}>
<IconCircle
icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
size="lg"
/>
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Discover New Feeds</Trans>
</Text>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>
Custom feeds built by the community bring you new experiences and
help you find the content you love.
</Trans>
</Text>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
height: '100%',
},
contentContainer: {
paddingBottom: 100,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
paddingHorizontal: 18,
paddingVertical: 12,
},
savedFeed: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
gap: 12,
borderBottomWidth: hairlineWidth,
},
savedFeedMobile: {
paddingVertical: 10,
},
offlineSlug: {
borderWidth: hairlineWidth,
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
headerBtnGroup: {
flexDirection: 'row',
gap: 15,
alignItems: 'center',
},
})