Pinned feeds cards (#4526)

* Add lists support to FeedCard

* Add useSavedFeeds query, similar to usePinnedFeedInfos

* Integrate into Feeds screen

* Fix alignment on mobile

* Update usages

* Add placeholder loading state

* Handle no feeds state

* Reuse previous data for placeholder

* Staged loading

* Improve staged loading

* Use setQueryData approach to pre-caching

* Add types for a little more safety

* Fix precaching

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Eric Bailey 2024-06-21 16:50:23 -05:00 committed by GitHub
parent cb37647949
commit 4d6787009c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 447 additions and 233 deletions

View File

@ -1,6 +1,11 @@
import React from 'react'
import {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>
{creator && (
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
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
}`
}

View File

@ -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,
)
}

View File

@ -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)
}

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) {
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: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
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,6 +283,7 @@ export function FeedsScreen(_props: Props) {
}
}
if (!hasSession || (hasSession && canShowDiscoverSection)) {
slices.push({
key: 'popularFeedsHeader',
type: 'popularFeedsHeader',
@ -341,13 +355,14 @@ 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,30 +585,27 @@ 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,
]}>
<FeedCard.Header>
<View
style={[
a.align_center,
@ -616,91 +627,61 @@ function FollowingFeed() {
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>
<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}