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
parent
da8499c881
commit
859588c3f6
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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,
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue