[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
This commit is contained in:
Samuel Newman
2025-03-28 18:35:32 +02:00
committed by Eric Bailey
parent 522eab8dcb
commit 52fe45ea41
11 changed files with 731 additions and 391 deletions
+18 -14
View File
@@ -1,8 +1,8 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyGraphDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
AtUri,
RichText as RichTextApi,
} from '@atproto/api'
@@ -23,15 +23,14 @@ import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
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, LinkProps} from '#/components/Link'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, RichTextProps} from '#/components/RichText'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
type Props = {
view: AppBskyFeedDefs.GeneratorView
@@ -294,14 +293,19 @@ function SaveButtonInner({
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
variant="solid"
color={savedFeedConfig ? 'secondary' : 'primary'}
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
<>
{isPending && <ButtonIcon size="md" icon={Loader} />}
<ButtonText>Unpin Feed</ButtonText>
</>
) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
<>
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
<ButtonText>Pin Feed</ButtonText>
</>
)}
</Button>
+19 -8
View File
@@ -1,14 +1,14 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {type GestureResponderEvent, View} from 'react-native'
import {
moderateProfile,
ModerationOpts,
type ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {type LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
export function Default({
profile,
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
LogEvents['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -301,6 +307,7 @@ export function FollowButtonInner({
onPress: onPressProp,
onFollow,
colorInverted,
withIcon = true,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -386,7 +393,9 @@ export function FollowButtonInner({
color="secondary"
{...rest}
onPress={onPressUnfollow}>
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
{withIcon && (
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
@@ -397,7 +406,9 @@ export function FollowButtonInner({
color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{withIcon && (
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
)}
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
@@ -5,7 +5,7 @@ import Animated, {
LinearTransition,
ZoomInEasyDown,
} from 'react-native-reanimated'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {ListMethods} from '#/view/com/util/List'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
import {
popularInterests,
useInterestsDisplayNames,
@@ -31,7 +31,7 @@ import {
tokens,
useBreakpoints,
useTheme,
ViewStyleProp,
type ViewStyleProp,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -452,12 +452,14 @@ let Tabs = ({
selectedInterest,
hasSearchText,
interestsDisplayNames,
TabComponent = Tab,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
hasSearchText: boolean
interestsDisplayNames: Record<string, string>
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0)
@@ -532,7 +534,7 @@ let Tabs = ({
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
<Tab
<TabComponent
key={interest}
onSelectTab={handleSelectTab}
active={active}
@@ -547,6 +549,7 @@ let Tabs = ({
)
}
Tabs = memo(Tabs)
export {Tabs}
let Tab = ({
onSelectTab,
@@ -822,7 +825,7 @@ function Empty({message}: {message: string}) {
)
}
function boostInterests(boosts?: string[]) {
export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
+3 -3
View File
@@ -1,5 +1,5 @@
import {StyleSheet, TextProps} from 'react-native'
import type {PathProps, SvgProps} from 'react-native-svg'
import {StyleSheet, type TextProps} from 'react-native'
import {type PathProps, type SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure'
@@ -19,7 +19,7 @@ export const sizes = {
lg: 24,
xl: 28,
'2xl': 32,
}
} as const
export function useCommonSVGProps(props: Props) {
const t = useTheme()
+1
View File
@@ -2,6 +2,7 @@ export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'remove_show_latest_button'
+20
View File
@@ -1,3 +1,5 @@
import {type FeedDescriptor} from '#/state/queries/post-feed'
export type MetricEvents = {
// App events
init: {
@@ -202,6 +204,7 @@ export type MetricEvents = {
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
}
'suggestedUser:follow': {
logContext:
@@ -239,6 +242,7 @@ export type MetricEvents = {
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -318,6 +322,22 @@ export type MetricEvents = {
context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
}
'explore:module:seen': {
module:
| 'trendingTopics'
| 'trendingVideos'
| 'suggestedAccounts'
| 'suggestedFeeds'
| 'suggestedStarterPacks'
| `feed:${FeedDescriptor}`
}
'explore:module:searchButtonPress': {
module: 'suggestedAccounts' | 'suggestedFeeds'
}
'explore:suggestedAccounts:tabPressed': {
tab: string
}
'progressGuide:hide': {}
'progressGuide:followDialog:open': {}
+373 -352
View File
@@ -1,135 +1,49 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useCallback, useMemo, useRef, useState} from 'react'
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
import {type AppBskyActorDefs, type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
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 {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {useProgressGuide} from '#/state/shell/progress-guide'
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/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import {atoms as a, useTheme} 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 {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 {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
import {
SuggestedAccountsTabBar,
SuggestedProfileCard,
} from './modules/ExploreSuggestedAccounts'
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
}) {
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme()
const {_} = useLingui()
const items: LoadMoreItem[] = 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={[]}>
<View style={[a.pb_2xl]}>
<Button
label={_(msg`Load more`)}
onPress={item.onLoadMore}
@@ -140,93 +54,27 @@ function LoadMore({
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,
]}>
<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>
)}
{item.message}
</Text>
<View style={[a.flex_1, a.align_end]}>
{item.isLoadingMore && <Loader size="lg" />}
</View>
{item.isLoadingMore ? (
<Loader size="sm" />
) : (
<ChevronDownIcon
size="sm"
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
/>
)}
</View>
)}
</Button>
@@ -235,13 +83,31 @@ function LoadMore({
}
type ExploreScreenItems =
| {
type: 'topBorder'
key: string
}
| {
type: 'header'
key: string
title: string
description: string
style?: ViewStyleProp['style']
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'
@@ -269,9 +135,9 @@ type ExploreScreenItems =
| {
type: 'loadMore'
key: string
message: string
isLoadingMore: boolean
onLoadMore: () => void
items: ExploreScreenItems[]
}
| {
type: 'profilePlaceholder'
@@ -288,19 +154,38 @@ type ExploreScreenItems =
error: string
}
export function Explore() {
export function Explore({
focusSearchInput,
}: {
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
}) {
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: profiles,
hasNextPage: hasNextProfilesPage,
isLoading: isLoadingProfiles,
isFetchingNextPage: isFetchingNextProfilesPage,
error: profilesError,
fetchNextPage: fetchNextProfilesPage,
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
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 {
data: feeds,
hasNextPage: hasNextFeedsPage,
@@ -310,6 +195,24 @@ export function Explore() {
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
const profiles: typeof suggestedProfiles & typeof interestProfiles =
!selectedInterest ? suggestedProfiles : interestProfiles
const hasNextProfilesPage = !selectedInterest
? hasNextSuggestedProfilesPage
: hasNextInterestProfilesPage
const isLoadingProfiles = !selectedInterest
? isLoadingSuggestedProfiles
: isLoadingInterestProfiles
const isFetchingNextProfilesPage = !selectedInterest
? isFetchingNextSuggestedProfilesPage
: isFetchingNextInterestProfilesPage
const profilesError = !selectedInterest
? suggestedProfilesError
: interestProfilesError
const fetchNextProfilesPage = !selectedInterest
? fetchNextSuggestedProfilesPage
: fetchNextInterestProfilesPage
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = useCallback(async () => {
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
@@ -327,8 +230,13 @@ export function Explore() {
])
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) {
@@ -339,156 +247,198 @@ export function Explore() {
hasNextFeedsPage,
feedsError,
fetchNextFeedsPage,
hasPressedLoadMoreFeeds,
])
const items = useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = []
i.push({
type: 'trendingTopics',
key: `trending-topics`,
})
const addTopBorder = () => {
i.push({
type: 'topBorder',
key: `top-border`,
})
}
// temp - disable trending videos
// if (isNative) {
// i.push({
// type: 'trendingVideos',
// key: `trending-videos`,
// })
// }
const addTrendingTopicsModule = () => {
i.push({
type: 'trendingTopics',
key: `trending-topics`,
})
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,
})
// temp - disable trending videos
// if (isNative) {
// i.push({
// type: 'trendingVideos',
// key: `trending-videos`,
// })
// }
}
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,
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 (profiles && 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)) {
seen.add(actor.did)
profileItems.push({
type: 'profile',
key: actor.did,
profile: actor,
recId: page.recId,
})
}
}
}
if (profileItems.length === 0) {
// 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,
})
}
}
}
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'})
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,
})
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,
})
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,
})
// 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) {
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 {
i.push(...feedItems)
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'})
}
}
}
// Dynamic module ordering
addTopBorder()
if (guide?.guide === 'follow-10' && !guide.isComplete) {
addSuggestedFollowsModule()
addTrendingTopicsModule()
} 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'})
}
addTrendingTopicsModule()
addSuggestedFollowsModule()
}
if (gate('explore_show_suggested_feeds')) {
addSuggestedFeedsModule()
}
return i
@@ -506,19 +456,61 @@ export function Explore() {
preferencesError,
hasNextProfilesPage,
hasNextFeedsPage,
guide,
gate,
moderationOpts,
hasPressedLoadMoreFeeds,
])
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]} />
)
case 'header': {
return (
<SuggestedItemsHeader
title={item.title}
description={item.description}
style={item.style}
icon={item.icon}
/>
<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.border_b,
t.atoms.border_contrast_low,
a.pb_md,
t.atoms.bg,
]}>
<ModuleHeader.Container style={a.border_transparent}>
<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': {
@@ -532,29 +524,12 @@ export function Explore() {
}
case 'profile': {
return (
<View style={[a.border_b, t.atoms.border_contrast_low]}>
<ProfileCardWithFollowBtn
profile={item.profile}
noBg
noBorder
showKnownFollowers
onPress={() => {
logger.metric('suggestedUser:press', {
logContext: 'Explore',
recId: item.recId,
position: index,
})
}}
onFollow={() => {
logger.metric('suggestedUser:follow', {
logContext: 'Explore',
location: 'Card',
recId: item.recId,
position: index,
})
}}
/>
</View>
<SuggestedProfileCard
profile={item.profile}
moderationOpts={moderationOpts!}
recId={item.recId}
position={index}
/>
)
}
case 'feed': {
@@ -571,7 +546,7 @@ export function Explore() {
)
}
case 'loadMore': {
return <LoadMore item={item} moderationOpts={moderationOpts} />
return <LoadMore item={item} />
}
case 'profilePlaceholder': {
return <ProfileCardFeedLoadingPlaceholder />
@@ -616,11 +591,50 @@ export function Explore() {
}
}
},
[t, moderationOpts],
[t, focusSearchInput, moderationOpts, selectedInterest],
)
const stickyHeaderIndices = useMemo(
() =>
items.reduce(
(acc, curr) =>
['topBorder', 'header', 'tabbedHeader'].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 {
continue
}
if (!alreadyReportedRef.current.has(module)) {
alreadyReportedRef.current.set(module, module)
logger.metric('explore:module:seen', {module})
}
}
},
[],
)
// note: actually not a screen, instead it's nested within
// the search screen. so we don't need Layout.Screen
return (
<List
data={items}
@@ -630,6 +644,13 @@ export function Explore() {
contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
stickyHeaderIndices={stickyHeaderIndices}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
/>
)
}
const viewabilityConfig: ViewabilityConfig = {
itemVisiblePercentThreshold: 100,
}
+8 -1
View File
@@ -266,6 +266,10 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete])
const focusSearchInput = useCallback(() => {
textInput.current?.focus()
}, [])
const showHeader = !gtMobile || navButton !== 'menu'
return (
@@ -399,6 +403,7 @@ export function SearchScreenShell({
query={query}
queryWithParams={queryWithParams}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
</View>
</Layout.Screen>
@@ -409,10 +414,12 @@ let SearchScreenInner = ({
query,
queryWithParams,
headerHeight,
focusSearchInput,
}: {
query: string
queryWithParams: string
headerHeight: number
focusSearchInput: () => void
}): React.ReactNode => {
const t = useTheme()
const setMinimalShellMode = useSetMinimalShellMode()
@@ -438,7 +445,7 @@ let SearchScreenInner = ({
onPageSelected={onPageSelected}
/>
) : hasSession ? (
<Explore />
<Explore focusSearchInput={focusSearchInput} />
) : (
<Layout.Center>
<View style={a.flex_1}>
@@ -0,0 +1,83 @@
import {View} from 'react-native'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {logger} from '#/logger'
import {
atoms as a,
native,
useGutters,
useTheme,
type ViewStyleProp,
} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {sizes as iconSizes} from '#/components/icons/common'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {Text, type TextProps} from '#/components/Typography'
export function Container({
style,
children,
}: {children: React.ReactNode} & ViewStyleProp) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
return (
<View
style={[
t.atoms.bg,
a.border_b,
t.atoms.border_contrast_low,
a.flex_row,
a.align_center,
gutters,
a.pt_2xs,
a.pb_sm,
a.gap_sm,
style,
]}>
{children}
</View>
)
}
export function Icon({
icon: Comp,
size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const iconSize = iconSizes[size]
return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} />
</View>
)
}
export function TitleText({style, ...props}: TextProps) {
return <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} {...props} />
}
export function SearchButton({
label,
metricsTag,
onPress,
}: {
label: string
metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
onPress?: () => void
}) {
return (
<Button
label={label}
size="small"
variant="ghost"
color="secondary"
shape="round"
PressableComponent={native(PressableScale)}
onPress={() => {
logger.metric('explore:module:searchButtonPress', {module: metricsTag})
onPress?.()
}}>
<ButtonIcon icon={SearchIcon} size="lg" />
</Button>
)
}
@@ -0,0 +1,181 @@
import {memo} from 'react'
import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
popularInterests,
useInterestsDisplayNames,
} from '#/screens/Onboarding/state'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function SuggestedAccountsTabBar({
selectedInterest,
onSelectInterest,
}: {
selectedInterest: string | null
onSelectInterest: (interest: string | null) => void
}) {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
return (
<BlockDrawerGesture>
<Tabs
interests={['all', ...interests]}
selectedInterest={selectedInterest || 'all'}
onSelectTab={tab => {
logger.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
onSelectInterest(tab === 'all' ? null : tab)
}}
hasSearchText={false}
interestsDisplayNames={{
all: _(msg`All`),
...interestsDisplayNames,
}}
TabComponent={Tab}
/>
</BlockDrawerGesture>
)
}
let Tab = ({
onSelectTab,
interest,
active,
index,
interestsDisplayName,
onLayout,
}: {
onSelectTab: (index: number) => void
interest: string
active: boolean
index: number
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
onPress={() => onSelectTab(index)}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
Tab = memo(Tab)
/**
* Profile card for suggested accounts. Note: border is on the bottom edge
*/
let SuggestedProfileCard = ({
profile,
moderationOpts,
recId,
position,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
recId?: number
position: number
}): React.ReactNode => {
const t = useTheme()
return (
<ProfileCard.Link
profile={profile}
style={[a.flex_1]}
onPress={() => {
logger.metric('suggestedUser:press', {
logContext: 'Explore',
recId,
position,
})
}}>
<View
style={[
a.w_full,
a.py_md,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
a.flex_1,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="ExploreSuggestedAccounts"
onFollow={() => {
logger.metric('suggestedUser:follow', {
logContext: 'Explore',
location: 'Card',
recId,
position,
})
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={3} />
</ProfileCard.Outer>
</View>
</ProfileCard.Link>
)
}
SuggestedProfileCard = memo(SuggestedProfileCard)
export {SuggestedProfileCard}
+16 -7
View File
@@ -1,9 +1,12 @@
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
import {
InfiniteData,
type AppBskyActorDefs,
type AppBskyActorSearchActors,
} from '@atproto/api'
import {
type InfiniteData,
keepPreviousData,
QueryClient,
QueryKey,
type QueryClient,
type QueryKey,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query'
@@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query]
export const RQKEY_PAGINATED = (query: string, limit?: number) => [
RQKEY_ROOT_PAGINATED,
query,
limit,
]
export function useActorSearch({
query,
@@ -42,10 +49,12 @@ export function useActorSearchPaginated({
query,
enabled,
maintainData,
limit = 25,
}: {
query: string
enabled?: boolean
maintainData?: boolean
limit?: number
}) {
const agent = useAgent()
return useInfiniteQuery<
@@ -56,11 +65,11 @@ export function useActorSearchPaginated({
string | undefined
>({
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY_PAGINATED(query),
queryKey: RQKEY_PAGINATED(query, limit),
queryFn: async ({pageParam}) => {
const res = await agent.searchActors({
q: query,
limit: 25,
limit,
cursor: pageParam,
})
return res.data