diff options
Diffstat (limited to 'src/view/screens/Search/Explore.tsx')
-rw-r--r-- | src/view/screens/Search/Explore.tsx | 556 |
1 files changed, 556 insertions, 0 deletions
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx new file mode 100644 index 000000000..f6e998838 --- /dev/null +++ b/src/view/screens/Search/Explore.tsx @@ -0,0 +1,556 @@ +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 {useSession} from '#/state/session' +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 {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import { + FeedFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from 'view/com/util/LoadingPlaceholder' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Button} from '#/components/Button' +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 {hasSession} = useSession() + 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]}> + <FeedSourceCard + feedUri={item.feed.uri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave + hideTopBorder + /> + </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, hasSession, 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" + /> + ) +} |