Onboarding recommended follows (#1457)

* upgrade api package

* add RecommendedFollows as a step in onboarding

* add list of recommended follows from suggested actor model

* remove dead code

* hoist suggestedActors into onboarding model

* add comments

* load more suggested follows on follow

* styling changes

* add animation

* tweak animations

* adjust styling slightly

* adjust styles on mobile

* styling improvements for web

* fix text alignment in RecommendedFollows

* dedupe inserted suggestions

* fix animation duration

* Minor spacing tweak

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com> and Eric Bailey <git@esb.lol>
zio/stable
Ansh 2023-09-20 01:18:50 +05:30 committed by GitHub
parent da8499c881
commit 859588c3f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 450 additions and 20 deletions

View File

@ -25,7 +25,7 @@
"build:apk": "eas build -p android --profile dev-android-apk" "build:apk": "eas build -p android --profile dev-android-apk"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.6.12", "@atproto/api": "^0.6.13",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",

View File

@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {hasProp} from 'lib/type-guards' import {hasProp} from 'lib/type-guards'
import {track} from 'lib/analytics/analytics' import {track} from 'lib/analytics/analytics'
import {SuggestedActorsModel} from './suggested-actors'
export const OnboardingScreenSteps = { export const OnboardingScreenSteps = {
Welcome: 'Welcome', Welcome: 'Welcome',
RecommendedFeeds: 'RecommendedFeeds', RecommendedFeeds: 'RecommendedFeeds',
RecommendedFollows: 'RecommendedFollows',
Home: 'Home', Home: 'Home',
} as const } as const
@ -16,7 +18,11 @@ export class OnboardingModel {
// state // state
step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
// data
suggestedActors: SuggestedActorsModel
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
this.suggestedActors = new SuggestedActorsModel(this.rootStore)
makeAutoObservable(this, { makeAutoObservable(this, {
rootStore: false, rootStore: false,
hydrate: false, hydrate: false,
@ -56,6 +62,11 @@ export class OnboardingModel {
this.step = 'RecommendedFeeds' this.step = 'RecommendedFeeds'
return this.step return this.step
} else if (this.step === 'RecommendedFeeds') { } else if (this.step === 'RecommendedFeeds') {
this.step = 'RecommendedFollows'
// prefetch recommended follows
this.suggestedActors.loadMore(true)
return this.step
} else if (this.step === 'RecommendedFollows') {
this.finish() this.finish()
return this.step return this.step
} else { } else {

View File

@ -19,6 +19,7 @@ export class SuggestedActorsModel {
loadMoreCursor: string | undefined = undefined loadMoreCursor: string | undefined = undefined
error = '' error = ''
hasMore = false hasMore = false
lastInsertedAtIndex = -1
// data // data
suggestions: SuggestedActor[] = [] suggestions: SuggestedActor[] = []
@ -110,6 +111,24 @@ export class SuggestedActorsModel {
} }
}) })
async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
// fetch suggestions
const res =
await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: actor,
})
const {suggestions: moreSuggestions} = res.data
this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
// dedupe
const toInsert = moreSuggestions.filter(
s => !this.suggestions.find(s2 => s2.did === s.did),
)
// insert
this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
// update index
this.lastInsertedAtIndex = indexToInsertAt
}
// state transitions // state transitions
// = // =

View File

@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {Welcome} from './onboarding/Welcome' import {Welcome} from './onboarding/Welcome'
import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
import {RecommendedFollows} from './onboarding/RecommendedFollows'
export const Onboarding = observer(function OnboardingImpl() { export const Onboarding = observer(function OnboardingImpl() {
const pal = usePalette('default') const pal = usePalette('default')
@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() {
{store.onboarding.step === 'RecommendedFeeds' && ( {store.onboarding.step === 'RecommendedFeeds' && (
<RecommendedFeeds next={next} /> <RecommendedFeeds next={next} />
)} )}
{store.onboarding.step === 'RecommendedFollows' && (
<RecommendedFollows next={next} />
)}
</ErrorBoundary> </ErrorBoundary>
</SafeAreaView> </SafeAreaView>
) )

View File

@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
<Text <Text
type="2xl-medium" type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}> style={{color: '#fff', position: 'relative', top: -1}}>
Done Next
</Text> </Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View> </View>
@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({
marginBottom: 16, marginBottom: 16,
marginHorizontal: 16, marginHorizontal: 16,
marginTop: 16, marginTop: 16,
alignItems: 'center',
}, },
buttonText: { buttonText: {
textAlign: 'center', textAlign: 'center',

View File

@ -0,0 +1,204 @@
import React from 'react'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
import {Text} from 'view/com/util/text/Text'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Button} from 'view/com/util/forms/Button'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {RecommendedFollowsItem} from './RecommendedFollowsItem'
type Props = {
next: () => void
}
export const RecommendedFollows = observer(function RecommendedFollowsImpl({
next,
}: Props) {
const store = useStores()
const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries()
React.useEffect(() => {
// Load suggested actors if not already loaded
// prefetch should happen in the onboarding model
if (
!store.onboarding.suggestedActors.hasLoaded ||
store.onboarding.suggestedActors.isEmpty
) {
store.onboarding.suggestedActors.loadMore(true)
}
}, [store])
const title = (
<>
<Text
style={[
pal.textLight,
tdStyles.title1,
isTabletOrMobile && tdStyles.title1Small,
]}>
Follow some
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Recommended
</Text>
<Text
style={[
pal.link,
tdStyles.title2,
isTabletOrMobile && tdStyles.title2Small,
]}>
Users
</Text>
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
Follow some users to get started. We can recommend you more users based
on who you find interesting.
</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
}}>
<Button onPress={next} testID="continueBtn">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 2,
gap: 6,
}}>
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
Done
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
</Button>
</View>
</>
)
return (
<>
<TabletOrDesktop>
<TitleColumnLayout
testID="recommendedFollowsOnboarding"
title={title}
horizontal
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
contentStyle={{paddingHorizontal: 0}}>
{store.onboarding.suggestedActors.isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={store.onboarding.suggestedActors.suggestions}
renderItem={({item, index}) => (
<RecommendedFollowsItem item={item} index={index} />
)}
keyExtractor={(item, index) => item.did + index.toString()}
style={{flex: 1}}
/>
)}
</TitleColumnLayout>
</TabletOrDesktop>
<Mobile>
<View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
<View>
<ViewHeader
title="Recommended Follows"
showBackButton={false}
showOnDesktop
/>
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
Check out some recommended users. Follow them to see similar
users.
</Text>
</View>
{store.onboarding.suggestedActors.isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={store.onboarding.suggestedActors.suggestions}
renderItem={({item, index}) => (
<RecommendedFollowsItem item={item} index={index} />
)}
keyExtractor={(item, index) => item.did + index.toString()}
style={{flex: 1}}
/>
)}
<Button
onPress={next}
label="Continue"
testID="continueBtn"
style={mStyles.button}
labelStyle={mStyles.buttonText}
/>
</View>
</Mobile>
</>
)
})
const tdStyles = StyleSheet.create({
container: {
flex: 1,
marginHorizontal: 16,
justifyContent: 'space-between',
},
title1: {
fontSize: 36,
fontWeight: '800',
textAlign: 'right',
},
title1Small: {
fontSize: 24,
},
title2: {
fontSize: 58,
fontWeight: '800',
textAlign: 'right',
},
title2Small: {
fontSize: 36,
},
description: {
maxWidth: 400,
marginTop: 10,
marginLeft: 'auto',
textAlign: 'right',
},
})
const mStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
},
header: {
marginBottom: 16,
marginHorizontal: 16,
},
button: {
marginBottom: 16,
marginHorizontal: 16,
marginTop: 16,
alignItems: 'center',
},
buttonText: {
textAlign: 'center',
fontSize: 18,
paddingVertical: 4,
},
})

View File

@ -0,0 +1,160 @@
import React, {useMemo} from 'react'
import {View, StyleSheet, ActivityIndicator} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {FollowButton} from 'view/com/profile/FollowButton'
import {usePalette} from 'lib/hooks/usePalette'
import {SuggestedActor} from 'state/models/discovery/suggested-actors'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {s} from 'lib/styles'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import Animated, {FadeInRight} from 'react-native-reanimated'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
type Props = {
item: SuggestedActor
index: number
}
export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const delay = useMemo(() => {
return (
50 *
(Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) %
5)
)
}, [index, store.onboarding.suggestedActors.lastInsertedAtIndex])
return (
<Animated.View
entering={FadeInRight.delay(delay).springify()}
style={[
styles.cardContainer,
pal.view,
pal.border,
{
maxWidth: isMobile ? undefined : 670,
borderRightWidth: isMobile ? undefined : 1,
},
]}>
<ProfileCard key={item.did} profile={item} index={index} />
</Animated.View>
)
}
export const ProfileCard = observer(function ProfileCardImpl({
profile,
index,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
index: number
}) {
const store = useStores()
const pal = usePalette('default')
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
const [addingMoreSuggestions, setAddingMoreSuggestions] =
React.useState(false)
return (
<View style={styles.card}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text
type="2xl-bold"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
<FollowButton
did={profile.did}
labelStyle={styles.followButton}
onToggleFollow={async isFollow => {
if (isFollow) {
setAddingMoreSuggestions(true)
await store.onboarding.suggestedActors.insertSuggestionsByActor(
profile.did,
index,
)
setAddingMoreSuggestions(false)
}
}}
/>
</View>
{profile.description ? (
<View style={styles.details}>
<Text type="lg" style={pal.text} numberOfLines={4}>
{profile.description as string}
</Text>
</View>
) : undefined}
{addingMoreSuggestions ? (
<View style={styles.addingMoreContainer}>
<ActivityIndicator size="small" color={pal.colors.text} />
<Text style={[pal.text]}>Finding similar accounts...</Text>
</View>
) : null}
</View>
)
})
const styles = StyleSheet.create({
cardContainer: {
borderTopWidth: 1,
},
card: {
paddingHorizontal: 10,
},
layout: {
flexDirection: 'row',
alignItems: 'center',
},
layoutAvi: {
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
details: {
paddingLeft: 54,
paddingRight: 10,
paddingBottom: 10,
},
addingMoreContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 54,
paddingTop: 4,
paddingBottom: 12,
gap: 4,
},
followButton: {
fontSize: 16,
},
})

View File

