[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:
Samuel Newman
2025-04-03 03:21:15 +03:00
committed by GitHub
parent 8d1f97b5ff
commit 87da619aaa
45 changed files with 3834 additions and 2178 deletions
+13 -13
View File
@@ -3,13 +3,13 @@ import {
ActivityIndicator,
AppState,
Dimensions,
ListRenderItemInfo,
StyleProp,
type ListRenderItemInfo,
type StyleProp,
StyleSheet,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -24,21 +24,21 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useTrendingSettings} from '#/state/preferences/trending'
import {STALE} from '#/state/queries'
import {
AuthorFilter,
FeedDescriptor,
FeedParams,
FeedPostSlice,
FeedPostSliceItem,
type AuthorFilter,
type FeedDescriptor,
type FeedParams,
type FeedPostSlice,
type FeedPostSliceItem,
pollLatest,
RQKEY,
usePostFeedQuery,
} from '#/state/queries/post-feed'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import {List, ListRef} from '#/view/com/util/List'
import {List, type ListRef} from '#/view/com/util/List'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
import {
@@ -767,14 +767,14 @@ const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
})
function isThreadParentAt<T>(arr: Array<T>, i: number) {
export function isThreadParentAt<T>(arr: Array<T>, i: number) {
if (arr.length === 1) {
return false
}
return i < arr.length - 1
}
function isThreadChildAt<T>(arr: Array<T>, i: number) {
export function isThreadChildAt<T>(arr: Array<T>, i: number) {
if (arr.length === 1) {
return false
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {I18n} from '@lingui/core'
import {type I18n} from '@lingui/core'
export const formatCount = (i18n: I18n, num: number) => {
return i18n.number(num, {
-641
View File
@@ -1,641 +0,0 @@
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 {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative, 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 {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {
FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos'
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_heavy, t.atoms.text]}>{title}</Text>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{description}
</Text>
</View>
</View>
)
}
type LoadMoreItem =
| {
type: 'profile'
key: string
avatar: string | undefined
moderation: ModerationDecision
}
| {
type: 'feed'
key: string
avatar: string | undefined
moderation: undefined
}
function LoadMore({
item,
moderationOpts,
}: {
item: ExploreScreenItems & {type: 'loadMore'}
moderationOpts?: ModerationOpts
}) {
const t = useTheme()
const {_} = useLingui()
const items: LoadMoreItem[] = React.useMemo(() => {
return item.items
.map(_item => {
let loadMoreItem: LoadMoreItem | undefined
if (_item.type === 'profile') {
loadMoreItem = {
type: 'profile',
key: _item.profile.did,
avatar: _item.profile.avatar,
moderation: moderateProfile(_item.profile, moderationOpts!),
}
} else if (_item.type === 'feed') {
loadMoreItem = {
type: 'feed',
key: _item.feed.uri,
avatar: _item.feed.avatar,
moderation: undefined,
}
}
return loadMoreItem
})
.filter(n => !!n)
}, [item.items, moderationOpts])
if (items.length === 0) return null
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 * items.length,
},
]}>
<View
style={[
a.align_center,
a.justify_center,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: 0,
borderWidth: 1,
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={[
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: (i + 1) * 15,
borderWidth: 1,
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')}
type="user"
/>
) : _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: '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
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: 6, subsequentPageLimit: 10})
const {
data: feeds,
hasNextPage: hasNextFeedsPage,
isLoading: isLoadingFeeds,
isFetchingNextPage: isFetchingNextFeedsPage,
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
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[] = []
i.push({
type: 'trendingTopics',
key: `trending-topics`,
})
if (isNative) {
i.push({
type: 'trendingVideos',
key: `trending-videos`,
})
}
i.push({
type: 'recommendations',
key: `recommendations`,
})
i.push({
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()
const profileItems: ExploreScreenItems[] = []
for (const page of profiles.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
profileItems.push({
type: 'profile',
key: actor.did,
profile: actor,
recId: page.recId,
})
}
}
}
if (hasNextProfilesPage) {
// splice off 3 as previews if we have a next page
const previews = profileItems.splice(-3)
// push remainder
i.push(...profileItems)
i.push({
type: 'loadMore',
key: 'loadMoreProfiles',
isLoadingMore: isLoadingMoreProfiles,
onLoadMore: onLoadMoreProfiles,
items: previews,
})
} else {
i.push(...profileItems)
}
} 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`Choose your own timeline! Feeds built by the community help you find 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()
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 (hasNextFeedsPage) {
const preview = feedItems.splice(-3)
i.push(...feedItems)
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
items: preview,
})
} else {
i.push(...feedItems)
}
} 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,
hasNextProfilesPage,
hasNextFeedsPage,
])
const renderItem = React.useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => {
switch (item.type) {
case 'header': {
return (
<SuggestedItemsHeader
title={item.title}
description={item.description}
style={item.style}
icon={item.icon}
/>
)
}
case 'trendingTopics': {
return <ExploreTrendingTopics />
}
case 'trendingVideos': {
return <ExploreTrendingVideos />
}
case 'recommendations': {
return <ExploreRecommendations />
}
case 'profile': {
return (
<View style={[a.border_b, t.atoms.border_contrast_low]}>
<ProfileCardWithFollowBtn
profile={item.profile}
noBg
noBorder
showKnownFollowers
onPress={() => {
logEvent('suggestedUser:press', {
logContext: 'Explore',
recId: item.recId,
position: index,
})
}}
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext: 'Explore',
location: 'Card',
recId: item.recId,
position: index,
})
}}
/>
</View>
)
}
case 'feed': {
return (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default view={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],
)
// note: actually not a screen, instead it's nested within
// the search screen. so we don't need Layout.Screen
return (
<List
data={items}
renderItem={renderItem}
keyExtractor={item => item.key}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
)
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
export {SearchScreen} from '#/view/screens/Search/Search'
@@ -14,7 +14,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import * as Prompt from '#/components/Prompt'
import {
TrendingTopic,