get suggested accounts working on native
This commit is contained in:
@@ -90,6 +90,9 @@ export type MetricEvents = {
|
||||
selectedInterests: string[]
|
||||
selectedInterestsLength: number
|
||||
}
|
||||
'onboarding:suggestedAccounts:tabPressed': {
|
||||
tab: string
|
||||
}
|
||||
'onboarding:suggestedAccounts:nextPressed': {
|
||||
selectedAccountsLength: number
|
||||
skipped: boolean
|
||||
@@ -249,6 +252,7 @@ export type MetricEvents = {
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
location: 'Card' | 'Profile'
|
||||
recId?: number
|
||||
position: number
|
||||
|
||||
@@ -23,7 +23,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
|
||||
const COL_WIDTH = 420
|
||||
const ONBOARDING_COL_WIDTH = 420
|
||||
|
||||
export const OnboardingControls = createPortalGroup()
|
||||
|
||||
@@ -92,7 +92,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
top: paddingTop.paddingTop + insets.top - 1,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}>
|
||||
<View
|
||||
style={[a.w_full, a.align_start, {maxWidth: ONBOARDING_COL_WIDTH}]}>
|
||||
<Button
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
color="secondary"
|
||||
@@ -113,16 +114,11 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
|
||||
contentContainerStyle={{borderWidth: 0}}
|
||||
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
|
||||
// @ts-ignore web only --prf
|
||||
// @ts-expect-error web only --prf
|
||||
dataSet={{'stable-gutters': 1}}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_center,
|
||||
gtMobile ? a.px_5xl : a.px_xl,
|
||||
a.debug,
|
||||
]}>
|
||||
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
|
||||
style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
|
||||
<View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
|
||||
<View style={[a.w_full, a.align_center, paddingTop]}>
|
||||
<View
|
||||
style={[
|
||||
@@ -166,7 +162,6 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
<View
|
||||
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
|
||||
style={[
|
||||
// @ts-ignore web only -prf
|
||||
isWeb ? a.fixed : a.absolute,
|
||||
{bottom: 0, left: 0, right: 0},
|
||||
t.atoms.bg,
|
||||
@@ -184,7 +179,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
{maxWidth: COL_WIDTH},
|
||||
{maxWidth: ONBOARDING_COL_WIDTH},
|
||||
gtMobile && [a.flex_row, a.justify_between, a.align_center],
|
||||
]}>
|
||||
{gtMobile &&
|
||||
|
||||
@@ -160,7 +160,16 @@ export function StepInterests() {
|
||||
|
||||
<View style={[a.w_full, a.pt_2xl]}>
|
||||
{isLoading ? (
|
||||
<Loader size="xl" />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_md,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{minHeight: 400},
|
||||
]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : isError || !data ? (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,29 +1,82 @@
|
||||
import {useCallback, useContext, useState} from 'react'
|
||||
import {useCallback, useContext, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
|
||||
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
|
||||
import {logger} from '#/logger'
|
||||
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {OnboardingControls} from '#/screens/Onboarding/Layout'
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {
|
||||
Context,
|
||||
popularInterests,
|
||||
useInterestsDisplayNames,
|
||||
} from '#/screens/Onboarding/state'
|
||||
import {SuggestedAccountsTabBar} from '#/screens/Search/modules/ExploreSuggestedAccounts'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
|
||||
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {bulkWriteFollows} from '../util'
|
||||
|
||||
export function StepSuggestedAccounts() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const agent = useAgent()
|
||||
const {currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {dispatch} = useContext(Context)
|
||||
const {state, dispatch} = useContext(Context)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
|
||||
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
|
||||
// keeping track of who was followed via the follow all button
|
||||
// so we can enable/disable the button without having to dig through the shadow cache
|
||||
const [followedUsers, setFollowedUsers] = useState<string[]>([])
|
||||
|
||||
/*
|
||||
* Special language handling copied wholesale from the Explore screen
|
||||
*/
|
||||
// FIGURE OUT BEFORE MERGE: IS THIS POPULATED BEFORE FIRST ONBOARDING
|
||||
const {contentLanguages} = useLanguagePrefs()
|
||||
const useFullExperience = useMemo(() => {
|
||||
if (contentLanguages.length === 0) return true
|
||||
return bcp47Match.basicFilter('en', contentLanguages).length > 0
|
||||
}, [contentLanguages])
|
||||
const interestsDisplayNames = useInterestsDisplayNames()
|
||||
const interests = Object.keys(interestsDisplayNames)
|
||||
.sort(boostInterests(popularInterests))
|
||||
.sort(boostInterests(state.interestsStepResults.selectedInterests))
|
||||
const {
|
||||
data: suggestedUsers,
|
||||
isLoading,
|
||||
error,
|
||||
isRefetching,
|
||||
refetch,
|
||||
} = useSuggestedUsers({
|
||||
category: selectedInterest || (useFullExperience ? null : interests[0]),
|
||||
search: !useFullExperience,
|
||||
})
|
||||
|
||||
const isError = !!error
|
||||
|
||||
const saveInterests = useCallback(async () => {
|
||||
setSaving(true)
|
||||
@@ -42,7 +95,47 @@ export function StepSuggestedAccounts() {
|
||||
dispatch({type: 'finish'})
|
||||
}, [onboardDispatch, dispatch])
|
||||
|
||||
const isError = false
|
||||
const followableDids =
|
||||
suggestedUsers?.actors
|
||||
.filter(
|
||||
user =>
|
||||
user.did !== currentAccount?.did &&
|
||||
!isBlockedOrBlocking(user) &&
|
||||
!isMuted(user) &&
|
||||
!user.viewer?.following &&
|
||||
!followedUsers.includes(user.did),
|
||||
)
|
||||
.map(user => user.did) ?? []
|
||||
|
||||
const {mutate: followAll, isPending: isFollowingAll} = useMutation({
|
||||
mutationFn: async () => {
|
||||
for (const did of followableDids) {
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: 'pending',
|
||||
})
|
||||
}
|
||||
const uris = await bulkWriteFollows(agent, followableDids)
|
||||
for (const did of followableDids) {
|
||||
const uri = uris.get(did)
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: uri,
|
||||
})
|
||||
}
|
||||
return followableDids
|
||||
},
|
||||
onSuccess: newlyFollowed => {
|
||||
toast.show(_(msg`Done!`), {type: 'success'})
|
||||
setFollowedUsers(followed => [...followed, ...newlyFollowed])
|
||||
},
|
||||
onError: () => {
|
||||
toast.show(
|
||||
_(msg`Failed to follow all suggested accounts, please try again`),
|
||||
{type: 'error'},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const canFollowAll = followableDids.length > 0 && !isFollowingAll
|
||||
|
||||
return (
|
||||
<View style={[a.align_start]} testID="onboardingInterests">
|
||||
@@ -50,29 +143,76 @@ export function StepSuggestedAccounts() {
|
||||
<Trans>Suggested Accounts</Trans>
|
||||
</Text>
|
||||
|
||||
<SuggestedAccountsTabBar
|
||||
selectedInterest={selectedInterest}
|
||||
onSelectInterest={setSelectedInterest}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
a.mt_sm,
|
||||
!gtMobile ? {marginHorizontal: tokens.space.xl * -1} : a.max_w_full,
|
||||
a.flex_1,
|
||||
a.justify_start,
|
||||
]}>
|
||||
<SuggestedAccountsTabBar
|
||||
selectedInterest={selectedInterest}
|
||||
onSelectInterest={setSelectedInterest}
|
||||
priorityInterests={state.interestsStepResults.selectedInterests}
|
||||
leftPadding={gtMobile ? 0 : tokens.space.xl}
|
||||
/>
|
||||
|
||||
{isLoading || !moderationOpts ? (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_md,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{minHeight: 400},
|
||||
]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : isError ? (
|
||||
<View style={[a.flex_1, a.px_xl, a.pt_5xl]}>
|
||||
<Text
|
||||
style={[a.text_center, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
An error occurred while fetching suggested accounts.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{suggestedUsers?.actors.map((user, index) => (
|
||||
<SuggestedProfileCard
|
||||
key={user.did}
|
||||
profile={user}
|
||||
moderationOpts={moderationOpts}
|
||||
position={index}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
{isError ? (
|
||||
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
|
||||
<Button
|
||||
// disabled={isFetching}
|
||||
variant="solid"
|
||||
disabled={isRefetching}
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Retry`)}
|
||||
// onPress={() => refetch()}
|
||||
>
|
||||
onPress={() => refetch()}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ArrowRotateCounterClockwise} position="right" />
|
||||
<ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Skip this flow`)}
|
||||
@@ -85,17 +225,18 @@ export function StepSuggestedAccounts() {
|
||||
) : (
|
||||
<View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
|
||||
<Button
|
||||
disabled={saving}
|
||||
disabled={saving || !canFollowAll}
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Follow all accounts`)}>
|
||||
label={_(msg`Follow all accounts`)}
|
||||
onPress={() => followAll()}>
|
||||
<ButtonText>
|
||||
<Trans>Follow all</Trans>
|
||||
</ButtonText>
|
||||
{isFollowingAll && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Continue to next step`)}
|
||||
@@ -103,7 +244,7 @@ export function StepSuggestedAccounts() {
|
||||
<ButtonText>
|
||||
<Trans>Continue</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={saving ? Loader : ChevronRight} />
|
||||
<ButtonIcon icon={saving ? Loader : ChevronRightIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
@@ -111,3 +252,79 @@ export function StepSuggestedAccounts() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
recId,
|
||||
position,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
recId?: number
|
||||
position: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<ProfileCard.Link
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logger.metric(
|
||||
'suggestedUser:press',
|
||||
{
|
||||
logContext: 'Explore',
|
||||
recId,
|
||||
position,
|
||||
},
|
||||
{statsig: true},
|
||||
)
|
||||
}}>
|
||||
{s => (
|
||||
<>
|
||||
<SubtleHover hover={s.hovered || s.pressed} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.py_lg,
|
||||
a.px_xl,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<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,
|
||||
},
|
||||
{statsig: true},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
||||
</ProfileCard.Outer>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
$Typed,
|
||||
AppBskyGraphFollow,
|
||||
AppBskyGraphGetFollows,
|
||||
BskyAgent,
|
||||
ComAtprotoRepoApplyWrites,
|
||||
type $Typed,
|
||||
type AppBskyGraphFollow,
|
||||
type AppBskyGraphGetFollows,
|
||||
type BskyAgent,
|
||||
type ComAtprotoRepoApplyWrites,
|
||||
} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import chunk from 'lodash.chunk'
|
||||
@@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
|
||||
}
|
||||
await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
|
||||
|
||||
const followUris = new Map()
|
||||
const followUris = new Map<string, string>()
|
||||
for (const r of followWrites) {
|
||||
followUris.set(
|
||||
r.value.subject,
|
||||
r.value.subject as string,
|
||||
`at://${session.did}/app.bsky.graph.follow/${r.rkey}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -751,6 +751,7 @@ export function Explore({
|
||||
selectedInterest={selectedInterest}
|
||||
onSelectInterest={setSelectedInterest}
|
||||
hideDefaultTab={item.hideDefaultTab}
|
||||
logContext="Explore"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
popularInterests,
|
||||
useInterestsDisplayNames,
|
||||
} from '#/screens/Onboarding/state'
|
||||
import {useTheme} from '#/alf'
|
||||
import {tokens, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -55,14 +55,21 @@ export function useLoadEnoughProfiles({
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move to own file -sfn
|
||||
export function SuggestedAccountsTabBar({
|
||||
selectedInterest,
|
||||
onSelectInterest,
|
||||
hideDefaultTab,
|
||||
priorityInterests,
|
||||
leftPadding = tokens.space.md,
|
||||
logContext = 'Explore',
|
||||
}: {
|
||||
selectedInterest: string | null
|
||||
onSelectInterest: (interest: string | null) => void
|
||||
priorityInterests?: string[]
|
||||
hideDefaultTab?: boolean
|
||||
leftPadding?: number
|
||||
logContext?: 'Explore' | 'Onboarding'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const interestsDisplayNames = useInterestsDisplayNames()
|
||||
@@ -71,6 +78,8 @@ export function SuggestedAccountsTabBar({
|
||||
const interests = Object.keys(interestsDisplayNames)
|
||||
.sort(boostInterests(popularInterests))
|
||||
.sort(boostInterests(personalizedInterests))
|
||||
.sort(boostInterests(priorityInterests))
|
||||
|
||||
return (
|
||||
<BlockDrawerGesture>
|
||||
<Tabs
|
||||
@@ -79,11 +88,19 @@ export function SuggestedAccountsTabBar({
|
||||
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
||||
}
|
||||
onSelectTab={tab => {
|
||||
logger.metric(
|
||||
'explore:suggestedAccounts:tabPressed',
|
||||
{tab: tab},
|
||||
{statsig: true},
|
||||
)
|
||||
if (logContext === 'Explore') {
|
||||
logger.metric(
|
||||
'explore:suggestedAccounts:tabPressed',
|
||||
{tab: tab},
|
||||
{statsig: true},
|
||||
)
|
||||
} else {
|
||||
logger.metric(
|
||||
'onboarding:suggestedAccounts:tabPressed',
|
||||
{tab: tab},
|
||||
{statsig: true},
|
||||
)
|
||||
}
|
||||
onSelectInterest(tab === 'all' ? null : tab)
|
||||
}}
|
||||
hasSearchText={false}
|
||||
@@ -99,7 +116,7 @@ export function SuggestedAccountsTabBar({
|
||||
contentContainerStyle={[
|
||||
{
|
||||
// visual alignment
|
||||
paddingLeft: a.px_md.paddingLeft,
|
||||
paddingLeft: leftPadding,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useSuggestedUsers({
|
||||
isLoading: searched.isLoading,
|
||||
error: searched.error,
|
||||
isRefetching: searched.isRefetching,
|
||||
refetch: searched.refetch,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
@@ -50,6 +51,7 @@ export function useSuggestedUsers({
|
||||
isLoading: curated.isLoading,
|
||||
error: curated.error,
|
||||
isRefetching: curated.isRefetching,
|
||||
refetch: curated.refetch,
|
||||
}
|
||||
}
|
||||
}, [curated, searched, search])
|
||||
|
||||
Reference in New Issue
Block a user