Bluesky app fork with some witchin' additions 💫

[Reduced Onboarding] Add profile step (#3933)

* 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>
(cherry picked from commit 087186e3867b0eefb11a056b0b644f5585fa16bd)

* UI tweaks

* Revert layout change

* Gate avi upload

* Support returning to profile step

* Add Statsig

---------

Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Eric Bailey
Eric Bailey
Hailey
Dan Abramov
and committed by
GitHub
4e37e2f5 80ce6f98

+876 -36
+1
package.json
··· 185 185 "react-native-uitextview": "^1.1.6", 186 186 "react-native-url-polyfill": "^1.3.0", 187 187 "react-native-uuid": "^2.0.1", 188 + "react-native-view-shot": "^3.8.0", 188 189 "react-native-web": "~0.19.6", 189 190 "react-native-web-webview": "^1.0.2", 190 191 "react-native-webview": "13.6.4",
+2 -1
src/components/Dialog/index.tsx
··· 213 213 return ( 214 214 <BottomSheetView 215 215 style={[ 216 - a.p_xl, 216 + a.py_xl, 217 + a.px_xl, 217 218 { 218 219 paddingTop: 40, 219 220 borderTopLeftRadius: 40,
+2
src/lib/analytics/types.ts
··· 150 150 } 151 151 'OnboardingV2:StepModeration:Start': {} 152 152 'OnboardingV2:StepModeration:End': {} 153 + 'OnboardingV2:StepProfile:Start': {} 154 + 'OnboardingV2:StepProfile:End': {} 153 155 'OnboardingV2:StepFinished:Start': {} 154 156 'OnboardingV2:StepFinished:End': {} 155 157 'OnboardingV2:Complete': {}
src/lib/media/avatar-generator.tsx

This is a binary file and will not be displayed.

+1
src/lib/statsig/events.ts
··· 48 48 selectedFeedsLength: number 49 49 } 50 50 'onboarding:moderation:nextPressed': {} 51 + 'onboarding:profile:nextPressed': {} 51 52 'onboarding:finished:nextPressed': {} 52 53 'home:feedDisplayed': { 53 54 feedUrl: string
+1 -1
src/screens/Onboarding/Layout.tsx
··· 23 23 import {leading, P, Text} from '#/components/Typography' 24 24 import {IS_DEV} from '#/env' 25 25 26 - const COL_WIDTH = 500 26 + const COL_WIDTH = 420 27 27 28 28 export const OnboardingControls = createPortalGroup() 29 29
+23
src/screens/Onboarding/StepFinished.tsx
··· 12 12 import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 13 13 import {useAgent} from '#/state/session' 14 14 import {useOnboardingDispatch} from '#/state/shell' 15 + import {uploadBlob} from 'lib/api' 15 16 import { 16 17 DescriptionText, 17 18 OnboardingControls, ··· 46 47 const finishOnboarding = React.useCallback(async () => { 47 48 setSaving(true) 48 49 50 + // TODO uncomment 49 51 const { 50 52 interestsStepResults, 51 53 suggestedAccountsStepResults, 52 54 algoFeedsStepResults, 53 55 topicalFeedsStepResults, 56 + profileStepResults, 54 57 } = state 55 58 const {selectedInterests} = interestsStepResults 56 59 const selectedFeeds = [ ··· 110 113 } 111 114 })(), 112 115 ]) 116 + 117 + if (gate('reduced_onboarding_and_home_algo')) { 118 + await getAgent().upsertProfile(async existing => { 119 + existing = existing ?? {} 120 + 121 + if (profileStepResults.imageUri && profileStepResults.imageMime) { 122 + const res = await uploadBlob( 123 + getAgent(), 124 + profileStepResults.imageUri, 125 + profileStepResults.imageMime, 126 + ) 127 + 128 + if (res.data.blob) { 129 + existing.avatar = res.data.blob 130 + } 131 + } 132 + 133 + return existing 134 + }) 135 + } 113 136 } catch (e: any) { 114 137 logger.info(`onboarding: bulk save failed`) 115 138 logger.error(e)
+1 -1
src/screens/Onboarding/StepInterests/index.tsx
··· 31 31 export function StepInterests() { 32 32 const {_} = useLingui() 33 33 const t = useTheme() 34 - const {track} = useAnalytics() 35 34 const {gtMobile} = useBreakpoints() 35 + const {track} = useAnalytics() 36 36 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 37 37 const [saving, setSaving] = React.useState(false) 38 38 const [interests, setInterests] = React.useState<string[]>(
+77
src/screens/Onboarding/StepProfile/AvatarCircle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {Image as ExpoImage} from 'expo-image' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' 8 + import {useAvatar} from '#/screens/Onboarding/StepProfile/index' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Button, ButtonIcon} from '#/components/Button' 11 + import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' 12 + import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive' 13 + 14 + export function AvatarCircle({ 15 + openLibrary, 16 + openCreator, 17 + }: { 18 + openLibrary: () => unknown 19 + openCreator: () => unknown 20 + }) { 21 + const {_} = useLingui() 22 + const t = useTheme() 23 + const {avatar} = useAvatar() 24 + 25 + const styles = React.useMemo( 26 + () => ({ 27 + imageContainer: [ 28 + a.rounded_full, 29 + a.overflow_hidden, 30 + a.align_center, 31 + a.justify_center, 32 + a.border, 33 + t.atoms.border_contrast_low, 34 + t.atoms.bg_contrast_25, 35 + { 36 + height: 200, 37 + width: 200, 38 + }, 39 + ], 40 + }), 41 + [t.atoms.bg_contrast_25, t.atoms.border_contrast_low], 42 + ) 43 + 44 + return ( 45 + <View> 46 + {avatar.useCreatedAvatar ? ( 47 + <AvatarCreatorCircle avatar={avatar} size={200} /> 48 + ) : avatar.image ? ( 49 + <ExpoImage 50 + source={avatar.image.path} 51 + style={styles.imageContainer} 52 + accessibilityIgnoresInvertColors 53 + transition={{duration: 300, effect: 'cross-dissolve'}} 54 + /> 55 + ) : ( 56 + <View style={styles.imageContainer}> 57 + <StreamingLive 58 + height={100} 59 + width={100} 60 + style={{color: t.palette.contrast_200}} 61 + /> 62 + </View> 63 + )} 64 + <View style={[a.absolute, {bottom: 2, right: 2}]}> 65 + <Button 66 + label={_(msg`Select an avatar`)} 67 + size="large" 68 + shape="round" 69 + variant="solid" 70 + color="primary" 71 + onPress={avatar.useCreatedAvatar ? openCreator : openLibrary}> 72 + <ButtonIcon icon={Pencil} /> 73 + </Button> 74 + </View> 75 + </View> 76 + ) 77 + }
+43
src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {Avatar} from '#/screens/Onboarding/StepProfile/index' 5 + import {atoms as a, useTheme} from '#/alf' 6 + 7 + export function AvatarCreatorCircle({ 8 + avatar, 9 + size = 125, 10 + }: { 11 + avatar: Avatar 12 + size?: number 13 + }) { 14 + const t = useTheme() 15 + const Icon = avatar.placeholder.component 16 + 17 + const styles = React.useMemo( 18 + () => ({ 19 + imageContainer: [ 20 + a.rounded_full, 21 + a.overflow_hidden, 22 + a.align_center, 23 + a.justify_center, 24 + a.border, 25 + t.atoms.border_contrast_high, 26 + { 27 + height: size, 28 + width: size, 29 + backgroundColor: avatar.backgroundColor, 30 + }, 31 + ], 32 + }), 33 + [avatar.backgroundColor, size, t.atoms.border_contrast_high], 34 + ) 35 + 36 + return ( 37 + <View> 38 + <View style={styles.imageContainer}> 39 + <Icon height={85} width={85} style={{color: t.palette.white}} /> 40 + </View> 41 + </View> 42 + ) 43 + }
+145
src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {Avatar} from '#/screens/Onboarding/StepProfile/index' 7 + import { 8 + AvatarColor, 9 + avatarColors, 10 + emojiItems, 11 + EmojiName, 12 + emojiNames, 13 + } from '#/screens/Onboarding/StepProfile/types' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Button, ButtonIcon} from '#/components/Button' 16 + import {Text} from '#/components/Typography' 17 + 18 + const ACTIVE_BORDER_WIDTH = 3 19 + const ACTIVE_BORDER_STYLES = { 20 + top: -ACTIVE_BORDER_WIDTH, 21 + bottom: -ACTIVE_BORDER_WIDTH, 22 + left: -ACTIVE_BORDER_WIDTH, 23 + right: -ACTIVE_BORDER_WIDTH, 24 + opacity: 0.5, 25 + borderWidth: 3, 26 + } 27 + 28 + export function AvatarCreatorItems({ 29 + type, 30 + avatar, 31 + setAvatar, 32 + }: { 33 + type: 'emojis' | 'colors' 34 + avatar: Avatar 35 + setAvatar: React.Dispatch<React.SetStateAction<Avatar>> 36 + }) { 37 + const {_} = useLingui() 38 + const t = useTheme() 39 + const isEmojis = type === 'emojis' 40 + 41 + const onSelectEmoji = React.useCallback( 42 + (emoji: EmojiName) => { 43 + setAvatar(prev => ({ 44 + ...prev, 45 + placeholder: emojiItems[emoji], 46 + })) 47 + }, 48 + [setAvatar], 49 + ) 50 + 51 + const onSelectColor = React.useCallback( 52 + (color: AvatarColor) => { 53 + setAvatar(prev => ({ 54 + ...prev, 55 + backgroundColor: color, 56 + })) 57 + }, 58 + [setAvatar], 59 + ) 60 + 61 + return ( 62 + <View style={[a.w_full]}> 63 + <Text style={[a.pb_md, t.atoms.text_contrast_medium]}> 64 + {isEmojis ? ( 65 + <Trans>Select an emoji</Trans> 66 + ) : ( 67 + <Trans>Select a color</Trans> 68 + )} 69 + </Text> 70 + 71 + <View 72 + style={[ 73 + a.flex_row, 74 + a.align_start, 75 + a.justify_start, 76 + a.flex_wrap, 77 + a.gap_md, 78 + ]}> 79 + {isEmojis 80 + ? emojiNames.map(emojiName => ( 81 + <Button 82 + key={emojiName} 83 + label={_(msg`Select the ${emojiName} emoji as your avatar`)} 84 + size="small" 85 + shape="round" 86 + variant="solid" 87 + color="secondary" 88 + onPress={() => onSelectEmoji(emojiName)}> 89 + <ButtonIcon icon={emojiItems[emojiName].component} /> 90 + {avatar.placeholder.name === emojiName && ( 91 + <View 92 + style={[ 93 + a.absolute, 94 + a.rounded_full, 95 + ACTIVE_BORDER_STYLES, 96 + { 97 + borderColor: avatar.backgroundColor, 98 + }, 99 + ]} 100 + /> 101 + )} 102 + </Button> 103 + )) 104 + : avatarColors.map(color => ( 105 + <Button 106 + key={color} 107 + label={_(msg`Choose this color as your avatar`)} 108 + size="small" 109 + shape="round" 110 + variant="solid" 111 + onPress={() => onSelectColor(color)}> 112 + {ctx => ( 113 + <> 114 + <View 115 + style={[ 116 + a.absolute, 117 + a.inset_0, 118 + a.rounded_full, 119 + { 120 + opacity: ctx.hovered || ctx.pressed ? 0.8 : 1, 121 + backgroundColor: color, 122 + }, 123 + ]} 124 + /> 125 + 126 + {avatar.backgroundColor === color && ( 127 + <View 128 + style={[ 129 + a.absolute, 130 + a.rounded_full, 131 + ACTIVE_BORDER_STYLES, 132 + { 133 + borderColor: color, 134 + }, 135 + ]} 136 + /> 137 + )} 138 + </> 139 + )} 140 + </Button> 141 + ))} 142 + </View> 143 + </View> 144 + ) 145 + }
+67
src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import ViewShot from 'react-native-view-shot' 4 + 5 + import {useAvatar} from '#/screens/Onboarding/StepProfile/index' 6 + import {atoms as a} from '#/alf' 7 + 8 + const SIZE_MULTIPLIER = 1.5 9 + 10 + export interface PlaceholderCanvasRef { 11 + capture: () => Promise<string> 12 + } 13 + 14 + // This component is supposed to be invisible to the user. We only need this for ViewShot to have something to 15 + // "screenshot". 16 + export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>( 17 + function PlaceholderCanvas({}, ref) { 18 + const {avatar} = useAvatar() 19 + const viewshotRef = React.useRef() 20 + const Icon = avatar.placeholder.component 21 + 22 + const styles = React.useMemo( 23 + () => ({ 24 + container: [a.absolute, {top: -2000}], 25 + imageContainer: [ 26 + a.align_center, 27 + a.justify_center, 28 + {height: 150 * SIZE_MULTIPLIER, width: 150 * SIZE_MULTIPLIER}, 29 + ], 30 + }), 31 + [], 32 + ) 33 + 34 + React.useImperativeHandle(ref, () => ({ 35 + // @ts-ignore this library doesn't have types 36 + capture: viewshotRef.current.capture, 37 + })) 38 + 39 + return ( 40 + <View style={styles.container}> 41 + <ViewShot 42 + // @ts-ignore this library doesn't have types 43 + ref={viewshotRef} 44 + options={{ 45 + fileName: 'placeholderAvatar', 46 + format: 'jpg', 47 + quality: 0.8, 48 + height: 150 * SIZE_MULTIPLIER, 49 + width: 150 * SIZE_MULTIPLIER, 50 + }}> 51 + <View 52 + style={[ 53 + styles.imageContainer, 54 + {backgroundColor: avatar.backgroundColor}, 55 + ]} 56 + collapsable={false}> 57 + <Icon 58 + height={85 * SIZE_MULTIPLIER} 59 + width={85 * SIZE_MULTIPLIER} 60 + style={{color: 'white'}} 61 + /> 62 + </View> 63 + </ViewShot> 64 + </View> 65 + ) 66 + }, 67 + )
+293 -29
src/screens/Onboarding/StepProfile/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {Image as ExpoImage} from 'expo-image' 4 + import { 5 + ImagePickerOptions, 6 + launchImageLibraryAsync, 7 + MediaTypeOptions, 8 + } from 'expo-image-picker' 3 9 import {msg, Trans} from '@lingui/macro' 4 10 import {useLingui} from '@lingui/react' 5 11 12 + import {useAnalytics} from '#/lib/analytics/analytics' 13 + import {logEvent} from '#/lib/statsig/statsig' 14 + import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' 15 + import {compressIfNeeded} from 'lib/media/manip' 16 + import {openCropper} from 'lib/media/picker' 17 + import {getDataUriSize} from 'lib/media/util' 18 + import {isNative, isWeb} from 'platform/detection' 6 19 import { 7 20 DescriptionText, 8 21 OnboardingControls, 9 22 TitleText, 10 23 } from '#/screens/Onboarding/Layout' 11 24 import {Context} from '#/screens/Onboarding/state' 12 - import {atoms as a} from '#/alf' 25 + import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' 26 + import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' 27 + import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' 28 + import { 29 + PlaceholderCanvas, 30 + PlaceholderCanvasRef, 31 + } from '#/screens/Onboarding/StepProfile/PlaceholderCanvas' 32 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 13 33 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 34 + import * as Dialog from '#/components/Dialog' 14 35 import {IconCircle} from '#/components/IconCircle' 15 36 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 37 + import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 16 38 import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive' 39 + import {Text} from '#/components/Typography' 40 + import {AvatarColor, avatarColors, Emoji, emojiItems} from './types' 41 + 42 + export interface Avatar { 43 + image?: { 44 + path: string 45 + mime: string 46 + size: number 47 + width: number 48 + height: number 49 + } 50 + backgroundColor: AvatarColor 51 + placeholder: Emoji 52 + useCreatedAvatar: boolean 53 + } 54 + 55 + interface IAvatarContext { 56 + avatar: Avatar 57 + setAvatar: React.Dispatch<React.SetStateAction<Avatar>> 58 + } 59 + 60 + const AvatarContext = React.createContext<IAvatarContext>({} as IAvatarContext) 61 + export const useAvatar = () => React.useContext(AvatarContext) 62 + 63 + const randomColor = 64 + avatarColors[Math.floor(Math.random() * avatarColors.length)] 17 65 18 66 export function StepProfile() { 19 67 const {_} = useLingui() 20 - const {dispatch} = React.useContext(Context) 68 + const t = useTheme() 69 + const {gtMobile} = useBreakpoints() 70 + const {track} = useAnalytics() 71 + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 72 + const creatorControl = Dialog.useDialogControl() 73 + const [error, setError] = React.useState('') 74 + 75 + const {state, dispatch} = React.useContext(Context) 76 + const [avatar, setAvatar] = React.useState<Avatar>({ 77 + image: state.profileStepResults?.image, 78 + placeholder: emojiItems.at, 79 + backgroundColor: randomColor, 80 + useCreatedAvatar: false, 81 + }) 82 + 83 + const canvasRef = React.useRef<PlaceholderCanvasRef>(null) 84 + 85 + React.useEffect(() => { 86 + track('OnboardingV2:StepProfile:Start') 87 + }, [track]) 88 + 89 + const openPicker = React.useCallback( 90 + async (opts?: ImagePickerOptions) => { 91 + const response = await launchImageLibraryAsync({ 92 + exif: false, 93 + mediaTypes: MediaTypeOptions.Images, 94 + quality: 1, 95 + ...opts, 96 + }) 97 + 98 + return (response.assets ?? []) 99 + .slice(0, 1) 100 + .filter(asset => { 101 + if ( 102 + !asset.mimeType?.startsWith('image/') || 103 + (!asset.mimeType?.endsWith('jpeg') && 104 + !asset.mimeType?.endsWith('jpg') && 105 + !asset.mimeType?.endsWith('png')) 106 + ) { 107 + setError(_(msg`Only .jpg and .png files are supported`)) 108 + return false 109 + } 110 + return true 111 + }) 112 + .map(image => ({ 113 + mime: 'image/jpeg', 114 + height: image.height, 115 + width: image.width, 116 + path: image.uri, 117 + size: getDataUriSize(image.uri), 118 + })) 119 + }, 120 + [_, setError], 121 + ) 21 122 22 - const onContinue = React.useCallback(() => { 123 + const onContinue = React.useCallback(async () => { 124 + let imageUri = avatar?.image?.path 125 + if (!imageUri || avatar.useCreatedAvatar) { 126 + imageUri = await canvasRef.current?.capture() 127 + } 128 + 129 + if (imageUri) { 130 + dispatch({ 131 + type: 'setProfileStepResults', 132 + image: avatar.image, 133 + imageUri, 134 + imageMime: avatar.image?.mime ?? 'image/jpeg', 135 + }) 136 + } 137 + 23 138 dispatch({type: 'next'}) 24 - }, [dispatch]) 139 + track('OnboardingV2:StepProfile:End') 140 + logEvent('onboarding:profile:nextPressed', {}) 141 + }, [avatar.image, avatar.useCreatedAvatar, dispatch, track]) 142 + 143 + const onDoneCreating = React.useCallback(() => { 144 + setAvatar(prev => ({ 145 + ...prev, 146 + useCreatedAvatar: true, 147 + })) 148 + creatorControl.close() 149 + }, [creatorControl]) 150 + 151 + const openLibrary = React.useCallback(async () => { 152 + if (!(await requestPhotoAccessIfNeeded())) { 153 + return 154 + } 155 + 156 + setError('') 157 + 158 + const items = await openPicker({ 159 + aspect: [1, 1], 160 + }) 161 + let image = items[0] 162 + if (!image) return 163 + 164 + if (!isWeb) { 165 + image = await openCropper({ 166 + mediaType: 'photo', 167 + cropperCircleOverlay: true, 168 + height: image.height, 169 + width: image.width, 170 + path: image.path, 171 + }) 172 + } 173 + image = await compressIfNeeded(image, 1000000) 174 + 175 + // If we are on mobile, prefetching the image will load the image into memory before we try and display it, 176 + // stopping any brief flickers. 177 + if (isNative) { 178 + await ExpoImage.prefetch(image.path) 179 + } 180 + 181 + setAvatar(prev => ({ 182 + ...prev, 183 + image, 184 + useCreatedAvatar: false, 185 + })) 186 + }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError]) 187 + 188 + const onSecondaryPress = React.useCallback(() => { 189 + if (avatar.useCreatedAvatar) { 190 + openLibrary() 191 + } else { 192 + creatorControl.open() 193 + } 194 + }, [avatar.useCreatedAvatar, creatorControl, openLibrary]) 195 + 196 + const value = React.useMemo( 197 + () => ({ 198 + avatar, 199 + setAvatar, 200 + }), 201 + [avatar], 202 + ) 25 203 26 204 return ( 27 - <View style={[a.align_start]}> 28 - <IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> 205 + <AvatarContext.Provider value={value}> 206 + <View style={[a.align_start, t.atoms.bg, a.justify_between]}> 207 + <IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> 208 + <TitleText> 209 + <Trans>Give your profile a face</Trans> 210 + </TitleText> 211 + <DescriptionText> 212 + <Trans> 213 + Help people know you're not a bot by uploading a picture or creating 214 + an avatar. 215 + </Trans> 216 + </DescriptionText> 217 + <View 218 + style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 40}]}> 219 + <AvatarCircle 220 + openLibrary={openLibrary} 221 + openCreator={creatorControl.open} 222 + /> 223 + 224 + {error && ( 225 + <View 226 + style={[ 227 + a.flex_row, 228 + a.gap_sm, 229 + a.align_center, 230 + a.mt_xl, 231 + a.py_md, 232 + a.px_lg, 233 + a.border, 234 + a.rounded_md, 235 + t.atoms.bg_contrast_25, 236 + t.atoms.border_contrast_low, 237 + ]}> 238 + <CircleInfo_Stroke2_Corner0_Rounded size="sm" /> 239 + <Text style={[a.leading_snug]}>{error}</Text> 240 + </View> 241 + )} 242 + </View> 243 + 244 + <OnboardingControls.Portal> 245 + <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> 246 + <Button 247 + variant="gradient" 248 + color="gradient_sky" 249 + size="large" 250 + label={_(msg`Continue to next step`)} 251 + onPress={onContinue}> 252 + <ButtonText> 253 + <Trans>Continue</Trans> 254 + </ButtonText> 255 + <ButtonIcon icon={ChevronRight} position="right" /> 256 + </Button> 257 + <Button 258 + variant="ghost" 259 + color="primary" 260 + size="large" 261 + label={_(msg`Open avatar creator`)} 262 + onPress={onSecondaryPress}> 263 + <ButtonText> 264 + {avatar.useCreatedAvatar ? ( 265 + <Trans>Upload a photo instead</Trans> 266 + ) : ( 267 + <Trans>Create an avatar instead</Trans> 268 + )} 269 + </ButtonText> 270 + </Button> 271 + </View> 272 + </OnboardingControls.Portal> 273 + </View> 29 274 30 - <TitleText> 31 - <Trans>Give your profile a face</Trans> 32 - </TitleText> 33 - <DescriptionText> 34 - <Trans> 35 - Help people know you're not a bot by uploading a picture or creating 36 - an avatar. 37 - </Trans> 38 - </DescriptionText> 275 + <Dialog.Outer control={creatorControl}> 276 + <Dialog.Handle /> 277 + <Dialog.Inner 278 + label="Avatar creator" 279 + style={[ 280 + { 281 + width: 'auto', 282 + maxWidth: 410, 283 + }, 284 + ]}> 285 + <View style={[a.align_center, {paddingTop: 20}]}> 286 + <AvatarCreatorCircle avatar={avatar} /> 287 + </View> 39 288 40 - <OnboardingControls.Portal> 41 - <Button 42 - variant="gradient" 43 - color="gradient_sky" 44 - size="large" 45 - label={_(msg`Continue to next step`)} 46 - onPress={onContinue}> 47 - <ButtonText> 48 - <Trans>Continue</Trans> 49 - </ButtonText> 50 - <ButtonIcon icon={ChevronRight} position="right" /> 51 - </Button> 52 - </OnboardingControls.Portal> 53 - </View> 289 + <View style={[a.pt_3xl, a.gap_lg]}> 290 + <AvatarCreatorItems 291 + type="emojis" 292 + avatar={avatar} 293 + setAvatar={setAvatar} 294 + /> 295 + <AvatarCreatorItems 296 + type="colors" 297 + avatar={avatar} 298 + setAvatar={setAvatar} 299 + /> 300 + </View> 301 + <View style={[a.pt_4xl]}> 302 + <Button 303 + variant="solid" 304 + color="primary" 305 + size="large" 306 + label={_(msg`Done`)} 307 + onPress={onDoneCreating}> 308 + <ButtonText> 309 + <Trans>Done</Trans> 310 + </ButtonText> 311 + </Button> 312 + </View> 313 + </Dialog.Inner> 314 + </Dialog.Outer> 315 + 316 + <PlaceholderCanvas ref={canvasRef} /> 317 + </AvatarContext.Provider> 54 318 ) 55 319 }
+148
src/screens/Onboarding/StepProfile/types.ts
··· 1 + import {Alien_Stroke2_Corner0_Rounded as Alien} from '#/components/icons/Alien' 2 + import {Apple_Stroke2_Corner0_Rounded as Apple} from '#/components/icons/Apple' 3 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 4 + import {Atom_Stroke2_Corner0_Rounded as Atom} from '#/components/icons/Atom' 5 + import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate' 6 + import {Coffee_Stroke2_Corner0_Rounded as Coffee} from '#/components/icons/Coffee' 7 + import { 8 + EmojiArc_Stroke2_Corner0_Rounded as EmojiArc, 9 + EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes, 10 + } from '#/components/icons/Emoji' 11 + import {Explosion_Stroke2_Corner0_Rounded as Explosion} from '#/components/icons/Explosion' 12 + import {GameController_Stroke2_Corner0_Rounded as GameController} from '#/components/icons/GameController' 13 + import {Lab_Stroke2_Corner0_Rounded as Lab} from '#/components/icons/Lab' 14 + import {Leaf_Stroke2_Corner0_Rounded as Leaf} from '#/components/icons/Leaf' 15 + import {MusicNote_Stroke2_Corner0_Rounded as MusicNote} from '#/components/icons/MusicNote' 16 + import {PiggyBank_Stroke2_Corner0_Rounded as PiggyBank} from '#/components/icons/PiggyBank' 17 + import {Pizza_Stroke2_Corner0_Rounded as Pizza} from '#/components/icons/Pizza' 18 + import {Poop_Stroke2_Corner0_Rounded as Poop} from '#/components/icons/Poop' 19 + import {Rose_Stroke2_Corner0_Rounded as Rose} from '#/components/icons/Rose' 20 + import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka' 21 + import {UFO_Stroke2_Corner0_Rounded as UFO} from '#/components/icons/UFO' 22 + import {Zap_Stroke2_Corner0_Rounded as Zap} from '#/components/icons/Zap' 23 + 24 + /** 25 + * If you want to add or remove icons from the selection, just add the name to the `emojiNames` array and 26 + * add the item to the `emojiItems` record.. 27 + */ 28 + 29 + export const emojiNames = [ 30 + 'at', 31 + 'arc', 32 + 'heartEyes', 33 + 'alien', 34 + 'apple', 35 + 'atom', 36 + 'celebrate', 37 + 'coffee', 38 + 'gameController', 39 + 'leaf', 40 + 'musicNote', 41 + 'pizza', 42 + 'rose', 43 + 'shaka', 44 + 'ufo', 45 + 'zap', 46 + 'explosion', 47 + 'lab', 48 + 'piggyBank', 49 + 'poop', 50 + ] as const 51 + export type EmojiName = (typeof emojiNames)[number] 52 + 53 + export interface Emoji { 54 + name: EmojiName 55 + component: typeof EmojiArc 56 + } 57 + export const emojiItems: Record<EmojiName, Emoji> = { 58 + at: { 59 + name: 'at', 60 + component: At, 61 + }, 62 + arc: { 63 + name: 'arc', 64 + component: EmojiArc, 65 + }, 66 + heartEyes: { 67 + name: 'heartEyes', 68 + component: EmojiHeartEyes, 69 + }, 70 + alien: { 71 + name: 'alien', 72 + component: Alien, 73 + }, 74 + apple: { 75 + name: 'apple', 76 + component: Apple, 77 + }, 78 + atom: { 79 + name: 'atom', 80 + component: Atom, 81 + }, 82 + celebrate: { 83 + name: 'celebrate', 84 + component: Celebrate, 85 + }, 86 + coffee: { 87 + name: 'coffee', 88 + component: Coffee, 89 + }, 90 + gameController: { 91 + name: 'gameController', 92 + component: GameController, 93 + }, 94 + leaf: { 95 + name: 'leaf', 96 + component: Leaf, 97 + }, 98 + musicNote: { 99 + name: 'musicNote', 100 + component: MusicNote, 101 + }, 102 + pizza: { 103 + name: 'pizza', 104 + component: Pizza, 105 + }, 106 + rose: { 107 + name: 'rose', 108 + component: Rose, 109 + }, 110 + shaka: { 111 + name: 'shaka', 112 + component: Shaka, 113 + }, 114 + ufo: { 115 + name: 'ufo', 116 + component: UFO, 117 + }, 118 + zap: { 119 + name: 'zap', 120 + component: Zap, 121 + }, 122 + explosion: { 123 + name: 'explosion', 124 + component: Explosion, 125 + }, 126 + lab: { 127 + name: 'lab', 128 + component: Lab, 129 + }, 130 + piggyBank: { 131 + name: 'piggyBank', 132 + component: PiggyBank, 133 + }, 134 + poop: { 135 + name: 'poop', 136 + component: Poop, 137 + }, 138 + } 139 + 140 + export const avatarColors = [ 141 + '#FE8311', 142 + '#FED811', 143 + '#73DF84', 144 + '#1185FE', 145 + '#EF75EA', 146 + '#F55454', 147 + ] as const 148 + export type AvatarColor = (typeof avatarColors)[number]
+1 -1
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 69 69 70 70 export function StepSuggestedAccounts() { 71 71 const {_} = useLingui() 72 + const {gtMobile} = useBreakpoints() 72 73 const {track} = useAnalytics() 73 74 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 74 - const {gtMobile} = useBreakpoints() 75 75 const suggestedDids = React.useMemo(() => { 76 76 return aggregateInterestItems( 77 77 state.interestsStepResults.selectedInterests,
+30 -3
src/screens/Onboarding/state.ts
··· 13 13 | 'algoFeeds' 14 14 | 'topicalFeeds' 15 15 | 'moderation' 16 + | 'profile' 16 17 | 'finished' 17 18 activeStepIndex: number 18 19 ··· 30 31 feedUris: string[] 31 32 } 32 33 profileStepResults: { 34 + image?: { 35 + path: string 36 + mime: string 37 + size: number 38 + width: number 39 + height: number 40 + } 33 41 imageUri?: string 34 42 imageMime?: string 35 43 } ··· 64 72 } 65 73 | { 66 74 type: 'setProfileStepResults' 75 + image?: OnboardingState['profileStepResults']['image'] 67 76 imageUri: string 68 77 imageMime: string 69 78 } ··· 80 89 81 90 export const initialState: OnboardingState = { 82 91 hasPrev: false, 83 - totalSteps: 7, 92 + totalSteps: 8, 84 93 activeStep: 'interests', 85 94 activeStepIndex: 1, 86 95 ··· 102 111 feedUris: [], 103 112 }, 104 113 profileStepResults: { 114 + image: undefined, 105 115 imageUri: '', 106 116 imageMime: '', 107 117 }, ··· 168 178 next.activeStep = 'moderation' 169 179 next.activeStepIndex = 6 170 180 } else if (s.activeStep === 'moderation') { 181 + next.activeStep = 'profile' 182 + next.activeStepIndex = 7 183 + } else if (s.activeStep === 'profile') { 171 184 next.activeStep = 'finished' 172 - next.activeStepIndex = 7 185 + next.activeStepIndex = 8 173 186 } 174 187 break 175 188 } ··· 189 202 } else if (s.activeStep === 'moderation') { 190 203 next.activeStep = 'topicalFeeds' 191 204 next.activeStepIndex = 5 192 - } else if (s.activeStep === 'finished') { 205 + } else if (s.activeStep === 'profile') { 193 206 next.activeStep = 'moderation' 194 207 next.activeStepIndex = 6 208 + } else if (s.activeStep === 'finished') { 209 + next.activeStep = 'profile' 210 + next.activeStepIndex = 7 195 211 } 196 212 break 197 213 } ··· 226 242 } 227 243 break 228 244 } 245 + case 'setProfileStepResults': { 246 + next.profileStepResults = { 247 + image: a.image, 248 + imageUri: a.imageUri, 249 + imageMime: a.imageMime, 250 + } 251 + break 252 + } 229 253 } 230 254 231 255 const state = { ··· 243 267 suggestedAccountsStepResults: state.suggestedAccountsStepResults, 244 268 algoFeedsStepResults: state.algoFeedsStepResults, 245 269 topicalFeedsStepResults: state.topicalFeedsStepResults, 270 + profileStepResults: state.profileStepResults, 246 271 }) 247 272 248 273 if (s.activeStep !== state.activeStep) { ··· 276 301 feedUris: [], 277 302 }, 278 303 profileStepResults: { 304 + image: undefined, 279 305 imageUri: '', 280 306 imageMime: '', 281 307 }, ··· 330 356 } 331 357 case 'setProfileStepResults': { 332 358 next.profileStepResults = { 359 + image: a.image, 333 360 imageUri: a.imageUri, 334 361 imageMime: a.imageMime, 335 362 }
+41
yarn.lock
··· 9114 9114 resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" 9115 9115 integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== 9116 9116 9117 + base64-arraybuffer@^1.0.2: 9118 + version "1.0.2" 9119 + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" 9120 + integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== 9121 + 9117 9122 base64-js@^1.0.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: 9118 9123 version "1.5.1" 9119 9124 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" ··· 10238 10243 integrity sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A== 10239 10244 dependencies: 10240 10245 hyphenate-style-name "^1.0.3" 10246 + 10247 + css-line-break@^2.1.0: 10248 + version "2.1.0" 10249 + resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" 10250 + integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w== 10251 + dependencies: 10252 + utrie "^1.0.2" 10241 10253 10242 10254 css-loader@^6.5.1: 10243 10255 version "6.8.1" ··· 13232 13244 lodash "^4.17.21" 13233 13245 pretty-error "^4.0.0" 13234 13246 tapable "^2.0.0" 13247 + 13248 + html2canvas@^1.4.1: 13249 + version "1.4.1" 13250 + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" 13251 + integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA== 13252 + dependencies: 13253 + css-line-break "^2.1.0" 13254 + text-segmentation "^1.0.3" 13235 13255 13236 13256 htmlparser2@^6.1.0: 13237 13257 version "6.1.0" ··· 18827 18847 resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.1.tgz#ed4e2dfb1683eddb66967eb5dca140dfe1abddb9" 18828 18848 integrity sha512-cptnoIbL53GTCrWlb/+jrDC6tvb7ypIyzbXNJcpR3Vab0mkeaaVd5qnB3f0whXYzS+SMoSQLcUUB0gEWqkPC0g== 18829 18849 18850 + react-native-view-shot@^3.8.0: 18851 + version "3.8.0" 18852 + resolved "https://registry.yarnpkg.com/react-native-view-shot/-/react-native-view-shot-3.8.0.tgz#1aa1905f0e79428ca32bf80c16fd4abc719c600b" 18853 + integrity sha512-4cU8SOhMn3YQIrskh+5Q8VvVRxQOu8/s1M9NAL4z5BY1Rm0HXMWkQJ4N0XsZ42+Yca+y86ISF3LC5qdLPvPuiA== 18854 + dependencies: 18855 + html2canvas "^1.4.1" 18856 + 18830 18857 react-native-web-webview@^1.0.2: 18831 18858 version "1.0.2" 18832 18859 resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02" ··· 20831 20858 glob "^7.1.4" 20832 20859 minimatch "^3.0.4" 20833 20860 20861 + text-segmentation@^1.0.3: 20862 + version "1.0.3" 20863 + resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943" 20864 + integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw== 20865 + dependencies: 20866 + utrie "^1.0.2" 20867 + 20834 20868 text-table@^0.2.0: 20835 20869 version "0.2.0" 20836 20870 resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" ··· 21485 21519 version "1.0.1" 21486 21520 resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 21487 21521 integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 21522 + 21523 + utrie@^1.0.2: 21524 + version "1.0.2" 21525 + resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" 21526 + integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw== 21527 + dependencies: 21528 + base64-arraybuffer "^1.0.2" 21488 21529 21489 21530 uuid@^3.0.1, uuid@^3.3.2: 21490 21531 version "3.4.0"