diff --git a/.eslintrc.js b/.eslintrc.js index 18802cdf18..6165517f77 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,10 +7,52 @@ module.exports = { 'prettier', ], parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint', 'detox', 'react', 'lingui'], + plugins: [ + '@typescript-eslint', + 'detox', + 'react', + 'lingui', + 'simple-import-sort', + ], rules: { 'react/no-unescaped-entities': 0, 'react-native/no-inline-styles': 0, + 'simple-import-sort/imports': [ + 'warn', + { + groups: [ + // Side effect imports. + ['^\\u0000'], + // Node.js builtins prefixed with `node:`. + ['^node:'], + // Packages. + // Things that start with a letter (or digit or underscore), or `@` followed by a letter. + // React/React Native priortized, followed by expo + // Followed by all packages excluding unprefixed relative ones + [ + '^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)', + '^(expo(.*)$)|^(expo$)', + '^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w', + ], + // Relative imports. + // Ideally, anything that starts with a dot or # + // due to unprefixed relative imports being used, we whitelist the relative paths we use + // (?:$|\\/) matches end of string or / + [ + '^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)', + '^(?:#\\/)?view(?:$|\\/)', + '^(?:#\\/)?screens(?:$|\\/)', + '^(?:#\\/)?alf(?:$|\\/)', + '^(?:#\\/)?components(?:$|\\/)', + '^#\\/', + '^\\.', + ], + // anything else - hopefully we don't have any of these + ['^'], + ], + }, + ], + 'simple-import-sort/exports': 'warn', }, ignorePatterns: [ '**/__mocks__/*.ts', @@ -31,4 +73,8 @@ module.exports = { settings: { componentWrapperFunctions: ['observer'], }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + }, } diff --git a/app.config.js b/app.config.js index dcbb0e4597..c151862ab0 100644 --- a/app.config.js +++ b/app.config.js @@ -161,6 +161,7 @@ module.exports = function (config) { './plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidStylesWindowBackgroundPlugin.js', + './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/shareExtension/withShareExtensions.js', ].filter(Boolean), extra: { diff --git a/assets/icons/calendar_stroke2_corner0_rounded.svg b/assets/icons/calendar_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..703f389dba --- /dev/null +++ b/assets/icons/calendar_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/envelope_stroke2_corner0_rounded.svg b/assets/icons/envelope_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..c3ab45980b --- /dev/null +++ b/assets/icons/envelope_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/lock_stroke2_corner0_rounded.svg b/assets/icons/lock_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..8b094ba5eb --- /dev/null +++ b/assets/icons/lock_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pencilLine_stroke2_corner0_rounded.svg b/assets/icons/pencilLine_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..c58bef9fa2 --- /dev/null +++ b/assets/icons/pencilLine_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/ticket_stroke2_corner0_rounded.svg b/assets/icons/ticket_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..a45a90ae5f --- /dev/null +++ b/assets/icons/ticket_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index e744d8cd91..9baf7df172 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.73.0", + "version": "1.74.0", "private": true, "engines": { "node": ">=18" @@ -49,6 +49,7 @@ "@atproto/api": "^0.12.2", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", + "@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1", "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.4.2", "@expo/webpack-config": "^19.0.0", @@ -56,7 +57,6 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.5.1", "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.3.1", @@ -93,6 +93,7 @@ "@tiptap/pm": "^2.0.0-beta.220", "@tiptap/react": "^2.0.0-beta.220", "@tiptap/suggestion": "^2.0.0-beta.220", + "@types/invariant": "^2.2.37", "@types/node": "^18.16.2", "@zxing/text-encoding": "^0.9.0", "array.prototype.findlast": "^1.2.3", @@ -237,6 +238,7 @@ "eslint-plugin-lingui": "^0.2.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-native-a11y": "^3.3.0", + "eslint-plugin-simple-import-sort": "^12.0.0", "html-webpack-plugin": "^5.5.0", "husky": "^8.0.3", "is-ci": "^3.0.1", diff --git a/plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js b/plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js new file mode 100644 index 0000000000..704ead054b --- /dev/null +++ b/plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js @@ -0,0 +1,28 @@ +const {withStringsXml, AndroidConfig} = require('@expo/config-plugins') + +module.exports = function withAndroidSplashScreenStatusBarTranslucentPlugin( + appConfig, +) { + return withStringsXml(appConfig, function (decoratedAppConfig) { + try { + decoratedAppConfig.modResults = AndroidConfig.Strings.setStringItem( + [ + { + _: 'true', + $: { + name: 'expo_splash_screen_status_bar_translucent', + translatable: 'false', + }, + }, + ], + decoratedAppConfig.modResults, + ) + } catch (e) { + console.error( + `withAndroidSplashScreenStatusBarTranslucentPlugin failed`, + e, + ) + } + return decoratedAppConfig + }) +} diff --git a/src/App.native.tsx b/src/App.native.tsx index e825ffa00a..d6e726a592 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -1,59 +1,56 @@ import 'react-native-url-polyfill/auto' import 'lib/sentry' // must be near top - -import React, {useState, useEffect} from 'react' -import {RootSiblingParent} from 'react-native-root-siblings' -import * as SplashScreen from 'expo-splash-screen' -import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' -import { - SafeAreaProvider, - initialWindowMetrics, -} from 'react-native-safe-area-context' - import 'view/icons' -import {ThemeProvider as Alf} from '#/alf' -import {useColorModeTheme} from '#/alf/util/useColorModeTheme' -import {init as initPersistedState} from '#/state/persisted' -import {listenSessionDropped} from './state/events' -import {ThemeProvider} from 'lib/ThemeContext' -import {s} from 'lib/styles' -import {Shell} from 'view/shell' -import * as notifications from 'lib/notifications/notifications' -import * as Toast from 'view/com/util/Toast' +import React, {useEffect, useState} from 'react' +import {GestureHandlerRootView} from 'react-native-gesture-handler' +import {RootSiblingParent} from 'react-native-root-siblings' +import { + initialWindowMetrics, + SafeAreaProvider, +} from 'react-native-safe-area-context' +import * as SplashScreen from 'expo-splash-screen' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client' + +import {Provider as StatsigProvider} from '#/lib/statsig/statsig' +import {init as initPersistedState} from '#/state/persisted' +import * as persisted from '#/state/persisted' +import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' +import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import * as notifications from 'lib/notifications/notifications' import { - queryClient, asyncStoragePersister, dehydrateOptions, + queryClient, } from 'lib/react-query' -import {TestCtrls} from 'view/com/testing/TestCtrls' -import {Provider as ShellStateProvider} from 'state/shell' -import {Provider as ModalStateProvider} from 'state/modals' +import {s} from 'lib/styles' +import {ThemeProvider} from 'lib/ThemeContext' import {Provider as DialogStateProvider} from 'state/dialogs' -import {Provider as LightboxStateProvider} from 'state/lightbox' -import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as InvitesStateProvider} from 'state/invites' +import {Provider as LightboxStateProvider} from 'state/lightbox' +import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as PrefsStateProvider} from 'state/preferences' -import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' -import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' -import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' -import I18nProvider from './locale/i18nProvider' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import { Provider as SessionProvider, useSession, useSessionApi, } from 'state/session' -import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' -import * as persisted from '#/state/persisted' -import {Splash} from '#/Splash' +import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' +import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' +import {TestCtrls} from 'view/com/testing/TestCtrls' +import * as Toast from 'view/com/util/Toast' +import {Shell} from 'view/shell' +import {ThemeProvider as Alf} from '#/alf' +import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {Provider as PortalProvider} from '#/components/Portal' -import {Provider as StatsigProvider} from '#/lib/statsig/statsig' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useIntentHandler} from 'lib/hooks/useIntentHandler' -import {StatusBar} from 'expo-status-bar' -import {isAndroid} from 'platform/detection' +import {Splash} from '#/Splash' +import I18nProvider from './locale/i18nProvider' +import {listenSessionDropped} from './state/events' SplashScreen.preventAutoHideAsync() @@ -77,7 +74,6 @@ function InnerApp() { return ( - {isAndroid && } () @@ -554,10 +554,14 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { ref={navigationRef} linking={LINKING} theme={theme} + onStateChange={() => { + logEvent('router:navigate', {}) + }} onReady={() => { attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() + logEvent('router:navigate', {}) }}> {children} @@ -693,11 +697,11 @@ function logModuleInitTime() { } export { - navigate, - resetToTab, - reset, - handleLink, - TabsNavigator, FlatNavigator, + handleLink, + navigate, + reset, + resetToTab, RoutesContainer, + TabsNavigator, } diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 0b473ba903..45ab72ca61 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,6 +1,7 @@ import {Platform} from 'react-native' -import {web, native} from '#/alf/util/platform' + import * as tokens from '#/alf/tokens' +import {native, web} from '#/alf/util/platform' export const atoms = { /* @@ -157,6 +158,12 @@ export const atoms = { align_end: { alignItems: 'flex-end', }, + align_baseline: { + alignItems: 'baseline', + }, + align_stretch: { + alignItems: 'stretch', + }, self_auto: { alignSelf: 'auto', }, @@ -247,10 +254,10 @@ export const atoms = { fontWeight: tokens.fontWeight.normal, }, font_semibold: { - fontWeight: '500', + fontWeight: tokens.fontWeight.semibold, }, font_bold: { - fontWeight: tokens.fontWeight.semibold, + fontWeight: tokens.fontWeight.bold, }, italic: { fontStyle: 'italic', @@ -300,6 +307,9 @@ export const atoms = { /* * Padding */ + p_0: { + padding: 0, + }, p_2xs: { padding: tokens.space._2xs, }, @@ -330,6 +340,10 @@ export const atoms = { p_5xl: { padding: tokens.space._5xl, }, + px_0: { + paddingLeft: 0, + paddingRight: 0, + }, px_2xs: { paddingLeft: tokens.space._2xs, paddingRight: tokens.space._2xs, @@ -370,6 +384,10 @@ export const atoms = { paddingLeft: tokens.space._5xl, paddingRight: tokens.space._5xl, }, + py_0: { + paddingTop: 0, + paddingBottom: 0, + }, py_2xs: { paddingTop: tokens.space._2xs, paddingBottom: tokens.space._2xs, @@ -410,6 +428,9 @@ export const atoms = { paddingTop: tokens.space._5xl, paddingBottom: tokens.space._5xl, }, + pt_0: { + paddingTop: 0, + }, pt_2xs: { paddingTop: tokens.space._2xs, }, @@ -440,6 +461,9 @@ export const atoms = { pt_5xl: { paddingTop: tokens.space._5xl, }, + pb_0: { + paddingBottom: 0, + }, pb_2xs: { paddingBottom: tokens.space._2xs, }, @@ -470,6 +494,9 @@ export const atoms = { pb_5xl: { paddingBottom: tokens.space._5xl, }, + pl_0: { + paddingLeft: 0, + }, pl_2xs: { paddingLeft: tokens.space._2xs, }, @@ -500,6 +527,9 @@ export const atoms = { pl_5xl: { paddingLeft: tokens.space._5xl, }, + pr_0: { + paddingRight: 0, + }, pr_2xs: { paddingRight: tokens.space._2xs, }, @@ -534,9 +564,8 @@ export const atoms = { /* * Margin */ - mx_auto: { - marginLeft: 'auto', - marginRight: 'auto', + m_0: { + margin: 0, }, m_2xs: { margin: tokens.space._2xs, @@ -568,6 +597,13 @@ export const atoms = { m_5xl: { margin: tokens.space._5xl, }, + m_auto: { + margin: 'auto', + }, + mx_0: { + marginLeft: 0, + marginRight: 0, + }, mx_2xs: { marginLeft: tokens.space._2xs, marginRight: tokens.space._2xs, @@ -608,6 +644,14 @@ export const atoms = { marginLeft: tokens.space._5xl, marginRight: tokens.space._5xl, }, + mx_auto: { + marginLeft: 'auto', + marginRight: 'auto', + }, + my_0: { + marginTop: 0, + marginBottom: 0, + }, my_2xs: { marginTop: tokens.space._2xs, marginBottom: tokens.space._2xs, @@ -648,6 +692,13 @@ export const atoms = { marginTop: tokens.space._5xl, marginBottom: tokens.space._5xl, }, + my_auto: { + marginTop: 'auto', + marginBottom: 'auto', + }, + mt_0: { + marginTop: 0, + }, mt_2xs: { marginTop: tokens.space._2xs, }, @@ -678,6 +729,12 @@ export const atoms = { mt_5xl: { marginTop: tokens.space._5xl, }, + mt_auto: { + marginTop: 'auto', + }, + mb_0: { + marginBottom: 0, + }, mb_2xs: { marginBottom: tokens.space._2xs, }, @@ -708,6 +765,12 @@ export const atoms = { mb_5xl: { marginBottom: tokens.space._5xl, }, + mb_auto: { + marginBottom: 'auto', + }, + ml_0: { + marginLeft: 0, + }, ml_2xs: { marginLeft: tokens.space._2xs, }, @@ -738,6 +801,12 @@ export const atoms = { ml_5xl: { marginLeft: tokens.space._5xl, }, + ml_auto: { + marginLeft: 'auto', + }, + mr_0: { + marginRight: 0, + }, mr_2xs: { marginRight: tokens.space._2xs, }, @@ -768,4 +837,7 @@ export const atoms = { mr_5xl: { marginRight: tokens.space._5xl, }, + mr_auto: { + marginRight: 'auto', + }, } as const diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 4045c831c5..1bddd95d43 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -1,8 +1,8 @@ import { BLUE_HUE, - RED_HUE, - GREEN_HUE, generateScale, + GREEN_HUE, + RED_HUE, } from '#/alf/util/colorGeneration' export const scale = generateScale(6, 100) @@ -116,8 +116,8 @@ export const borderRadius = { export const fontWeight = { normal: '400', - semibold: '600', - bold: '900', + semibold: '500', + bold: '600', } as const export const gradients = { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index a85a1c4fd4..07e101f85c 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,31 +1,31 @@ import React, {useImperativeHandle} from 'react' -import {View, Dimensions, Keyboard, Pressable} from 'react-native' +import {Dimensions, Pressable, View} from 'react-native' +import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import BottomSheet, { BottomSheetBackdropProps, BottomSheetScrollView, + BottomSheetScrollViewMethods, BottomSheetTextInput, BottomSheetView, useBottomSheet, WINDOW_HEIGHT, -} from '@gorhom/bottom-sheet' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import Animated, {useAnimatedStyle} from 'react-native-reanimated' +} from '@discord/bottom-sheet/src' -import {useTheme, atoms as a, flatten} from '#/alf' -import {Portal} from '#/components/Portal' -import {createInput} from '#/components/forms/TextField' import {logger} from '#/logger' import {useDialogStateControlContext} from '#/state/dialogs' - +import {isNative} from 'platform/detection' +import {atoms as a, flatten, useTheme} from '#/alf' +import {Context} from '#/components/Dialog/context' import { - DialogOuterProps, DialogControlProps, DialogInnerProps, + DialogOuterProps, } from '#/components/Dialog/types' -import {Context} from '#/components/Dialog/context' -import {isNative} from 'platform/detection' +import {createInput} from '#/components/forms/TextField' +import {Portal} from '#/components/Portal' -export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' // @ts-ignore export const Input = createInput(BottomSheetTextInput) @@ -122,7 +122,6 @@ export function Outer({ ) const onCloseInner = React.useCallback(() => { - Keyboard.dismiss() try { closeCallback.current?.() } catch (e: any) { @@ -206,16 +205,14 @@ export function Inner({children, style}: DialogInnerProps) { ) } -export function ScrollableInner({ - children, - keyboardDismissMode, - style, -}: DialogInnerProps) { +export const ScrollableInner = React.forwardRef< + BottomSheetScrollViewMethods, + DialogInnerProps +>(function ScrollableInner({children, style}, ref) { const insets = useSafeAreaInsets() return ( + contentContainerStyle={isNative ? a.pb_4xl : undefined} + ref={ref}> {children} ) -} +}) export function Handle() { const t = useTheme() - const onTouchStart = React.useCallback(() => { - Keyboard.dismiss() - }, []) - return ( - + (null) + useOnKeyboardDidShow(() => { + ref.current?.scrollToEnd({animated: true}) + }) + return ( - + {isLoading ? ( @@ -57,8 +62,6 @@ function ReportDialogInner(props: ReportDialogProps) { ) : ( )} - - ) } diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 451810a5ea..700d15e6d6 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -1,19 +1,11 @@ import React from 'react' -import {View, Pressable} from 'react-native' - -import {useTheme, atoms} from '#/alf' -import {Text} from '#/components/Typography' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import * as TextField from '#/components/forms/TextField' -import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' - -import {DateFieldProps} from '#/components/forms/DateField/types' -import { - localizeDate, - toSimpleDateString, -} from '#/components/forms/DateField/utils' import DatePicker from 'react-native-date-picker' -import {isAndroid} from 'platform/detection' + +import {useTheme} from '#/alf' +import {DateFieldProps} from '#/components/forms/DateField/types' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {DateFieldButton} from './index.shared' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -24,18 +16,10 @@ export function DateField({ label, isInvalid, testID, + accessibilityHint, }: DateFieldProps) { const t = useTheme() const [open, setOpen] = React.useState(false) - const { - state: pressed, - onIn: onPressIn, - onOut: onPressOut, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - - const {chromeFocus, chromeError, chromeErrorHover} = - TextField.useSharedInputStyles() const onChangeInternal = React.useCallback( (date: Date) => { @@ -47,50 +31,29 @@ export function DateField({ [onChangeDate, setOpen], ) + const onPress = React.useCallback(() => { + setOpen(true) + }, []) + const onCancel = React.useCallback(() => { setOpen(false) }, []) return ( - - setOpen(true)} - onPressIn={onPressIn} - onPressOut={onPressOut} - onFocus={onFocus} - onBlur={onBlur} - style={[ - { - paddingTop: 16, - paddingBottom: 16, - borderColor: 'transparent', - borderWidth: 2, - }, - atoms.flex_row, - atoms.flex_1, - atoms.w_full, - atoms.px_lg, - atoms.rounded_sm, - t.atoms.bg_contrast_50, - focused || pressed ? chromeFocus : {}, - isInvalid ? chromeError : {}, - isInvalid && (focused || pressed) ? chromeErrorHover : {}, - ]}> - - - - {localizeDate(value)} - - + <> + {open && ( )} - + ) } diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx new file mode 100644 index 0000000000..1f54bdc8be --- /dev/null +++ b/src/components/forms/DateField/index.shared.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import {Pressable, View} from 'react-native' + +import {android, atoms as a, useTheme, web} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' +import {Text} from '#/components/Typography' +import {localizeDate} from './utils' + +// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press +// iOS: open a dialog with an inline date picker +// Android: open the date picker modal + +export function DateFieldButton({ + label, + value, + onPress, + isInvalid, + accessibilityHint, +}: { + label: string + value: string + onPress: () => void + isInvalid?: boolean + accessibilityHint?: string +}) { + const t = useTheme() + + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = + TextField.useSharedInputStyles() + + return ( + + + + + {localizeDate(value)} + + + + ) +} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index 49e47a01e3..5662bb5941 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -1,11 +1,16 @@ import React from 'react' import {View} from 'react-native' - -import {useTheme, atoms} from '#/alf' -import * as TextField from '#/components/forms/TextField' -import {toSimpleDateString} from '#/components/forms/DateField/utils' -import {DateFieldProps} from '#/components/forms/DateField/types' import DatePicker from 'react-native-date-picker' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {DateFieldProps} from '#/components/forms/DateField/types' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {DateFieldButton} from './index.shared' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -22,8 +27,12 @@ export function DateField({ onChangeDate, testID, label, + isInvalid, + accessibilityHint, }: DateFieldProps) { + const {_} = useLingui() const t = useTheme() + const control = Dialog.useDialogControl() const onChangeInternal = React.useCallback( (date: Date | undefined) => { @@ -36,17 +45,44 @@ export function DateField({ ) return ( - - + - + + + + + + + + + + + + ) } diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx index 32f38a5d16..982d32711a 100644 --- a/src/components/forms/DateField/index.web.tsx +++ b/src/components/forms/DateField/index.web.tsx @@ -1,11 +1,12 @@ import React from 'react' -import {TextInput, TextInputProps, StyleSheet} from 'react-native' +import {StyleSheet, TextInput, TextInputProps} from 'react-native' // @ts-ignore import {unstable_createElement} from 'react-native-web' -import * as TextField from '#/components/forms/TextField' -import {toSimpleDateString} from '#/components/forms/DateField/utils' import {DateFieldProps} from '#/components/forms/DateField/types' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import * as TextField from '#/components/forms/TextField' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' export * as utils from '#/components/forms/DateField/utils' export const Label = TextField.Label @@ -37,6 +38,7 @@ export function DateField({ label, isInvalid, testID, + accessibilityHint, }: DateFieldProps) { const handleOnChange = React.useCallback( (e: any) => { @@ -52,12 +54,14 @@ export function DateField({ return ( + {}} testID={testID} + accessibilityHint={accessibilityHint} /> ) diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts index 129f5672d4..5400cf9037 100644 --- a/src/components/forms/DateField/types.ts +++ b/src/components/forms/DateField/types.ts @@ -4,4 +4,5 @@ export type DateFieldProps = { label: string isInvalid?: boolean testID?: string + accessibilityHint?: string } diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx new file mode 100644 index 0000000000..9e72df8790 --- /dev/null +++ b/src/components/forms/FormError.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Text} from '#/components/Typography' + +export function FormError({error}: {error?: string}) { + const t = useTheme() + + if (!error) return null + + return ( + + + + + {error} + + + + ) +} diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx new file mode 100644 index 0000000000..f2d11062a5 --- /dev/null +++ b/src/components/forms/HostingProvider.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {isAndroid} from '#/platform/detection' +import {ServerInputDialog} from '#/view/com/auth/server-input' +import {atoms as a, useTheme} from '#/alf' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Button} from '../Button' +import {useDialogControl} from '../Dialog' +import {Text} from '../Typography' + +export function HostingProvider({ + serviceUrl, + onSelectServiceUrl, + onOpenDialog, +}: { + serviceUrl: string + onSelectServiceUrl: (provider: string) => void + onOpenDialog?: () => void +}) { + const serverInputControl = useDialogControl() + const t = useTheme() + const {_} = useLingui() + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + serverInputControl.open() + if (onOpenDialog) { + onOpenDialog() + } + }, [onOpenDialog, serverInputControl]) + + return ( + <> + + + + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index b37f4bfae9..0bdeca6458 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -1,19 +1,20 @@ import React from 'react' import { - View, + AccessibilityProps, + StyleSheet, TextInput, TextInputProps, TextStyle, + View, ViewStyle, - StyleSheet, - AccessibilityProps, } from 'react-native' +import {mergeRefs} from '#/lib/merge-refs' import {HITSLOP_20} from 'lib/constants' -import {useTheme, atoms as a, web, android} from '#/alf' -import {Text} from '#/components/Typography' +import {android, atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' +import {Text} from '#/components/Typography' const Context = React.createContext<{ inputRef: React.RefObject | null @@ -125,9 +126,10 @@ export function useSharedInputStyles() { export type InputProps = Omit & { label: string - value: string - onChangeText: (value: string) => void + value?: string + onChangeText?: (value: string) => void isInvalid?: boolean + inputRef?: React.RefObject } export function createInput(Component: typeof TextInput) { @@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) { value, onChangeText, isInvalid, + inputRef, ...rest }: InputProps) { const t = useTheme() @@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) { ) } + const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) + return ( <> }) { unknown) { + React.useEffect(() => { + const subscription = Keyboard.addListener('keyboardDidShow', cb) + + return () => { + subscription.remove() + } + }, [cb]) +} diff --git a/src/components/icons/Calendar.tsx b/src/components/icons/Calendar.tsx new file mode 100644 index 0000000000..b3816f28b5 --- /dev/null +++ b/src/components/icons/Calendar.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z', +}) diff --git a/src/components/icons/Envelope.tsx b/src/components/icons/Envelope.tsx new file mode 100644 index 0000000000..8e40346cdc --- /dev/null +++ b/src/components/icons/Envelope.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z', +}) diff --git a/src/components/icons/Lock.tsx b/src/components/icons/Lock.tsx new file mode 100644 index 0000000000..87830b3794 --- /dev/null +++ b/src/components/icons/Lock.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Pencil.tsx b/src/components/icons/Pencil.tsx new file mode 100644 index 0000000000..854d51a3b2 --- /dev/null +++ b/src/components/icons/Pencil.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/Ticket.tsx b/src/components/icons/Ticket.tsx new file mode 100644 index 0000000000..1a8059c2a1 --- /dev/null +++ b/src/components/icons/Ticket.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z', +}) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index b830959767..2de15b64ee 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -1,4 +1,5 @@ export type LogEvents = { + // App events init: { initMs: number } @@ -14,6 +15,35 @@ export type LogEvents = { secondsActive: number } 'state:foreground': {} + 'router:navigate': {} + + // Screen events + 'splash:signInPressed': {} + 'splash:createAccountPressed': {} + 'signup:nextPressed': { + activeStep: number + } + 'onboarding:interests:nextPressed': { + selectedInterests: string[] + selectedInterestsLength: number + } + 'onboarding:suggestedAccounts:nextPressed': { + selectedAccountsLength: number + skipped: boolean + } + 'onboarding:followingFeed:nextPressed': {} + 'onboarding:algoFeeds:nextPressed': { + selectedPrimaryFeeds: string[] + selectedPrimaryFeedsLength: number + selectedSecondaryFeeds: string[] + selectedSecondaryFeedsLength: number + } + 'onboarding:topicalFeeds:nextPressed': { + selectedFeeds: string[] + selectedFeedsLength: number + } + 'onboarding:moderation:nextPressed': {} + 'onboarding:finished:nextPressed': {} 'feed:endReached': { feedType: string itemCount: number @@ -22,6 +52,10 @@ export type LogEvents = { feedType: string reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' } + + // Data events + 'account:create:begin': {} + 'account:create:success': {} 'post:create': { imageCount: number isReply: boolean diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 9fa6cce2d9..a9d69fda7b 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -1,13 +1,14 @@ import React from 'react' import {Platform} from 'react-native' +import {AppState, AppStateStatus} from 'react-native' +import {sha256} from 'js-sha256' import { Statsig, StatsigProvider, useGate as useStatsigGate, } from 'statsig-react-native-expo' -import {AppState, AppStateStatus} from 'react-native' + import {useSession} from '../../state/session' -import {sha256} from 'js-sha256' import {LogEvents} from './events' export type {LogEvents} @@ -24,7 +25,13 @@ const statsigOptions = { type FlatJSONRecord = Record< string, - string | number | boolean | null | undefined + | string + | number + | boolean + | null + | undefined + // Technically not scalar but Statsig will stringify it which works for us: + | string[] > let getCurrentRouteName: () => string | null | undefined = () => null @@ -39,11 +46,21 @@ export function logEvent( eventName: E & string, rawMetadata: LogEvents[E] & FlatJSONRecord, ) { - const fullMetadata = { - ...rawMetadata, - } as Record // Statsig typings are unnecessarily strict here. - fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' - Statsig.logEvent(eventName, null, fullMetadata) + try { + const fullMetadata = { + ...rawMetadata, + } as Record // Statsig typings are unnecessarily strict here. + fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' + if (Statsig.initializeCalled()) { + Statsig.logEvent(eventName, null, fullMetadata) + } + } catch (e) { + // A log should never interrupt the calling code, whatever happens. + // Rethrow on a clean stack. + setTimeout(() => { + throw e + }) + } } export function useGate(gateName: string) { diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index a18fef453e..bc07b32ec6 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -27,6 +27,7 @@ export function sanitizeHandle(handle: string, prefix = ''): string { export interface IsValidHandle { handleChars: boolean + hyphenStartOrEnd: boolean frontLength: boolean totalLength: boolean overall: boolean @@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle { const results = { handleChars: !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), + hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), frontLength: str.length >= 3, totalLength: fullHandle.length <= 253, } diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx new file mode 100644 index 0000000000..d0d4c784d0 --- /dev/null +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {logEvent} from '#/lib/statsig/statsig' +import {colors} from '#/lib/styles' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import * as TextField from '#/components/forms/TextField' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(_(msg`Already signed in as @${account.handle}`)) + } else { + await initSession(account) + logEvent('account:loggedIn', { + logContext: 'ChooseAccountForm', + withPassword: false, + }) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(_(msg`Signed in as @${account.handle}`)) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], + ) + + return ( + Select account}> + + + Sign in as... + + + {accounts.map(account => ( + + + + + ))} + + + + + + + + + ) +} diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx new file mode 100644 index 0000000000..580452e75b --- /dev/null +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -0,0 +1,184 @@ +import React, {useEffect, useState} from 'react' +import {ActivityIndicator, Keyboard, View} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as EmailValidator from 'email-validator' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {isNetworkError} from '#/lib/strings/errors' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {FormError} from '#/components/forms/FormError' +import {HostingProvider} from '#/components/forms/HostingProvider' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const ForgotPasswordForm = ({ + error, + serviceUrl, + serviceDescription, + setError, + setServiceUrl, + onPressBack, + onEmailSent, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressBack: () => void + onEmailSent: () => void +}) => { + const t = useTheme() + const [isProcessing, setIsProcessing] = useState(false) + const [email, setEmail] = useState('') + const {screen} = useAnalytics() + const {_} = useLingui() + + useEffect(() => { + screen('Signin:ForgotPassword') + }, [screen]) + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + }, []) + + const onPressNext = async () => { + if (!EmailValidator.validate(email)) { + return setError(_(msg`Your email appears to be invalid.`)) + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) + onEmailSent() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + Reset password}> + + + Hosting provider + + + + + + Email address + + + + + + + + + + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + + + + + + + + + {!serviceDescription || isProcessing ? ( + + ) : ( + + )} + {!serviceDescription || isProcessing ? ( + + Processing... + + ) : undefined} + + + + + + ) +} diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx new file mode 100644 index 0000000000..e28b48b402 --- /dev/null +++ b/src/screens/Login/FormContainer.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { + ScrollView, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from 'react-native' + +import {isWeb} from '#/platform/detection' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function FormContainer({ + testID, + title, + children, + style, + contentContainerStyle, +}: { + testID?: string + title?: React.ReactNode + children: React.ReactNode + style?: StyleProp + contentContainerStyle?: StyleProp +}) { + const {gtMobile} = useBreakpoints() + const t = useTheme() + return ( + + + {title && !gtMobile && ( + + {title} + + )} + {children} + + + ) +} + +const styles = StyleSheet.create({ + maxHeight: { + // @ts-ignore web only -prf + maxHeight: isWeb ? '100vh' : undefined, + height: !isWeb ? '100%' : undefined, + }, +}) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx new file mode 100644 index 0000000000..6bf215ee56 --- /dev/null +++ b/src/screens/Login/LoginForm.tsx @@ -0,0 +1,266 @@ +import React, {useRef, useState} from 'react' +import { + ActivityIndicator, + Keyboard, + LayoutAnimation, + TextInput, + View, +} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {isNetworkError} from '#/lib/strings/errors' +import {cleanError} from '#/lib/strings/errors' +import {createFullHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {useSessionApi} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {FormError} from '#/components/forms/FormError' +import {HostingProvider} from '#/components/forms/HostingProvider' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void +}) => { + const {track} = useAnalytics() + const t = useTheme() + const [isProcessing, setIsProcessing] = useState(false) + const [identifier, setIdentifier] = useState(initialHandle) + const [password, setPassword] = useState('') + const passwordInputRef = useRef(null) + const {_} = useLingui() + const {login} = useSessionApi() + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + track('Signin:PressedSelectService') + }, [track]) + + const onPressNext = async () => { + if (isProcessing) return + Keyboard.dismiss() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setError('') + setIsProcessing(true) + + try { + // try to guess the handle if the user just gave their own username + let fullIdent = identifier + if ( + !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + // TODO remove double login + await login( + { + service: serviceUrl, + identifier: fullIdent, + password, + }, + 'LoginForm', + ) + } catch (e: any) { + const errMsg = e.toString() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + logger.debug('Failed to login due to invalid credentials', { + error: errMsg, + }) + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + logger.warn('Failed to login due to network error', {error: errMsg}) + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + logger.warn('Failed to login', {error: errMsg}) + setError(cleanError(errMsg)) + } + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + Sign in}> + + + Hosting provider + + + + + + Account + + + + + { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityHint={_( + msg`Input the username or email address you used at signup`, + )} + /> + + + + + + + + + + + + + + {!serviceDescription && error ? ( + + ) : !serviceDescription ? ( + <> + + + Connecting... + + + ) : isReady ? ( + + ) : undefined} + + + ) +} diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx new file mode 100644 index 0000000000..5407f3f1e3 --- /dev/null +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -0,0 +1,50 @@ +import React, {useEffect} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +export const PasswordUpdatedForm = ({ + onPressNext, +}: { + onPressNext: () => void +}) => { + const {screen} = useAnalytics() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + useEffect(() => { + screen('Signin:PasswordUpdatedForm') + }, [screen]) + + return ( + + + Password updated! + + + You can now sign in with your new password. + + + + + + ) +} diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx new file mode 100644 index 0000000000..ab0a223678 --- /dev/null +++ b/src/screens/Login/ScreenTransition.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' + +export function ScreenTransition({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx new file mode 100644 index 0000000000..4583720aa8 --- /dev/null +++ b/src/screens/Login/ScreenTransition.web.tsx @@ -0,0 +1 @@ +export {Fragment as ScreenTransition} from 'react' diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx new file mode 100644 index 0000000000..e7b4886550 --- /dev/null +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -0,0 +1,192 @@ +import React, {useEffect, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {BskyAgent} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {isNetworkError} from '#/lib/strings/errors' +import {cleanError} from '#/lib/strings/errors' +import {checkAndFormatResetCode} from '#/lib/strings/password' +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {FormError} from '#/components/forms/FormError' +import * as TextField from '#/components/forms/TextField' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' + +export const SetNewPasswordForm = ({ + error, + serviceUrl, + setError, + onPressBack, + onPasswordSet, +}: { + error: string + serviceUrl: string + setError: (v: string) => void + onPressBack: () => void + onPasswordSet: () => void +}) => { + const {screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) + + const [isProcessing, setIsProcessing] = useState(false) + const [resetCode, setResetCode] = useState('') + const [password, setPassword] = useState('') + + const onPressNext = async () => { + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we + // don't get to call onBlur first + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !password) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password, + }) + onPasswordSet() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + + return ( + Set new password}> + + + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + + + + + Reset code + + + setError('')} + onBlur={onBlur} + editable={!isProcessing} + accessibilityHint={_( + msg`Input code sent to your email for password reset`, + )} + /> + + + + + New password + + + + + + + + + + + + {isProcessing ? ( + + ) : ( + + )} + {isProcessing ? ( + + Updating... + + ) : undefined} + + + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx new file mode 100644 index 0000000000..49f7518b0d --- /dev/null +++ b/src/screens/Login/index.tsx @@ -0,0 +1,174 @@ +import React from 'react' +import {KeyboardAvoidingView} from 'react-native' +import {LayoutAnimationConfig} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {DEFAULT_SERVICE} from '#/lib/constants' +import {logger} from '#/logger' +import {useServiceQuery} from '#/state/queries/service' +import {SessionAccount, useSession} from '#/state/session' +import {useLoggedOutView} from '#/state/shell/logged-out' +import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' +import {LoginForm} from '#/screens/Login/LoginForm' +import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm' +import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' +import {atoms as a} from '#/alf' +import {ChooseAccountForm} from './ChooseAccountForm' +import {ScreenTransition} from './ScreenTransition' + +enum Forms { + Login, + ChooseAccount, + ForgotPassword, + SetNewPassword, + PasswordUpdated, +} + +export const Login = ({onPressBack}: {onPressBack: () => void}) => { + const {_} = useLingui() + + const {accounts} = useSession() + const {track} = useAnalytics() + const {requestedAccountSwitchTo} = useLoggedOutView() + const requestedAccount = accounts.find( + acc => acc.did === requestedAccountSwitchTo, + ) + + const [error, setError] = React.useState('') + const [serviceUrl, setServiceUrl] = React.useState( + requestedAccount?.service || DEFAULT_SERVICE, + ) + const [initialHandle, setInitialHandle] = React.useState( + requestedAccount?.handle || '', + ) + const [currentForm, setCurrentForm] = React.useState( + requestedAccount + ? Forms.Login + : accounts.length + ? Forms.ChooseAccount + : Forms.Login, + ) + + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) + + const onSelectAccount = (account?: SessionAccount) => { + if (account?.service) { + setServiceUrl(account.service) + } + setInitialHandle(account?.handle || '') + setCurrentForm(Forms.Login) + } + + const gotoForm = (form: Forms) => { + setError('') + setCurrentForm(form) + } + + React.useEffect(() => { + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') + } + }, [serviceError, serviceUrl, _]) + + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } + + let content = null + let title = '' + let description = '' + + switch (currentForm) { + case Forms.Login: + title = _(msg`Sign in`) + description = _(msg`Enter your username and password`) + content = ( + + accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack() + } + onPressForgotPassword={onPressForgotPassword} + onPressRetryConnect={refetchService} + /> + ) + break + case Forms.ChooseAccount: + title = _(msg`Sign in`) + description = _(msg`Select from an existing account`) + content = ( + + ) + break + case Forms.ForgotPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + gotoForm(Forms.Login)} + onEmailSent={() => gotoForm(Forms.SetNewPassword)} + /> + ) + break + case Forms.SetNewPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + gotoForm(Forms.ForgotPassword)} + onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} + /> + ) + break + case Forms.PasswordUpdated: + title = _(msg`Password updated`) + description = _(msg`You can now sign in with your new password.`) + content = ( + gotoForm(Forms.Login)} /> + ) + break + } + + return ( + + + + {content} + + + + ) +} diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx index 1a4e4c493b..35f525ef28 100644 --- a/src/screens/Onboarding/StepAlgoFeeds/index.tsx +++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx @@ -1,26 +1,26 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {IS_PROD} from '#/env' -import {atoms as a, tokens, useTheme} from '#/alf' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Toggle from '#/components/forms/Toggle' -import {Text} from '#/components/Typography' -import {Loader} from '#/components/Loader' -import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {useAnalytics} from '#/lib/analytics/analytics' - -import {Context} from '#/screens/Onboarding/state' +import {logEvent} from '#/lib/statsig/statsig' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' +import {atoms as a, tokens, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' import {IconCircle} from '#/components/IconCircle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {IS_PROD} from '#/env' export type FeedConfig = { default: boolean @@ -89,6 +89,12 @@ export function StepAlgoFeeds() { selectedSecondaryFeeds: secondaryFeedUris, selectedSecondaryFeedsLength: secondaryFeedUris.length, }) + logEvent('onboarding:algoFeeds:nextPressed', { + selectedPrimaryFeeds: primaryFeedUris, + selectedPrimaryFeedsLength: primaryFeedUris.length, + selectedSecondaryFeeds: secondaryFeedUris, + selectedSecondaryFeedsLength: secondaryFeedUris.length, + }) }, [primaryFeedUris, secondaryFeedUris, dispatch, track]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 944dcb96d2..0c81d2d252 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,33 +1,33 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useAnalytics} from '#/lib/analytics/analytics' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText, ButtonIcon} from '#/components/Button' -import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' -import {Text} from '#/components/Typography' -import {useOnboardingDispatch} from '#/state/shell' -import {Loader} from '#/components/Loader' import {useSetSaveFeedsMutation} from '#/state/queries/preferences' import {getAgent} from '#/state/session' -import {useAnalytics} from '#/lib/analytics/analytics' - -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingDispatch} from '#/state/shell' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' -import {IconCircle} from '#/components/IconCircle' +import {Context} from '#/screens/Onboarding/state' import { bulkWriteFollows, sortPrimaryAlgorithmFeeds, } from '#/screens/Onboarding/util' +import {atoms as a, useTheme} 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' +import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' +import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' export function StepFinished() { const {_} = useLingui() @@ -76,6 +76,7 @@ export function StepFinished() { onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') track('OnboardingV2:Complete') + logEvent('onboarding:finished:nextPressed', {}) }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx index 898afad134..e886a08911 100644 --- a/src/screens/Onboarding/StepFollowingFeed.tsx +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -1,28 +1,28 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Text} from '#/components/Typography' -import {Divider} from '#/components/Divider' -import * as Toggle from '#/components/forms/Toggle' import {useAnalytics} from '#/lib/analytics/analytics' - -import {Context} from '#/screens/Onboarding/state' -import { - Title, - Description, - OnboardingControls, -} from '#/screens/Onboarding/Layout' +import {logEvent} from '#/lib/statsig/statsig' import { usePreferencesQuery, useSetFeedViewPreferencesMutation, } from 'state/queries/preferences' +import { + Description, + OnboardingControls, + Title, +} from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' import {IconCircle} from '#/components/IconCircle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' +import {Text} from '#/components/Typography' export function StepFollowingFeed() { const {_} = useLingui() @@ -46,6 +46,7 @@ export function StepFollowingFeed() { const onContinue = React.useCallback(() => { dispatch({type: 'next'}) track('OnboardingV2:StepFollowingFeed:End') + logEvent('onboarding:followingFeed:nextPressed', {}) }, [track, dispatch]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index ea23b74f09..8f34cced93 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -1,32 +1,32 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useQuery} from '@tanstack/react-query' -import {logger} from '#/logger' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' -import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' -import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Loader} from '#/components/Loader' -import * as Toggle from '#/components/forms/Toggle' -import {getAgent} from '#/state/session' import {useAnalytics} from '#/lib/analytics/analytics' -import {Text} from '#/components/Typography' -import {useOnboardingDispatch} from '#/state/shell' +import {logEvent} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' - -import {Context, ApiResponseMap} from '#/screens/Onboarding/state' +import {logger} from '#/logger' +import {getAgent} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {ApiResponseMap, Context} from '#/screens/Onboarding/state' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' import {IconCircle} from '#/components/IconCircle' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' export function StepInterests() { const {_} = useLingui() @@ -107,6 +107,10 @@ export function StepInterests() { selectedInterests: interests, selectedInterestsLength: interests.length, }) + logEvent('onboarding:interests:nextPressed', { + selectedInterests: interests, + selectedInterestsLength: interests.length, + }) } catch (e: any) { logger.info(`onboading: error saving interests`) logger.error(e) diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx index 9b52f9f439..c5bdf56224 100644 --- a/src/screens/Onboarding/StepModeration/index.tsx +++ b/src/screens/Onboarding/StepModeration/index.tsx @@ -1,27 +1,27 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' -import {msg, Trans} from '@lingui/macro' import {LABELS} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' -import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {Loader} from '#/components/Loader' import {useAnalytics} from '#/lib/analytics/analytics' - +import {logEvent} from '#/lib/statsig/statsig' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences' import { Description, OnboardingControls, Title, } from '#/screens/Onboarding/Layout' -import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' -import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' import {Context} from '#/screens/Onboarding/state' +import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' +import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {IconCircle} from '#/components/IconCircle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Loader} from '#/components/Loader' export function StepModeration() { const {_} = useLingui() @@ -45,6 +45,7 @@ export function StepModeration() { const onContinue = React.useCallback(() => { dispatch({type: 'next'}) track('OnboardingV2:StepModeration:End') + logEvent('onboarding:moderation:nextPressed', {}) }, [track, dispatch]) React.useEffect(() => { diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index bdf94d8246..2e6161362c 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -1,33 +1,33 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a, useBreakpoints} from '#/alf' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {Text} from '#/components/Typography' -import {useProfilesQuery} from '#/state/queries/profile' -import {Loader} from '#/components/Loader' -import * as Toggle from '#/components/forms/Toggle' -import {useModerationOpts} from '#/state/queries/preferences' import {useAnalytics} from '#/lib/analytics/analytics' +import {logEvent} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' - -import {Context} from '#/screens/Onboarding/state' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfilesQuery} from '#/state/queries/profile' import { - Title, Description, OnboardingControls, + Title, } from '#/screens/Onboarding/Layout' +import {Context} from '#/screens/Onboarding/state' import { SuggestedAccountCard, SuggestedAccountCardPlaceholder, } from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard' import {aggregateInterestItems} from '#/screens/Onboarding/util' +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' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' export function Inner({ profiles, @@ -110,12 +110,20 @@ export function StepSuggestedAccounts() { track('OnboardingV2:StepSuggestedAccounts:End', { selectedAccountsLength: dids.length, }) + logEvent('onboarding:suggestedAccounts:nextPressed', { + selectedAccountsLength: dids.length, + skipped: false, + }) }, [dids, setSaving, dispatch, track]) const handleSkip = React.useCallback(() => { // if a user comes back and clicks skip, erase follows dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []}) dispatch({type: 'next'}) + logEvent('onboarding:suggestedAccounts:nextPressed', { + selectedAccountsLength: 0, + skipped: true, + }) }, [dispatch]) const isLoading = isProfilesLoading && moderationOpts diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx index 089363c233..26b1c243b0 100644 --- a/src/screens/Onboarding/StepTopicalFeeds.tsx +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -1,28 +1,28 @@ import React from 'react' import {View} from 'react-native' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Toggle from '#/components/forms/Toggle' -import {Loader} from '#/components/Loader' import {useAnalytics} from '#/lib/analytics/analytics' +import {logEvent} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' - -import {Context} from '#/screens/Onboarding/state' -import { - Title, - Description, - OnboardingControls, -} from '#/screens/Onboarding/Layout' -import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' -import {aggregateInterestItems} from '#/screens/Onboarding/util' -import {IconCircle} from '#/components/IconCircle' import {IS_TEST_USER} from 'lib/constants' import {useSession} from 'state/session' +import { + Description, + OnboardingControls, + Title, +} from '#/screens/Onboarding/Layout' +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 {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {IconCircle} from '#/components/IconCircle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass' +import {Loader} from '#/components/Loader' export function StepTopicalFeeds() { const {_} = useLingui() @@ -62,6 +62,10 @@ export function StepTopicalFeeds() { selectedFeeds: selectedFeedUris, selectedFeedsLength: selectedFeedUris.length, }) + logEvent('onboarding:topicalFeeds:nextPressed', { + selectedFeeds: selectedFeedUris, + selectedFeedsLength: selectedFeedUris.length, + }) }, [selectedFeedUris, dispatch, track]) React.useEffect(() => { diff --git a/src/view/com/auth/create/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx similarity index 86% rename from src/view/com/auth/create/CaptchaWebView.tsx rename to src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index b0de8b4a4b..50918c4ce6 100644 --- a/src/view/com/auth/create/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -1,8 +1,9 @@ import React from 'react' +import {StyleSheet} from 'react-native' import {WebView, WebViewNavigation} from 'react-native-webview' import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' -import {StyleSheet} from 'react-native' -import {CreateAccountState} from 'view/com/auth/create/state' + +import {SignupState} from '#/screens/Signup/state' const ALLOWED_HOSTS = [ 'bsky.social', @@ -17,24 +18,24 @@ const ALLOWED_HOSTS = [ export function CaptchaWebView({ url, stateParam, - uiState, + state, onSuccess, onError, }: { url: string stateParam: string - uiState?: CreateAccountState + state?: SignupState onSuccess: (code: string) => void onError: () => void }) { const redirectHost = React.useMemo(() => { - if (!uiState?.serviceUrl) return 'bsky.app' + if (!state?.serviceUrl) return 'bsky.app' - return uiState?.serviceUrl && - new URL(uiState?.serviceUrl).host === 'staging.bsky.dev' + return state?.serviceUrl && + new URL(state?.serviceUrl).host === 'staging.bsky.dev' ? 'staging.bsky.app' : 'bsky.app' - }, [uiState?.serviceUrl]) + }, [state?.serviceUrl]) const wasSuccessful = React.useRef(false) diff --git a/src/view/com/auth/create/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx similarity index 100% rename from src/view/com/auth/create/CaptchaWebView.web.tsx rename to src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx new file mode 100644 index 0000000000..311c697e7b --- /dev/null +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {createFullHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' +import {atoms as a, useTheme} from '#/alf' +import {FormError} from '#/components/forms/FormError' + +const CAPTCHA_PATH = '/gate/signup' + +export function StepCaptcha() { + const {_} = useLingui() + const theme = useTheme() + const {state, dispatch} = useSignupContext() + const submit = useSubmitSignup({state, dispatch}) + + const [completed, setCompleted] = React.useState(false) + + const stateParam = React.useMemo(() => nanoid(15), []) + const url = React.useMemo(() => { + const newUrl = new URL(state.serviceUrl) + newUrl.pathname = CAPTCHA_PATH + newUrl.searchParams.set( + 'handle', + createFullHandle(state.handle, state.userDomain), + ) + newUrl.searchParams.set('state', stateParam) + newUrl.searchParams.set('colorScheme', theme.name) + + return newUrl.href + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + + const onSuccess = React.useCallback( + (code: string) => { + setCompleted(true) + submit(code) + }, + [submit], + ) + + const onError = React.useCallback(() => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + }, [_, dispatch]) + + return ( + + + + {!completed ? ( + + ) : ( + + )} + + + + + ) +} + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + marginTop: 10, + }, + // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. + touchable: { + ...(isWeb && {cursor: 'pointer'}), + }, + container: { + minHeight: 500, + width: '100%', + paddingBottom: 20, + overflow: 'hidden', + }, + center: { + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx new file mode 100644 index 0000000000..44a33b8331 --- /dev/null +++ b/src/screens/Signup/StepHandle.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import { + createFullHandle, + IsValidHandle, + validateHandle, +} from '#/lib/strings/handles' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {useSignupContext} from '#/screens/Signup/state' +import {atoms as a, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +export function StepHandle() { + const {_} = useLingui() + const t = useTheme() + const {state, dispatch} = useSignupContext() + + const [validCheck, setValidCheck] = React.useState({ + handleChars: false, + hyphenStartOrEnd: false, + frontLength: false, + totalLength: true, + overall: false, + }) + + useFocusEffect( + React.useCallback(() => { + setValidCheck(validateHandle(state.handle, state.userDomain)) + }, [state.handle, state.userDomain]), + ) + + const onHandleChange = React.useCallback( + (value: string) => { + if (state.error) { + dispatch({type: 'setError', value: ''}) + } + + dispatch({ + type: 'setHandle', + value, + }) + }, + [dispatch, state.error], + ) + + return ( + + + + + + + + + + Your full handle will be{' '} + + @{createFullHandle(state.handle, state.userDomain)} + + + + + {state.error ? ( + + + {state.error} + + ) : undefined} + {validCheck.hyphenStartOrEnd ? ( + + + + Only contains letters, numbers, and hyphens + + + ) : ( + + + + Doesn't begin or end with a hyphen + + + )} + + + {!validCheck.totalLength ? ( + + No longer than 253 characters + + ) : ( + + At least 3 characters + + )} + + + + + ) +} + +function IsValidIcon({valid}: {valid: boolean}) { + const t = useTheme() + if (!valid) { + return + } + return +} diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx new file mode 100644 index 0000000000..8a656203fc --- /dev/null +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {View} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const Policies = ({ + serviceDescription, + needsGuardian, + under13, +}: { + serviceDescription: ComAtprotoServerDescribeServer.OutputSchema + needsGuardian: boolean + under13: boolean +}) => { + const t = useTheme() + const {_} = useLingui() + + if (!serviceDescription) { + return + } + + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + + ) + } + + const els = [] + if (tos) { + els.push( + + {_(msg`Terms of Service`)} + , + ) + } + if (pp) { + els.push( + + {_(msg`Privacy Policy`)} + , + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + + {' '} + and{' '} + , + ) + } + + return ( + + + By creating an account you agree to the {els}. + + + {under13 ? ( + + You must be 13 years of age or older to sign up. + + ) : needsGuardian ? ( + + + If you are not yet an adult according to the laws of your country, + your parent or legal guardian must read these Terms on your behalf. + + + ) : undefined} + + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx new file mode 100644 index 0000000000..136592a0b1 --- /dev/null +++ b/src/screens/Signup/StepInfo/index.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {is13, is18, useSignupContext} from '#/screens/Signup/state' +import {Policies} from '#/screens/Signup/StepInfo/Policies' +import {atoms as a} from '#/alf' +import * as DateField from '#/components/forms/DateField' +import {FormError} from '#/components/forms/FormError' +import {HostingProvider} from '#/components/forms/HostingProvider' +import * as TextField from '#/components/forms/TextField' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {Loader} from '#/components/Loader' + +function sanitizeDate(date: Date): Date { + if (!date || date.toString() === 'Invalid Date') { + logger.error(`Create account: handled invalid date for birthDate`, { + hasDate: !!date, + }) + return new Date() + } + return date +} + +export function StepInfo() { + const {_} = useLingui() + const {state, dispatch} = useSignupContext() + + return ( + + + + + + Hosting provider + + + dispatch({type: 'setServiceUrl', value: v}) + } + /> + + {state.isLoading ? ( + + + + ) : state.serviceDescription ? ( + <> + {state.serviceDescription.inviteCodeRequired && ( + + + Invite code + + + + { + dispatch({ + type: 'setInviteCode', + value: value.trim(), + }) + }} + label={_(msg`Required for this provider`)} + defaultValue={state.inviteCode} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + + + )} + + + Email + + + + { + dispatch({ + type: 'setEmail', + value: value.trim(), + }) + }} + label={_(msg`Enter your email address`)} + defaultValue={state.email} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + + + + + Password + + + + { + dispatch({ + type: 'setPassword', + value, + }) + }} + label={_(msg`Choose your password`)} + defaultValue={state.password} + secureTextEntry + autoComplete="new-password" + /> + + + + + Your birth date + + { + dispatch({ + type: 'setDateOfBirth', + value: sanitizeDate(new Date(date)), + }) + }} + label={_(msg`Date of birth`)} + accessibilityHint={_(msg`Select your date of birth`)} + /> + + + + ) : undefined} + + + ) +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx new file mode 100644 index 0000000000..b84f4d0d76 --- /dev/null +++ b/src/screens/Signup/index.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import {ScrollView, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {FEEDBACK_FORM_URL} from '#/lib/constants' +import {logEvent} from '#/lib/statsig/statsig' +import {createFullHandle} from '#/lib/strings/handles' +import {useServiceQuery} from '#/state/queries/service' +import {getAgent} from '#/state/session' +import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import { + initialState, + reducer, + SignupContext, + SignupStep, + useSubmitSignup, +} from '#/screens/Signup/state' +import {StepCaptcha} from '#/screens/Signup/StepCaptcha' +import {StepHandle} from '#/screens/Signup/StepHandle' +import {StepInfo} from '#/screens/Signup/StepInfo' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Signup({onPressBack}: {onPressBack: () => void}) { + const {_} = useLingui() + const t = useTheme() + const {screen} = useAnalytics() + const [state, dispatch] = React.useReducer(reducer, initialState) + const submit = useSubmitSignup({state, dispatch}) + + const { + data: serviceInfo, + isFetching, + isError, + refetch, + } = useServiceQuery(state.serviceUrl) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + if (isFetching) { + dispatch({type: 'setIsLoading', value: true}) + } else if (!isFetching) { + dispatch({type: 'setIsLoading', value: false}) + } + }, [isFetching]) + + React.useEffect(() => { + if (isError) { + dispatch({type: 'setServiceDescription', value: undefined}) + dispatch({ + type: 'setError', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } else if (serviceInfo) { + dispatch({type: 'setServiceDescription', value: serviceInfo}) + dispatch({type: 'setError', value: ''}) + } + }, [_, serviceInfo, isError]) + + const onNextPress = React.useCallback(async () => { + if (state.activeStep === SignupStep.HANDLE) { + try { + dispatch({type: 'setIsLoading', value: true}) + + const res = await getAgent().resolveHandle({ + handle: createFullHandle(state.handle, state.userDomain), + }) + + if (res.data.did) { + dispatch({ + type: 'setError', + value: _(msg`That handle is already taken.`), + }) + return + } + } catch (e) { + // Don't have to handle + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + } + + // phoneVerificationRequired is actually whether a captcha is required + if ( + state.activeStep === SignupStep.HANDLE && + !state.serviceDescription?.phoneVerificationRequired + ) { + submit() + return + } + + dispatch({type: 'next'}) + logEvent('signup:nextPressed', { + activeStep: state.activeStep, + }) + }, [ + _, + state.activeStep, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.userDomain, + submit, + ]) + + const onBackPress = React.useCallback(() => { + if (state.activeStep !== SignupStep.INFO) { + dispatch({type: 'prev'}) + } else { + onPressBack() + } + }, [onPressBack, state.activeStep]) + + return ( + + + + + + + Step {state.activeStep + 1} of{' '} + {state.serviceDescription && + !state.serviceDescription.phoneVerificationRequired + ? '2' + : '3'} + + + {state.activeStep === SignupStep.INFO ? ( + Your account + ) : state.activeStep === SignupStep.HANDLE ? ( + Your user handle + ) : ( + Complete the challenge + )} + + + + + {state.activeStep === SignupStep.INFO ? ( + + ) : state.activeStep === SignupStep.HANDLE ? ( + + ) : ( + + )} + + + + + {state.activeStep !== SignupStep.CAPTCHA && ( + <> + {isError ? ( + + ) : ( + + )} + + )} + + + + + + + Having trouble?{' '} + + Contact support + + + + + + + + ) +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts new file mode 100644 index 0000000000..f185e2d449 --- /dev/null +++ b/src/screens/Signup/state.ts @@ -0,0 +1,320 @@ +import React, {useCallback} from 'react' +import {LayoutAnimation} from 'react-native' +import { + ComAtprotoServerCreateAccount, + ComAtprotoServerDescribeServer, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as EmailValidator from 'email-validator' + +import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' +import {cleanError} from '#/lib/strings/errors' +import {createFullHandle, validateHandle} from '#/lib/strings/handles' +import {getAge} from '#/lib/strings/time' +import {logger} from '#/logger' +import { + DEFAULT_PROD_FEEDS, + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, +} from '#/state/queries/preferences' +import {useSessionApi} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export enum SignupStep { + INFO, + HANDLE, + CAPTCHA, +} + +export type SignupState = { + hasPrev: boolean + canNext: boolean + activeStep: SignupStep + + serviceUrl: string + serviceDescription?: ServiceDescription + userDomain: string + dateOfBirth: Date + email: string + password: string + inviteCode: string + handle: string + + error: string + isLoading: boolean +} + +export type SignupAction = + | {type: 'prev'} + | {type: 'next'} + | {type: 'finish'} + | {type: 'setStep'; value: SignupStep} + | {type: 'setServiceUrl'; value: string} + | {type: 'setServiceDescription'; value: ServiceDescription | undefined} + | {type: 'setEmail'; value: string} + | {type: 'setPassword'; value: string} + | {type: 'setDateOfBirth'; value: Date} + | {type: 'setInviteCode'; value: string} + | {type: 'setHandle'; value: string} + | {type: 'setVerificationCode'; value: string} + | {type: 'setError'; value: string} + | {type: 'setCanNext'; value: boolean} + | {type: 'setIsLoading'; value: boolean} + +export const initialState: SignupState = { + hasPrev: false, + canNext: false, + activeStep: SignupStep.INFO, + + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + dateOfBirth: DEFAULT_DATE, + email: '', + password: '', + handle: '', + inviteCode: '', + + error: '', + isLoading: false, +} + +export function is13(date: Date) { + return getAge(date) >= 13 +} + +export function is18(date: Date) { + return getAge(date) >= 18 +} + +export function reducer(s: SignupState, a: SignupAction): SignupState { + let next = {...s} + + switch (a.type) { + case 'prev': { + if (s.activeStep !== SignupStep.INFO) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep-- + next.error = '' + } + break + } + case 'next': { + if (s.activeStep !== SignupStep.CAPTCHA) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep++ + next.error = '' + } + break + } + case 'setStep': { + next.activeStep = a.value + break + } + case 'setServiceUrl': { + next.serviceUrl = a.value + break + } + case 'setServiceDescription': { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + next.serviceDescription = a.value + next.userDomain = a.value?.availableUserDomains[0] ?? '' + next.isLoading = false + break + } + + case 'setEmail': { + next.email = a.value + break + } + case 'setPassword': { + next.password = a.value + break + } + case 'setDateOfBirth': { + next.dateOfBirth = a.value + break + } + case 'setInviteCode': { + next.inviteCode = a.value + break + } + case 'setHandle': { + next.handle = a.value + break + } + case 'setCanNext': { + next.canNext = a.value + break + } + case 'setIsLoading': { + next.isLoading = a.value + break + } + case 'setError': { + next.error = a.value + break + } + } + + next.hasPrev = next.activeStep !== SignupStep.INFO + + switch (next.activeStep) { + case SignupStep.INFO: { + const isValidEmail = EmailValidator.validate(next.email) + next.canNext = + !!(next.email && next.password && next.dateOfBirth) && + (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) && + is13(next.dateOfBirth) && + isValidEmail + break + } + case SignupStep.HANDLE: { + next.canNext = + !!next.handle && validateHandle(next.handle, next.userDomain).overall + break + } + } + + logger.debug('signup', next) + + if (s.activeStep !== next.activeStep) { + logger.debug('signup: step changed', {activeStep: next.activeStep}) + } + + return next +} + +interface IContext { + state: SignupState + dispatch: React.Dispatch +} +export const SignupContext = React.createContext({} as IContext) +export const useSignupContext = () => React.useContext(SignupContext) + +export function useSubmitSignup({ + state, + dispatch, +}: { + state: SignupState + dispatch: (action: SignupAction) => void +}) { + const {_} = useLingui() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() + const onboardingDispatch = useOnboardingDispatch() + + return useCallback( + async (verificationCode?: string) => { + if (!state.email) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(state.email)) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!state.password) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your password.`), + }) + } + if (!state.handle) { + dispatch({type: 'setStep', value: SignupStep.HANDLE}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your handle.`), + }) + } + if ( + state.serviceDescription?.phoneVerificationRequired && + !verificationCode + ) { + dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) + return dispatch({ + type: 'setError', + value: _(msg`Please complete the verification captcha.`), + }) + } + dispatch({type: 'setError', value: ''}) + dispatch({type: 'setIsLoading', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: state.serviceUrl, + email: state.email, + handle: createFullHandle(state.handle, state.userDomain), + password: state.password, + inviteCode: state.inviteCode.trim(), + verificationCode: verificationCode, + }) + setBirthDate({birthDate: state.dateOfBirth}) + if (IS_PROD_SERVICE(state.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + dispatch({ + type: 'setError', + value: _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ), + }) + dispatch({type: 'setStep', value: SignupStep.INFO}) + return + } + + if ([400, 429].includes(e.status)) { + logger.warn('Failed to create account', {message: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, { + message: e, + }) + } + + const error = cleanError(errMsg) + const isHandleError = error.toLowerCase().includes('handle') + + dispatch({type: 'setIsLoading', value: false}) + dispatch({type: 'setError', value: cleanError(errMsg)}) + dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + }, + [ + state.email, + state.password, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.serviceUrl, + state.userDomain, + state.inviteCode, + state.dateOfBirth, + dispatch, + _, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + ], + ) +} diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 951105a509..26bb6792fd 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import {SharedValue, useSharedValue} from 'react-native-reanimated' + import {DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' @@ -53,7 +54,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { >('auto') const closeAllDialogs = React.useCallback(() => { - activeDialogs.current.forEach(dialog => dialog.current.close()) + openDialogs.current.forEach(id => { + const dialog = activeDialogs.current.get(id) + if (dialog) dialog.current.close() + }) return openDialogs.current.size > 0 }, []) @@ -74,15 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const context = React.useMemo( () => ({ - activeDialogs: { - current: new Map(), - }, - openDialogs: { - current: new Set(), - }, + activeDialogs, + openDialogs, importantForAccessibility, }), - [importantForAccessibility], + [importantForAccessibility, activeDialogs, openDialogs], ) const controls = React.useMemo( () => ({closeAllDialogs, setDialogIsOpen}), diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index b6748bfad5..c7dba30892 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,26 +1,26 @@ import React from 'react' import { - BskyAgent, AtpPersistSessionHandler, BSKY_LABELER_DID, + BskyAgent, } from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {jwtDecode} from 'jwt-decode' -import {IS_DEV} from '#/env' -import {IS_TEST_USER} from '#/lib/constants' -import {isWeb} from '#/platform/detection' +import {track} from '#/lib/analytics/analytics' import {networkRetry} from '#/lib/async/retry' +import {IS_TEST_USER} from '#/lib/constants' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {hasProp} from '#/lib/type-guards' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' import {PUBLIC_BSKY_AGENT} from '#/state/queries' -import {emitSessionDropped} from '../events' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' -import {track} from '#/lib/analytics/analytics' -import {hasProp} from '#/lib/type-guards' +import {IS_DEV} from '#/env' +import {emitSessionDropped} from '../events' import {readLabelers} from './agent-config' -import {logEvent, LogEvents} from '#/lib/statsig/statsig' let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT @@ -230,6 +230,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }: any) => { logger.info(`session: creating account`) track('Try Create Account') + logEvent('account:create:begin', {}) const agent = new BskyAgent({service}) @@ -290,6 +291,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.debug(`session: created account`, {}, logger.DebugContext.session) track('Create Account') + logEvent('account:create:success', {}) }, [upsertAccount, queryClient, clearCurrentAccount], ) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 603abbab2d..c8c81dd771 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,27 +1,28 @@ import React from 'react' -import {View, Pressable} from 'react-native' +import {Pressable, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' import {useNavigation} from '@react-navigation/native' -import {isIOS, isNative} from 'platform/detection' -import {Login} from 'view/com/auth/login/Login' -import {CreateAccount} from 'view/com/auth/create/CreateAccount' -import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {SplashScreen} from './SplashScreen' -import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useAnalytics} from '#/lib/analytics/analytics' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {logEvent} from '#/lib/statsig/statsig' +import {s} from '#/lib/styles' +import {isIOS, isNative} from '#/platform/detection' +import {useSession} from '#/state/session' import { useLoggedOutView, useLoggedOutViewControls, } from '#/state/shell/logged-out' -import {useSession} from '#/state/session' -import {Text} from '#/view/com/util/text/Text' +import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' import {NavigationProp} from 'lib/routes/types' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {Text} from '#/view/com/util/text/Text' +import {Login} from '#/screens/Login' +import {Signup} from '#/screens/Signup' +import {SplashScreen} from './SplashScreen' enum ScreenState { S_LoginOrCreateAccount, @@ -133,10 +134,14 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { {screenState === ScreenState.S_LoginOrCreateAccount ? ( setScreenState(ScreenState.S_Login)} - onPressCreateAccount={() => + onPressSignin={() => { + setScreenState(ScreenState.S_Login) + logEvent('splash:signInPressed', {}) + }} + onPressCreateAccount={() => { setScreenState(ScreenState.S_CreateAccount) - } + logEvent('splash:createAccountPressed', {}) + }} /> ) : undefined} {screenState === ScreenState.S_Login ? ( @@ -148,7 +153,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( - setScreenState(ScreenState.S_LoginOrCreateAccount) } diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index f3d7834766..5e431742e1 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,23 +1,21 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {View} from 'react-native' +import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {Text} from 'view/com/util/text/Text' -import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {CenteredView} from '../util/Views' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' + +import {sanitizeAppLanguageSetting} from '#/locale/helpers' +import {APP_LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' -import {sanitizeAppLanguageSetting} from '#/locale/helpers' -import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' -import {APP_LANGUAGES} from '#/locale/languages' +import {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' +import {CenteredView} from '../util/Views' export const SplashScreen = ({ onPressSignin, @@ -26,7 +24,7 @@ export const SplashScreen = ({ onPressSignin: () => void onPressCreateAccount: () => void }) => { - const pal = usePalette('default') + const t = useTheme() const {_} = useLingui() const langPrefs = useLanguagePrefs() @@ -46,49 +44,62 @@ export const SplashScreen = ({ ) return ( - + - + - - + + - + What's up? - - + + - - + + - + style={[ + a.absolute, + a.inset_0, + {left: 'auto'}, + {pointerEvents: 'none'}, + a.align_center, + a.justify_center, + ]}> + @@ -136,44 +142,3 @@ export const SplashScreen = ({ ) } - -const styles = StyleSheet.create({ - container: { - height: '100%', - }, - hero: { - flex: 2, - justifyContent: 'center', - alignItems: 'center', - }, - btns: { - paddingBottom: 0, - }, - title: { - textAlign: 'center', - fontSize: 68, - fontWeight: 'bold', - }, - subtitle: { - textAlign: 'center', - fontSize: 42, - fontWeight: 'bold', - }, - btn: { - borderRadius: 32, - paddingVertical: 16, - marginBottom: 20, - marginHorizontal: 20, - }, - btnLabel: { - textAlign: 'center', - fontSize: 21, - }, - footer: { - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 24, - justifyContent: 'center', - alignItems: 'center', - }, -}) diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index f1921c7ffb..5361baa768 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -1,21 +1,22 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native' +import {Pressable, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Text} from 'view/com/util/text/Text' -import {TextLink} from '../util/Link' -import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {CenteredView} from '../util/Views' -import {isWeb} from 'platform/detection' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeAppLanguageSetting} from '#/locale/helpers' +import {APP_LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {Trans, msg} from '@lingui/macro' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' -import {useLingui} from '@lingui/react' -import {sanitizeAppLanguageSetting} from '#/locale/helpers' -import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' -import {APP_LANGUAGES} from '#/locale/languages' +import {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {CenteredView} from '../util/Views' export const SplashScreen = ({ onDismiss, @@ -26,10 +27,9 @@ export const SplashScreen = ({ onPressSignin: () => void onPressCreateAccount: () => void }) => { - const pal = usePalette('default') - const {isTabletOrMobile} = useWebMediaQueries() - const styles = useStyles() - const isMobileWeb = isWeb && isTabletOrMobile + const {_} = useLingui() + const t = useTheme() + const {isTabletOrMobile: isMobileWeb} = useWebMediaQueries() return ( <> @@ -48,61 +48,88 @@ export const SplashScreen = ({ icon="x" size={24} style={{ - color: String(pal.text.color), + color: String(t.atoms.text.color), }} /> )} - + - + + - - + + + + + + What's up? + - - + + -