bsky-app/src/view/screens/Search/Explore.tsx
Eric Bailey 077da08309
Explore feed cards (#4521)
* Replace FeedSourceCard on Explore page

(cherry picked from commit e7e9787bfaa9368bfaeaaa4ca144ab77b438219c)

* Replace FeedSourceCard on Search page

(cherry picked from commit ac47aade7622d359eee9509763cda666d964d8a3)
2024-06-17 13:56:11 -05:00

553 lines
15 KiB
TypeScript

import React from 'react'
import {View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
moderateProfile,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {cleanError} from 'lib/strings/errors'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {
FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
function SuggestedItemsHeader({
title,
description,
style,
icon: Icon,
}: {
title: string
description: string
icon: React.ComponentType<SVGIconProps>
} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
isWeb
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
style,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Icon
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{description}
</Text>
</View>
</View>
)
}
type LoadMoreItems =
| {
type: 'profile'
key: string
avatar: string
moderation: ModerationDecision
}
| {
type: 'feed'
key: string
avatar: string
moderation: undefined
}
function LoadMore({
item,
moderationOpts,
}: {
item: ExploreScreenItems & {type: 'loadMore'}
moderationOpts?: ModerationOpts
}) {
const t = useTheme()
const {_} = useLingui()
const items = React.useMemo(() => {
return item.items
.map(_item => {
if (_item.type === 'profile') {
return {
type: 'profile',
key: _item.profile.did,
avatar: _item.profile.avatar,
moderation: moderateProfile(_item.profile, moderationOpts!),
}
} else if (_item.type === 'feed') {
return {
type: 'feed',
key: _item.feed.uri,
avatar: _item.feed.avatar,
moderation: undefined,
}
}
return undefined
})
.filter(Boolean) as LoadMoreItems[]
}, [item.items, moderationOpts])
const type = items[0].type
return (
<View style={[]}>
<Button
label={_(msg`Load more`)}
onPress={item.onLoadMore}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.px_lg,
a.py_md,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View
style={[
a.relative,
{
height: 32,
width: 32 + 15 * 3,
},
]}>
<View
style={[
a.align_center,
a.justify_center,
a.border,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: 0,
backgroundColor: t.palette.primary_500,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: type === 'profile' ? 999 : 4,
zIndex: 4,
},
]}>
<ArrowBottom fill={t.palette.white} />
</View>
{items.map((_item, i) => {
return (
<View
key={_item.key}
style={[
a.border,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: (i + 1) * 15,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: _item.type === 'profile' ? 999 : 4,
zIndex: 3 - i,
},
]}>
{moderationOpts && (
<>
{_item.type === 'profile' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
moderation={_item.moderation.ui('avatar')}
/>
) : _item.type === 'feed' ? (
<UserAvatar
size={28}
avatar={_item.avatar}
type="algo"
/>
) : null}
</>
)}
</View>
)
})}
</View>
<Text
style={[
a.pl_sm,
a.leading_snug,
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
]}>
{type === 'profile' ? (
<Trans>Load more suggested follows</Trans>
) : (
<Trans>Load more suggested feeds</Trans>
)}
</Text>
<View style={[a.flex_1, a.align_end]}>
{item.isLoadingMore && <Loader size="lg" />}
</View>
</View>
)}
</Button>
</View>
)
}
type ExploreScreenItems =
| {
type: 'header'
key: string
title: string
description: string
style?: ViewStyleProp['style']
icon: React.ComponentType<SVGIconProps>
}
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileViewBasic
}
| {
type: 'feed'
key: string
feed: AppBskyFeedDefs.GeneratorView
}
| {
type: 'loadMore'
key: string
isLoadingMore: boolean
onLoadMore: () => void
items: ExploreScreenItems[]
}
| {
type: 'profilePlaceholder'
key: string
}
| {
type: 'feedPlaceholder'
key: string
}
| {
type: 'error'
key: string
message: string
error: string
}
export function Explore() {
const {_} = useLingui()
const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts()
const {
data: profiles,
hasNextPage: hasNextProfilesPage,
isLoading: isLoadingProfiles,
isFetchingNextPage: isFetchingNextProfilesPage,
error: profilesError,
fetchNextPage: fetchNextProfilesPage,
} = useSuggestedFollowsQuery({limit: 3})
const {
data: feeds,
hasNextPage: hasNextFeedsPage,
isLoading: isLoadingFeeds,
isFetchingNextPage: isFetchingNextFeedsPage,
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 3})
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = React.useCallback(async () => {
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
return
try {
await fetchNextProfilesPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [
isFetchingNextProfilesPage,
hasNextProfilesPage,
profilesError,
fetchNextProfilesPage,
])
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
const onLoadMoreFeeds = React.useCallback(async () => {
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
try {
await fetchNextFeedsPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [
isFetchingNextFeedsPage,
hasNextFeedsPage,
feedsError,
fetchNextFeedsPage,
])
const items = React.useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = [
{
type: 'header',
key: 'suggested-follows-header',
title: _(msg`Suggested accounts`),
description: _(
msg`Follow more accounts to get connected to your interests and build your network.`,
),
icon: Person,
},
]
if (profiles) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of profiles.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
i.push({
type: 'profile',
key: actor.did,
profile: actor,
})
}
}
}
i.push({
type: 'loadMore',
key: 'loadMoreProfiles',
isLoadingMore: isLoadingMoreProfiles,
onLoadMore: onLoadMoreProfiles,
items: i.filter(item => item.type === 'profile').slice(-3),
})
} else {
if (profilesError) {
i.push({
type: 'error',
key: 'profilesError',
message: _(msg`Failed to load suggested follows`),
error: cleanError(profilesError),
})
} else {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
}
}
i.push({
type: 'header',
key: 'suggested-feeds-header',
title: _(msg`Discover new feeds`),
description: _(
msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`,
),
style: [a.pt_5xl],
icon: ListSparkle,
})
if (feeds && preferences) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of feeds.pages) {
for (const feed of page.feeds) {
if (!seen.has(feed.uri)) {
seen.add(feed.uri)
i.push({
type: 'feed',
key: feed.uri,
feed,
})
}
}
}
if (feedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError),
})
} else {
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
items: i.filter(item => item.type === 'feed').slice(-3),
})
}
} else {
if (feedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError),
})
} else {
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
}
}
return i
}, [
_,
profiles,
feeds,
preferences,
onLoadMoreFeeds,
onLoadMoreProfiles,
isLoadingMoreProfiles,
isLoadingMoreFeeds,
profilesError,
feedsError,
preferencesError,
])
const renderItem = React.useCallback(
({item}: {item: ExploreScreenItems}) => {
switch (item.type) {
case 'header': {
return (
<SuggestedItemsHeader
title={item.title}
description={item.description}
style={item.style}
icon={item.icon}
/>
)
}
case 'profile': {
return (
<View style={[a.border_b, t.atoms.border_contrast_low]}>
<ProfileCardWithFollowBtn profile={item.profile} noBg noBorder />
</View>
)
}
case 'feed': {
return (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default feed={item.feed} />
</View>
)
}
case 'loadMore': {
return <LoadMore item={item} moderationOpts={moderationOpts} />
}
case 'profilePlaceholder': {
return <ProfileCardFeedLoadingPlaceholder />
}
case 'feedPlaceholder': {
return <FeedFeedLoadingPlaceholder />
}
case 'error': {
return (
<View
style={[
a.border_t,
a.pt_md,
a.px_md,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.flex_row,
a.gap_md,
a.p_lg,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size="md" fill={t.palette.negative_400} />
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.font_bold, a.leading_snug]}>
{item.message}
</Text>
<Text
style={[
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{item.error}
</Text>
</View>
</View>
</View>
)
}
}
},
[t, moderationOpts],
)
return (
<List
data={items}
renderItem={renderItem}
keyExtractor={item => item.key}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 200}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
)
}