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?
+
-
-
+
-
+
+
+
+
-
+
>
)
}
-function Footer({styles}: {styles: ReturnType}) {
- const pal = usePalette('default')
- const {_} = useLingui()
+function Footer() {
+ const t = useTheme()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
@@ -122,39 +149,39 @@ function Footer({styles}: {styles: ReturnType}) {
)
return (
-
-
-
-
+
+
+ Business
+
+
+ Blog
+
+
+ Jobs
+
-
+
-
-
+
+
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
-
)
}
-const useStyles = () => {
- return StyleSheet.create({
- container: {
- height: '100%',
- },
- containerInner: {
- height: '100%',
- justifyContent: 'center',
- // @ts-ignore web only
- paddingBottom: '20vh',
- paddingHorizontal: 20,
- },
- containerInnerMobile: {
- paddingBottom: 50,
- },
- title: {
- textAlign: 'center',
- color: colors.blue3,
- fontSize: 68,
- fontWeight: 'bold',
- paddingBottom: 10,
- },
- titleMobile: {
- textAlign: 'center',
- color: colors.blue3,
- fontSize: 58,
- fontWeight: 'bold',
- },
- subtitle: {
- textAlign: 'center',
- color: colors.gray5,
- fontSize: 52,
- fontWeight: 'bold',
- paddingBottom: 30,
- },
- subtitleMobile: {
- textAlign: 'center',
- color: colors.gray5,
- fontSize: 42,
- fontWeight: 'bold',
- paddingBottom: 30,
- },
- btns: {
- gap: 10,
- justifyContent: 'center',
- paddingBottom: 40,
- },
- btn: {
- borderRadius: 30,
- paddingHorizontal: 24,
- paddingVertical: 12,
- minWidth: 220,
- },
- btnLabel: {
- textAlign: 'center',
- fontSize: 18,
- },
- notice: {
- paddingHorizontal: 40,
- textAlign: 'center',
- },
- footer: {
- position: 'absolute',
- left: 0,
- right: 0,
- bottom: 0,
- padding: 20,
- borderTopWidth: 1,
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 20,
- },
- footerDivider: {flexGrow: 1},
- footerLink: {},
- })
-}
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
deleted file mode 100644
index d193802fe0..0000000000
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import React from 'react'
-import {
- ActivityIndicator,
- ScrollView,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useCreateAccount, useSubmitCreateAccount} from './state'
-import {useServiceQuery} from '#/state/queries/service'
-import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
-
-import {Step1} from './Step1'
-import {Step2} from './Step2'
-import {Step3} from './Step3'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {TextLink} from '../../util/Link'
-import {getAgent} from 'state/session'
-import {createFullHandle, validateHandle} from 'lib/strings/handles'
-
-export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
- const {screen} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
- const [uiState, uiDispatch] = useCreateAccount()
- const {isTabletOrDesktop} = useWebMediaQueries()
- const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
- React.useEffect(() => {
- screen('CreateAccount')
- }, [screen])
-
- // fetch service info
- // =
-
- const {
- data: serviceInfo,
- isFetching: serviceInfoIsFetching,
- error: serviceInfoError,
- refetch: refetchServiceInfo,
- } = useServiceQuery(uiState.serviceUrl)
-
- React.useEffect(() => {
- if (serviceInfo) {
- uiDispatch({type: 'set-service-description', value: serviceInfo})
- uiDispatch({type: 'set-error', value: ''})
- } else if (serviceInfoError) {
- uiDispatch({
- type: 'set-error',
- value: _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
- })
- }
- }, [_, uiDispatch, serviceInfo, serviceInfoError])
-
- // event handlers
- // =
-
- const onPressBackInner = React.useCallback(() => {
- if (uiState.canBack) {
- uiDispatch({type: 'back'})
- } else {
- onPressBack()
- }
- }, [uiState, uiDispatch, onPressBack])
-
- const onPressNext = React.useCallback(async () => {
- if (!uiState.canNext) {
- return
- }
-
- if (uiState.step === 2) {
- if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
- return
- }
-
- uiDispatch({type: 'set-processing', value: true})
- try {
- const res = await getAgent().resolveHandle({
- handle: createFullHandle(uiState.handle, uiState.userDomain),
- })
-
- if (res.data.did) {
- uiDispatch({
- type: 'set-error',
- value: _(msg`That handle is already taken.`),
- })
- return
- }
- } catch (e) {
- // Don't need to handle
- } finally {
- uiDispatch({type: 'set-processing', value: false})
- }
-
- if (!uiState.isCaptchaRequired) {
- try {
- await submit()
- } catch {
- // dont need to handle here
- }
- // We don't need to go to the next page if there wasn't a captcha required
- return
- }
- }
-
- uiDispatch({type: 'next'})
- }, [
- uiState.canNext,
- uiState.step,
- uiState.isCaptchaRequired,
- uiState.handle,
- uiState.userDomain,
- uiDispatch,
- _,
- submit,
- ])
-
- // rendering
- // =
-
- return (
-
-
-
- {uiState.step === 1 && (
-
- )}
- {uiState.step === 2 && (
-
- )}
- {uiState.step === 3 && (
-
- )}
-
-
-
-
- Back
-
-
-
- {uiState.canNext ? (
-
- {uiState.isProcessing ? (
-
- ) : (
-
- Next
-
- )}
-
- ) : serviceInfoError ? (
- refetchServiceInfo()}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Retry`)}
- accessibilityHint=""
- accessibilityLiveRegion="polite"
- hitSlop={HITSLOP_10}>
-
- Retry
-
-
- ) : serviceInfoIsFetching ? (
- <>
-
-
- Connecting...
-
- >
- ) : undefined}
-
-
-
-
-
- Having trouble?{' '}
-
-
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- stepContainer: {
- paddingHorizontal: 20,
- paddingVertical: 20,
- },
-})
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
deleted file mode 100644
index 803e2ad32b..0000000000
--- a/src/view/com/auth/create/Policies.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {TextLink} from '../../util/Link'
-import {Text} from '../../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-
-export const Policies = ({
- serviceDescription,
- needsGuardian,
-}: {
- serviceDescription: ServiceDescription
- needsGuardian: boolean
-}) => {
- const pal = usePalette('default')
- 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(
- ,
- )
- }
- if (pp) {
- els.push(
- ,
- )
- }
- if (els.length === 2) {
- els.splice(
- 1,
- 0,
-
- {' '}
- and{' '}
- ,
- )
- }
- return (
-
-
- By creating an account you agree to the {els}.
-
- {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.
-
-
- )}
-
- )
-}
-
-function validWebLink(url?: string): string | undefined {
- return url && (url.startsWith('http://') || url.startsWith('https://'))
- ? url
- : undefined
-}
-
-const styles = StyleSheet.create({
- policies: {
- flexDirection: 'column',
- gap: 8,
- },
- errorIcon: {
- borderWidth: 1,
- borderColor: colors.white,
- borderRadius: 30,
- width: 16,
- height: 16,
- alignItems: 'center',
- justifyContent: 'center',
- },
-})
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
deleted file mode 100644
index 1f6852f8cc..0000000000
--- a/src/view/com/auth/create/Step1.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import React from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- StyleSheet,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch, is18} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {DateInput} from 'view/com/util/forms/DateInput'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {TextInput} from '../util/TextInput'
-import {Policies} from './Policies'
-import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {logger} from '#/logger'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-import {toNiceDomain} from '#/lib/strings/url-helpers'
-
-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 Step1({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const serverInputControl = useDialogControl()
-
- const onPressSelectService = React.useCallback(() => {
- serverInputControl.open()
- Keyboard.dismiss()
- }, [serverInputControl])
-
- const birthDate = React.useMemo(() => {
- return sanitizeDate(uiState.birthDate)
- }, [uiState.birthDate])
-
- return (
-
- uiDispatch({type: 'set-service-url', value: url})}
- />
-
-
- {uiState.error ? (
-
- ) : undefined}
-
-
-
- Hosting provider
-
-
-
-
-
-
- {toNiceDomain(uiState.serviceUrl)}
-
-
-
-
-
-
-
-
-
- {!uiState.serviceDescription ? (
-
- ) : (
- <>
- {uiState.isInviteCodeRequired && (
-
-
- Invite code
-
- uiDispatch({type: 'set-invite-code', value})}
- accessibilityLabel={_(msg`Invite code`)}
- accessibilityHint={_(msg`Input invite code to proceed`)}
- autoCapitalize="none"
- autoComplete="off"
- autoCorrect={false}
- autoFocus={true}
- />
-
- )}
-
- {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
- <>
-
-
- Email address
-
- uiDispatch({type: 'set-email', value})}
- accessibilityLabel={_(msg`Email`)}
- accessibilityHint={_(msg`Input email for Bluesky account`)}
- accessibilityLabelledBy="email"
- autoCapitalize="none"
- autoComplete="email"
- autoCorrect={false}
- autoFocus={!uiState.isInviteCodeRequired}
- />
-
-
-
-
- Password
-
- uiDispatch({type: 'set-password', value})}
- accessibilityLabel={_(msg`Password`)}
- accessibilityHint={_(msg`Set password`)}
- accessibilityLabelledBy="password"
- autoCapitalize="none"
- autoComplete="new-password"
- autoCorrect={false}
- />
-
-
-
-
- Your birth date
-
-
- uiDispatch({type: 'set-birth-date', value})
- }
- buttonType="default-light"
- buttonStyle={[pal.border, styles.dateInputButton]}
- buttonLabelType="lg"
- accessibilityLabel={_(msg`Birthday`)}
- accessibilityHint={_(msg`Enter your birth date`)}
- accessibilityLabelledBy="birthDate"
- />
-
-
- {uiState.serviceDescription && (
-
- )}
- >
- ) : undefined}
- >
- )}
-
- )
-}
-
-const styles = StyleSheet.create({
- error: {
- borderRadius: 6,
- marginBottom: 10,
- },
- dateInputButton: {
- borderWidth: 1,
- borderRadius: 6,
- paddingVertical: 14,
- },
- // @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'}),
- },
-})
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
deleted file mode 100644
index 5c262977f1..0000000000
--- a/src/view/com/auth/create/Step2.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {CreateAccountState, CreateAccountDispatch} from './state'
-import {Text} from 'view/com/util/text/Text'
-import {StepHeader} from './StepHeader'
-import {s} from 'lib/styles'
-import {TextInput} from '../util/TextInput'
-import {
- createFullHandle,
- IsValidHandle,
- validateHandle,
-} from 'lib/strings/handles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {atoms as a, useTheme} from '#/alf'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
-import {useFocusEffect} from '@react-navigation/native'
-
-/** STEP 3: Your user handle
- * @field User handle
- */
-export function Step2({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const t = useTheme()
-
- const [validCheck, setValidCheck] = React.useState({
- handleChars: false,
- frontLength: false,
- totalLength: true,
- overall: false,
- })
-
- useFocusEffect(
- React.useCallback(() => {
- setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
-
- // Disabling this, because we only want to run this when we focus the screen
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []),
- )
-
- const onHandleChange = React.useCallback(
- (value: string) => {
- if (uiState.error) {
- uiDispatch({type: 'set-error', value: ''})
- }
-
- setValidCheck(validateHandle(value, uiState.userDomain))
- uiDispatch({type: 'set-handle', value})
- },
- [uiDispatch, uiState.error, uiState.userDomain],
- )
-
- return (
-
-
-
-
-
-
- Your full handle will be{' '}
-
- @{createFullHandle(uiState.handle, uiState.userDomain)}
-
-
-
-
- {uiState.error ? (
-
-
-
- {uiState.error}
-
-
- ) : undefined}
-
-
-
- May only contain letters and numbers
-
-
-
-
- {!validCheck.totalLength ? (
-
- May not be longer than 253 characters
-
- ) : (
-
- Must be at least 3 characters
-
- )}
-
-
-
-
- )
-}
-
-function IsValidIcon({valid}: {valid: boolean}) {
- const t = useTheme()
-
- if (!valid) {
- return
- }
-
- return
-}
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
deleted file mode 100644
index 53fdfdde81..0000000000
--- a/src/view/com/auth/create/Step3.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import React from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {
- CreateAccountState,
- CreateAccountDispatch,
- useSubmitCreateAccount,
-} from './state'
-import {StepHeader} from './StepHeader'
-import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {isWeb} from 'platform/detection'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {nanoid} from 'nanoid/non-secure'
-import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
-import {useTheme} from 'lib/ThemeContext'
-import {createFullHandle} from 'lib/strings/handles'
-
-const CAPTCHA_PATH = '/gate/signup'
-
-export function Step3({
- uiState,
- uiDispatch,
-}: {
- uiState: CreateAccountState
- uiDispatch: CreateAccountDispatch
-}) {
- const {_} = useLingui()
- const theme = useTheme()
- const submit = useSubmitCreateAccount(uiState, uiDispatch)
-
- const [completed, setCompleted] = React.useState(false)
-
- const stateParam = React.useMemo(() => nanoid(15), [])
- const url = React.useMemo(() => {
- const newUrl = new URL(uiState.serviceUrl)
- newUrl.pathname = CAPTCHA_PATH
- newUrl.searchParams.set(
- 'handle',
- createFullHandle(uiState.handle, uiState.userDomain),
- )
- newUrl.searchParams.set('state', stateParam)
- newUrl.searchParams.set('colorScheme', theme.colorScheme)
-
- console.log(newUrl)
-
- return newUrl.href
- }, [
- uiState.serviceUrl,
- uiState.handle,
- uiState.userDomain,
- stateParam,
- theme.colorScheme,
- ])
-
- const onSuccess = React.useCallback(
- (code: string) => {
- setCompleted(true)
- submit(code)
- },
- [submit],
- )
-
- const onError = React.useCallback(() => {
- uiDispatch({
- type: 'set-error',
- value: _(msg`Error receiving captcha response.`),
- })
- }, [_, uiDispatch])
-
- return (
-
-
-
- {!completed ? (
-
- ) : (
-
- )}
-
-
- {uiState.error ? (
-
- ) : undefined}
-
- )
-}
-
-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/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx
deleted file mode 100644
index a98b392d8d..0000000000
--- a/src/view/com/auth/create/StepHeader.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans} from '@lingui/macro'
-import {CreateAccountState} from './state'
-
-export function StepHeader({
- uiState,
- title,
- children,
-}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
- const pal = usePalette('default')
- const numSteps = 3
- return (
-
-
-
- {uiState.step === 3 ? (
- Last step!
- ) : (
-
- Step {uiState.step} of {numSteps}
-
- )}
-
-
-
- {title}
-
-
- {children}
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 20,
- },
-})
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
deleted file mode 100644
index 840084dcb7..0000000000
--- a/src/view/com/auth/create/state.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import {useCallback, useReducer} from 'react'
-import {
- ComAtprotoServerDescribeServer,
- ComAtprotoServerCreateAccount,
-} from '@atproto/api'
-import {I18nContext, useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import * as EmailValidator from 'email-validator'
-import {getAge} from 'lib/strings/time'
-import {logger} from '#/logger'
-import {createFullHandle, validateHandle} from '#/lib/strings/handles'
-import {cleanError} from '#/lib/strings/errors'
-import {useOnboardingDispatch} from '#/state/shell/onboarding'
-import {useSessionApi} from '#/state/session'
-import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants'
-import {
- DEFAULT_PROD_FEEDS,
- usePreferencesSetBirthDateMutation,
- useSetSaveFeedsMutation,
-} from 'state/queries/preferences'
-
-export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
-const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
-
-export type CreateAccountAction =
- | {type: 'set-step'; value: number}
- | {type: 'set-error'; value: string | undefined}
- | {type: 'set-processing'; value: boolean}
- | {type: 'set-service-url'; value: string}
- | {type: 'set-service-description'; value: ServiceDescription | undefined}
- | {type: 'set-user-domain'; value: string}
- | {type: 'set-invite-code'; value: string}
- | {type: 'set-email'; value: string}
- | {type: 'set-password'; value: string}
- | {type: 'set-handle'; value: string}
- | {type: 'set-birth-date'; value: Date}
- | {type: 'next'}
- | {type: 'back'}
-
-export interface CreateAccountState {
- // state
- step: number
- error: string | undefined
- isProcessing: boolean
- serviceUrl: string
- serviceDescription: ServiceDescription | undefined
- userDomain: string
- inviteCode: string
- email: string
- password: string
- handle: string
- birthDate: Date
-
- // computed
- canBack: boolean
- canNext: boolean
- isInviteCodeRequired: boolean
- isCaptchaRequired: boolean
-}
-
-export type CreateAccountDispatch = (action: CreateAccountAction) => void
-
-export function useCreateAccount() {
- const {_} = useLingui()
-
- return useReducer(createReducer({_}), {
- step: 1,
- error: undefined,
- isProcessing: false,
- serviceUrl: DEFAULT_SERVICE,
- serviceDescription: undefined,
- userDomain: '',
- inviteCode: '',
- email: '',
- password: '',
- handle: '',
- birthDate: DEFAULT_DATE,
-
- canBack: false,
- canNext: false,
- isInviteCodeRequired: false,
- isCaptchaRequired: false,
- })
-}
-
-export function useSubmitCreateAccount(
- uiState: CreateAccountState,
- uiDispatch: CreateAccountDispatch,
-) {
- const {_} = useLingui()
- const {createAccount} = useSessionApi()
- const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
- const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
- const onboardingDispatch = useOnboardingDispatch()
-
- return useCallback(
- async (verificationCode?: string) => {
- if (!uiState.email) {
- uiDispatch({type: 'set-step', value: 1})
- console.log('no email?')
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please enter your email.`),
- })
- }
- if (!EmailValidator.validate(uiState.email)) {
- uiDispatch({type: 'set-step', value: 1})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Your email appears to be invalid.`),
- })
- }
- if (!uiState.password) {
- uiDispatch({type: 'set-step', value: 1})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please choose your password.`),
- })
- }
- if (!uiState.handle) {
- uiDispatch({type: 'set-step', value: 2})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please choose your handle.`),
- })
- }
- if (uiState.isCaptchaRequired && !verificationCode) {
- uiDispatch({type: 'set-step', value: 3})
- return uiDispatch({
- type: 'set-error',
- value: _(msg`Please complete the verification captcha.`),
- })
- }
- uiDispatch({type: 'set-error', value: ''})
- uiDispatch({type: 'set-processing', value: true})
-
- try {
- onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
- await createAccount({
- service: uiState.serviceUrl,
- email: uiState.email,
- handle: createFullHandle(uiState.handle, uiState.userDomain),
- password: uiState.password,
- inviteCode: uiState.inviteCode.trim(),
- verificationCode: uiState.isCaptchaRequired
- ? verificationCode
- : undefined,
- })
- setBirthDate({birthDate: uiState.birthDate})
- if (!IS_TEST_USER(uiState.handle)) {
- setSavedFeeds(DEFAULT_PROD_FEEDS)
- }
- } catch (e: any) {
- onboardingDispatch({type: 'skip'}) // undo starting the onboard
- let errMsg = e.toString()
- if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
- errMsg = _(
- msg`Invite code not accepted. Check that you input it correctly and try again.`,
- )
- uiDispatch({type: 'set-step', value: 1})
- }
-
- 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')
-
- uiDispatch({type: 'set-processing', value: false})
- uiDispatch({type: 'set-error', value: cleanError(errMsg)})
- uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
- }
- },
- [
- uiState.email,
- uiState.password,
- uiState.handle,
- uiState.isCaptchaRequired,
- uiState.serviceUrl,
- uiState.userDomain,
- uiState.inviteCode,
- uiState.birthDate,
- uiDispatch,
- _,
- onboardingDispatch,
- createAccount,
- setBirthDate,
- setSavedFeeds,
- ],
- )
-}
-
-export function is13(state: CreateAccountState) {
- return getAge(state.birthDate) >= 13
-}
-
-export function is18(state: CreateAccountState) {
- return getAge(state.birthDate) >= 18
-}
-
-function createReducer({_}: {_: I18nContext['_']}) {
- return function reducer(
- state: CreateAccountState,
- action: CreateAccountAction,
- ): CreateAccountState {
- switch (action.type) {
- case 'set-step': {
- return compute({...state, step: action.value})
- }
- case 'set-error': {
- return compute({...state, error: action.value})
- }
- case 'set-processing': {
- return compute({...state, isProcessing: action.value})
- }
- case 'set-service-url': {
- return compute({
- ...state,
- serviceUrl: action.value,
- serviceDescription:
- state.serviceUrl !== action.value
- ? undefined
- : state.serviceDescription,
- })
- }
- case 'set-service-description': {
- return compute({
- ...state,
- serviceDescription: action.value,
- userDomain: action.value?.availableUserDomains[0] || '',
- })
- }
- case 'set-user-domain': {
- return compute({...state, userDomain: action.value})
- }
- case 'set-invite-code': {
- return compute({...state, inviteCode: action.value})
- }
- case 'set-email': {
- return compute({...state, email: action.value})
- }
- case 'set-password': {
- return compute({...state, password: action.value})
- }
- case 'set-handle': {
- return compute({...state, handle: action.value})
- }
- case 'set-birth-date': {
- return compute({...state, birthDate: action.value})
- }
- case 'next': {
- if (state.step === 1) {
- if (!is13(state)) {
- return compute({
- ...state,
- error: _(
- msg`Unfortunately, you do not meet the requirements to create an account.`,
- ),
- })
- }
- }
- return compute({...state, error: '', step: state.step + 1})
- }
- case 'back': {
- return compute({...state, error: '', step: state.step - 1})
- }
- }
- }
-}
-
-function compute(state: CreateAccountState): CreateAccountState {
- let canNext = true
- if (state.step === 1) {
- canNext =
- !!state.serviceDescription &&
- (!state.isInviteCodeRequired || !!state.inviteCode) &&
- !!state.email &&
- !!state.password
- } else if (state.step === 2) {
- canNext =
- !!state.handle && validateHandle(state.handle, state.userDomain).overall
- } else if (state.step === 3) {
- // Step 3 will automatically redirect as soon as the captcha completes
- canNext = false
- }
- return {
- ...state,
- canBack: state.step > 1,
- canNext,
- isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
- isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
- }
-}
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
deleted file mode 100644
index e754c84834..0000000000
--- a/src/view/com/auth/login/ChooseAccountForm.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React from 'react'
-import {ScrollView, TouchableOpacity, View} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {UserAvatar} from '../../util/UserAvatar'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {styles} from './styles'
-import {useSession, useSessionApi, SessionAccount} from '#/state/session'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useLoggedOutViewControls} from '#/state/shell/logged-out'
-import * as Toast from '#/view/com/util/Toast'
-import {logEvent} from '#/lib/statsig/statsig'
-
-function AccountItem({
- account,
- onSelect,
- isCurrentAccount,
-}: {
- account: SessionAccount
- onSelect: (account: SessionAccount) => void
- isCurrentAccount: boolean
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {data: profile} = useProfileQuery({did: account.did})
-
- const onPress = React.useCallback(() => {
- onSelect(account)
- }, [account, onSelect])
-
- return (
-
-
-
-
-
-
-
- {profile?.displayName || account.handle}{' '}
-
-
- {account.handle}
-
-
- {isCurrentAccount ? (
-
- ) : (
-
- )}
-
-
- )
-}
-export const ChooseAccountForm = ({
- onSelectAccount,
- onPressBack,
-}: {
- onSelectAccount: (account?: SessionAccount) => void
- onPressBack: () => void
-}) => {
- const {track, screen} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
- 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 (
-
-
- Sign in as...
-
- {accounts.map(account => (
-
- ))}
- onSelectAccount(undefined)}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Login to account that is not listed`)}
- accessibilityHint="">
-
-
-
- Other account
-
-
-
-
-
-
-
-
- Back
-
-
-
-
-
- )
-}
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
deleted file mode 100644
index 322da2b8fd..0000000000
--- a/src/view/com/auth/login/ForgotPasswordForm.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import * as EmailValidator from 'email-validator'
-import {BskyAgent} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {styles} from './styles'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-
-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 pal = usePalette('default')
- const theme = useTheme()
- const [isProcessing, setIsProcessing] = useState(false)
- const [email, setEmail] = useState('')
- const {screen} = useAnalytics()
- const {_} = useLingui()
- const serverInputControl = useDialogControl()
-
- useEffect(() => {
- screen('Signin:ForgotPassword')
- }, [screen])
-
- const onPressSelectService = React.useCallback(() => {
- serverInputControl.open()
- Keyboard.dismiss()
- }, [serverInputControl])
-
- 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
-
-
-
- Enter the email you used to create your account. We'll send you a
- "reset code" so you can set a new password.
-
-
-
-
-
-
- {toNiceDomain(serviceUrl)}
-
-
-
-
-
-
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {!serviceDescription || isProcessing ? (
-
- ) : !email ? (
-
- Next
-
- ) : (
-
-
- Next
-
-
- )}
- {!serviceDescription || isProcessing ? (
-
- Processing...
-
- ) : undefined}
-
-
-
-
-
- Already have a code?
-
-
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
deleted file mode 100644
index bc931ac04c..0000000000
--- a/src/view/com/auth/login/Login.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {KeyboardAvoidingView} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {DEFAULT_SERVICE} from '#/lib/constants'
-import {usePalette} from 'lib/hooks/usePalette'
-import {logger} from '#/logger'
-import {ChooseAccountForm} from './ChooseAccountForm'
-import {LoginForm} from './LoginForm'
-import {ForgotPasswordForm} from './ForgotPasswordForm'
-import {SetNewPasswordForm} from './SetNewPasswordForm'
-import {PasswordUpdatedForm} from './PasswordUpdatedForm'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import {useSession, SessionAccount} from '#/state/session'
-import {useServiceQuery} from '#/state/queries/service'
-import {useLoggedOutView} from '#/state/shell/logged-out'
-
-enum Forms {
- Login,
- ChooseAccount,
- ForgotPassword,
- SetNewPassword,
- PasswordUpdated,
-}
-
-export const Login = ({onPressBack}: {onPressBack: () => void}) => {
- const {_} = useLingui()
- const pal = usePalette('default')
-
- const {accounts} = useSession()
- const {track} = useAnalytics()
- const {requestedAccountSwitchTo} = useLoggedOutView()
- const requestedAccount = accounts.find(
- a => a.did === requestedAccountSwitchTo,
- )
-
- const [error, setError] = useState('')
- const [serviceUrl, setServiceUrl] = useState(
- requestedAccount?.service || DEFAULT_SERVICE,
- )
- const [initialHandle, setInitialHandle] = useState(
- requestedAccount?.handle || '',
- )
- const [currentForm, setCurrentForm] = 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)
- }
-
- 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 onPressRetryConnect = () => refetchService()
- const onPressForgotPassword = () => {
- track('Signin:PressedForgotPassword')
- setCurrentForm(Forms.ForgotPassword)
- }
-
- return (
-
- {currentForm === Forms.Login ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.ChooseAccount ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.ForgotPassword ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.SetNewPassword ? (
-
-
-
- ) : undefined}
- {currentForm === Forms.PasswordUpdated ? (
-
-
-
- ) : undefined}
-
- )
-}
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
deleted file mode 100644
index 92f4955756..0000000000
--- a/src/view/com/auth/login/LoginForm.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import React, {useState, useRef} from 'react'
-import {
- ActivityIndicator,
- Keyboard,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {createFullHandle} from 'lib/strings/handles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {useSessionApi} from '#/state/session'
-import {cleanError} from 'lib/strings/errors'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {styles} from './styles'
-import {useLingui} from '@lingui/react'
-import {useDialogControl} from '#/components/Dialog'
-
-import {ServerInputDialog} from '../server-input'
-
-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 pal = usePalette('default')
- const theme = 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 serverInputControl = useDialogControl()
-
- const onPressSelectService = () => {
- serverInputControl.open()
- Keyboard.dismiss()
- track('Signin:PressedSelectService')
- }
-
- const onPressNext = async () => {
- Keyboard.dismiss()
- 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()
- 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 into
-
-
-
-
-
-
- {toNiceDomain(serviceUrl)}
-
-
-
-
-
-
-
-
- Account
-
-
-
-
- {
- passwordInputRef.current?.focus()
- }}
- blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
- keyboardAppearance={theme.colorScheme}
- value={identifier}
- onChangeText={str =>
- setIdentifier((str || '').toLowerCase().trim())
- }
- editable={!isProcessing}
- accessibilityLabel={_(msg`Username or email address`)}
- accessibilityHint={_(
- msg`Input the username or email address you used at signup`,
- )}
- />
-
-
-
-
-
-
- Forgot
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {!serviceDescription && error ? (
-
-
- Retry
-
-
- ) : !serviceDescription ? (
- <>
-
-
- Connecting...
-
- >
- ) : isProcessing ? (
-
- ) : isReady ? (
-
-
- Next
-
-
- ) : undefined}
-
-
- )
-}
diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx
deleted file mode 100644
index 71f750b141..0000000000
--- a/src/view/com/auth/login/PasswordUpdatedForm.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, {useEffect} from 'react'
-import {TouchableOpacity, View} from 'react-native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {styles} from './styles'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export const PasswordUpdatedForm = ({
- onPressNext,
-}: {
- onPressNext: () => void
-}) => {
- const {screen} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
-
- useEffect(() => {
- screen('Signin:PasswordUpdatedForm')
- }, [screen])
-
- return (
- <>
-
-
- Password updated!
-
-
- You can now sign in with your new password.
-
-
-
-
-
- Okay
-
-
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
deleted file mode 100644
index 6d1584c86c..0000000000
--- a/src/view/com/auth/login/SetNewPasswordForm.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {
- ActivityIndicator,
- TextInput,
- TouchableOpacity,
- View,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {BskyAgent} from '@atproto/api'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {s} from 'lib/styles'
-import {isNetworkError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {checkAndFormatResetCode} from 'lib/strings/password'
-import {logger} from '#/logger'
-import {styles} from './styles'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export const SetNewPasswordForm = ({
- error,
- serviceUrl,
- setError,
- onPressBack,
- onPasswordSet,
-}: {
- error: string
- serviceUrl: string
- setError: (v: string) => void
- onPressBack: () => void
- onPasswordSet: () => void
-}) => {
- const pal = usePalette('default')
- const theme = useTheme()
- const {screen} = useAnalytics()
- const {_} = useLingui()
-
- 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(
- '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.
-
-
-
-
-
- setError('')}
- onBlur={onBlur}
- editable={!isProcessing}
- accessible={true}
- accessibilityLabel={_(msg`Reset code`)}
- accessibilityHint={_(
- msg`Input code sent to your email for password reset`,
- )}
- />
-
-
-
-
-
-
- {error ? (
-
-
-
-
-
- {error}
-
-
- ) : undefined}
-
-
-
- Back
-
-
-
- {isProcessing ? (
-
- ) : !resetCode || !password ? (
-
- Next
-
- ) : (
-
-
- Next
-
-
- )}
- {isProcessing ? (
-
- Updating...
-
- ) : undefined}
-
-
- >
- )
-}
diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts
deleted file mode 100644
index 9dccc2803b..0000000000
--- a/src/view/com/auth/login/styles.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import {StyleSheet} from 'react-native'
-import {colors} from 'lib/styles'
-import {isWeb} from '#/platform/detection'
-
-export const styles = StyleSheet.create({
- screenTitle: {
- marginBottom: 10,
- marginHorizontal: 20,
- },
- instructions: {
- marginBottom: 20,
- marginHorizontal: 20,
- },
- group: {
- borderWidth: 1,
- borderRadius: 10,
- marginBottom: 20,
- marginHorizontal: 20,
- },
- groupLabel: {
- paddingHorizontal: 20,
- paddingBottom: 5,
- },
- groupContent: {
- borderTopWidth: 1,
- flexDirection: 'row',
- alignItems: 'center',
- },
- noTopBorder: {
- borderTopWidth: 0,
- },
- groupContentIcon: {
- marginLeft: 10,
- },
- account: {
- borderTopWidth: 1,
- paddingHorizontal: 20,
- paddingVertical: 4,
- },
- accountLast: {
- borderBottomWidth: 1,
- marginBottom: 20,
- paddingVertical: 8,
- },
- textInput: {
- flex: 1,
- width: '100%',
- paddingVertical: 10,
- paddingHorizontal: 12,
- fontSize: 17,
- letterSpacing: 0.25,
- fontWeight: '400',
- borderRadius: 10,
- },
- textInputInnerBtn: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: 6,
- paddingHorizontal: 8,
- marginHorizontal: 6,
- },
- textBtn: {
- flexDirection: 'row',
- flex: 1,
- alignItems: 'center',
- },
- textBtnLabel: {
- flex: 1,
- paddingVertical: 10,
- paddingHorizontal: 12,
- },
- textBtnFakeInnerBtn: {
- flexDirection: 'row',
- alignItems: 'center',
- borderRadius: 6,
- paddingVertical: 6,
- paddingHorizontal: 8,
- marginHorizontal: 6,
- },
- accountText: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'baseline',
- paddingVertical: 10,
- },
- accountTextOther: {
- paddingLeft: 12,
- },
- error: {
- backgroundColor: colors.red4,
- flexDirection: 'row',
- alignItems: 'center',
- marginTop: -5,
- marginHorizontal: 20,
- marginBottom: 15,
- borderRadius: 8,
- paddingHorizontal: 8,
- paddingVertical: 8,
- },
- errorIcon: {
- borderWidth: 1,
- borderColor: colors.white,
- color: colors.white,
- borderRadius: 30,
- width: 16,
- height: 16,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 5,
- },
- dimmed: {opacity: 0.5},
-
- maxHeight: {
- // @ts-ignore web only -prf
- maxHeight: isWeb ? '100vh' : undefined,
- height: !isWeb ? '100%' : undefined,
- },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 238cfc502c..af86f13a3f 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,33 +1,33 @@
-import React, {useRef, useEffect} from 'react'
+import React, {useEffect, useRef} from 'react'
import {StyleSheet} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'
-import BottomSheet from '@gorhom/bottom-sheet'
-import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
-import {usePalette} from 'lib/hooks/usePalette'
+import BottomSheet from '@discord/bottom-sheet/src'
-import {useModals, useModalControls} from '#/state/modals'
-import * as EditProfileModal from './EditProfile'
-import * as RepostModal from './Repost'
-import * as SelfLabelModal from './SelfLabel'
-import * as ThreadgateModal from './Threadgate'
-import * as CreateOrEditListModal from './CreateOrEditList'
-import * as UserAddRemoveListsModal from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddRemoveUsers'
+import {useModalControls, useModals} from '#/state/modals'
+import {usePalette} from 'lib/hooks/usePalette'
+import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
+import * as AddAppPassword from './AddAppPasswords'
import * as AltImageModal from './AltImage'
import * as EditImageModal from './AltImage'
-import * as DeleteAccountModal from './DeleteAccount'
-import * as ChangeHandleModal from './ChangeHandle'
-import * as InviteCodesModal from './InviteCodes'
-import * as AddAppPassword from './AddAppPasswords'
-import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
-import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
+import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
-import * as SwitchAccountModal from './SwitchAccount'
-import * as LinkWarningModal from './LinkWarning'
+import * as CreateOrEditListModal from './CreateOrEditList'
+import * as DeleteAccountModal from './DeleteAccount'
+import * as EditProfileModal from './EditProfile'
import * as EmbedConsentModal from './EmbedConsent'
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
+import * as InviteCodesModal from './InviteCodes'
+import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
+import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
+import * as LinkWarningModal from './LinkWarning'
+import * as ListAddUserModal from './ListAddRemoveUsers'
+import * as RepostModal from './Repost'
+import * as SelfLabelModal from './SelfLabel'
+import * as SwitchAccountModal from './SwitchAccount'
+import * as ThreadgateModal from './Threadgate'
+import * as UserAddRemoveListsModal from './UserAddRemoveLists'
+import * as VerifyEmailModal from './VerifyEmail'
const DEFAULT_SNAPPOINTS = ['90%']
const HANDLE_HEIGHT = 24
diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index 892b07c9a2..03bef719e9 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -5,22 +5,23 @@ import {
TouchableOpacity,
View,
} from 'react-native'
-import {Text} from '../util/text/Text'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
+import {BottomSheetScrollView} from '@discord/bottom-sheet/src'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useProfileQuery} from '#/state/queries/profile'
+import {SessionAccount, useSession, useSessionApi} from '#/state/session'
+import {useCloseAllActiveElements} from '#/state/util'
import {useAnalytics} from 'lib/analytics/analytics'
+import {Haptics} from 'lib/haptics'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
-import {UserAvatar} from '../util/UserAvatar'
+import {usePalette} from 'lib/hooks/usePalette'
+import {makeProfileLink} from 'lib/routes/links'
+import {s} from 'lib/styles'
import {AccountDropdownBtn} from '../util/AccountDropdownBtn'
import {Link} from '../util/Link'
-import {makeProfileLink} from 'lib/routes/links'
-import {BottomSheetScrollView} from '@gorhom/bottom-sheet'
-import {Haptics} from 'lib/haptics'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useSession, useSessionApi, SessionAccount} from '#/state/session'
-import {useProfileQuery} from '#/state/queries/profile'
-import {useCloseAllActiveElements} from '#/state/util'
+import {Text} from '../util/text/Text'
+import {UserAvatar} from '../util/UserAvatar'
export const snapPoints = ['40%', '90%']
diff --git a/src/view/com/modals/util.tsx b/src/view/com/modals/util.tsx
index 06f394ec49..c047a0523c 100644
--- a/src/view/com/modals/util.tsx
+++ b/src/view/com/modals/util.tsx
@@ -1,4 +1,4 @@
export {
BottomSheetScrollView as ScrollView,
BottomSheetTextInput as TextInput,
-} from '@gorhom/bottom-sheet'
+} from '@discord/bottom-sheet/src'
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index ba74ba6d8d..c1159379d6 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -1,36 +1,36 @@
import React, {useEffect, useRef} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
-import {Trans, msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {List, ListMethods} from '../util/List'
-import {PostThreadItem} from './PostThreadItem'
-import {ComposePrompt} from '../composer/Prompt'
-import {ViewHeader} from '../util/ViewHeader'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {
- ThreadNode,
- ThreadPost,
- ThreadNotFound,
- ThreadBlocked,
- usePostThreadQuery,
sortThread,
+ ThreadBlocked,
+ ThreadNode,
+ ThreadNotFound,
+ ThreadPost,
+ usePostThreadQuery,
} from '#/state/queries/post-thread'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
import {
useModerationOpts,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
-import {isAndroid, isNative, isWeb} from '#/platform/detection'
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from 'lib/strings/errors'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {ComposePrompt} from '../composer/Prompt'
+import {List, ListMethods} from '../util/List'
+import {Text} from '../util/text/Text'
+import {ViewHeader} from '../util/ViewHeader'
+import {PostThreadItem} from './PostThreadItem'
// FlatList maintainVisibleContentPosition breaks if too many items
// are prepended. This seems to be an optimal number based on *shrug*.
@@ -371,7 +371,7 @@ export function PostThread({
return (
<>