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
parent
cb37647949
commit
4d6787009c
|
@ -1,6 +1,11 @@
|
|||
import React from 'react'
|
||||
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 {useLingui} from '@lingui/react'
|
||||
|
||||
|
@ -20,23 +25,35 @@ import {Button, ButtonIcon} from '#/components/Button'
|
|||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
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 * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
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 (
|
||||
<Link feed={feed}>
|
||||
<Link feed={view}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={feed.avatar} />
|
||||
<TitleAndByline title={feed.displayName} creator={feed.creator} />
|
||||
<Action uri={feed.uri} pin />
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline title={displayName} creator={view.creator} />
|
||||
<Action uri={view.uri} pin />
|
||||
</Header>
|
||||
<Description description={feed.description} />
|
||||
<Likes count={feed.likeCount || 0} />
|
||||
<Description description={view.description} />
|
||||
{type === 'feed' && <Likes count={view.likeCount || 0} />}
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
|
@ -46,13 +63,10 @@ export function Link({
|
|||
children,
|
||||
feed,
|
||||
}: {
|
||||
children: React.ReactElement
|
||||
feed: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
} & Omit<LinkProps, 'to'>) {
|
||||
const href = React.useMemo(() => {
|
||||
const urip = new AtUri(feed.uri)
|
||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||
return createProfileFeedHref({feed})
|
||||
}, [feed])
|
||||
return <InternalLink to={href}>{children}</InternalLink>
|
||||
}
|
||||
|
@ -62,11 +76,33 @@ export function Outer({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}) {
|
||||
return <UserAvatar type="algo" size={40} avatar={src} />
|
||||
export type AvatarProps = {src: string | undefined; size?: number}
|
||||
|
||||
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({
|
||||
|
@ -74,22 +110,54 @@ export function TitleAndByline({
|
|||
creator,
|
||||
}: {
|
||||
title: string
|
||||
creator: AppBskyActorDefs.ProfileViewBasic
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}`
|
||||
}
|
||||
|
|
|
@ -9,20 +9,24 @@ import {
|
|||
} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {RQKEY as listQueryKey} from '#/state/queries/list'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {router} from '#/routes'
|
||||
import {FeedDescriptor} from './post-feed'
|
||||
import {precacheResolvedUri} from './resolve-uri'
|
||||
|
||||
export type FeedSourceFeedInfo = {
|
||||
type: 'feed'
|
||||
|
@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||
const agent = useAgent()
|
||||
const limit = options?.limit || 10
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Make sure this doesn't invalidate unless really needed.
|
||||
const selectArgs = useMemo(
|
||||
|
@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||
limit,
|
||||
cursor: pageParam,
|
||||
})
|
||||
|
||||
// precache feeds
|
||||
for (const feed of res.data.feeds) {
|
||||
const hydratedFeed = hydrateFeedGenerator(feed)
|
||||
precacheFeed(queryClient, hydratedFeed)
|
||||
}
|
||||
|
||||
return res.data
|
||||
},
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
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 {useAgent} from '#/state/session'
|
||||
|
@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) {
|
|||
enabled: !!didOrHandle,
|
||||
})
|
||||
}
|
||||
|
||||
export function precacheResolvedUri(
|
||||
queryClient: QueryClient,
|
||||
handle: string,
|
||||
did: string,
|
||||
) {
|
||||
queryClient.setQueryData<string>(RQKEY(handle), did)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue