Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Ansh
Paul Frazee
and committed by
GitHub
859588c3 da8499c8

+450 -20
+1 -1
package.json
··· 25 25 "build:apk": "eas build -p android --profile dev-android-apk" 26 26 }, 27 27 "dependencies": { 28 - "@atproto/api": "^0.6.12", 28 + "@atproto/api": "^0.6.13", 29 29 "@bam.tech/react-native-image-resizer": "^3.0.4", 30 30 "@braintree/sanitize-url": "^6.0.2", 31 31 "@emoji-mart/react": "^1.1.1",
+11
src/state/models/discovery/onboarding.ts
··· 2 2 import {RootStoreModel} from '../root-store' 3 3 import {hasProp} from 'lib/type-guards' 4 4 import {track} from 'lib/analytics/analytics' 5 + import {SuggestedActorsModel} from './suggested-actors' 5 6 6 7 export const OnboardingScreenSteps = { 7 8 Welcome: 'Welcome', 8 9 RecommendedFeeds: 'RecommendedFeeds', 10 + RecommendedFollows: 'RecommendedFollows', 9 11 Home: 'Home', 10 12 } as const 11 13 ··· 16 18 // state 17 19 step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() 18 20 21 + // data 22 + suggestedActors: SuggestedActorsModel 23 + 19 24 constructor(public rootStore: RootStoreModel) { 25 + this.suggestedActors = new SuggestedActorsModel(this.rootStore) 20 26 makeAutoObservable(this, { 21 27 rootStore: false, 22 28 hydrate: false, ··· 56 62 this.step = 'RecommendedFeeds' 57 63 return this.step 58 64 } else if (this.step === 'RecommendedFeeds') { 65 + this.step = 'RecommendedFollows' 66 + // prefetch recommended follows 67 + this.suggestedActors.loadMore(true) 68 + return this.step 69 + } else if (this.step === 'RecommendedFollows') { 59 70 this.finish() 60 71 return this.step 61 72 } else {
+19
src/state/models/discovery/suggested-actors.ts
··· 19 19 loadMoreCursor: string | undefined = undefined 20 20 error = '' 21 21 hasMore = false 22 + lastInsertedAtIndex = -1 22 23 23 24 // data 24 25 suggestions: SuggestedActor[] = [] ··· 109 110 this._xIdle(e) 110 111 } 111 112 }) 113 + 114 + async insertSuggestionsByActor(actor: string, indexToInsertAt: number) { 115 + // fetch suggestions 116 + const res = 117 + await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({ 118 + actor: actor, 119 + }) 120 + const {suggestions: moreSuggestions} = res.data 121 + this.rootStore.me.follows.hydrateProfiles(moreSuggestions) 122 + // dedupe 123 + const toInsert = moreSuggestions.filter( 124 + s => !this.suggestions.find(s2 => s2.did === s.did), 125 + ) 126 + // insert 127 + this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert) 128 + // update index 129 + this.lastInsertedAtIndex = indexToInsertAt 130 + } 112 131 113 132 // state transitions 114 133 // =
+4
src/view/com/auth/Onboarding.tsx
··· 7 7 import {useStores} from 'state/index' 8 8 import {Welcome} from './onboarding/Welcome' 9 9 import {RecommendedFeeds} from './onboarding/RecommendedFeeds' 10 + import {RecommendedFollows} from './onboarding/RecommendedFollows' 10 11 11 12 export const Onboarding = observer(function OnboardingImpl() { 12 13 const pal = usePalette('default') ··· 27 28 )} 28 29 {store.onboarding.step === 'RecommendedFeeds' && ( 29 30 <RecommendedFeeds next={next} /> 31 + )} 32 + {store.onboarding.step === 'RecommendedFollows' && ( 33 + <RecommendedFollows next={next} /> 30 34 )} 31 35 </ErrorBoundary> 32 36 </SafeAreaView>
+2 -1
src/view/com/auth/onboarding/RecommendedFeeds.tsx
··· 96 96 <Text 97 97 type="2xl-medium" 98 98 style={{color: '#fff', position: 'relative', top: -1}}> 99 - Done 99 + Next 100 100 </Text> 101 101 <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> 102 102 </View> ··· 215 215 marginBottom: 16, 216 216 marginHorizontal: 16, 217 217 marginTop: 16, 218 + alignItems: 'center', 218 219 }, 219 220 buttonText: { 220 221 textAlign: 'center',
+204
src/view/com/auth/onboarding/RecommendedFollows.tsx
··· 1 + import React from 'react' 2 + import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' 3 + import {observer} from 'mobx-react-lite' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' 6 + import {Text} from 'view/com/util/text/Text' 7 + import {ViewHeader} from 'view/com/util/ViewHeader' 8 + import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' 9 + import {Button} from 'view/com/util/forms/Button' 10 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 11 + import {usePalette} from 'lib/hooks/usePalette' 12 + import {useStores} from 'state/index' 13 + import {RecommendedFollowsItem} from './RecommendedFollowsItem' 14 + 15 + type Props = { 16 + next: () => void 17 + } 18 + export const RecommendedFollows = observer(function RecommendedFollowsImpl({ 19 + next, 20 + }: Props) { 21 + const store = useStores() 22 + const pal = usePalette('default') 23 + const {isTabletOrMobile} = useWebMediaQueries() 24 + 25 + React.useEffect(() => { 26 + // Load suggested actors if not already loaded 27 + // prefetch should happen in the onboarding model 28 + if ( 29 + !store.onboarding.suggestedActors.hasLoaded || 30 + store.onboarding.suggestedActors.isEmpty 31 + ) { 32 + store.onboarding.suggestedActors.loadMore(true) 33 + } 34 + }, [store]) 35 + 36 + const title = ( 37 + <> 38 + <Text 39 + style={[ 40 + pal.textLight, 41 + tdStyles.title1, 42 + isTabletOrMobile && tdStyles.title1Small, 43 + ]}> 44 + Follow some 45 + </Text> 46 + <Text 47 + style={[ 48 + pal.link, 49 + tdStyles.title2, 50 + isTabletOrMobile && tdStyles.title2Small, 51 + ]}> 52 + Recommended 53 + </Text> 54 + <Text 55 + style={[ 56 + pal.link, 57 + tdStyles.title2, 58 + isTabletOrMobile && tdStyles.title2Small, 59 + ]}> 60 + Users 61 + </Text> 62 + <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> 63 + Follow some users to get started. We can recommend you more users based 64 + on who you find interesting. 65 + </Text> 66 + <View 67 + style={{ 68 + flexDirection: 'row', 69 + justifyContent: 'flex-end', 70 + marginTop: 20, 71 + }}> 72 + <Button onPress={next} testID="continueBtn"> 73 + <View 74 + style={{ 75 + flexDirection: 'row', 76 + alignItems: 'center', 77 + paddingLeft: 2, 78 + gap: 6, 79 + }}> 80 + <Text 81 + type="2xl-medium" 82 + style={{color: '#fff', position: 'relative', top: -1}}> 83 + Done 84 + </Text> 85 + <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> 86 + </View> 87 + </Button> 88 + </View> 89 + </> 90 + ) 91 + 92 + return ( 93 + <> 94 + <TabletOrDesktop> 95 + <TitleColumnLayout 96 + testID="recommendedFollowsOnboarding" 97 + title={title} 98 + horizontal 99 + titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} 100 + contentStyle={{paddingHorizontal: 0}}> 101 + {store.onboarding.suggestedActors.isLoading ? ( 102 + <ActivityIndicator size="large" /> 103 + ) : ( 104 + <FlatList 105 + data={store.onboarding.suggestedActors.suggestions} 106 + renderItem={({item, index}) => ( 107 + <RecommendedFollowsItem item={item} index={index} /> 108 + )} 109 + keyExtractor={(item, index) => item.did + index.toString()} 110 + style={{flex: 1}} 111 + /> 112 + )} 113 + </TitleColumnLayout> 114 + </TabletOrDesktop> 115 + 116 + <Mobile> 117 + <View style={[mStyles.container]} testID="recommendedFollowsOnboarding"> 118 + <View> 119 + <ViewHeader 120 + title="Recommended Follows" 121 + showBackButton={false} 122 + showOnDesktop 123 + /> 124 + <Text type="lg-medium" style={[pal.text, mStyles.header]}> 125 + Check out some recommended users. Follow them to see similar 126 + users. 127 + </Text> 128 + </View> 129 + {store.onboarding.suggestedActors.isLoading ? ( 130 + <ActivityIndicator size="large" /> 131 + ) : ( 132 + <FlatList 133 + data={store.onboarding.suggestedActors.suggestions} 134 + renderItem={({item, index}) => ( 135 + <RecommendedFollowsItem item={item} index={index} /> 136 + )} 137 + keyExtractor={(item, index) => item.did + index.toString()} 138 + style={{flex: 1}} 139 + /> 140 + )} 141 + <Button 142 + onPress={next} 143 + label="Continue" 144 + testID="continueBtn" 145 + style={mStyles.button} 146 + labelStyle={mStyles.buttonText} 147 + /> 148 + </View> 149 + </Mobile> 150 + </> 151 + ) 152 + }) 153 + 154 + const tdStyles = StyleSheet.create({ 155 + container: { 156 + flex: 1, 157 + marginHorizontal: 16, 158 + justifyContent: 'space-between', 159 + }, 160 + title1: { 161 + fontSize: 36, 162 + fontWeight: '800', 163 + textAlign: 'right', 164 + }, 165 + title1Small: { 166 + fontSize: 24, 167 + }, 168 + title2: { 169 + fontSize: 58, 170 + fontWeight: '800', 171 + textAlign: 'right', 172 + }, 173 + title2Small: { 174 + fontSize: 36, 175 + }, 176 + description: { 177 + maxWidth: 400, 178 + marginTop: 10, 179 + marginLeft: 'auto', 180 + textAlign: 'right', 181 + }, 182 + }) 183 + 184 + const mStyles = StyleSheet.create({ 185 + container: { 186 + flex: 1, 187 + justifyContent: 'space-between', 188 + }, 189 + header: { 190 + marginBottom: 16, 191 + marginHorizontal: 16, 192 + }, 193 + button: { 194 + marginBottom: 16, 195 + marginHorizontal: 16, 196 + marginTop: 16, 197 + alignItems: 'center', 198 + }, 199 + buttonText: { 200 + textAlign: 'center', 201 + fontSize: 18, 202 + paddingVertical: 4, 203 + }, 204 + })
+160
src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
··· 1 + import React, {useMemo} from 'react' 2 + import {View, StyleSheet, ActivityIndicator} from 'react-native' 3 + import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 + import {observer} from 'mobx-react-lite' 5 + import {useStores} from 'state/index' 6 + import {FollowButton} from 'view/com/profile/FollowButton' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {SuggestedActor} from 'state/models/discovery/suggested-actors' 9 + import {sanitizeDisplayName} from 'lib/strings/display-names' 10 + import {sanitizeHandle} from 'lib/strings/handles' 11 + import {s} from 'lib/styles' 12 + import {UserAvatar} from 'view/com/util/UserAvatar' 13 + import {Text} from 'view/com/util/text/Text' 14 + import Animated, {FadeInRight} from 'react-native-reanimated' 15 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 16 + 17 + type Props = { 18 + item: SuggestedActor 19 + index: number 20 + } 21 + export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { 22 + const pal = usePalette('default') 23 + const store = useStores() 24 + const {isMobile} = useWebMediaQueries() 25 + const delay = useMemo(() => { 26 + return ( 27 + 50 * 28 + (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % 29 + 5) 30 + ) 31 + }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) 32 + 33 + return ( 34 + <Animated.View 35 + entering={FadeInRight.delay(delay).springify()} 36 + style={[ 37 + styles.cardContainer, 38 + pal.view, 39 + pal.border, 40 + { 41 + maxWidth: isMobile ? undefined : 670, 42 + borderRightWidth: isMobile ? undefined : 1, 43 + }, 44 + ]}> 45 + <ProfileCard key={item.did} profile={item} index={index} /> 46 + </Animated.View> 47 + ) 48 + } 49 + 50 + export const ProfileCard = observer(function ProfileCardImpl({ 51 + profile, 52 + index, 53 + }: { 54 + profile: AppBskyActorDefs.ProfileViewBasic 55 + index: number 56 + }) { 57 + const store = useStores() 58 + const pal = usePalette('default') 59 + const moderation = moderateProfile(profile, store.preferences.moderationOpts) 60 + const [addingMoreSuggestions, setAddingMoreSuggestions] = 61 + React.useState(false) 62 + 63 + return ( 64 + <View style={styles.card}> 65 + <View style={styles.layout}> 66 + <View style={styles.layoutAvi}> 67 + <UserAvatar 68 + size={40} 69 + avatar={profile.avatar} 70 + moderation={moderation.avatar} 71 + /> 72 + </View> 73 + <View style={styles.layoutContent}> 74 + <Text 75 + type="2xl-bold" 76 + style={[s.bold, pal.text]} 77 + numberOfLines={1} 78 + lineHeight={1.2}> 79 + {sanitizeDisplayName( 80 + profile.displayName || sanitizeHandle(profile.handle), 81 + moderation.profile, 82 + )} 83 + </Text> 84 + <Text type="xl" style={[pal.textLight]} numberOfLines={1}> 85 + {sanitizeHandle(profile.handle, '@')} 86 + </Text> 87 + </View> 88 + 89 + <FollowButton 90 + did={profile.did} 91 + labelStyle={styles.followButton} 92 + onToggleFollow={async isFollow => { 93 + if (isFollow) { 94 + setAddingMoreSuggestions(true) 95 + await store.onboarding.suggestedActors.insertSuggestionsByActor( 96 + profile.did, 97 + index, 98 + ) 99 + setAddingMoreSuggestions(false) 100 + } 101 + }} 102 + /> 103 + </View> 104 + {profile.description ? ( 105 + <View style={styles.details}> 106 + <Text type="lg" style={pal.text} numberOfLines={4}> 107 + {profile.description as string} 108 + </Text> 109 + </View> 110 + ) : undefined} 111 + {addingMoreSuggestions ? ( 112 + <View style={styles.addingMoreContainer}> 113 + <ActivityIndicator size="small" color={pal.colors.text} /> 114 + <Text style={[pal.text]}>Finding similar accounts...</Text> 115 + </View> 116 + ) : null} 117 + </View> 118 + ) 119 + }) 120 + 121 + const styles = StyleSheet.create({ 122 + cardContainer: { 123 + borderTopWidth: 1, 124 + }, 125 + card: { 126 + paddingHorizontal: 10, 127 + }, 128 + layout: { 129 + flexDirection: 'row', 130 + alignItems: 'center', 131 + }, 132 + layoutAvi: { 133 + width: 54, 134 + paddingLeft: 4, 135 + paddingTop: 8, 136 + paddingBottom: 10, 137 + }, 138 + layoutContent: { 139 + flex: 1, 140 + paddingRight: 10, 141 + paddingTop: 10, 142 + paddingBottom: 10, 143 + }, 144 + details: { 145 + paddingLeft: 54, 146 + paddingRight: 10, 147 + paddingBottom: 10, 148 + }, 149 + addingMoreContainer: { 150 + flexDirection: 'row', 151 + alignItems: 'center', 152 + paddingLeft: 54, 153 + paddingTop: 4, 154 + paddingBottom: 12, 155 + gap: 4, 156 + }, 157 + followButton: { 158 + fontSize: 16, 159 + }, 160 + })
+4
src/view/com/auth/onboarding/WelcomeMobile.tsx
··· 88 88 onPress={next} 89 89 label="Continue" 90 90 testID="continueBtn" 91 + style={[styles.buttonContainer]} 91 92 labelStyle={styles.buttonText} 92 93 /> 93 94 </View> ··· 116 117 }, 117 118 spacer: { 118 119 height: 20, 120 + }, 121 + buttonContainer: { 122 + alignItems: 'center', 119 123 }, 120 124 buttonText: { 121 125 textAlign: 'center',
+7 -3
src/view/com/profile/FollowButton.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {StyleProp, TextStyle, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import {Button, ButtonType} from '../util/forms/Button' 5 5 import {useStores} from 'state/index' ··· 11 11 followedType = 'default', 12 12 did, 13 13 onToggleFollow, 14 + labelStyle, 14 15 }: { 15 16 unfollowedType?: ButtonType 16 17 followedType?: ButtonType 17 18 did: string 18 19 onToggleFollow?: (v: boolean) => void 20 + labelStyle?: StyleProp<TextStyle> 19 21 }) { 20 22 const store = useStores() 21 23 const followState = store.me.follows.getFollowState(did) ··· 28 30 const updatedFollowState = await store.me.follows.fetchFollowState(did) 29 31 if (updatedFollowState === FollowState.Following) { 30 32 try { 33 + onToggleFollow?.(false) 31 34 await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) 32 35 store.me.follows.removeFollow(did) 33 - onToggleFollow?.(false) 34 36 } catch (e: any) { 35 37 store.log.error('Failed to delete follow', e) 36 38 Toast.show('An issue occurred, please try again.') 37 39 } 38 40 } else if (updatedFollowState === FollowState.NotFollowing) { 39 41 try { 42 + onToggleFollow?.(true) 40 43 const res = await store.agent.follow(did) 41 44 store.me.follows.addFollow(did, res.uri) 42 - onToggleFollow?.(true) 43 45 } catch (e: any) { 44 46 store.log.error('Failed to create follow', e) 45 47 Toast.show('An issue occurred, please try again.') ··· 52 54 type={ 53 55 followState === FollowState.Following ? followedType : unfollowedType 54 56 } 57 + labelStyle={labelStyle} 55 58 onPress={onToggleFollowInner} 56 59 label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} 60 + withLoading={true} 57 61 /> 58 62 ) 59 63 })
+34 -11
src/view/com/util/forms/Button.tsx
··· 7 7 Pressable, 8 8 ViewStyle, 9 9 PressableStateCallbackType, 10 + ActivityIndicator, 11 + View, 10 12 } from 'react-native' 11 13 import {Text} from '../text/Text' 12 14 import {useTheme} from 'lib/ThemeContext' ··· 48 50 accessibilityHint, 49 51 accessibilityLabelledBy, 50 52 onAccessibilityEscape, 53 + withLoading = false, 51 54 }: React.PropsWithChildren<{ 52 55 type?: ButtonType 53 56 label?: string 54 57 style?: StyleProp<ViewStyle> 55 58 labelStyle?: StyleProp<TextStyle> 56 - onPress?: () => void 59 + onPress?: () => void | Promise<void> 57 60 testID?: string 58 61 accessibilityLabel?: string 59 62 accessibilityHint?: string 60 63 accessibilityLabelledBy?: string 61 64 onAccessibilityEscape?: () => void 65 + withLoading?: boolean 62 66 }>) { 63 67 const theme = useTheme() 64 68 const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( ··· 138 142 }, 139 143 ) 140 144 145 + const [isLoading, setIsLoading] = React.useState(false) 141 146 const onPressWrapped = React.useCallback( 142 - (event: Event) => { 147 + async (event: Event) => { 143 148 event.stopPropagation() 144 149 event.preventDefault() 145 - onPress?.() 150 + withLoading && setIsLoading(true) 151 + await onPress?.() 152 + withLoading && setIsLoading(false) 146 153 }, 147 - [onPress], 154 + [onPress, withLoading], 148 155 ) 149 156 150 157 const getStyle = React.useCallback( ··· 160 167 [typeOuterStyle, style], 161 168 ) 162 169 170 + const renderChildern = React.useCallback(() => { 171 + if (!label) { 172 + return children 173 + } 174 + 175 + return ( 176 + <View style={styles.labelContainer}> 177 + {label && withLoading && isLoading ? ( 178 + <ActivityIndicator size={12} color={typeLabelStyle.color} /> 179 + ) : null} 180 + <Text type="button" style={[typeLabelStyle, labelStyle]}> 181 + {label} 182 + </Text> 183 + </View> 184 + ) 185 + }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) 186 + 163 187 return ( 164 188 <Pressable 165 189 style={getStyle} 166 190 onPress={onPressWrapped} 191 + disabled={isLoading} 167 192 testID={testID} 168 193 accessibilityRole="button" 169 194 accessibilityLabel={accessibilityLabel} 170 195 accessibilityHint={accessibilityHint} 171 196 accessibilityLabelledBy={accessibilityLabelledBy} 172 197 onAccessibilityEscape={onAccessibilityEscape}> 173 - {label ? ( 174 - <Text type="button" style={[typeLabelStyle, labelStyle]}> 175 - {label} 176 - </Text> 177 - ) : ( 178 - children 179 - )} 198 + {renderChildern} 180 199 </Pressable> 181 200 ) 182 201 } ··· 186 205 paddingHorizontal: 14, 187 206 paddingVertical: 8, 188 207 borderRadius: 24, 208 + }, 209 + labelContainer: { 210 + flexDirection: 'row', 211 + gap: 8, 189 212 }, 190 213 })
+4 -4
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.6.12": 38 - version "0.6.12" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.12.tgz#f39ad9d225aafc5fd90f07d0011d63435c657775" 40 - integrity sha512-9R8F78553GI47Iq4FDVwL05LorWTQZQ6FmFsDF/+yryiA+a/VVyvYG4USSptURBZCRgZA5VgTW1We/PwAcDfEA== 37 + "@atproto/api@^0.6.13": 38 + version "0.6.13" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.13.tgz#26caeae983c577dfedf6ab1f054b31eb158d8ca6" 40 + integrity sha512-MudGswKKFJmeh4RoN9LZnYoHib0L7QEIzOdkRU26Fr0dBqSQrnWwLYA9zsNjNg/mZKDyweYkP1ChTNkQvNiYFw== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.2.0" 43 43 "@atproto/lexicon" "^0.2.0"