@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
onPress={next} onPress={next}
label="Continue" label="Continue"
testID="continueBtn" testID="continueBtn"
style={[styles.buttonContainer]}
labelStyle={styles.buttonText} labelStyle={styles.buttonText}
/> />
</View> </View>
@ -117,6 +118,9 @@ const styles = StyleSheet.create({
spacer: { spacer: {
height: 20, height: 20,
}, },
buttonContainer: {
alignItems: 'center',
},
buttonText: { buttonText: {
textAlign: 'center', textAlign: 'center',
fontSize: 18, fontSize: 18,

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {StyleProp, TextStyle, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Button, ButtonType} from '../util/forms/Button' import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({
followedType = 'default', followedType = 'default',
did, did,
onToggleFollow, onToggleFollow,
labelStyle,
}: { }: {
unfollowedType?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType followedType?: ButtonType
did: string did: string
onToggleFollow?: (v: boolean) => void onToggleFollow?: (v: boolean) => void
labelStyle?: StyleProp<TextStyle>
}) { }) {
const store = useStores() const store = useStores()
const followState = store.me.follows.getFollowState(did) const followState = store.me.follows.getFollowState(did)
@ -28,18 +30,18 @@ export const FollowButton = observer(function FollowButtonImpl({
const updatedFollowState = await store.me.follows.fetchFollowState(did) const updatedFollowState = await store.me.follows.fetchFollowState(did)
if (updatedFollowState === FollowState.Following) { if (updatedFollowState === FollowState.Following) {
try { try {
onToggleFollow?.(false)
await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did) store.me.follows.removeFollow(did)
onToggleFollow?.(false)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to delete follow', e) store.log.error('Failed to delete follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
} }
} else if (updatedFollowState === FollowState.NotFollowing) { } else if (updatedFollowState === FollowState.NotFollowing) {
try { try {
onToggleFollow?.(true)
const res = await store.agent.follow(did) const res = await store.agent.follow(did)
store.me.follows.addFollow(did, res.uri) store.me.follows.addFollow(did, res.uri)
onToggleFollow?.(true)
} catch (e: any) { } catch (e: any) {
store.log.error('Failed to create follow', e) store.log.error('Failed to create follow', e)
Toast.show('An issue occurred, please try again.') Toast.show('An issue occurred, please try again.')
@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({
type={ type={
followState === FollowState.Following ? followedType : unfollowedType followState === FollowState.Following ? followedType : unfollowedType
} }
labelStyle={labelStyle}
onPress={onToggleFollowInner} onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
withLoading={true}
/> />
) )
}) })

View File

@ -7,6 +7,8 @@ import {
Pressable, Pressable,
ViewStyle, ViewStyle,
PressableStateCallbackType, PressableStateCallbackType,
ActivityIndicator,
View,
} from 'react-native' } from 'react-native'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
@ -48,17 +50,19 @@ export function Button({
accessibilityHint, accessibilityHint,
accessibilityLabelledBy, accessibilityLabelledBy,
onAccessibilityEscape, onAccessibilityEscape,
withLoading = false,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
type?: ButtonType type?: ButtonType
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
onPress?: () => void onPress?: () => void | Promise<void>
testID?: string testID?: string
accessibilityLabel?: string accessibilityLabel?: string
accessibilityHint?: string accessibilityHint?: string
accessibilityLabelledBy?: string accessibilityLabelledBy?: string
onAccessibilityEscape?: () => void onAccessibilityEscape?: () => void
withLoading?: boolean
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@ -138,13 +142,16 @@ export function Button({
}, },
) )
const [isLoading, setIsLoading] = React.useState(false)
const onPressWrapped = React.useCallback( const onPressWrapped = React.useCallback(
(event: Event) => { async (event: Event) => {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
onPress?.() withLoading && setIsLoading(true)
await onPress?.()
withLoading && setIsLoading(false)
}, },
[onPress], [onPress, withLoading],
) )
const getStyle = React.useCallback( const getStyle = React.useCallback(
@ -160,23 +167,35 @@ export function Button({
[typeOuterStyle, style], [typeOuterStyle, style],
) )
const renderChildern = React.useCallback(() => {
if (!label) {
return children
}
return (
<View style={styles.labelContainer}>
{label && withLoading && isLoading ? (
<ActivityIndicator size={12} color={typeLabelStyle.color} />
) : null}
<Text type="button" style={[typeLabelStyle, labelStyle]}>
{label}
</Text>
</View>
)
}, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
return ( return (
<Pressable <Pressable
style={getStyle} style={getStyle}
onPress={onPressWrapped} onPress={onPressWrapped}
disabled={isLoading}
testID={testID} testID={testID}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
accessibilityLabelledBy={accessibilityLabelledBy} accessibilityLabelledBy={accessibilityLabelledBy}
onAccessibilityEscape={onAccessibilityEscape}> onAccessibilityEscape={onAccessibilityEscape}>
{label ? ( {renderChildern}
<Text type="button" style={[typeLabelStyle, labelStyle]}>
{label}
</Text>
) : (
children
)}
</Pressable> </Pressable>
) )
} }
@ -187,4 +206,8 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
borderRadius: 24, borderRadius: 24,
}, },
labelContainer: {
flexDirection: 'row',
gap: 8,
},
}) })

View File

@ -34,10 +34,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.6.12": "@atproto/api@^0.6.13":
version "0.6.12" version "0.6.13"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.12.tgz#f39ad9d225aafc5fd90f07d0011d63435c657775" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.13.tgz#26caeae983c577dfedf6ab1f054b31eb158d8ca6"
integrity sha512-9R8F78553GI47Iq4FDVwL05LorWTQZQ6FmFsDF/+yryiA+a/VVyvYG4USSptURBZCRgZA5VgTW1We/PwAcDfEA== integrity sha512-MudGswKKFJmeh4RoN9LZnYoHib0L7QEIzOdkRU26Fr0dBqSQrnWwLYA9zsNjNg/mZKDyweYkP1ChTNkQvNiYFw==
dependencies: dependencies:
"@atproto/common-web" "^0.2.0" "@atproto/common-web" "^0.2.0"
"@atproto/lexicon" "^0.2.0" "@atproto/lexicon" "^0.2.0"