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 "react-native-uitextview": "^1.1.6", 186 "react-native-url-polyfill": "^1.3.0", 187 "react-native-uuid": "^2.0.1", 188 "react-native-web": "~0.19.6", 189 "react-native-web-webview": "^1.0.2", 190 "react-native-webview": "13.6.4",
··· 185 "react-native-uitextview": "^1.1.6", 186 "react-native-url-polyfill": "^1.3.0", 187 "react-native-uuid": "^2.0.1", 188 + "react-native-view-shot": "^3.8.0", 189 "react-native-web": "~0.19.6", 190 "react-native-web-webview": "^1.0.2", 191 "react-native-webview": "13.6.4",
+2 -1
src/components/Dialog/index.tsx
··· 213 return ( 214 <BottomSheetView 215 style={[ 216 - a.p_xl, 217 { 218 paddingTop: 40, 219 borderTopLeftRadius: 40,
··· 213 return ( 214 <BottomSheetView 215 style={[ 216 + a.py_xl, 217 + a.px_xl, 218 { 219 paddingTop: 40, 220 borderTopLeftRadius: 40,
+2
src/lib/analytics/types.ts
··· 150 } 151 'OnboardingV2:StepModeration:Start': {} 152 'OnboardingV2:StepModeration:End': {} 153 'OnboardingV2:StepFinished:Start': {} 154 'OnboardingV2:StepFinished:End': {} 155 'OnboardingV2:Complete': {}
··· 150 } 151 'OnboardingV2:StepModeration:Start': {} 152 'OnboardingV2:StepModeration:End': {} 153 + 'OnboardingV2:StepProfile:Start': {} 154 + 'OnboardingV2:StepProfile:End': {} 155 'OnboardingV2:StepFinished:Start': {} 156 'OnboardingV2:StepFinished:End': {} 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 selectedFeedsLength: number 49 } 50 'onboarding:moderation:nextPressed': {} 51 'onboarding:finished:nextPressed': {} 52 'home:feedDisplayed': { 53 feedUrl: string
··· 48 selectedFeedsLength: number 49 } 50 'onboarding:moderation:nextPressed': {} 51 + 'onboarding:profile:nextPressed': {} 52 'onboarding:finished:nextPressed': {} 53 'home:feedDisplayed': { 54 feedUrl: string
+1 -1
src/screens/Onboarding/Layout.tsx
··· 23 import {leading, P, Text} from '#/components/Typography' 24 import {IS_DEV} from '#/env' 25 26 - const COL_WIDTH = 500 27 28 export const OnboardingControls = createPortalGroup() 29
··· 23 import {leading, P, Text} from '#/components/Typography' 24 import {IS_DEV} from '#/env' 25 26 + const COL_WIDTH = 420 27 28 export const OnboardingControls = createPortalGroup() 29
+23
src/screens/Onboarding/StepFinished.tsx
··· 12 import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 13 import {useAgent} from '#/state/session' 14 import {useOnboardingDispatch} from '#/state/shell' 15 import { 16 DescriptionText, 17 OnboardingControls, ··· 46 const finishOnboarding = React.useCallback(async () => { 47 setSaving(true) 48 49 const { 50 interestsStepResults, 51 suggestedAccountsStepResults, 52 algoFeedsStepResults, 53 topicalFeedsStepResults, 54 } = state 55 const {selectedInterests} = interestsStepResults 56 const selectedFeeds = [ ··· 110 } 111 })(), 112 ]) 113 } catch (e: any) { 114 logger.info(`onboarding: bulk save failed`) 115 logger.error(e)
··· 12 import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' 13 import {useAgent} from '#/state/session' 14 import {useOnboardingDispatch} from '#/state/shell' 15 + import {uploadBlob} from 'lib/api' 16 import { 17 DescriptionText, 18 OnboardingControls, ··· 47 const finishOnboarding = React.useCallback(async () => { 48 setSaving(true) 49 50 + // TODO uncomment 51 const { 52 interestsStepResults, 53 suggestedAccountsStepResults, 54 algoFeedsStepResults, 55 topicalFeedsStepResults, 56 + profileStepResults, 57 } = state 58 const {selectedInterests} = interestsStepResults 59 const selectedFeeds = [ ··· 113 } 114 })(), 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 + } 136 } catch (e: any) { 137 logger.info(`onboarding: bulk save failed`) 138 logger.error(e)
+1 -1
src/screens/Onboarding/StepInterests/index.tsx
··· 31 export function StepInterests() { 32 const {_} = useLingui() 33 const t = useTheme() 34 - const {track} = useAnalytics() 35 const {gtMobile} = useBreakpoints() 36 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 37 const [saving, setSaving] = React.useState(false) 38 const [interests, setInterests] = React.useState<string[]>(
··· 31 export function StepInterests() { 32 const {_} = useLingui() 33 const t = useTheme() 34 const {gtMobile} = useBreakpoints() 35 + const {track} = useAnalytics() 36 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 37 const [saving, setSaving] = React.useState(false) 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 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 { 7 DescriptionText, 8 OnboardingControls, 9 TitleText, 10 } from '#/screens/Onboarding/Layout' 11 import {Context} from '#/screens/Onboarding/state' 12 - import {atoms as a} from '#/alf' 13 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 import {IconCircle} from '#/components/IconCircle' 15 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 16 import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive' 17 18 export function StepProfile() { 19 const {_} = useLingui() 20 - const {dispatch} = React.useContext(Context) 21 22 - const onContinue = React.useCallback(() => { 23 dispatch({type: 'next'}) 24 - }, [dispatch]) 25 26 return ( 27 - <View style={[a.align_start]}> 28 - <IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> 29 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> 39 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> 54 ) 55 }
··· 1 import React from 'react' 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' 9 import {msg, Trans} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' 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' 19 import { 20 DescriptionText, 21 OnboardingControls, 22 TitleText, 23 } from '#/screens/Onboarding/Layout' 24 import {Context} from '#/screens/Onboarding/state' 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' 33 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 34 + import * as Dialog from '#/components/Dialog' 35 import {IconCircle} from '#/components/IconCircle' 36 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 37 + import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 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)] 65 66 export function StepProfile() { 67 const {_} = useLingui() 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 + ) 122 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 + 138 dispatch({type: 'next'}) 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 + ) 203 204 return ( 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> 274 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> 288 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> 318 ) 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 70 export function StepSuggestedAccounts() { 71 const {_} = useLingui() 72 const {track} = useAnalytics() 73 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 74 - const {gtMobile} = useBreakpoints() 75 const suggestedDids = React.useMemo(() => { 76 return aggregateInterestItems( 77 state.interestsStepResults.selectedInterests,
··· 69 70 export function StepSuggestedAccounts() { 71 const {_} = useLingui() 72 + const {gtMobile} = useBreakpoints() 73 const {track} = useAnalytics() 74 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 75 const suggestedDids = React.useMemo(() => { 76 return aggregateInterestItems( 77 state.interestsStepResults.selectedInterests,
+30 -3
src/screens/Onboarding/state.ts
··· 13 | 'algoFeeds' 14 | 'topicalFeeds' 15 | 'moderation' 16 | 'finished' 17 activeStepIndex: number 18 ··· 30 feedUris: string[] 31 } 32 profileStepResults: { 33 imageUri?: string 34 imageMime?: string 35 } ··· 64 } 65 | { 66 type: 'setProfileStepResults' 67 imageUri: string 68 imageMime: string 69 } ··· 80 81 export const initialState: OnboardingState = { 82 hasPrev: false, 83 - totalSteps: 7, 84 activeStep: 'interests', 85 activeStepIndex: 1, 86 ··· 102 feedUris: [], 103 }, 104 profileStepResults: { 105 imageUri: '', 106 imageMime: '', 107 }, ··· 168 next.activeStep = 'moderation' 169 next.activeStepIndex = 6 170 } else if (s.activeStep === 'moderation') { 171 next.activeStep = 'finished' 172 - next.activeStepIndex = 7 173 } 174 break 175 } ··· 189 } else if (s.activeStep === 'moderation') { 190 next.activeStep = 'topicalFeeds' 191 next.activeStepIndex = 5 192 - } else if (s.activeStep === 'finished') { 193 next.activeStep = 'moderation' 194 next.activeStepIndex = 6 195 } 196 break 197 } ··· 226 } 227 break 228 } 229 } 230 231 const state = { ··· 243 suggestedAccountsStepResults: state.suggestedAccountsStepResults, 244 algoFeedsStepResults: state.algoFeedsStepResults, 245 topicalFeedsStepResults: state.topicalFeedsStepResults, 246 }) 247 248 if (s.activeStep !== state.activeStep) { ··· 276 feedUris: [], 277 }, 278 profileStepResults: { 279 imageUri: '', 280 imageMime: '', 281 }, ··· 330 } 331 case 'setProfileStepResults': { 332 next.profileStepResults = { 333 imageUri: a.imageUri, 334 imageMime: a.imageMime, 335 }
··· 13 | 'algoFeeds' 14 | 'topicalFeeds' 15 | 'moderation' 16 + | 'profile' 17 | 'finished' 18 activeStepIndex: number 19 ··· 31 feedUris: string[] 32 } 33 profileStepResults: { 34 + image?: { 35 + path: string 36 + mime: string 37 + size: number 38 + width: number 39 + height: number 40 + } 41 imageUri?: string 42 imageMime?: string 43 } ··· 72 } 73 | { 74 type: 'setProfileStepResults' 75 + image?: OnboardingState['profileStepResults']['image'] 76 imageUri: string 77 imageMime: string 78 } ··· 89 90 export const initialState: OnboardingState = { 91 hasPrev: false, 92 + totalSteps: 8, 93 activeStep: 'interests', 94 activeStepIndex: 1, 95 ··· 111 feedUris: [], 112 }, 113 profileStepResults: { 114 + image: undefined, 115 imageUri: '', 116 imageMime: '', 117 }, ··· 178 next.activeStep = 'moderation' 179 next.activeStepIndex = 6 180 } else if (s.activeStep === 'moderation') { 181 + next.activeStep = 'profile' 182 + next.activeStepIndex = 7 183 + } else if (s.activeStep === 'profile') { 184 next.activeStep = 'finished' 185 + next.activeStepIndex = 8 186 } 187 break 188 } ··· 202 } else if (s.activeStep === 'moderation') { 203 next.activeStep = 'topicalFeeds' 204 next.activeStepIndex = 5 205 + } else if (s.activeStep === 'profile') { 206 next.activeStep = 'moderation' 207 next.activeStepIndex = 6 208 + } else if (s.activeStep === 'finished') { 209 + next.activeStep = 'profile' 210 + next.activeStepIndex = 7 211 } 212 break 213 } ··· 242 } 243 break 244 } 245 + case 'setProfileStepResults': { 246 + next.profileStepResults = { 247 + image: a.image, 248 + imageUri: a.imageUri, 249 + imageMime: a.imageMime, 250 + } 251 + break 252 + } 253 } 254 255 const state = { ··· 267 suggestedAccountsStepResults: state.suggestedAccountsStepResults, 268 algoFeedsStepResults: state.algoFeedsStepResults, 269 topicalFeedsStepResults: state.topicalFeedsStepResults, 270 + profileStepResults: state.profileStepResults, 271 }) 272 273 if (s.activeStep !== state.activeStep) { ··· 301 feedUris: [], 302 }, 303 profileStepResults: { 304 + image: undefined, 305 imageUri: '', 306 imageMime: '', 307 }, ··· 356 } 357 case 'setProfileStepResults': { 358 next.profileStepResults = { 359 + image: a.image, 360 imageUri: a.imageUri, 361 imageMime: a.imageMime, 362 }
+41
yarn.lock
··· 9114 resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" 9115 integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== 9116 9117 base64-js@^1.0.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: 9118 version "1.5.1" 9119 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" ··· 10238 integrity sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A== 10239 dependencies: 10240 hyphenate-style-name "^1.0.3" 10241 10242 css-loader@^6.5.1: 10243 version "6.8.1" ··· 13232 lodash "^4.17.21" 13233 pretty-error "^4.0.0" 13234 tapable "^2.0.0" 13235 13236 htmlparser2@^6.1.0: 13237 version "6.1.0" ··· 18827 resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.1.tgz#ed4e2dfb1683eddb66967eb5dca140dfe1abddb9" 18828 integrity sha512-cptnoIbL53GTCrWlb/+jrDC6tvb7ypIyzbXNJcpR3Vab0mkeaaVd5qnB3f0whXYzS+SMoSQLcUUB0gEWqkPC0g== 18829 18830 react-native-web-webview@^1.0.2: 18831 version "1.0.2" 18832 resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02" ··· 20831 glob "^7.1.4" 20832 minimatch "^3.0.4" 20833 20834 text-table@^0.2.0: 20835 version "0.2.0" 20836 resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" ··· 21485 version "1.0.1" 21486 resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 21487 integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 21488 21489 uuid@^3.0.1, uuid@^3.3.2: 21490 version "3.4.0"
··· 9114 resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" 9115 integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== 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 + 9122 base64-js@^1.0.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: 9123 version "1.5.1" 9124 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" ··· 10243 integrity sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A== 10244 dependencies: 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" 10253 10254 css-loader@^6.5.1: 10255 version "6.8.1" ··· 13244 lodash "^4.17.21" 13245 pretty-error "^4.0.0" 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" 13255 13256 htmlparser2@^6.1.0: 13257 version "6.1.0" ··· 18847 resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.1.tgz#ed4e2dfb1683eddb66967eb5dca140dfe1abddb9" 18848 integrity sha512-cptnoIbL53GTCrWlb/+jrDC6tvb7ypIyzbXNJcpR3Vab0mkeaaVd5qnB3f0whXYzS+SMoSQLcUUB0gEWqkPC0g== 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 + 18857 react-native-web-webview@^1.0.2: 18858 version "1.0.2" 18859 resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02" ··· 20858 glob "^7.1.4" 20859 minimatch "^3.0.4" 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 + 20868 text-table@^0.2.0: 20869 version "0.2.0" 20870 resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" ··· 21519 version "1.0.1" 21520 resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 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" 21529 21530 uuid@^3.0.1, uuid@^3.3.2: 21531 version "3.4.0"