Onboarding avatar creator or upload (#2860)

* add screen to onboarding flow

* update base

* add icon

* fix icon

* fix after merge

* create flatlist

* add emoji list

* add state context, pressables

* select/update

* add camera icon

* add photo selection button

* image selection

* cleanup

* add most needed icons

* fix icon naming

* add icons

* export path strings for emoji

* canvas drawing for web

* types

* move breakpoints to individual steps

* create canvas

* canvas working 🎉

* update state

* it works!

* working on both platforms

* remove comments

* remove log

* remove unused web canvas

* animate picture selection/removal

* compress images on web correctly

* add times icon

* scrollable horizontal flatlist on web

* prefetch

* adjustments

* add more assets

* remove unused smiles

* add all the icons

* adjust color options

* animate grow/shrink selections

* change layout on tablet/desktop

* better web layout

* fix path

* adjust web layout

* organize

* organize imports and cleanup styles

* make generated images smaller

* implement design changes

use row for buttons on web

use RNGH FlatList

random color at start

improve logic

update dialog for web

update dialog style on mobile

some more progress

create dialog

simplify context

start implementing design

* rm change

* cleanup imports

* trigger a pr label

* Formatting

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Hailey
2024-04-16 06:30:22 -07:00
committed by Eric Bailey
parent 282ad4b17d
commit 087186e386
21 changed files with 904 additions and 20 deletions
+1
View File
@@ -179,6 +179,7 @@
"react-native-uitextview": "^1.1.6",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-view-shot": "^3.8.0",
"react-native-web": "~0.19.6",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.6.4",
+2 -1
View File
@@ -207,7 +207,8 @@ export function Inner({children, style}: DialogInnerProps) {
return (
<BottomSheetView
style={[
a.p_xl,
a.py_xl,
a.px_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
+2
View File
@@ -149,6 +149,8 @@ export type TrackPropertiesMap = {
}
'OnboardingV2:StepModeration:Start': {}
'OnboardingV2:StepModeration:End': {}
'OnboardingV2:StepProfile:Start': {}
'OnboardingV2:StepProfile:End': {}
'OnboardingV2:StepFinished:Start': {}
'OnboardingV2:StepFinished:End': {}
'OnboardingV2:Complete': {}
View File
+1 -2
View File
@@ -114,8 +114,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
contentContainerStyle={{borderWidth: 0}}
// @ts-ignore web only --prf
dataSet={{'stable-gutters': 1}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
<View style={[a.flex_row, a.justify_center]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View style={[a.w_full, a.align_center, paddingTop]}>
<View
@@ -12,7 +12,7 @@ import {
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {atoms as a, tokens, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
@@ -63,6 +63,7 @@ export function StepAlgoFeeds() {
const {_} = useLingui()
const {track} = useAnalytics()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {state, dispatch} = React.useContext(Context)
const [primaryFeedUris, setPrimaryFeedUris] = React.useState<string[]>(
PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean),
@@ -97,7 +98,7 @@ export function StepAlgoFeeds() {
}, [track])
return (
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
<TitleText>
+24 -2
View File
@@ -15,12 +15,13 @@ import {
OnboardingControls,
TitleText,
} from '#/screens/Onboarding/Layout'
import {uploadBlob} from 'lib/api'
import {Context} from '#/screens/Onboarding/state'
import {
bulkWriteFollows,
sortPrimaryAlgorithmFeeds,
} from '#/screens/Onboarding/util'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -33,6 +34,7 @@ import {Text} from '#/components/Typography'
export function StepFinished() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {state, dispatch} = React.useContext(Context)
const onboardDispatch = useOnboardingDispatch()
@@ -42,11 +44,13 @@ export function StepFinished() {
const finishOnboarding = React.useCallback(async () => {
setSaving(true)
// TODO uncomment
const {
interestsStepResults,
suggestedAccountsStepResults,
algoFeedsStepResults,
topicalFeedsStepResults,
profileStepResults,
} = state
const {selectedInterests} = interestsStepResults
const selectedFeeds = [
@@ -68,6 +72,24 @@ export function StepFinished() {
})
})(),
])
await getAgent().upsertProfile(async existing => {
existing = existing ?? {}
if (profileStepResults.imageUri && profileStepResults.imageMime) {
const res = await uploadBlob(
getAgent(),
profileStepResults.imageUri,
profileStepResults.imageMime,
)
if (res.data.blob) {
existing.avatar = res.data.blob
}
}
return existing
})
} catch (e: any) {
logger.info(`onboarding: bulk save failed`)
logger.error(e)
@@ -87,7 +109,7 @@ export function StepFinished() {
}, [track])
return (
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={Check} style={[a.mb_2xl]} />
<TitleText>
+3 -2
View File
@@ -15,7 +15,7 @@ import {
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {atoms as a} from '#/alf'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
@@ -26,6 +26,7 @@ import {Text} from '#/components/Typography'
export function StepFollowingFeed() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {dispatch} = React.useContext(Context)
@@ -55,7 +56,7 @@ export function StepFollowingFeed() {
return (
// Hack for now to move the image container up
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={FilterTimeline} style={[a.mb_2xl]} />
<TitleText>
@@ -31,8 +31,8 @@ import {Text} from '#/components/Typography'
export function StepInterests() {
const {_} = useLingui()
const t = useTheme()
const {track} = useAnalytics()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [saving, setSaving] = React.useState(false)
const [interests, setInterests] = React.useState<string[]>(
@@ -143,7 +143,9 @@ export function StepInterests() {
)
return (
<View style={[a.align_start]} testID="onboardingInterests">
<View
style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}
testID="onboardingInterests">
<IconCircle
icon={isError ? EmojiSad : Hashtag}
style={[
@@ -4,6 +4,7 @@ import {LABELS} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -16,7 +17,6 @@ import {
import {Context} from '#/screens/Onboarding/state'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
@@ -25,6 +25,7 @@ import {Loader} from '#/components/Loader'
export function StepModeration() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {state, dispatch} = React.useContext(Context)
const {data: preferences} = usePreferencesQuery()
@@ -53,7 +54,7 @@ export function StepModeration() {
}, [track])
return (
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
<TitleText>
@@ -0,0 +1,85 @@
import React from 'react'
import {TouchableOpacity, TouchableOpacityProps, View} from 'react-native'
import {Image as ExpoImage} from 'expo-image'
import {atoms as a, native, useTheme, web} from '#/alf'
import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
function AvatarBottomButton({...props}: TouchableOpacityProps) {
const t = useTheme()
return (
<TouchableOpacity
{...props}
style={[
a.absolute,
a.rounded_full,
a.align_center,
a.justify_center,
{backgroundColor: t.palette.primary_500},
{height: 48, width: 48, bottom: 2, right: 2},
]}>
{props.children}
</TouchableOpacity>
)
}
export function AvatarCircle({
openLibrary,
openCreator,
}: {
openLibrary: () => unknown
openCreator: () => unknown
}) {
const t = useTheme()
const {avatar} = useAvatar()
const styles = React.useMemo(
() => ({
imageContainer: [
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
{
height: 200,
width: 200,
},
web({borderWidth: 2}),
native({borderWidth: 1}),
],
}),
[t.atoms.bg_contrast_25, t.atoms.border_contrast_low],
)
return (
<View>
{avatar.useCreatedAvatar ? (
<AvatarCreatorCircle avatar={avatar} size={200} />
) : avatar.image ? (
<ExpoImage
source={avatar.image.path}
style={styles.imageContainer}
accessibilityIgnoresInvertColors
transition={{duration: 300, effect: 'cross-dissolve'}}
/>
) : (
<View style={styles.imageContainer}>
<StreamingLive
height={100}
width={100}
style={{color: t.palette.contrast_200}}
/>
</View>
)}
<AvatarBottomButton
onPress={avatar.useCreatedAvatar ? openCreator : openLibrary}>
<Pencil size="md" style={{color: t.palette.white}} />
</AvatarBottomButton>
</View>
)
}
@@ -0,0 +1,44 @@
import React from 'react'
import {View} from 'react-native'
import {Avatar} from '#/screens/Onboarding/StepProfile/index'
import {atoms as a, native, useTheme, web} from '#/alf'
export function AvatarCreatorCircle({
avatar,
size = 125,
}: {
avatar: Avatar
size?: number
}) {
const t = useTheme()
const Icon = avatar.placeholder.component
const styles = React.useMemo(
() => ({
imageContainer: [
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
t.atoms.border_contrast_high,
{
height: size,
width: size,
backgroundColor: avatar.backgroundColor,
},
web({borderWidth: 2}),
native({borderWidth: 1}),
],
}),
[avatar.backgroundColor, size, t.atoms.border_contrast_high],
)
return (
<View>
<View style={styles.imageContainer}>
<Icon height={85} width={85} style={{color: t.palette.white}} />
</View>
</View>
)
}
@@ -0,0 +1,175 @@
import React from 'react'
import {ListRenderItemInfo, Pressable, View} from 'react-native'
// Using the FlatList from RNGH allows us to nest the scroll views on Android. The default FlatList won't allow
// scrolling inside of the vertical ScrollView
import {FlatList} from 'react-native-gesture-handler'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Avatar} from '#/screens/Onboarding/StepProfile/index'
import {
AvatarColor,
avatarColors,
emojiItems,
EmojiName,
emojiNames,
} from '#/screens/Onboarding/StepProfile/types'
function Circle({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const styles = React.useMemo(
() => ({
container: [
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
t.atoms.bg_contrast_25,
web({borderWidth: 2}),
native({borderWidth: 1}),
{
height: 60,
width: 60,
margin: 4,
},
],
}),
[t.atoms.bg_contrast_25],
)
return (
<View style={[styles.container, t.atoms.border_contrast_high]}>
{children}
</View>
)
}
function ColorItem({
color,
setAvatar,
}: {
color: AvatarColor
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}) {
const onPress = React.useCallback(() => {
setAvatar(prev => ({
...prev,
backgroundColor: color,
}))
}, [color, setAvatar])
return (
<Circle>
<Pressable
accessibilityRole="button"
style={[a.h_full, a.w_full, {backgroundColor: color}]}
onPress={onPress}
/>
</Circle>
)
}
function EmojiItem({
emojiName,
avatar,
setAvatar,
}: {
emojiName: EmojiName
avatar: Avatar
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}) {
const t = useTheme()
const Icon = React.useMemo(() => emojiItems[emojiName].component, [emojiName])
const onPress = React.useCallback(() => {
setAvatar(prev => ({
...prev,
placeholder: emojiItems[emojiName],
}))
}, [emojiName, setAvatar])
const selected = React.useMemo(
() => avatar.placeholder.name === emojiName,
[avatar.placeholder.name, emojiName],
)
return (
<Circle>
<Pressable
accessibilityRole="button"
style={[a.flex_1, a.justify_center, a.align_center]}
onPress={onPress}>
<Icon
style={selected ? t.atoms.text : t.atoms.text_contrast_low}
height={30}
width={30}
/>
</Pressable>
</Circle>
)
}
export function AvatarCreatorItems({
type,
avatar,
setAvatar,
}: {
type: 'emojis' | 'colors'
avatar: Avatar
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}) {
const {gtMobile} = useBreakpoints()
const styles = React.useMemo(
() => ({
flatListOuter: gtMobile
? {
height: 338,
}
: [a.flex_row, a.align_center, {height: 70}],
}),
[gtMobile],
)
const colorRenderItem = React.useCallback(
({item}: ListRenderItemInfo<AvatarColor>) => {
return <ColorItem color={item} setAvatar={setAvatar} />
},
[setAvatar],
)
const emojiRenderItem = React.useCallback(
({item}: ListRenderItemInfo<EmojiName>) => {
return (
<EmojiItem emojiName={item} avatar={avatar} setAvatar={setAvatar} />
)
},
[avatar, setAvatar],
)
return (
<View
style={[
styles.flatListOuter,
gtMobile && type === 'colors' && {width: 125},
]}>
<FlatList<any>
// Changing the value of numColumns on the fly isn't supported, so we want the flatlist to re-render whenever
// the size of the screen changes. Should only happen when `isTabletOrDesktop` changes.
key={gtMobile ? 0 : 1}
data={type === 'colors' ? avatarColors : emojiNames}
renderItem={type === 'colors' ? colorRenderItem : emojiRenderItem}
keyExtractor={item => item}
style={[gtMobile && {marginHorizontal: 10}]}
contentContainerStyle={[
a.align_center,
gtMobile && type === 'colors' && a.pr_xs,
!gtMobile && {paddingHorizontal: 40},
]}
numColumns={gtMobile && type === 'emojis' ? 4 : undefined}
showsHorizontalScrollIndicator={gtMobile && type === 'colors'}
horizontal={!gtMobile}
/>
</View>
)
}
@@ -0,0 +1,67 @@
import React from 'react'
import {View} from 'react-native'
import ViewShot from 'react-native-view-shot'
import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
import {atoms as a} from '#/alf'
const SIZE_MULTIPLIER = 1.5
export interface PlaceholderCanvasRef {
capture: () => Promise<string>
}
// This component is supposed to be invisible to the user. We only need this for ViewShot to have something to
// "screenshot".
export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
function PlaceholderCanvas({}, ref) {
const {avatar} = useAvatar()
const viewshotRef = React.useRef()
const Icon = avatar.placeholder.component
const styles = React.useMemo(
() => ({
container: [a.absolute, {top: -2000}],
imageContainer: [
a.align_center,
a.justify_center,
{height: 150 * SIZE_MULTIPLIER, width: 150 * SIZE_MULTIPLIER},
],
}),
[],
)
React.useImperativeHandle(ref, () => ({
// @ts-ignore this library doesn't have types
capture: viewshotRef.current.capture,
}))
return (
<View style={styles.container}>
<ViewShot
// @ts-ignore this library doesn't have types
ref={viewshotRef}
options={{
fileName: 'placeholderAvatar',
format: 'jpg',
quality: 0.8,
height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER,
}}>
<View
style={[
styles.imageContainer,
{backgroundColor: avatar.backgroundColor},
]}
collapsable={false}>
<Icon
height={85 * SIZE_MULTIPLIER}
width={85 * SIZE_MULTIPLIER}
style={{color: 'white'}}
/>
</View>
</ViewShot>
</View>
)
},
)
@@ -0,0 +1,262 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {Image as ExpoImage} from 'expo-image'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {Context} from '#/screens/Onboarding/state'
import {
TitleText,
DescriptionText,
OnboardingControls,
} from '#/screens/Onboarding/Layout'
import {Emoji, emojiItems, AvatarColor, avatarColors} from './types'
import {
PlaceholderCanvas,
PlaceholderCanvasRef,
} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {IconCircle} from '#/components/IconCircle'
import {useAnalytics} from '#/lib/analytics/analytics'
import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
import * as Dialog from '#/components/Dialog'
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
import {openPicker} from 'lib/media/picker.shared'
import {isNative, isWeb} from 'platform/detection'
import {openCropper} from 'lib/media/picker'
import {compressIfNeeded} from 'lib/media/manip'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
export interface Avatar {
image?: {
path: string
mime: string
size: number
width: number
height: number
}
backgroundColor: AvatarColor
placeholder: Emoji
useCreatedAvatar: boolean
}
interface IAvatarContext {
avatar: Avatar
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}
const AvatarContext = React.createContext<IAvatarContext>({} as IAvatarContext)
export const useAvatar = () => React.useContext(AvatarContext)
const randomColor =
avatarColors[Math.floor(Math.random() * avatarColors.length)]
export function StepProfile() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const creatorControl = Dialog.useDialogControl()
const {state, dispatch} = React.useContext(Context)
const [avatar, setAvatar] = React.useState<Avatar>({
placeholder: emojiItems.at,
backgroundColor: randomColor,
useCreatedAvatar: false,
})
const canvasRef = React.useRef<PlaceholderCanvasRef>(null)
React.useEffect(() => {
track('OnboardingV2:StepProfile:Start')
}, [track])
const onContinue = React.useCallback(async () => {
let imageUri = avatar?.image?.path
if (!imageUri || avatar.useCreatedAvatar) {
imageUri = await canvasRef.current?.capture()
}
if (imageUri) {
dispatch({
type: 'setProfileStepResults',
imageUri,
imageMime: avatar.image?.mime ?? 'image/jpeg',
})
}
dispatch({type: 'next'})
track('OnboardingV2:StepProfile:End')
}, [
avatar.image?.mime,
avatar.image?.path,
avatar.useCreatedAvatar,
dispatch,
track,
])
const onDoneCreating = React.useCallback(() => {
setAvatar(prev => ({
...prev,
useCreatedAvatar: true,
}))
creatorControl.close()
}, [creatorControl])
const openLibrary = React.useCallback(async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker({
aspect: [1, 1],
})
let image = items[0]
if (!image) return
// TODO we need an alf modal for the cropper
if (!isWeb) {
image = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
height: image.height,
width: image.width,
path: image.path,
})
}
image = await compressIfNeeded(image, 1000000)
// If we are on mobile, prefetching the image will load the image into memory before we try and display it,
// stopping any brief flickers.
if (isNative) {
await ExpoImage.prefetch(image.path)
}
setAvatar(prev => ({
...prev,
image,
useCreatedAvatar: false,
}))
}, [requestPhotoAccessIfNeeded, setAvatar])
const onSecondaryPress = React.useCallback(() => {
if (avatar.useCreatedAvatar) {
openLibrary()
} else {
creatorControl.open()
}
}, [avatar.useCreatedAvatar, creatorControl, openLibrary])
const value = React.useMemo(
() => ({
avatar,
setAvatar,
}),
[avatar],
)
return (
<AvatarContext.Provider value={value}>
<View style={[a.align_start, t.atoms.bg, a.justify_between]}>
<View style={[gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
<TitleText>
<Trans>Give your profile a face</Trans>
</TitleText>
<DescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or
creating an avatar.
</Trans>
</DescriptionText>
</View>
<View style={[a.w_full, a.align_center, {paddingTop: 80}]}>
<AvatarCircle
openLibrary={openLibrary}
openCreator={creatorControl.open}
/>
</View>
<View style={[a.w_full, a.px_2xl, a.pt_5xl]} />
<OnboardingControls.Portal>
<View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
<Button
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to next step`)}
onPress={onContinue}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
<Button
key={state.activeStep} // remove focus state on nav
variant="ghost"
color="primary"
size="large"
label={_(msg`Open avatar creator`)}
onPress={onSecondaryPress}>
<ButtonText>
{avatar.useCreatedAvatar ? (
<Trans>Upload a photo instead</Trans>
) : (
<Trans>Create an avatar instead</Trans>
)}
</ButtonText>
</Button>
</View>
</OnboardingControls.Portal>
</View>
<Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner label="Avatar creator">
<View style={[a.align_center, {paddingTop: 20}]}>
<AvatarCreatorCircle avatar={avatar} />
</View>
<View
style={[
a.pt_2xl,
a.align_center,
a.justify_center,
gtMobile && a.flex_row,
]}>
<AvatarCreatorItems
type="emojis"
avatar={avatar}
setAvatar={setAvatar}
/>
<AvatarCreatorItems
type="colors"
avatar={avatar}
setAvatar={setAvatar}
/>
</View>
<View style={[a.px_xl, a.pt_4xl]}>
<Button
key={state.activeStep} // remove focus state on nav
variant="solid"
color="primary"
size="large"
label={_(msg`Done`)}
onPress={onDoneCreating}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</Dialog.Inner>
</Dialog.Outer>
<PlaceholderCanvas ref={canvasRef} />
</AvatarContext.Provider>
)
}
+149
View File
@@ -0,0 +1,149 @@
import {
EmojiArc_Stroke2_Corner0_Rounded as EmojiArc,
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes,
} from '#/components/icons/Emoji'
import {Alien_Stroke2_Corner0_Rounded as Alien} from '#/components/icons/Alien'
import {Explosion_Stroke2_Corner0_Rounded as Explosion} from '#/components/icons/Explosion'
import {Lab_Stroke2_Corner0_Rounded as Lab} from '#/components/icons/Lab'
import {PiggyBank_Stroke2_Corner0_Rounded as PiggyBank} from '#/components/icons/PiggyBank'
import {Poop_Stroke2_Corner0_Rounded as Poop} from '#/components/icons/Poop'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Apple_Stroke2_Corner0_Rounded as Apple} from '#/components/icons/Apple'
import {Atom_Stroke2_Corner0_Rounded as Atom} from '#/components/icons/Atom'
import {Coffee_Stroke2_Corner0_Rounded as Coffee} from '#/components/icons/Coffee'
import {GameController_Stroke2_Corner0_Rounded as GameController} from '#/components/icons/GameController'
import {Leaf_Stroke2_Corner0_Rounded as Leaf} from '#/components/icons/Leaf'
import {MusicNote_Stroke2_Corner0_Rounded as MusicNote} from '#/components/icons/MusicNote'
import {Pizza_Stroke2_Corner0_Rounded as Pizza} from '#/components/icons/Pizza'
import {Rose_Stroke2_Corner0_Rounded as Rose} from '#/components/icons/Rose'
import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
import {UFO_Stroke2_Corner0_Rounded as UFO} from '#/components/icons/UFO'
import {Zap_Stroke2_Corner0_Rounded as Zap} from '#/components/icons/Zap'
import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
/**
* If you want to add or remove icons from the selection, just add the name to the `emojiNames` array and
* add the item to the `emojiItems` record..
*/
export const emojiNames = [
'at',
'arc',
'heartEyes',
'alien',
'apple',
'atom',
'celebrate',
'coffee',
'gameController',
'leaf',
'musicNote',
'pizza',
'rose',
'shaka',
'ufo',
'zap',
'explosion',
'lab',
'piggyBank',
'poop',
] as const
export type EmojiName = (typeof emojiNames)[number]
export interface Emoji {
name: EmojiName
component: typeof EmojiArc
}
export const emojiItems: Record<EmojiName, Emoji> = {
at: {
name: 'at',
component: At,
},
arc: {
name: 'arc',
component: EmojiArc,
},
heartEyes: {
name: 'heartEyes',
component: EmojiHeartEyes,
},
alien: {
name: 'alien',
component: Alien,
},
apple: {
name: 'apple',
component: Apple,
},
atom: {
name: 'atom',
component: Atom,
},
celebrate: {
name: 'celebrate',
component: Celebrate,
},
coffee: {
name: 'coffee',
component: Coffee,
},
gameController: {
name: 'gameController',
component: GameController,
},
leaf: {
name: 'leaf',
component: Leaf,
},
musicNote: {
name: 'musicNote',
component: MusicNote,
},
pizza: {
name: 'pizza',
component: Pizza,
},
rose: {
name: 'rose',
component: Rose,
},
shaka: {
name: 'shaka',
component: Shaka,
},
ufo: {
name: 'ufo',
component: UFO,
},
zap: {
name: 'zap',
component: Zap,
},
explosion: {
name: 'explosion',
component: Explosion,
},
lab: {
name: 'lab',
component: Lab,
},
piggyBank: {
name: 'piggyBank',
component: PiggyBank,
},
poop: {
name: 'poop',
component: Poop,
},
}
export const avatarColors = [
'hsl(204,70%,53%)',
'hsl(168,76%,42%)',
'hsl(282,38%,53%)',
'hsl(28,80%,52%)',
'hsl(5,79%,57%)',
'hsl(314,79%,57%)',
'hsl(41,79%,57%)',
] as const
export type AvatarColor = (typeof avatarColors)[number]
@@ -69,9 +69,9 @@ export function Inner({
export function StepSuggestedAccounts() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const {gtMobile} = useBreakpoints()
const suggestedDids = React.useMemo(() => {
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
@@ -133,7 +133,7 @@ export function StepSuggestedAccounts() {
}, [track])
return (
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={At} style={[a.mb_2xl]} />
<TitleText>
+3 -2
View File
@@ -16,7 +16,7 @@ import {
import {Context} from '#/screens/Onboarding/state'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {atoms as a} from '#/alf'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
@@ -26,6 +26,7 @@ import {Loader} from '#/components/Loader'
export function StepTopicalFeeds() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {currentAccount} = useSession()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
@@ -73,7 +74,7 @@ export function StepTopicalFeeds() {
}, [track])
return (
<View style={[a.align_start]}>
<View style={[a.align_start, gtMobile ? a.px_5xl : a.px_xl]}>
<IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
<TitleText>
+2
View File
@@ -13,6 +13,7 @@ import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepModeration} from '#/screens/Onboarding/StepModeration'
import {StepProfile} from '#/screens/Onboarding/StepProfile'
export function Onboarding() {
const {_} = useLingui()
@@ -62,6 +63,7 @@ export function Onboarding() {
{state.activeStep === 'algoFeeds' && <StepAlgoFeeds />}
{state.activeStep === 'topicalFeeds' && <StepTopicalFeeds />}
{state.activeStep === 'moderation' && <StepModeration />}
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'finished' && <StepFinished />}
</Layout>
</Context.Provider>
+31 -3
View File
@@ -12,6 +12,7 @@ export type OnboardingState = {
| 'algoFeeds'
| 'topicalFeeds'
| 'moderation'
| 'profile'
| 'finished'
activeStepIndex: number
@@ -28,6 +29,10 @@ export type OnboardingState = {
topicalFeedsStepResults: {
feedUris: string[]
}
profileStepResults: {
imageUri?: string
imageMime?: string
}
}
export type OnboardingAction =
@@ -57,6 +62,11 @@ export type OnboardingAction =
type: 'setTopicalFeedsStepResults'
feedUris: string[]
}
| {
type: 'setProfileStepResults'
imageUri: string
imageMime: string
}
export type ApiResponseMap = {
interests: string[]
@@ -70,7 +80,7 @@ export type ApiResponseMap = {
export const initialState: OnboardingState = {
hasPrev: false,
totalSteps: 7,
totalSteps: 8,
activeStep: 'interests',
activeStepIndex: 1,
@@ -91,6 +101,10 @@ export const initialState: OnboardingState = {
topicalFeedsStepResults: {
feedUris: [],
},
profileStepResults: {
imageUri: '',
imageMime: '',
},
}
export const INTEREST_TO_DISPLAY_NAME_DEFAULTS: {
@@ -154,8 +168,11 @@ export function reducer(
next.activeStep = 'moderation'
next.activeStepIndex = 6
} else if (s.activeStep === 'moderation') {
next.activeStep = 'finished'
next.activeStep = 'profile'
next.activeStepIndex = 7
} else if (s.activeStep === 'profile') {
next.activeStep = 'finished'
next.activeStepIndex = 8
}
break
}
@@ -175,9 +192,12 @@ export function reducer(
} else if (s.activeStep === 'moderation') {
next.activeStep = 'topicalFeeds'
next.activeStepIndex = 5
} else if (s.activeStep === 'finished') {
} else if (s.activeStep === 'profile') {
next.activeStep = 'moderation'
next.activeStepIndex = 6
} else if (s.activeStep === 'finished') {
next.activeStep = 'profile'
next.activeStepIndex = 7
}
break
}
@@ -212,6 +232,13 @@ export function reducer(
}
break
}
case 'setProfileStepResults': {
next.profileStepResults = {
imageUri: a.imageUri,
imageMime: a.imageMime,
}
break
}
}
const state = {
@@ -229,6 +256,7 @@ export function reducer(
suggestedAccountsStepResults: state.suggestedAccountsStepResults,
algoFeedsStepResults: state.algoFeedsStepResults,
topicalFeedsStepResults: state.topicalFeedsStepResults,
profileStepResults: state.profileStepResults,
})
if (s.activeStep !== state.activeStep) {
+41
View File
@@ -9054,6 +9054,11 @@ base-64@0.1.0, base-64@^0.1.0:
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@^1.0.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -10179,6 +10184,13 @@ css-in-js-utils@^3.1.0:
dependencies:
hyphenate-style-name "^1.0.3"
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
css-loader@^6.5.1:
version "6.8.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
@@ -13178,6 +13190,14 @@ html-webpack-plugin@^5.5.0:
pretty-error "^4.0.0"
tapable "^2.0.0"
html2canvas@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
htmlparser2@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
@@ -18767,6 +18787,13 @@ react-native-uuid@^2.0.1:
resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.1.tgz#ed4e2dfb1683eddb66967eb5dca140dfe1abddb9"
integrity sha512-cptnoIbL53GTCrWlb/+jrDC6tvb7ypIyzbXNJcpR3Vab0mkeaaVd5qnB3f0whXYzS+SMoSQLcUUB0gEWqkPC0g==
react-native-view-shot@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/react-native-view-shot/-/react-native-view-shot-3.8.0.tgz#1aa1905f0e79428ca32bf80c16fd4abc719c600b"
integrity sha512-4cU8SOhMn3YQIrskh+5Q8VvVRxQOu8/s1M9NAL4z5BY1Rm0HXMWkQJ4N0XsZ42+Yca+y86ISF3LC5qdLPvPuiA==
dependencies:
html2canvas "^1.4.1"
react-native-web-webview@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02"
@@ -20762,6 +20789,13 @@ test-exclude@^6.0.0:
glob "^7.1.4"
minimatch "^3.0.4"
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -21400,6 +21434,13 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
uuid@^3.0.1, uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"