From 087186e3867b0eefb11a056b0b644f5585fa16bd Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 16 Apr 2024 06:30:22 -0700 Subject: [PATCH] Onboarding avatar creator or upload (#2860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package.json | 1 + src/components/Dialog/index.tsx | 3 +- src/lib/analytics/types.ts | 2 + src/lib/media/avatar-generator.tsx | 0 src/screens/Onboarding/Layout.tsx | 3 +- .../Onboarding/StepAlgoFeeds/index.tsx | 5 +- src/screens/Onboarding/StepFinished.tsx | 26 +- src/screens/Onboarding/StepFollowingFeed.tsx | 5 +- .../Onboarding/StepInterests/index.tsx | 6 +- .../Onboarding/StepModeration/index.tsx | 5 +- .../Onboarding/StepProfile/AvatarCircle.tsx | 85 ++++++ .../StepProfile/AvatarCreatorCircle.tsx | 44 +++ .../StepProfile/AvatarCreatorItems.tsx | 175 ++++++++++++ .../StepProfile/PlaceholderCanvas.tsx | 67 +++++ src/screens/Onboarding/StepProfile/index.tsx | 262 ++++++++++++++++++ src/screens/Onboarding/StepProfile/types.ts | 149 ++++++++++ .../StepSuggestedAccounts/index.tsx | 4 +- src/screens/Onboarding/StepTopicalFeeds.tsx | 5 +- src/screens/Onboarding/index.tsx | 2 + src/screens/Onboarding/state.ts | 34 ++- yarn.lock | 41 +++ 21 files changed, 904 insertions(+), 20 deletions(-) create mode 100644 src/lib/media/avatar-generator.tsx create mode 100644 src/screens/Onboarding/StepProfile/AvatarCircle.tsx create mode 100644 src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx create mode 100644 src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx create mode 100644 src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx create mode 100644 src/screens/Onboarding/StepProfile/index.tsx create mode 100644 src/screens/Onboarding/StepProfile/types.ts diff --git a/package.json b/package.json index d088c90a3e..3d48b332d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 859e4965ce..987bfecce1 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -207,7 +207,8 @@ export function Inner({children, style}: DialogInnerProps) { return ( ) { contentContainerStyle={{borderWidth: 0}} // @ts-ignore web only --prf dataSet={{'stable-gutters': 1}}> - + ( PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean), @@ -97,7 +98,7 @@ export function StepAlgoFeeds() { }, [track]) return ( - + diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 56635c672d..d8b343e081 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -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 ( - + diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx index a1c7299f02..525af014c7 100644 --- a/src/screens/Onboarding/StepFollowingFeed.tsx +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -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 - + diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 6afc3c07ac..0f71ed7561 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -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( @@ -143,7 +143,9 @@ export function StepInterests() { ) return ( - + + diff --git a/src/screens/Onboarding/StepProfile/AvatarCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx new file mode 100644 index 0000000000..2c5b3a4c4d --- /dev/null +++ b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx @@ -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 ( + + {props.children} + + ) +} + +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 ( + + {avatar.useCreatedAvatar ? ( + + ) : avatar.image ? ( + + ) : ( + + + + )} + + + + + ) +} diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx new file mode 100644 index 0000000000..a81bef1d34 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx @@ -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 ( + + + + + + ) +} diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx new file mode 100644 index 0000000000..19cdd363d3 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx @@ -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 ( + + {children} + + ) +} + +function ColorItem({ + color, + setAvatar, +}: { + color: AvatarColor + setAvatar: React.Dispatch> +}) { + const onPress = React.useCallback(() => { + setAvatar(prev => ({ + ...prev, + backgroundColor: color, + })) + }, [color, setAvatar]) + + return ( + + + + ) +} + +function EmojiItem({ + emojiName, + avatar, + setAvatar, +}: { + emojiName: EmojiName + avatar: Avatar + setAvatar: React.Dispatch> +}) { + 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 ( + + + + + + ) +} + +export function AvatarCreatorItems({ + type, + avatar, + setAvatar, +}: { + type: 'emojis' | 'colors' + avatar: Avatar + setAvatar: React.Dispatch> +}) { + 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) => { + return + }, + [setAvatar], + ) + + const emojiRenderItem = React.useCallback( + ({item}: ListRenderItemInfo) => { + return ( + + ) + }, + [avatar, setAvatar], + ) + + return ( + + + // 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} + /> + + ) +} diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx new file mode 100644 index 0000000000..29ba39a0b4 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -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 +} + +// 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( + 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 ( + + + + + + + + ) + }, +) diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx new file mode 100644 index 0000000000..fdca3037c5 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -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> +} + +const AvatarContext = React.createContext({} 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({ + placeholder: emojiItems.at, + backgroundColor: randomColor, + useCreatedAvatar: false, + }) + + const canvasRef = React.useRef(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 ( + + + + + + Give your profile a face + + + + Help people know you're not a bot by uploading a picture or + creating an avatar. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepProfile/types.ts b/src/screens/Onboarding/StepProfile/types.ts new file mode 100644 index 0000000000..136834a1e6 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/types.ts @@ -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 = { + 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] diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index e9bc3f0fde..906c54379a 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -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 ( - + diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx index bfc9e91d1b..86ad779243 100644 --- a/src/screens/Onboarding/StepTopicalFeeds.tsx +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -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 ( - + diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index 9e5029e87a..32ad53497c 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -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' && } {state.activeStep === 'topicalFeeds' && } {state.activeStep === 'moderation' && } + {state.activeStep === 'profile' && } {state.activeStep === 'finished' && } diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index 969edbdd24..f813f14603 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -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) { diff --git a/yarn.lock b/yarn.lock index f9e8644cb1..ca97f1586f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"