diff --git a/package.json b/package.json
index 1a56c871..dcf3b517 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"build:apk": "eas build -p android --profile dev-android-apk"
},
"dependencies": {
- "@atproto/api": "^0.6.12",
+ "@atproto/api": "^0.6.13",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1",
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
index 09c9eac0..8ad321ed 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 0b3d3695..afa5e74e 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 6ea8cd79..a36544a0 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' && (
)}
+ {store.onboarding.step === 'RecommendedFollows' && (
+
+ )}
)
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index b39714ef..24fc9eef 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({
- Done
+ Next
@@ -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 00000000..f2710d2a
--- /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 = (
+ <>
+
+ Follow some
+
+
+ Recommended
+
+
+ Users
+
+
+ Follow some users to get started. We can recommend you more users based
+ on who you find interesting.
+
+
+
+
+ >
+ )
+
+ return (
+ <>
+
+
+ {store.onboarding.suggestedActors.isLoading ? (
+
+ ) : (
+ (
+
+ )}
+ keyExtractor={(item, index) => item.did + index.toString()}
+ style={{flex: 1}}
+ />
+ )}
+
+
+
+
+
+
+
+
+ Check out some recommended users. Follow them to see similar
+ users.
+
+
+ {store.onboarding.suggestedActors.isLoading ? (
+
+ ) : (
+ (
+
+ )}
+ keyExtractor={(item, index) => item.did + index.toString()}
+ style={{flex: 1}}
+ />
+ )}
+
+
+
+ >
+ )
+})
+
+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 00000000..144fdc2e
--- /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 = ({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 (
+
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+
+ {sanitizeDisplayName(
+ profile.displayName || sanitizeHandle(profile.handle),
+ moderation.profile,
+ )}
+
+
+ {sanitizeHandle(profile.handle, '@')}
+
+
+
+ {
+ if (isFollow) {
+ setAddingMoreSuggestions(true)
+ await store.onboarding.suggestedActors.insertSuggestionsByActor(
+ profile.did,
+ index,
+ )
+ setAddingMoreSuggestions(false)
+ }
+ }}
+ />
+
+ {profile.description ? (
+
+
+ {profile.description as string}
+
+
+ ) : undefined}
+ {addingMoreSuggestions ? (
+
+
+ Finding similar accounts...
+
+ ) : null}
+
+ )
+})
+
+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 19c8d52d..1f0a6437 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}
/>
@@ -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 6f6286e6..4b2b944f 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
}) {
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 8049d224..076fa1ba 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
labelStyle?: StyleProp
- onPress?: () => void
+ onPress?: () => void | Promise
testID?: string
accessibilityLabel?: string
accessibilityHint?: string
accessibilityLabelledBy?: string
onAccessibilityEscape?: () => void
+ withLoading?: boolean
}>) {
const theme = useTheme()
const typeOuterStyle = choose>(
@@ -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 (
+
+ {label && withLoading && isLoading ? (
+
+ ) : null}
+
+ {label}
+
+
+ )
+ }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
+
return (
- {label ? (
-
- {label}
-
- ) : (
- children
- )}
+ {renderChildern}
)
}
@@ -187,4 +206,8 @@ const styles = StyleSheet.create({
paddingVertical: 8,
borderRadius: 24,
},
+ labelContainer: {
+ flexDirection: 'row',
+ gap: 8,
+ },
})
diff --git a/yarn.lock b/yarn.lock
index 7d318585..a9400b4e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@^0.6.12":
- version "0.6.12"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.12.tgz#f39ad9d225aafc5fd90f07d0011d63435c657775"
- integrity sha512-9R8F78553GI47Iq4FDVwL05LorWTQZQ6FmFsDF/+yryiA+a/VVyvYG4USSptURBZCRgZA5VgTW1We/PwAcDfEA==
+"@atproto/api@^0.6.13":
+ version "0.6.13"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.13.tgz#26caeae983c577dfedf6ab1f054b31eb158d8ca6"
+ integrity sha512-MudGswKKFJmeh4RoN9LZnYoHib0L7QEIzOdkRU26Fr0dBqSQrnWwLYA9zsNjNg/mZKDyweYkP1ChTNkQvNiYFw==
dependencies:
"@atproto/common-web" "^0.2.0"
"@atproto/lexicon" "^0.2.0"