diff options
author | Ansh <anshnanda10@gmail.com> | 2023-09-20 01:18:50 +0530 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-20 01:18:50 +0530 |
commit | 859588c3f63949182acf3ca800b0229dd5e1d88e (patch) | |
tree | 909b6c8ae8c6ca47b6db080d9f891dac676fd685 /src | |
parent | da8499c8810eccbb448516adedcbb19a1964c081 (diff) | |
download | voidsky-859588c3f63949182acf3ca800b0229dd5e1d88e.tar.zst |
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>
Diffstat (limited to 'src')
-rw-r--r-- | src/state/models/discovery/onboarding.ts | 11 | ||||
-rw-r--r-- | src/state/models/discovery/suggested-actors.ts | 19 | ||||
-rw-r--r-- | src/view/com/auth/Onboarding.tsx | 4 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFeeds.tsx | 3 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFollows.tsx | 204 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFollowsItem.tsx | 160 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/WelcomeMobile.tsx | 4 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 10 | ||||
-rw-r--r-- | src/view/com/util/forms/Button.tsx | 45 |
9 files changed, 445 insertions, 15 deletions
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 09c9eac04..8ad321ed9 100644 --- a/src/state/models/discovery/onboarding.ts +++ b/src/state/models/discovery/onboarding.ts @@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {hasProp} from 'lib/type-guards' import {track} from 'lib/analytics/analytics' +import {SuggestedActorsModel} from './suggested-actors' export const OnboardingScreenSteps = { Welcome: 'Welcome', RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', Home: 'Home', } as const @@ -16,7 +18,11 @@ export class OnboardingModel { // state step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() + // data + suggestedActors: SuggestedActorsModel + constructor(public rootStore: RootStoreModel) { + this.suggestedActors = new SuggestedActorsModel(this.rootStore) makeAutoObservable(this, { rootStore: false, hydrate: false, @@ -56,6 +62,11 @@ export class OnboardingModel { this.step = 'RecommendedFeeds' return this.step } 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() return this.step } else { diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 0b3d36952..afa5e74e3 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -19,6 +19,7 @@ export class SuggestedActorsModel { loadMoreCursor: string | undefined = undefined error = '' hasMore = false + lastInsertedAtIndex = -1 // data 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 // = diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index 6ea8cd79e..a36544a03 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' +import {RecommendedFollows} from './onboarding/RecommendedFollows' export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') @@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() { {store.onboarding.step === 'RecommendedFeeds' && ( <RecommendedFeeds next={next} /> )} + {store.onboarding.step === 'RecommendedFollows' && ( + <RecommendedFollows next={next} /> + )} </ErrorBoundary> </SafeAreaView> ) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index b39714ef2..24fc9eef1 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Done + Next </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({ marginBottom: 16, marginHorizontal: 16, marginTop: 16, + alignItems: 'center', }, buttonText: { textAlign: 'center', diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx new file mode 100644 index 000000000..f2710d2ac --- /dev/null +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -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, + }, +}) diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx new file mode 100644 index 000000000..144fdc2e9 --- /dev/null +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -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, + }, +}) diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 19c8d52d0..1f0a64370 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ onPress={next} label="Continue" testID="continueBtn" + style={[styles.buttonContainer]} labelStyle={styles.buttonText} /> </View> @@ -117,6 +118,9 @@ const styles = StyleSheet.create({ spacer: { height: 20, }, + buttonContainer: { + alignItems: 'center', + }, buttonText: { textAlign: 'center', fontSize: 18, diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 6f6286e69..4b2b944f7 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {StyleProp, TextStyle, View} from 'react-native' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' @@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({ followedType = 'default', did, onToggleFollow, + labelStyle, }: { unfollowedType?: ButtonType followedType?: ButtonType did: string onToggleFollow?: (v: boolean) => void + labelStyle?: StyleProp<TextStyle> }) { const store = useStores() 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) if (updatedFollowState === FollowState.Following) { try { + onToggleFollow?.(false) await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) store.me.follows.removeFollow(did) - onToggleFollow?.(false) } catch (e: any) { store.log.error('Failed to delete follow', e) Toast.show('An issue occurred, please try again.') } } else if (updatedFollowState === FollowState.NotFollowing) { try { + onToggleFollow?.(true) const res = await store.agent.follow(did) store.me.follows.addFollow(did, res.uri) - onToggleFollow?.(true) } catch (e: any) { store.log.error('Failed to create follow', e) Toast.show('An issue occurred, please try again.') @@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({ type={ followState === FollowState.Following ? followedType : unfollowedType } + labelStyle={labelStyle} onPress={onToggleFollowInner} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} + withLoading={true} /> ) }) diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 8049d2243..076fa1baa 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -7,6 +7,8 @@ import { Pressable, ViewStyle, PressableStateCallbackType, + ActivityIndicator, + View, } from 'react-native' import {Text} from '../text/Text' import {useTheme} from 'lib/ThemeContext' @@ -48,17 +50,19 @@ export function Button({ accessibilityHint, accessibilityLabelledBy, onAccessibilityEscape, + withLoading = false, }: React.PropsWithChildren<{ type?: ButtonType label?: string style?: StyleProp<ViewStyle> labelStyle?: StyleProp<TextStyle> - onPress?: () => void + onPress?: () => void | Promise<void> testID?: string accessibilityLabel?: string accessibilityHint?: string accessibilityLabelledBy?: string onAccessibilityEscape?: () => void + withLoading?: boolean }>) { const theme = useTheme() const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( @@ -138,13 +142,16 @@ export function Button({ }, ) + const [isLoading, setIsLoading] = React.useState(false) const onPressWrapped = React.useCallback( - (event: Event) => { + async (event: Event) => { event.stopPropagation() event.preventDefault() - onPress?.() + withLoading && setIsLoading(true) + await onPress?.() + withLoading && setIsLoading(false) }, - [onPress], + [onPress, withLoading], ) const getStyle = React.useCallback( @@ -160,23 +167,35 @@ export function Button({ [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 ( <Pressable style={getStyle} onPress={onPressWrapped} + disabled={isLoading} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} accessibilityLabelledBy={accessibilityLabelledBy} onAccessibilityEscape={onAccessibilityEscape}> - {label ? ( - <Text type="button" style={[typeLabelStyle, labelStyle]}> - {label} - </Text> - ) : ( - children - )} + {renderChildern} </Pressable> ) } @@ -187,4 +206,8 @@ const styles = StyleSheet.create({ paddingVertical: 8, borderRadius: 24, }, + labelContainer: { + flexDirection: 'row', + gap: 8, + }, }) |