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"