diff --git a/package.json b/package.json
index 6f73a5581b..2ff7557d1b 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.14.19",
+ "@atproto/api": "^0.14.20",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx
index a341a20611..ae42a7470b 100644
--- a/src/components/AvatarStack.tsx
+++ b/src/components/AvatarStack.tsx
@@ -19,7 +19,7 @@ export function AvatarStack({
numPending?: number
backgroundColor?: string
}) {
- const halfSize = size / 2
+ const translation = size / 3 // overlap by 1/3
const t = useTheme()
const moderationOpts = useModerationOpts()
@@ -43,7 +43,7 @@ export function AvatarStack({
a.flex_row,
a.align_center,
a.relative,
- {width: size + (items.length - 1) * halfSize},
+ {width: size + (items.length - 1) * (size - translation)},
]}>
{items.map((item, i) => (
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
index 41c3d41d89..cafa958ccb 100644
--- a/src/components/ProgressGuide/FollowDialog.tsx
+++ b/src/components/ProgressGuide/FollowDialog.tsx
@@ -1,5 +1,12 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
-import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native'
+import {
+ ScrollView,
+ type StyleProp,
+ TextInput,
+ useWindowDimensions,
+ View,
+ type ViewStyle,
+} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
@@ -453,6 +460,7 @@ let Tabs = ({
hasSearchText,
interestsDisplayNames,
TabComponent = Tab,
+ contentContainerStyle,
}: {
onSelectTab: (tab: string) => void
interests: string[]
@@ -460,6 +468,7 @@ let Tabs = ({
hasSearchText: boolean
interestsDisplayNames: Record
TabComponent?: React.ComponentType>
+ contentContainerStyle?: StyleProp
}): React.ReactNode => {
const listRef = useRef(null)
const [scrollX, setScrollX] = useState(0)
@@ -520,7 +529,7 @@ let Tabs = ({
+ iconSize?: IcoProps['size']
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
@@ -142,7 +144,7 @@ type ExploreScreenItems =
| {
type: 'profile'
key: string
- profile: AppBskyActorDefs.ProfileView
+ profile: AppBskyActorDefs.ProfileViewBasic
recId?: number
}
| {
@@ -181,6 +183,10 @@ type ExploreScreenItems =
key: string
}
| FeedPreviewItem
+ | {
+ type: 'interests-card'
+ key: 'interests-card'
+ }
export function Explore({
focusSearchInput,
@@ -197,33 +203,13 @@ export function Explore({
const gate = useGate()
const guide = useProgressGuide('follow-10')
const [selectedInterest, setSelectedInterest] = useState(null)
+ // TODO always get at least 10 back
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,
+ data: suggestedUsers,
+ isLoading: suggestedUsersIsLoading,
+ error: suggestedUsersError,
+ } = useGetSuggestedUsersQuery({
+ category: selectedInterest,
})
const {
data: feeds,
@@ -233,40 +219,10 @@ export function Explore({
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
+ const interestsNux = useNux(Nux.ExploreInterestsCard)
+ const showInterestsNux =
+ interestsNux.status === 'ready' && !interestsNux.nux?.completed
- 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,
@@ -349,55 +305,42 @@ export function Explore({
},
})
- if (!canShowSuggestedProfiles) {
+ if (suggestedUsersIsLoading) {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
- } else if (profilesError) {
+ } else if (suggestedUsersError) {
i.push({
type: 'error',
- key: 'profilesError',
+ key: 'suggestedUsersError',
message: _(msg`Failed to load suggested follows`),
- error: cleanError(profilesError),
+ error: cleanError(suggestedUsersError),
})
} else {
- if (profiles !== undefined) {
- if (profiles.pages.length > 0 && moderationOpts) {
+ if (suggestedUsers !== undefined) {
+ if (suggestedUsers.actors.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,
- })
- }
+ for (const actor of suggestedUsers.actors) {
+ if (!seen.has(actor.did) && !actor.viewer?.following) {
+ seen.add(actor.did)
+ profileItems.push({
+ type: 'profile',
+ key: actor.did,
+ profile: actor,
+ })
}
}
if (profileItems.length === 0) {
- if (!hasNextProfilesPage) {
- // no items! remove the header
- i.pop()
- }
+ // 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')
+ // no items! remove the header
+ i.pop()
}
} else {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
@@ -405,14 +348,11 @@ export function Explore({
}
return i
}, [
- profiles,
_,
- canShowSuggestedProfiles,
- hasNextProfilesPage,
- isLoadingMoreProfiles,
moderationOpts,
- onLoadMoreProfiles,
- profilesError,
+ suggestedUsers,
+ suggestedUsersIsLoading,
+ suggestedUsersError,
])
const suggestedFeedsModule = useMemo(() => {
const i: ExploreScreenItems[] = []
@@ -523,6 +463,7 @@ export function Explore({
key: 'suggested-starterPacks-header',
title: _(msg`Starter Packs`),
icon: StarterPack,
+ iconSize: 'xl',
})
if (isLoadingSuggestedSPs) {
@@ -558,6 +499,16 @@ export function Explore({
return i
}, [feedPreviewSlices, isFetchingNextPageFeedPreviews])
+ const interestsNuxModule = useMemo(() => {
+ if (!showInterestsNux) return []
+ return [
+ {
+ type: 'interests-card',
+ key: 'interests-card',
+ },
+ ]
+ }, [showInterestsNux])
+
const isNewUser = guide?.guide === 'follow-10' && !guide.isComplete
const items = useMemo(() => {
const i: ExploreScreenItems[] = []
@@ -565,6 +516,7 @@ export function Explore({
// Dynamic module ordering
i.push(topBorder)
+ i.push(...interestsNuxModule)
if (isNewUser) {
i.push(...suggestedFollowsModule)
i.push(...suggestedStarterPacksModule)
@@ -588,6 +540,7 @@ export function Explore({
suggestedFeedsModule,
trendingTopicsModule,
feedPreviewsModule,
+ interestsNuxModule,
gate,
])
@@ -612,7 +565,7 @@ export function Explore({
case 'header': {
return (
-
+
{item.title}
{item.searchButton && (
)
}
+ case 'interests-card': {
+ return
+ }
}
},
[
diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx
index e48f3d4550..20aafd623a 100644
--- a/src/screens/Search/Shell.tsx
+++ b/src/screens/Search/Shell.tsx
@@ -318,7 +318,7 @@ export function SearchScreenShell({
)}
-
+
diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx
index c965046a64..c6411d1c0d 100644
--- a/src/screens/Search/components/ModuleHeader.tsx
+++ b/src/screens/Search/components/ModuleHeader.tsx
@@ -6,14 +6,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {
- atoms as a,
- native,
- useGutters,
- useTheme,
- type ViewStyleProp,
- web,
-} from '#/alf'
+import {atoms as a, native, useTheme, type ViewStyleProp, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {sizes as iconSizes} from '#/components/icons/common'
@@ -27,13 +20,12 @@ export function Container({
headerHeight,
}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) {
const t = useTheme()
- const gutters = useGutters([0, 'base'])
return (
0 ? (
{
+ trendingPrompt.open()
+ }
+ const onConfirmClose = () => {
+ setClosing(true)
+ // if this fails, they can try again later
+ saveNux({
+ id: Nux.ExploreInterestsCard,
+ completed: true,
+ data: undefined,
+ }).catch(() => {})
+ }
+
+ return closing ? null : (
+ <>
+
+
+
+
+
+ Your interests
+
+
+ {preferences?.interests?.tags &&
+ preferences.interests.tags.length > 0 ? (
+
+ {preferences.interests.tags.map(tag => (
+
+
+ {interestsDisplayNames[tag]}
+
+
+ ))}
+
+ ) : null}
+
+
+
+ Your selected interests help us serve you content you care about.
+
+
+
+
+
+ Edit interests
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
index 070d75910c..6d36ef1a77 100644
--- a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
+++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
@@ -83,10 +83,16 @@ export function SuggestedAccountsTabBar({
}}
hasSearchText={false}
interestsDisplayNames={{
- all: _(msg`All`),
+ all: _(msg`For You`),
...interestsDisplayNames,
}}
TabComponent={Tab}
+ contentContainerStyle={[
+ {
+ // visual alignment
+ paddingLeft: a.px_md.paddingLeft,
+ },
+ ]}
/>
)
diff --git a/src/screens/Settings/SettingsInterests.tsx b/src/screens/Settings/SettingsInterests.tsx
index 308f753eaa..b3909b0b1f 100644
--- a/src/screens/Settings/SettingsInterests.tsx
+++ b/src/screens/Settings/SettingsInterests.tsx
@@ -16,6 +16,7 @@ import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
import {atoms as a, useGutters, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import * as Layout from '#/components/Layout'
@@ -49,8 +50,7 @@ export function SettingsInterests() {
t.atoms.text_contrast_medium,
]}>
- Selecting interests from the list below helps us deliver you
- higher quality content.
+ Your selected interests help us serve you content you care about.
@@ -135,25 +135,33 @@ function Inner({
}
return (
-
-
- {INTERESTS.map(interest => {
- const name = interestsDisplayNames[interest]
- if (!name) return null
- return (
-
-
-
- )
- })}
-
-
+ <>
+ {interests.length === 0 && (
+
+ We recommend selecting at least two interests.
+
+ )}
+
+
+
+ {INTERESTS.map(interest => {
+ const name = interestsDisplayNames[interest]
+ if (!name) return null
+ return (
+
+
+
+ )
+ })}
+
+
+ >
)
}
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 84ebc565c4..82ee44388f 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -19,6 +19,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
+import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import type * as bsky from '#/types/bsky'
import {castAsShadow, type Shadow} from './types'
@@ -149,6 +150,7 @@ function* findProfilesInCache(
yield* findAllProfilesInProfileQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
+ yield* findAllProfilesInSuggestedUsersQueryData(queryClient, did)
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
yield* findAllProfilesInListConvosQueryData(queryClient, did)
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 8166602c8a..8eb53a0a43 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -1,18 +1,26 @@
-import zod from 'zod'
+import type zod from 'zod'
-import {BaseNux} from '#/state/queries/nuxs/types'
+import {type BaseNux} from '#/state/queries/nuxs/types'
export enum Nux {
NeueTypography = 'NeueTypography',
+ ExploreInterestsCard = 'ExploreInterestsCard',
}
export const nuxNames = new Set(Object.values(Nux))
-export type AppNux = BaseNux<{
- id: Nux.NeueTypography
- data: undefined
-}>
+export type AppNux = BaseNux<
+ | {
+ id: Nux.NeueTypography
+ data: undefined
+ }
+ | {
+ id: Nux.ExploreInterestsCard
+ data: undefined
+ }
+>
export const NuxSchemas: Record | undefined> = {
[Nux.NeueTypography]: undefined,
+ [Nux.ExploreInterestsCard]: undefined,
}
diff --git a/src/state/queries/trending/useGetSuggestedUsersQuery.ts b/src/state/queries/trending/useGetSuggestedUsersQuery.ts
new file mode 100644
index 0000000000..eb97ad6666
--- /dev/null
+++ b/src/state/queries/trending/useGetSuggestedUsersQuery.ts
@@ -0,0 +1,71 @@
+import {
+ type AppBskyActorDefs,
+ type AppBskyUnspeccedGetSuggestedUsers,
+} from '@atproto/api'
+import {type QueryClient, useQuery} from '@tanstack/react-query'
+
+import {
+ aggregateUserInterests,
+ createBskyTopicsHeader,
+} from '#/lib/api/feed/utils'
+import {getContentLanguages} from '#/state/preferences/languages'
+import {STALE} from '#/state/queries'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAgent} from '#/state/session'
+
+export type QueryProps = {category?: string | null}
+
+export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users'
+export const createGetSuggestedUsersQueryKey = (props: QueryProps) => [
+ getSuggestedUsersQueryKeyRoot,
+ ...Object.values(props),
+]
+
+export function useGetSuggestedUsersQuery(props: QueryProps) {
+ const agent = useAgent()
+ const {data: preferences} = usePreferencesQuery()
+
+ return useQuery({
+ enabled: !!preferences,
+ refetchOnWindowFocus: true,
+ staleTime: STALE.MINUTES.ONE,
+ queryKey: createGetSuggestedUsersQueryKey(props),
+ queryFn: async () => {
+ const contentLangs = getContentLanguages().join(',')
+ const {data} = await agent.app.bsky.unspecced.getSuggestedUsers(
+ {
+ category: props.category ?? undefined,
+ },
+ {
+ headers: {
+ ...createBskyTopicsHeader(aggregateUserInterests(preferences)),
+ 'Accept-Language': contentLangs,
+ },
+ },
+ )
+
+ return data
+ },
+ })
+}
+
+export function* findAllProfilesInQueryData(
+ queryClient: QueryClient,
+ did: string,
+): Generator {
+ const responses =
+ queryClient.getQueriesData({
+ queryKey: [getSuggestedUsersQueryKeyRoot],
+ })
+ for (const [_, response] of responses) {
+ if (!response) {
+ continue
+ }
+
+ for (const actor of response.actors) {
+ if (actor.did === did) {
+ yield actor
+ }
+ }
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 0541080f64..88b7c83880 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -80,10 +80,10 @@
tlds "^1.234.0"
zod "^3.23.8"
-"@atproto/api@^0.14.19":
- version "0.14.19"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.19.tgz#fef8994e2b14e69a9e3a0aef043c7fcb34d6bf8c"
- integrity sha512-YYTqM0K0qk2TP7PguktPzlAQGLTL1bEGz6PgY5kqKJNX4o1318kJYB22DzjJYqV2NUCq0JQ9Lb0oskLvTisEOg==
+"@atproto/api@^0.14.20":
+ version "0.14.20"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.20.tgz#904c85a91748f3203fd929415cb8fb3bc78d35d3"
+ integrity sha512-Daip22+u9N+EVPk9PsEEVrTfjIqGczXnAT7o2EHGd0JsOzMbp3a6wmW1beKqYDzPf+Dc36/39JeUYYqhB3fKjg==
dependencies:
"@atproto/common-web" "^0.4.1"
"@atproto/lexicon" "^0.4.10"