diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index f20e517d43..b94881dd50 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -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 ? (
-
+ <>
+ {isPending && }
+ Unpin Feed
+ >
) : (
-
+ <>
+
+ Pin Feed
+ >
)}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index b56112dcf2..9bf015b8a1 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -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
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}>
-
+ {withIcon && (
+
+ )}
{isRound ? null : {unfollowLabel}}
) : (
@@ -397,7 +406,9 @@ export function FollowButtonInner({
color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
-
+ {withIcon && (
+
+ )}
{isRound ? null : {followLabel}}
)}
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 006f86574b..41c3d41d89 100644
--- a/src/components/ProgressGuide/FollowDialog.tsx
+++ b/src/components/ProgressGuide/FollowDialog.tsx
@@ -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
+ TabComponent?: React.ComponentType>
}): React.ReactNode => {
const listRef = useRef(null)
const [scrollX, setScrollX] = useState(0)
@@ -532,7 +534,7 @@ let Tabs = ({
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
- {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index 996ecb626c..bc1e045a48 100644
--- a/src/components/icons/common.tsx
+++ b/src/components/icons/common.tsx
@@ -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()
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index c88a97c751..d3334d82f6 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -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'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 33cdc25e58..6467583692 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -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': {}
diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx
index 699bf10bc7..a2008accc2 100644
--- a/src/screens/Search/Explore.tsx
+++ b/src/screens/Search/Explore.tsx
@@ -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
-} & ViewStyleProp) {
- const t = useTheme()
-
- return (
-
-
-
-
- {title}
-
-
- {description}
-
-
-
- )
-}
-
-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 (
-
+
)}
@@ -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
+ searchButton?: {
+ label: string
+ metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
+ tab: 'user' | 'profile' | 'feed'
+ }
+ }
+ | {
+ type: 'tabbedHeader'
+ key: string
+ title: string
+ icon: React.ComponentType
+ 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(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(() => {
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 (
+
+ )
case 'header': {
return (
-
+
+
+ {item.title}
+ {item.searchButton && (
+
+ focusSearchInput(item.searchButton?.tab || 'user')
+ }
+ />
+ )}
+
+ )
+ }
+ case 'tabbedHeader': {
+ return (
+
+
+
+ {item.title}
+ {item.searchButton && (
+
+ focusSearchInput(item.searchButton?.tab || 'user')
+ }
+ />
+ )}
+
+
+
)
}
case 'trendingTopics': {
@@ -532,29 +524,12 @@ export function Explore() {
}
case 'profile': {
return (
-
- {
- 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,
- })
- }}
- />
-
+
)
}
case 'feed': {
@@ -571,7 +546,7 @@ export function Explore() {
)
}
case 'loadMore': {
- return
+ return
}
case 'profilePlaceholder': {
return
@@ -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
@@ -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 ? (
-
+
) : (
diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx
new file mode 100644
index 0000000000..6fc8164f6a
--- /dev/null
+++ b/src/screens/Search/components/ModuleHeader.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function Icon({
+ icon: Comp,
+ size = 'lg',
+}: Pick, 'icon' | 'size'>) {
+ const iconSize = iconSizes[size]
+
+ return (
+
+
+
+ )
+}
+
+export function TitleText({style, ...props}: TextProps) {
+ return
+}
+
+export function SearchButton({
+ label,
+ metricsTag,
+ onPress,
+}: {
+ label: string
+ metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
+ onPress?: () => void
+}) {
+ return (
+
+ )
+}
diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
new file mode 100644
index 0000000000..aa93256106
--- /dev/null
+++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
@@ -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 (
+
+ {
+ logger.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
+ onSelectInterest(tab === 'all' ? null : tab)
+ }}
+ hasSearchText={false}
+ interestsDisplayNames={{
+ all: _(msg`All`),
+ ...interestsDisplayNames,
+ }}
+ TabComponent={Tab}
+ />
+
+ )
+}
+
+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 (
+
+ onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
+ }>
+
+
+ )
+}
+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 (
+ {
+ logger.metric('suggestedUser:press', {
+ logContext: 'Explore',
+ recId,
+ position,
+ })
+ }}>
+
+
+
+
+
+ {
+ logger.metric('suggestedUser:follow', {
+ logContext: 'Explore',
+ location: 'Card',
+ recId,
+ position,
+ })
+ }}
+ />
+
+
+
+
+
+ )
+}
+SuggestedProfileCard = memo(SuggestedProfileCard)
+export {SuggestedProfileCard}
diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts
index 6d6c46e040..0b5de23037 100644
--- a/src/state/queries/actor-search.ts
+++ b/src/state/queries/actor-search.ts
@@ -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