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"