[Explore] Base (#8053)
* migrate to #/screens * rm unneeded import * block drawer gesture on recent profiles * rm recommendations (#8056) * [Explore] Disable Trending videos (#8054) * remove giant header * disable * [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * restore statsig to log events * filter out followed profiles, make suer enough are loaded (#8090) * [Explore] Trending topics (#8055) * redesigned trending topics * rm borders on web * get post count / age / ranking from api * spacing tweaks * fetch more topics then slice * use api data for avis/category * rm top border * Integrate new SDK, part out components * Clean up * Use status field * Bump SDK * Send up interests and langs --------- Co-authored-by: Eric Bailey <git@esb.lol> * Clean up module spacing and borders (cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c) (cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1) * Switch back border ordering (cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9) * [Explore] Starter Packs (#8095) * Temp WIP (cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18) * New SP card * Load state * Revert change * Cleanup * Interests and caching * Count total * Format * Caching * [Explore] Feed previews module (#8075) * wip new hook * get fetching working, maybe * get feed previews rendering! * fix header height * working pin button * extract out FeedLink * add loader * only make preview:header sticky * Fix headers * Header tweaks * Fix moderation filter * Fix threading --------- Co-authored-by: Eric Bailey <git@esb.lol> * Space it out * Fix query key * Mock new endpoint, filter saved feeds * Make sure we're pinning, lower cache time * add news category * Remove log * Improve suggested accounts load state * Integrate new app view endpoint * fragment * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * maybe fix this --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -0,0 +1,923 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
||||
import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
|
||||
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
|
||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed'
|
||||
import {PostFeedItem} from '#/view/com/posts/PostFeedItem'
|
||||
import {ViewFullThread} from '#/view/com/posts/ViewFullThread'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
||||
import {
|
||||
StarterPackCard,
|
||||
StarterPackCardSkeleton,
|
||||
} from '#/screens/Search/components/StarterPackCard'
|
||||
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
||||
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
||||
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ModuleHeader from './components/ModuleHeader'
|
||||
import {
|
||||
type FeedPreviewItem,
|
||||
useFeedPreviews,
|
||||
} from './modules/ExploreFeedPreviews'
|
||||
import {
|
||||
SuggestedAccountsTabBar,
|
||||
SuggestedProfileCard,
|
||||
useLoadEnoughProfiles,
|
||||
} from './modules/ExploreSuggestedAccounts'
|
||||
|
||||
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<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.justify_center,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_sm,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{item.message}
|
||||
</Text>
|
||||
{item.isLoadingMore ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
size="sm"
|
||||
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type ExploreScreenItems =
|
||||
| {
|
||||
type: 'topBorder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'header'
|
||||
key: string
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
searchButton?: {
|
||||
label: string
|
||||
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||
tab: 'user' | 'profile' | 'feed'
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'tabbedHeader'
|
||||
key: string
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
searchButton?: {
|
||||
label: string
|
||||
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||
tab: 'user' | 'profile' | 'feed'
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'trendingTopics'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'trendingVideos'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'recommendations'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
recId?: number
|
||||
}
|
||||
| {
|
||||
type: 'feed'
|
||||
key: string
|
||||
feed: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
| {
|
||||
type: 'loadMore'
|
||||
key: string
|
||||
message: string
|
||||
isLoadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}
|
||||
| {
|
||||
type: 'profilePlaceholder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'feedPlaceholder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
key: string
|
||||
message: string
|
||||
error: string
|
||||
}
|
||||
| {
|
||||
type: 'starterPack'
|
||||
key: string
|
||||
view: AppBskyGraphDefs.StarterPackView
|
||||
}
|
||||
| {
|
||||
type: 'starterPackSkeleton'
|
||||
key: string
|
||||
}
|
||||
| FeedPreviewItem
|
||||
|
||||
export function Explore({
|
||||
focusSearchInput,
|
||||
headerHeight,
|
||||
}: {
|
||||
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
|
||||
headerHeight: number
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
const guide = useProgressGuide('follow-10')
|
||||
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
|
||||
const {
|
||||
data: suggestedProfiles,
|
||||
hasNextPage: hasNextSuggestedProfilesPage,
|
||||
isLoading: isLoadingSuggestedProfiles,
|
||||
isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
|
||||
error: suggestedProfilesError,
|
||||
fetchNextPage: fetchNextSuggestedProfilesPage,
|
||||
} = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10})
|
||||
const {
|
||||
data: interestProfiles,
|
||||
hasNextPage: hasNextInterestProfilesPage,
|
||||
isLoading: isLoadingInterestProfiles,
|
||||
isFetchingNextPage: isFetchingNextInterestProfilesPage,
|
||||
error: interestProfilesError,
|
||||
fetchNextPage: fetchNextInterestProfilesPage,
|
||||
} = useActorSearchPaginated({
|
||||
query: selectedInterest || '',
|
||||
enabled: !!selectedInterest,
|
||||
limit: 10,
|
||||
})
|
||||
const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({
|
||||
interest: selectedInterest,
|
||||
data: interestProfiles,
|
||||
isLoading: isLoadingInterestProfiles,
|
||||
isFetchingNextPage: isFetchingNextInterestProfilesPage,
|
||||
hasNextPage: hasNextInterestProfilesPage,
|
||||
fetchNextPage: fetchNextInterestProfilesPage,
|
||||
})
|
||||
const {
|
||||
data: feeds,
|
||||
hasNextPage: hasNextFeedsPage,
|
||||
isLoading: isLoadingFeeds,
|
||||
isFetchingNextPage: isFetchingNextFeedsPage,
|
||||
error: feedsError,
|
||||
fetchNextPage: fetchNextFeedsPage,
|
||||
} = useGetPopularFeedsQuery({limit: 10})
|
||||
|
||||
const profiles: typeof suggestedProfiles & typeof interestProfiles =
|
||||
!selectedInterest ? suggestedProfiles : interestProfiles
|
||||
const hasNextProfilesPage = !selectedInterest
|
||||
? hasNextSuggestedProfilesPage
|
||||
: hasNextInterestProfilesPage
|
||||
const isLoadingProfiles = !selectedInterest
|
||||
? isLoadingSuggestedProfiles
|
||||
: !canShowSuggestedProfiles
|
||||
const isFetchingNextProfilesPage = !selectedInterest
|
||||
? isFetchingNextSuggestedProfilesPage
|
||||
: !canShowSuggestedProfiles
|
||||
const profilesError = !selectedInterest
|
||||
? suggestedProfilesError
|
||||
: interestProfilesError
|
||||
const fetchNextProfilesPage = !selectedInterest
|
||||
? fetchNextSuggestedProfilesPage
|
||||
: fetchNextInterestProfilesPage
|
||||
|
||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||
const onLoadMoreProfiles = 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 {
|
||||
data: suggestedSPs,
|
||||
isLoading: isLoadingSuggestedSPs,
|
||||
error: suggestedSPsError,
|
||||
} = useSuggestedStarterPacksQuery()
|
||||
|
||||
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
||||
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
|
||||
const onLoadMoreFeeds = useCallback(async () => {
|
||||
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
||||
if (!hasPressedLoadMoreFeeds) {
|
||||
setHasPressedLoadMoreFeeds(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetchNextFeedsPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more suggested follows', {message: err})
|
||||
}
|
||||
}, [
|
||||
isFetchingNextFeedsPage,
|
||||
hasNextFeedsPage,
|
||||
feedsError,
|
||||
fetchNextFeedsPage,
|
||||
hasPressedLoadMoreFeeds,
|
||||
])
|
||||
|
||||
const {data: suggestedFeeds} = useGetSuggestedFeedsQuery()
|
||||
const {
|
||||
data: feedPreviewSlices,
|
||||
query: {
|
||||
isPending: isPendingFeedPreviews,
|
||||
isFetchingNextPage: isFetchingNextPageFeedPreviews,
|
||||
fetchNextPage: fetchNextPageFeedPreviews,
|
||||
hasNextPage: hasNextPageFeedPreviews,
|
||||
error: feedPreviewSlicesError,
|
||||
},
|
||||
} = useFeedPreviews(suggestedFeeds?.feeds ?? [])
|
||||
|
||||
const onLoadMoreFeedPreviews = useCallback(async () => {
|
||||
if (
|
||||
isPendingFeedPreviews ||
|
||||
isFetchingNextPageFeedPreviews ||
|
||||
!hasNextPageFeedPreviews ||
|
||||
feedPreviewSlicesError
|
||||
)
|
||||
return
|
||||
try {
|
||||
await fetchNextPageFeedPreviews()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more feed previews', {message: err})
|
||||
}
|
||||
}, [
|
||||
isPendingFeedPreviews,
|
||||
isFetchingNextPageFeedPreviews,
|
||||
hasNextPageFeedPreviews,
|
||||
feedPreviewSlicesError,
|
||||
fetchNextPageFeedPreviews,
|
||||
])
|
||||
|
||||
const items = useMemo<ExploreScreenItems[]>(() => {
|
||||
const i: ExploreScreenItems[] = []
|
||||
|
||||
const addTopBorder = () => {
|
||||
i.push({type: 'topBorder', key: 'top-border'})
|
||||
}
|
||||
|
||||
const addTrendingTopicsModule = () => {
|
||||
i.push({
|
||||
type: 'trendingTopics',
|
||||
key: `trending-topics`,
|
||||
})
|
||||
|
||||
// temp - disable trending videos
|
||||
// if (isNative) {
|
||||
// i.push({
|
||||
// type: 'trendingVideos',
|
||||
// key: `trending-videos`,
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
const addSuggestedFollowsModule = () => {
|
||||
i.push({
|
||||
type: 'tabbedHeader',
|
||||
key: 'suggested-accounts-header',
|
||||
title: _(msg`Suggested Accounts`),
|
||||
icon: Person,
|
||||
searchButton: {
|
||||
label: _(msg`Search for more accounts`),
|
||||
metricsTag: 'suggestedAccounts',
|
||||
tab: 'user',
|
||||
},
|
||||
})
|
||||
|
||||
if (!canShowSuggestedProfiles) {
|
||||
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
||||
} else if (profilesError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'profilesError',
|
||||
message: _(msg`Failed to load suggested follows`),
|
||||
error: cleanError(profilesError),
|
||||
})
|
||||
} else {
|
||||
if (profiles !== undefined) {
|
||||
if (profiles.pages.length > 0 && moderationOpts) {
|
||||
// Currently the responses contain duplicate items.
|
||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||
let seen = new Set()
|
||||
const profileItems: ExploreScreenItems[] = []
|
||||
for (const page of profiles.pages) {
|
||||
for (const actor of page.actors) {
|
||||
if (!seen.has(actor.did) && !actor.viewer?.following) {
|
||||
seen.add(actor.did)
|
||||
profileItems.push({
|
||||
type: 'profile',
|
||||
key: actor.did,
|
||||
profile: actor,
|
||||
recId: page.recId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileItems.length === 0) {
|
||||
if (!hasNextProfilesPage) {
|
||||
// no items! remove the header
|
||||
i.pop()
|
||||
}
|
||||
} else {
|
||||
i.push(...profileItems)
|
||||
}
|
||||
if (hasNextProfilesPage) {
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreProfiles',
|
||||
message: _(msg`Load more suggested accounts`),
|
||||
isLoadingMore: isLoadingMoreProfiles,
|
||||
onLoadMore: onLoadMoreProfiles,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log('no pages')
|
||||
}
|
||||
} else {
|
||||
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addSuggestedFeedsModule = () => {
|
||||
i.push({
|
||||
type: 'header',
|
||||
key: 'suggested-feeds-header',
|
||||
title: _(msg`Discover Feeds`),
|
||||
icon: ListSparkle,
|
||||
searchButton: {
|
||||
label: _(msg`Search for more feeds`),
|
||||
metricsTag: 'suggestedFeeds',
|
||||
tab: 'feed',
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
const feedItems: ExploreScreenItems[] = []
|
||||
for (const page of feeds.pages) {
|
||||
for (const feed of page.feeds) {
|
||||
if (!seen.has(feed.uri)) {
|
||||
seen.add(feed.uri)
|
||||
feedItems.push({
|
||||
type: 'feed',
|
||||
key: feed.uri,
|
||||
feed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feeds errors can occur during pagination, so feeds is truthy
|
||||
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 {
|
||||
if (feedItems.length === 0) {
|
||||
if (!hasNextFeedsPage) {
|
||||
i.pop()
|
||||
}
|
||||
} else {
|
||||
// This query doesn't follow the limit very well, so the first press of the
|
||||
// load more button just unslices the array back to ~10 items
|
||||
if (!hasPressedLoadMoreFeeds) {
|
||||
i.push(...feedItems.slice(0, 3))
|
||||
} else {
|
||||
i.push(...feedItems)
|
||||
}
|
||||
}
|
||||
if (hasNextFeedsPage) {
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreFeeds',
|
||||
message: _(msg`Load more suggested feeds`),
|
||||
isLoadingMore: isLoadingMoreFeeds,
|
||||
onLoadMore: onLoadMoreFeeds,
|
||||
})
|
||||
}
|
||||
}
|
||||
} 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'})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addSuggestedStarterPacksModule = () => {
|
||||
i.push({
|
||||
type: 'header',
|
||||
key: 'suggested-starterPacks-header',
|
||||
title: _(msg`Starter Packs`),
|
||||
icon: StarterPack,
|
||||
})
|
||||
|
||||
if (isLoadingSuggestedSPs) {
|
||||
Array.from({length: 3}).forEach((_, index) =>
|
||||
i.push({
|
||||
type: 'starterPackSkeleton',
|
||||
key: `starterPackSkeleton-${index}`,
|
||||
}),
|
||||
)
|
||||
} else if (suggestedSPsError || !suggestedSPs) {
|
||||
// just get rid of the section
|
||||
i.pop()
|
||||
} else {
|
||||
suggestedSPs.starterPacks.map(s => {
|
||||
i.push({
|
||||
type: 'starterPack',
|
||||
key: s.uri,
|
||||
view: s,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addFeedPreviews = () => {
|
||||
i.push(...feedPreviewSlices)
|
||||
if (isFetchingNextPageFeedPreviews) {
|
||||
i.push({
|
||||
type: 'preview:loading',
|
||||
key: 'preview-loading-more',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic module ordering
|
||||
|
||||
addTopBorder()
|
||||
|
||||
if (guide?.guide === 'follow-10' && !guide.isComplete) {
|
||||
addSuggestedFollowsModule()
|
||||
addSuggestedStarterPacksModule()
|
||||
addTrendingTopicsModule()
|
||||
} else {
|
||||
addTrendingTopicsModule()
|
||||
addSuggestedFollowsModule()
|
||||
addSuggestedStarterPacksModule()
|
||||
}
|
||||
|
||||
if (gate('explore_show_suggested_feeds')) {
|
||||
addSuggestedFeedsModule()
|
||||
}
|
||||
|
||||
addFeedPreviews()
|
||||
|
||||
return i
|
||||
}, [
|
||||
_,
|
||||
profiles,
|
||||
feeds,
|
||||
preferences,
|
||||
onLoadMoreFeeds,
|
||||
onLoadMoreProfiles,
|
||||
isLoadingMoreProfiles,
|
||||
isLoadingMoreFeeds,
|
||||
profilesError,
|
||||
feedsError,
|
||||
preferencesError,
|
||||
hasNextProfilesPage,
|
||||
hasNextFeedsPage,
|
||||
guide,
|
||||
gate,
|
||||
moderationOpts,
|
||||
hasPressedLoadMoreFeeds,
|
||||
suggestedSPs,
|
||||
isLoadingSuggestedSPs,
|
||||
suggestedSPsError,
|
||||
feedPreviewSlices,
|
||||
isFetchingNextPageFeedPreviews,
|
||||
canShowSuggestedProfiles,
|
||||
])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
||||
switch (item.type) {
|
||||
case 'topBorder':
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_t,
|
||||
headerHeight &&
|
||||
web({
|
||||
position: 'sticky',
|
||||
top: headerHeight,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
case 'header': {
|
||||
return (
|
||||
<ModuleHeader.Container>
|
||||
<ModuleHeader.Icon icon={item.icon} />
|
||||
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||
{item.searchButton && (
|
||||
<ModuleHeader.SearchButton
|
||||
{...item.searchButton}
|
||||
onPress={() =>
|
||||
focusSearchInput(item.searchButton?.tab || 'user')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ModuleHeader.Container>
|
||||
)
|
||||
}
|
||||
case 'tabbedHeader': {
|
||||
return (
|
||||
<View style={[a.pb_md]}>
|
||||
<ModuleHeader.Container style={[a.pb_xs]}>
|
||||
<ModuleHeader.Icon icon={item.icon} />
|
||||
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||
{item.searchButton && (
|
||||
<ModuleHeader.SearchButton
|
||||
{...item.searchButton}
|
||||
onPress={() =>
|
||||
focusSearchInput(item.searchButton?.tab || 'user')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ModuleHeader.Container>
|
||||
<SuggestedAccountsTabBar
|
||||
selectedInterest={selectedInterest}
|
||||
onSelectInterest={setSelectedInterest}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'trendingTopics': {
|
||||
return (
|
||||
<View style={[a.pb_md]}>
|
||||
<ExploreTrendingTopics />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'trendingVideos': {
|
||||
return <ExploreTrendingVideos />
|
||||
}
|
||||
case 'recommendations': {
|
||||
return <ExploreRecommendations />
|
||||
}
|
||||
case 'profile': {
|
||||
return (
|
||||
<SuggestedProfileCard
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
recId={item.recId}
|
||||
position={index}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'feed': {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
]}>
|
||||
<FeedCard.Default view={item.feed} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'starterPack': {
|
||||
return (
|
||||
<View style={[a.px_lg, a.pb_lg]}>
|
||||
<StarterPackCard view={item.view} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'starterPackSkeleton': {
|
||||
return (
|
||||
<View style={[a.px_lg, a.pb_lg]}>
|
||||
<StarterPackCardSkeleton />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'loadMore': {
|
||||
return (
|
||||
<View style={[a.border_t, t.atoms.border_contrast_low]}>
|
||||
<LoadMore item={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'profilePlaceholder': {
|
||||
return (
|
||||
<>
|
||||
{Array.from({length: 3}).map((_, index) => (
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}
|
||||
key={index}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
|
||||
</ProfileCard.Outer>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
case 'feedPlaceholder': {
|
||||
return <FeedFeedLoadingPlaceholder />
|
||||
}
|
||||
case 'error':
|
||||
case 'preview: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>
|
||||
)
|
||||
}
|
||||
// feed previews
|
||||
case 'preview:empty': {
|
||||
return null // what should we do here?
|
||||
}
|
||||
case 'preview:loading': {
|
||||
return (
|
||||
<View style={[a.py_2xl, a.flex_1, a.align_center]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
case 'preview:header': {
|
||||
return (
|
||||
<ModuleHeader.Container
|
||||
headerHeight={headerHeight}
|
||||
style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<ModuleHeader.FeedLink feed={item.feed}>
|
||||
<ModuleHeader.FeedAvatar feed={item.feed} />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<ModuleHeader.TitleText style={[a.text_lg]}>
|
||||
{item.feed.displayName}
|
||||
</ModuleHeader.TitleText>
|
||||
<ModuleHeader.SubtitleText>
|
||||
<Trans>
|
||||
By {sanitizeHandle(item.feed.creator.handle, '@')}
|
||||
</Trans>
|
||||
</ModuleHeader.SubtitleText>
|
||||
</View>
|
||||
</ModuleHeader.FeedLink>
|
||||
<ModuleHeader.PinButton feed={item.feed} />
|
||||
</ModuleHeader.Container>
|
||||
)
|
||||
}
|
||||
case 'preview:footer': {
|
||||
return <View style={[a.w_full, a.pt_2xl]} />
|
||||
}
|
||||
case 'preview:sliceItem': {
|
||||
const slice = item.slice
|
||||
const indexInSlice = item.indexInSlice
|
||||
const subItem = slice.items[indexInSlice]
|
||||
return (
|
||||
<PostFeedItem
|
||||
post={subItem.post}
|
||||
record={subItem.record}
|
||||
reason={indexInSlice === 0 ? slice.reason : undefined}
|
||||
feedContext={slice.feedContext}
|
||||
moderation={subItem.moderation}
|
||||
parentAuthor={subItem.parentAuthor}
|
||||
showReplyTo={item.showReplyTo}
|
||||
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
|
||||
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
|
||||
isThreadLastChild={
|
||||
isThreadChildAt(slice.items, indexInSlice) &&
|
||||
slice.items.length === indexInSlice + 1
|
||||
}
|
||||
isParentBlocked={subItem.isParentBlocked}
|
||||
isParentNotFound={subItem.isParentNotFound}
|
||||
hideTopBorder={item.hideTopBorder}
|
||||
rootPost={slice.items[0].post}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'preview:sliceViewFullThread': {
|
||||
return <ViewFullThread uri={item.uri} />
|
||||
}
|
||||
case 'preview:loadMoreError': {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label={_(
|
||||
msg`There was an issue fetching posts. Tap here to try again.`,
|
||||
)}
|
||||
onPress={fetchNextPageFeedPreviews}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
t,
|
||||
focusSearchInput,
|
||||
moderationOpts,
|
||||
selectedInterest,
|
||||
_,
|
||||
fetchNextPageFeedPreviews,
|
||||
headerHeight,
|
||||
],
|
||||
)
|
||||
|
||||
const stickyHeaderIndices = useMemo(
|
||||
() =>
|
||||
items.reduce(
|
||||
(acc, curr) =>
|
||||
['topBorder', 'preview:header'].includes(curr.type)
|
||||
? acc.concat(items.indexOf(curr))
|
||||
: acc,
|
||||
[] as number[],
|
||||
),
|
||||
[items],
|
||||
)
|
||||
|
||||
// track headers and report module viewability
|
||||
const alreadyReportedRef = useRef<Map<string, string>>(new Map())
|
||||
const onViewableItemsChanged = useCallback(
|
||||
({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken<ExploreScreenItems>[]
|
||||
changed: ViewToken<ExploreScreenItems>[]
|
||||
}) => {
|
||||
for (const {item} of viewableItems.filter(vi => vi.isViewable)) {
|
||||
let module: MetricEvents['explore:module:seen']['module']
|
||||
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
|
||||
module = item.type
|
||||
} else if (item.type === 'profile') {
|
||||
module = 'suggestedAccounts'
|
||||
} else if (item.type === 'feed') {
|
||||
module = 'suggestedFeeds'
|
||||
} else if (item.type === 'preview:header') {
|
||||
module = `feed:feedgen|${item.feed.uri}`
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (!alreadyReportedRef.current.has(module)) {
|
||||
alreadyReportedRef.current.set(module, module)
|
||||
logger.metric('explore:module:seen', {module})
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<List
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.key}
|
||||
desktopFixedHeight
|
||||
contentContainerStyle={{paddingBottom: 100}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
stickyHeaderIndices={native(stickyHeaderIndices)}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
onEndReached={onLoadMoreFeedPreviews}
|
||||
onEndReachedThreshold={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const viewabilityConfig: ViewabilityConfig = {
|
||||
itemVisiblePercentThreshold: 100,
|
||||
}
|
||||
Reference in New Issue
Block a user