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 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>
|
||||||
|
{creator && (
|
||||||
<Text
|
<Text
|
||||||
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
</Text>
|
</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
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
/*
|
||||||
|
* 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({
|
slices.push({
|
||||||
key: 'savedFeedsLoading',
|
key: 'savedFeedPlaceholder' + i,
|
||||||
type: 'savedFeedsLoading',
|
type: 'savedFeedPlaceholder',
|
||||||
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
})
|
||||||
})
|
})
|
||||||
} 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,6 +283,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasSession || (hasSession && canShowDiscoverSection)) {
|
||||||
slices.push({
|
slices.push({
|
||||||
key: 'popularFeedsHeader',
|
key: 'popularFeedsHeader',
|
||||||
type: 'popularFeedsHeader',
|
type: 'popularFeedsHeader',
|
||||||
|
@ -341,13 +355,14 @@ 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,30 +585,27 @@ 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,
|
||||||
]}>
|
]}>
|
||||||
|
<FeedCard.Header>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
@ -616,91 +627,61 @@ function FollowingFeed() {
|
||||||
fill={t.palette.white}
|
fill={t.palette.white}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<FeedCard.TitleAndByline title={_(msg`Following`)} />
|
||||||
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
|
</FeedCard.Header>
|
||||||
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
|
|
||||||
<Trans>Following</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in New Issue