Merge remote-tracking branch 'origin/main' into eric/mods-ui-fixes
* origin/main: (60 commits) Fix missing error on `PostThread` (#3295) Fix dialogs state references, fixes Android back button (#3314) [Statsig] Protect against early logEvent call crashing (#3315) Fix android splash jump (#3316) [Statsig] Onboarding and routing events (#3302) Fix problems with `BottomSheet` and the report dialog (#3297) bump pkg json version Adjust DateField timezone offset Fix key error on choose account Move some things around Bump button size on choose account Password flow tweaks Login form tweaks Remove log StepInfo tweaks Signup shell tweaks HostingProvider tweaks fix pencil svg in asset folder sort imports for files related to this PR dismiss keyboard when opening dialog ...
This commit is contained in:
+47
-1
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 871 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="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"/></svg>
|
||||
|
After Width: | Height: | Size: 768 B |
+4
-2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+36
-40
@@ -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 (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
{isAndroid && <StatusBar />}
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
|
||||
+79
-75
@@ -1,86 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
CommonActions,
|
||||
StackActions,
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
} from '@react-navigation/native'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
HomeTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
AllNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {State, RouteParams} from 'lib/routes/types'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
setEmailConfirmationRequested,
|
||||
} from './state/shell/reminders'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
CommonActions,
|
||||
createNavigationContainerRef,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
NavigationContainer,
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {RouteParams, State} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {
|
||||
setEmailConfirmationRequested,
|
||||
shouldRequestEmailConfirmation,
|
||||
} from './state/shell/reminders'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -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}
|
||||
</NavigationContainer>
|
||||
@@ -693,11 +697,11 @@ function logModuleInitTime() {
|
||||
}
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
reset,
|
||||
handleLink,
|
||||
TabsNavigator,
|
||||
FlatNavigator,
|
||||
handleLink,
|
||||
navigate,
|
||||
reset,
|
||||
resetToTab,
|
||||
RoutesContainer,
|
||||
TabsNavigator,
|
||||
}
|
||||
|
||||
+78
-6
@@ -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
|
||||
|
||||
+4
-4
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<BottomSheetScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode={keyboardDismissMode || 'on-drag'}
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_xl,
|
||||
@@ -227,24 +224,19 @@ export function ScrollableInner({
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}>
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}
|
||||
ref={ref}>
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
|
||||
const onTouchStart = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
|
||||
onTouchStart={onTouchStart}>
|
||||
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
GestureResponderEvent,
|
||||
ScrollViewProps,
|
||||
} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
import {BottomSheetProps} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SelectLabelerView} from './SelectLabelerView'
|
||||
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||
import {SubmitView} from './SubmitView'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
@@ -38,10 +40,13 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
} = useMyLabelersQuery()
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
|
||||
useOnKeyboardDidShow(() => {
|
||||
ref.current?.scrollToEnd({animated: true})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Report dialog`)}
|
||||
keyboardDismissMode="interactive">
|
||||
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, {height: 100}]}>
|
||||
<Loader size="xl" />
|
||||
@@ -57,8 +62,6 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
) : (
|
||||
<ReportDialogLoaded labelers={labelers} {...props} />
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
onPress={() => 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 : {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
|
||||
<Text
|
||||
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={onPress}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<DatePicker
|
||||
modal={isAndroid}
|
||||
open={isAndroid}
|
||||
modal
|
||||
open
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onConfirm={onChangeInternal}
|
||||
@@ -99,9 +62,9 @@ export function DateField({
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[a.relative, a.w_full]}
|
||||
{...web({
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
android({
|
||||
minHeight: 57.5,
|
||||
}),
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
hovered ? chromeHover : {},
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid || isInvalid ? chromeError : {},
|
||||
(isInvalid || isInvalid) && (hovered || focused)
|
||||
? chromeErrorHover
|
||||
: {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.pl_xs,
|
||||
t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<DatePicker
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={control.open}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Dialog.Outer control={control} testID={testID}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.Inner label={label}>
|
||||
<View style={a.gap_lg}>
|
||||
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||
<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
onPress={() => control.close()}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<TextField.Root isInvalid={isInvalid}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
onChangeText={() => {}}
|
||||
testID={testID}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</TextField.Root>
|
||||
)
|
||||
|
||||
@@ -4,4 +4,5 @@ export type DateFieldProps = {
|
||||
label: string
|
||||
isInvalid?: boolean
|
||||
testID?: string
|
||||
accessibilityHint?: string
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: t.palette.negative_400},
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Warning fill={t.palette.white} size="md" />
|
||||
<View>
|
||||
<Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
<Button
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.pr_sm,
|
||||
a.gap_xs,
|
||||
{paddingVertical: isAndroid ? 14 : 9},
|
||||
]}
|
||||
onPress={onPressSelectService}>
|
||||
{({hovered, pressed}) => {
|
||||
const interacted = hovered || pressed
|
||||
return (
|
||||
<>
|
||||
<View style={a.pr_xs}>
|
||||
<Globe
|
||||
size="md"
|
||||
fill={
|
||||
interacted ? t.palette.contrast_800 : t.palette.contrast_500
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
interacted
|
||||
? t.atoms.bg_contrast_300
|
||||
: t.atoms.bg_contrast_100,
|
||||
{marginLeft: 'auto', padding: 6},
|
||||
]}>
|
||||
<Pencil
|
||||
size="sm"
|
||||
style={{
|
||||
color: interacted
|
||||
? t.palette.contrast_800
|
||||
: t.palette.contrast_500,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<TextInput> | null
|
||||
@@ -125,9 +126,10 @@ export function useSharedInputStyles() {
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
label: string
|
||||
value: string
|
||||
onChangeText: (value: string) => void
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
ref={refs}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
hitSlop={HITSLOP_20}
|
||||
style={[
|
||||
a.relative,
|
||||
@@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
<Comp
|
||||
size="md"
|
||||
style={[
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
|
||||
ctx.hovered ? hover : {},
|
||||
ctx.focused ? focus : {},
|
||||
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
|
||||
export function useOnKeyboardDidShow(cb: () => unknown) {
|
||||
React.useEffect(() => {
|
||||
const subscription = Keyboard.addListener('keyboardDidShow', cb)
|
||||
|
||||
return () => {
|
||||
subscription.remove()
|
||||
}
|
||||
}, [cb])
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<E extends keyof LogEvents>(
|
||||
eventName: E & string,
|
||||
rawMetadata: LogEvents[E] & FlatJSONRecord,
|
||||
) {
|
||||
const fullMetadata = {
|
||||
...rawMetadata,
|
||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
Statsig.logEvent(eventName, null, fullMetadata)
|
||||
try {
|
||||
const fullMetadata = {
|
||||
...rawMetadata,
|
||||
} as Record<string, string> // 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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||
: _(msg`Sign in as ${account.handle}`)
|
||||
}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check size="sm" style={[{color: colors.green3}, a.mr_md]} />
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<FormContainer
|
||||
testID="chooseAccountForm"
|
||||
title={<Trans>Select account</Trans>}>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Sign in as...</Trans>
|
||||
</TextField.Label>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<AccountItem
|
||||
account={account}
|
||||
onSelect={onSelect}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseNewAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={() => onSelectAccount(undefined)}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
]}>
|
||||
<Trans>Other account</Trans>
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_row]}>
|
||||
<Button
|
||||
label={_(msg`Back`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressBack}>
|
||||
{_(msg`Back`)}
|
||||
</Button>
|
||||
<View style={[a.flex_1]} />
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
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 (
|
||||
<FormContainer
|
||||
testID="forgotPasswordForm"
|
||||
title={<Trans>Reset password</Trans>}>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</TextField.Label>
|
||||
<HostingProvider
|
||||
serviceUrl={serviceUrl}
|
||||
onSelectServiceUrl={setServiceUrl}
|
||||
onOpenDialog={onPressSelectService}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Email address</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={At} />
|
||||
<TextField.Input
|
||||
testID="forgotPasswordEmail"
|
||||
label={_(msg`Enter your email address`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={_(msg`Sets email for password reset`)}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||
<Trans>
|
||||
Enter the email you used to create your account. We'll send you a
|
||||
"reset code" so you can set a new password.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<FormError error={error} />
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||
<Button
|
||||
label={_(msg`Back`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressBack}>
|
||||
<ButtonText>
|
||||
<Trans>Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
{!serviceDescription || isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Button
|
||||
label={_(msg`Next`)}
|
||||
variant="solid"
|
||||
color={'primary'}
|
||||
size="medium"
|
||||
onPress={onPressNext}
|
||||
disabled={!email}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
{!serviceDescription || isProcessing ? (
|
||||
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||
<Trans>Processing...</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.border_contrast_medium,
|
||||
a.border_t,
|
||||
a.pt_2xl,
|
||||
a.mt_md,
|
||||
a.flex_row,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<Button
|
||||
testID="skipSendEmailButton"
|
||||
onPress={onEmailSent}
|
||||
label={_(msg`Go to next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Already have a code?</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
@@ -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<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
return (
|
||||
<ScrollView
|
||||
testID={testID}
|
||||
style={[styles.maxHeight, contentContainerStyle]}
|
||||
keyboardShouldPersistTaps="handled">
|
||||
<View
|
||||
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.pt_md], style]}>
|
||||
{title && !gtMobile && (
|
||||
<Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
maxHeight: {
|
||||
// @ts-ignore web only -prf
|
||||
maxHeight: isWeb ? '100vh' : undefined,
|
||||
height: !isWeb ? '100%' : undefined,
|
||||
},
|
||||
})
|
||||
@@ -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<boolean>(false)
|
||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const passwordInputRef = useRef<TextInput>(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 (
|
||||
<FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</TextField.Label>
|
||||
<HostingProvider
|
||||
serviceUrl={serviceUrl}
|
||||
onSelectServiceUrl={setServiceUrl}
|
||||
onOpenDialog={onPressSelectService}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Account</Trans>
|
||||
</TextField.Label>
|
||||
<View style={[a.gap_sm]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={At} />
|
||||
<TextField.Input
|
||||
testID="loginUsernameInput"
|
||||
label={_(msg`Username or email address`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="username"
|
||||
returnKeyType="next"
|
||||
textContentType="username"
|
||||
onSubmitEditing={() => {
|
||||
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`,
|
||||
)}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Lock} />
|
||||
<TextField.Input
|
||||
testID="loginPasswordInput"
|
||||
inputRef={passwordInputRef}
|
||||
label={_(msg`Password`)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
returnKeyType="done"
|
||||
enablesReturnKeyAutomatically={true}
|
||||
secureTextEntry={true}
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onSubmitEditing={onPressNext}
|
||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={
|
||||
identifier === ''
|
||||
? _(msg`Input your password`)
|
||||
: _(msg`Input the password tied to ${identifier}`)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
testID="forgotPasswordButton"
|
||||
onPress={onPressForgotPassword}
|
||||
label={_(msg`Forgot password?`)}
|
||||
accessibilityHint={_(msg`Opens password reset form`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
// t.atoms.bg_contrast_100,
|
||||
{marginLeft: 'auto', left: 6, padding: 6},
|
||||
a.z_10,
|
||||
]}>
|
||||
<ButtonText>
|
||||
<Trans>Forgot?</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
<FormError error={error} />
|
||||
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||
<Button
|
||||
label={_(msg`Back`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressBack}>
|
||||
<ButtonText>
|
||||
<Trans>Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
{!serviceDescription && error ? (
|
||||
<Button
|
||||
testID="loginRetryButton"
|
||||
label={_(msg`Retry`)}
|
||||
accessibilityHint={_(msg`Retries login`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressRetryConnect}>
|
||||
{_(msg`Retry`)}
|
||||
</Button>
|
||||
) : !serviceDescription ? (
|
||||
<>
|
||||
<ActivityIndicator />
|
||||
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||
<Trans>Connecting...</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : isReady ? (
|
||||
<Button
|
||||
label={_(msg`Next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium"
|
||||
onPress={onPressNext}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
) : undefined}
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<FormContainer
|
||||
testID="passwordUpdatedForm"
|
||||
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
|
||||
<Text style={[a.text_3xl, a.font_bold, a.text_center]}>
|
||||
<Trans>Password updated!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
|
||||
<Trans>You can now sign in with your new password.</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.justify_center]}>
|
||||
<Button
|
||||
onPress={onPressNext}
|
||||
label={_(msg`Close alert`)}
|
||||
accessibilityHint={_(msg`Closes password update alert`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium">
|
||||
<ButtonText>
|
||||
<Trans>Okay</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
|
||||
|
||||
export function ScreenTransition({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {Fragment as ScreenTransition} from 'react'
|
||||
@@ -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<boolean>(false)
|
||||
const [resetCode, setResetCode] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
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 (
|
||||
<FormContainer
|
||||
testID="setNewPasswordForm"
|
||||
title={<Trans>Set new password</Trans>}>
|
||||
<Text style={[a.leading_snug, a.mb_sm]}>
|
||||
<Trans>
|
||||
You will receive an email with a "reset code." Enter that code here,
|
||||
then enter your new password.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View>
|
||||
<TextField.Label>Reset code</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Ticket} />
|
||||
<TextField.Input
|
||||
testID="resetCodeInput"
|
||||
label={_(msg`Looks like XXXXX-XXXXX`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus={true}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
value={resetCode}
|
||||
onChangeText={setResetCode}
|
||||
onFocus={() => setError('')}
|
||||
onBlur={onBlur}
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={_(
|
||||
msg`Input code sent to your email for password reset`,
|
||||
)}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<TextField.Label>New password</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Lock} />
|
||||
<TextField.Input
|
||||
testID="newPasswordInput"
|
||||
label={_(msg`Enter a password`)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
returnKeyType="done"
|
||||
secureTextEntry={true}
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onSubmitEditing={onPressNext}
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={_(msg`Input new password`)}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
<FormError error={error} />
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Back`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressBack}>
|
||||
<ButtonText>
|
||||
<Trans>Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Button
|
||||
label={_(msg`Next`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium"
|
||||
onPress={onPressNext}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
|
||||
<Trans>Updating...</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
}
|
||||
@@ -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<string>('')
|
||||
const [serviceUrl, setServiceUrl] = React.useState<string>(
|
||||
requestedAccount?.service || DEFAULT_SERVICE,
|
||||
)
|
||||
const [initialHandle, setInitialHandle] = React.useState<string>(
|
||||
requestedAccount?.handle || '',
|
||||
)
|
||||
const [currentForm, setCurrentForm] = React.useState<Forms>(
|
||||
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 = (
|
||||
<LoginForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
serviceDescription={serviceDescription}
|
||||
initialHandle={initialHandle}
|
||||
setError={setError}
|
||||
setServiceUrl={setServiceUrl}
|
||||
onPressBack={() =>
|
||||
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 = (
|
||||
<ChooseAccountForm
|
||||
onSelectAccount={onSelectAccount}
|
||||
onPressBack={onPressBack}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case Forms.ForgotPassword:
|
||||
title = _(msg`Forgot Password`)
|
||||
description = _(msg`Let's get your password reset!`)
|
||||
content = (
|
||||
<ForgotPasswordForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
serviceDescription={serviceDescription}
|
||||
setError={setError}
|
||||
setServiceUrl={setServiceUrl}
|
||||
onPressBack={() => gotoForm(Forms.Login)}
|
||||
onEmailSent={() => gotoForm(Forms.SetNewPassword)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case Forms.SetNewPassword:
|
||||
title = _(msg`Forgot Password`)
|
||||
description = _(msg`Let's get your password reset!`)
|
||||
content = (
|
||||
<SetNewPasswordForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
setError={setError}
|
||||
onPressBack={() => 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 = (
|
||||
<PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
|
||||
<LoggedOutLayout leadin="" title={title} description={description}>
|
||||
<LayoutAnimationConfig skipEntering skipExiting>
|
||||
<ScreenTransition key={currentForm}>{content}</ScreenTransition>
|
||||
</LayoutAnimationConfig>
|
||||
</LoggedOutLayout>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+9
-8
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<ScreenTransition>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[styles.container, completed && styles.center]}>
|
||||
{!completed ? (
|
||||
<CaptchaWebView
|
||||
url={url}
|
||||
stateParam={stateParam}
|
||||
state={state}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<ActivityIndicator size="large" />
|
||||
)}
|
||||
</View>
|
||||
<FormError error={state.error} />
|
||||
</View>
|
||||
</ScreenTransition>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -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<IsValidHandle>({
|
||||
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 (
|
||||
<ScreenTransition>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={At} />
|
||||
<TextField.Input
|
||||
onChangeText={onHandleChange}
|
||||
label={_(msg`Input your user handle`)}
|
||||
defaultValue={state.handle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text style={[a.text_md, a.font_bold]}>
|
||||
@{createFullHandle(state.handle, state.userDomain)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{state.error ? (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={false} />
|
||||
<Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
{validCheck.hyphenStartOrEnd ? (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={validCheck.handleChars} />
|
||||
<Text style={[a.text_md, a.flex_1]}>
|
||||
<Trans>Only contains letters, numbers, and hyphens</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={validCheck.hyphenStartOrEnd} />
|
||||
<Text style={[a.text_md, a.flex_1]}>
|
||||
<Trans>Doesn't begin or end with a hyphen</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon
|
||||
valid={validCheck.frontLength && validCheck.totalLength}
|
||||
/>
|
||||
{!validCheck.totalLength ? (
|
||||
<Text style={[a.text_md, a.flex_1]}>
|
||||
<Trans>No longer than 253 characters</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[a.text_md, a.flex_1]}>
|
||||
<Trans>At least 3 characters</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScreenTransition>
|
||||
)
|
||||
}
|
||||
|
||||
function IsValidIcon({valid}: {valid: boolean}) {
|
||||
const t = useTheme()
|
||||
if (!valid) {
|
||||
return <Times size="md" style={{color: t.palette.negative_500}} />
|
||||
}
|
||||
return <Check size="md" style={{color: t.palette.positive_700}} />
|
||||
}
|
||||
@@ -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 <View />
|
||||
}
|
||||
|
||||
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
||||
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
||||
|
||||
if (!tos && !pp) {
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
|
||||
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
This service has not provided terms of service or a privacy policy.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const els = []
|
||||
if (tos) {
|
||||
els.push(
|
||||
<InlineLink key="tos" to={tos}>
|
||||
{_(msg`Terms of Service`)}
|
||||
</InlineLink>,
|
||||
)
|
||||
}
|
||||
if (pp) {
|
||||
els.push(
|
||||
<InlineLink key="pp" to={pp}>
|
||||
{_(msg`Privacy Policy`)}
|
||||
</InlineLink>,
|
||||
)
|
||||
}
|
||||
if (els.length === 2) {
|
||||
els.splice(
|
||||
1,
|
||||
0,
|
||||
<Text key="and" style={[t.atoms.text_contrast_medium]}>
|
||||
{' '}
|
||||
and{' '}
|
||||
</Text>,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>By creating an account you agree to the {els}.</Trans>
|
||||
</Text>
|
||||
|
||||
{under13 ? (
|
||||
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
You must be 13 years of age or older to sign up.
|
||||
</Text>
|
||||
) : needsGuardian ? (
|
||||
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function validWebLink(url?: string): string | undefined {
|
||||
return url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||
? url
|
||||
: undefined
|
||||
}
|
||||
@@ -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 (
|
||||
<ScreenTransition>
|
||||
<View style={[a.gap_md]}>
|
||||
<FormError error={state.error} />
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</TextField.Label>
|
||||
<HostingProvider
|
||||
serviceUrl={state.serviceUrl}
|
||||
onSelectServiceUrl={v =>
|
||||
dispatch({type: 'setServiceUrl', value: v})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{state.isLoading ? (
|
||||
<View style={[a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : state.serviceDescription ? (
|
||||
<>
|
||||
{state.serviceDescription.inviteCodeRequired && (
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Invite code</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Ticket} />
|
||||
<TextField.Input
|
||||
onChangeText={value => {
|
||||
dispatch({
|
||||
type: 'setInviteCode',
|
||||
value: value.trim(),
|
||||
})
|
||||
}}
|
||||
label={_(msg`Required for this provider`)}
|
||||
defaultValue={state.inviteCode}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Email</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Envelope} />
|
||||
<TextField.Input
|
||||
onChangeText={value => {
|
||||
dispatch({
|
||||
type: 'setEmail',
|
||||
value: value.trim(),
|
||||
})
|
||||
}}
|
||||
label={_(msg`Enter your email address`)}
|
||||
defaultValue={state.email}
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<View>
|
||||
<TextField.Label>
|
||||
<Trans>Password</Trans>
|
||||
</TextField.Label>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Lock} />
|
||||
<TextField.Input
|
||||
onChangeText={value => {
|
||||
dispatch({
|
||||
type: 'setPassword',
|
||||
value,
|
||||
})
|
||||
}}
|
||||
label={_(msg`Choose your password`)}
|
||||
defaultValue={state.password}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<View>
|
||||
<DateField.Label>
|
||||
<Trans>Your birth date</Trans>
|
||||
</DateField.Label>
|
||||
<DateField.DateField
|
||||
testID="date"
|
||||
value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
|
||||
onChangeDate={date => {
|
||||
dispatch({
|
||||
type: 'setDateOfBirth',
|
||||
value: sanitizeDate(new Date(date)),
|
||||
})
|
||||
}}
|
||||
label={_(msg`Date of birth`)}
|
||||
accessibilityHint={_(msg`Select your date of birth`)}
|
||||
/>
|
||||
</View>
|
||||
<Policies
|
||||
serviceDescription={state.serviceDescription}
|
||||
needsGuardian={!is18(state.dateOfBirth)}
|
||||
under13={!is13(state.dateOfBirth)}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</View>
|
||||
</ScreenTransition>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<SignupContext.Provider value={{state, dispatch}}>
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Create Account`)}
|
||||
description={_(msg`We're so excited to have you join us!`)}>
|
||||
<ScrollView
|
||||
testID="createAccount"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={a.h_full}
|
||||
keyboardDismissMode="on-drag">
|
||||
<View style={[a.flex_1, a.px_xl, a.pt_2xl, {paddingBottom: 100}]}>
|
||||
<View style={[a.gap_sm, a.pb_3xl]}>
|
||||
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
|
||||
{state.serviceDescription &&
|
||||
!state.serviceDescription.phoneVerificationRequired
|
||||
? '2'
|
||||
: '3'}
|
||||
</Text>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
{state.activeStep === SignupStep.INFO ? (
|
||||
<Trans>Your account</Trans>
|
||||
) : state.activeStep === SignupStep.HANDLE ? (
|
||||
<Trans>Your user handle</Trans>
|
||||
) : (
|
||||
<Trans>Complete the challenge</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_3xl]}>
|
||||
{state.activeStep === SignupStep.INFO ? (
|
||||
<StepInfo />
|
||||
) : state.activeStep === SignupStep.HANDLE ? (
|
||||
<StepHandle />
|
||||
) : (
|
||||
<StepCaptcha />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.justify_between, a.pb_lg]}>
|
||||
<Button
|
||||
label="Back"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onBackPress}>
|
||||
Back
|
||||
</Button>
|
||||
{state.activeStep !== SignupStep.CAPTCHA && (
|
||||
<>
|
||||
{isError ? (
|
||||
<Button
|
||||
label="Retry"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium"
|
||||
disabled={state.isLoading}
|
||||
onPress={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
label="Next"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium"
|
||||
disabled={!state.canNext || state.isLoading}
|
||||
onPress={onNextPress}>
|
||||
<ButtonText>Next</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.w_full, a.py_lg]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Having trouble?</Trans>{' '}
|
||||
<InlineLink to={FEEDBACK_FORM_URL({email: state.email})}>
|
||||
<Trans>Contact support</Trans>
|
||||
</InlineLink>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</LoggedOutLayout>
|
||||
</SignupContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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<SignupAction>
|
||||
}
|
||||
export const SignupContext = React.createContext<IContext>({} 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,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -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<IDialogContext>(
|
||||
() => ({
|
||||
activeDialogs: {
|
||||
current: new Map(),
|
||||
},
|
||||
openDialogs: {
|
||||
current: new Set(),
|
||||
},
|
||||
activeDialogs,
|
||||
openDialogs,
|
||||
importantForAccessibility,
|
||||
}),
|
||||
[importantForAccessibility],
|
||||
[importantForAccessibility, activeDialogs, openDialogs],
|
||||
)
|
||||
const controls = React.useMemo(
|
||||
() => ({closeAllDialogs, setDialogIsOpen}),
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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 ? (
|
||||
<SplashScreen
|
||||
onPressSignin={() => 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 ? (
|
||||
<CreateAccount
|
||||
<Signup
|
||||
onPressBack={() =>
|
||||
setScreenState(ScreenState.S_LoginOrCreateAccount)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
<CenteredView style={[a.h_full, a.flex_1]}>
|
||||
<ErrorBoundary>
|
||||
<View style={styles.hero}>
|
||||
<View style={[{flex: 1}, a.justify_center, a.align_center]}>
|
||||
<Logo width={92} fill="sky" />
|
||||
|
||||
<View style={{paddingTop: 40, paddingBottom: 6}}>
|
||||
<Logotype width={161} fill={pal.text.color} />
|
||||
<View style={[a.pb_sm, a.pt_5xl]}>
|
||||
<Logotype width={161} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
|
||||
<Text type="lg-medium" style={[pal.textLight]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_semibold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>What's up?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View testID="signinOrCreateAccount" style={styles.btns}>
|
||||
<TouchableOpacity
|
||||
<View testID="signinOrCreateAccount">
|
||||
<Button
|
||||
testID="createAccountButton"
|
||||
style={[styles.btn, {backgroundColor: colors.blue3}]}
|
||||
onPress={onPressCreateAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Create new account`)}
|
||||
label={_(msg`Create new account`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}>
|
||||
<Text style={[s.white, styles.btnLabel]}>
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create a new account</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
testID="signInButton"
|
||||
style={[styles.btn, pal.btn]}
|
||||
onPress={onPressSignin}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign in`)}
|
||||
label={_(msg`Sign in`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}>
|
||||
<Text style={[pal.text, styles.btnLabel]}>
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign In</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<View style={{position: 'relative'}}>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_md,
|
||||
a.pb_2xl,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<View style={a.relative}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
@@ -101,33 +112,28 @@ export const SplashScreen = ({
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: pal.textLight.color,
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: pal.text.color,
|
||||
color: t.atoms.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 10 + 4,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
size={10}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{left: 'auto'},
|
||||
{pointerEvents: 'none'},
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -136,44 +142,3 @@ export const SplashScreen = ({
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<CenteredView style={[styles.container, pal.view]}>
|
||||
<CenteredView style={[a.h_full, a.flex_1]}>
|
||||
<View
|
||||
testID="noSessionView"
|
||||
style={[
|
||||
styles.containerInner,
|
||||
isMobileWeb && styles.containerInnerMobile,
|
||||
pal.border,
|
||||
{alignItems: 'center'},
|
||||
a.h_full,
|
||||
a.justify_center,
|
||||
// @ts-ignore web only
|
||||
{paddingBottom: '20vh'},
|
||||
isMobileWeb && a.pb_5xl,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.align_center,
|
||||
a.gap_5xl,
|
||||
]}>
|
||||
<ErrorBoundary>
|
||||
<Logo width={92} fill="sky" />
|
||||
<View style={[a.justify_center, a.align_center]}>
|
||||
<Logo width={92} fill="sky" />
|
||||
|
||||
<View style={{paddingTop: 40, paddingBottom: 20}}>
|
||||
<Logotype width={161} fill={pal.text.color} />
|
||||
<View style={[a.pb_sm, a.pt_5xl]}>
|
||||
<Logotype width={161} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semibold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>What's up?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View testID="signinOrCreateAccount" style={styles.btns}>
|
||||
<TouchableOpacity
|
||||
<View
|
||||
testID="signinOrCreateAccount"
|
||||
style={[a.w_full, {maxWidth: 320}]}>
|
||||
<Button
|
||||
testID="createAccountButton"
|
||||
style={[styles.btn, {backgroundColor: colors.blue3}]}
|
||||
onPress={onPressCreateAccount}
|
||||
// TODO: web accessibility
|
||||
accessibilityRole="button">
|
||||
<Text style={[s.white, styles.btnLabel]}>
|
||||
accessibilityRole="button"
|
||||
label={_(msg`Create new account`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create a new account</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
testID="signInButton"
|
||||
style={[styles.btn, pal.btn]}
|
||||
onPress={onPressSignin}
|
||||
// TODO: web accessibility
|
||||
accessibilityRole="button">
|
||||
<Text style={[pal.text, styles.btnLabel]}>
|
||||
label={_(msg`Sign in`)}
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}
|
||||
style={[a.mx_xl, a.mb_xl]}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign In</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
<Footer styles={styles} />
|
||||
<Footer />
|
||||
</CenteredView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
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<typeof useStyles>}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.footer, pal.view, pal.border]}>
|
||||
<TextLink
|
||||
href="https://bsky.social"
|
||||
text={_(msg`Business`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/blog"
|
||||
text={_(msg`Blog`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<TextLink
|
||||
href="https://bsky.social/about/join"
|
||||
text={_(msg`Jobs`)}
|
||||
style={[styles.footerLink, pal.link]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{top: 'auto'},
|
||||
a.p_xl,
|
||||
a.border_t,
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.gap_xl,
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<InlineLink to="https://bsky.social">
|
||||
<Trans>Business</Trans>
|
||||
</InlineLink>
|
||||
<InlineLink to="https://bsky.social/about/blog">
|
||||
<Trans>Blog</Trans>
|
||||
</InlineLink>
|
||||
<InlineLink to="https://bsky.social/about/join">
|
||||
<Trans>Jobs</Trans>
|
||||
</InlineLink>
|
||||
|
||||
<View style={styles.footerDivider} />
|
||||
<View style={a.flex_1} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
}}>
|
||||
<Text aria-hidden={true} style={[pal.textLight]}>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.flex_shrink]}>
|
||||
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
|
||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="chevron-down"
|
||||
size={12}
|
||||
style={[pal.textLight, {flexShrink: 0}]}
|
||||
<ChevronDown
|
||||
fill={t.atoms.text.color}
|
||||
size="xs"
|
||||
style={a.flex_shrink}
|
||||
/>
|
||||
|
||||
<select
|
||||
@@ -183,79 +210,3 @@ function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
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: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Create Account`)}
|
||||
description={_(msg`We're so excited to have you join us!`)}>
|
||||
<ScrollView
|
||||
testID="createAccount"
|
||||
style={pal.view}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag">
|
||||
<View style={styles.stepContainer}>
|
||||
{uiState.step === 1 && (
|
||||
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
|
||||
)}
|
||||
{uiState.step === 2 && (
|
||||
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
|
||||
)}
|
||||
{uiState.step === 3 && (
|
||||
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
|
||||
)}
|
||||
</View>
|
||||
<View style={[s.flexRow, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressBackInner}
|
||||
testID="backBtn"
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text type="xl" style={pal.link}>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{uiState.canNext ? (
|
||||
<TouchableOpacity
|
||||
testID="nextBtn"
|
||||
onPress={onPressNext}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}>
|
||||
{uiState.isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : serviceInfoError ? (
|
||||
<TouchableOpacity
|
||||
testID="retryConnectBtn"
|
||||
onPress={() => refetchServiceInfo()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Retry`)}
|
||||
accessibilityHint=""
|
||||
accessibilityLiveRegion="polite"
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Retry</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : serviceInfoIsFetching ? (
|
||||
<>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text type="xl" style={[pal.text, s.pr5]}>
|
||||
<Trans>Connecting...</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : undefined}
|
||||
</View>
|
||||
|
||||
<View style={styles.stepContainer}>
|
||||
<View
|
||||
style={[
|
||||
s.flexRow,
|
||||
s.alignCenter,
|
||||
pal.viewLight,
|
||||
{borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
|
||||
]}>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
<Trans>Having trouble?</Trans>{' '}
|
||||
</Text>
|
||||
<TextLink
|
||||
type="md"
|
||||
style={pal.link}
|
||||
text={_(msg`Contact support`)}
|
||||
href={FEEDBACK_FORM_URL({email: uiState.email})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
|
||||
</ScrollView>
|
||||
</LoggedOutLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stepContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
})
|
||||
@@ -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 <View />
|
||||
}
|
||||
const tos = validWebLink(serviceDescription.links?.termsOfService)
|
||||
const pp = validWebLink(serviceDescription.links?.privacyPolicy)
|
||||
if (!tos && !pp) {
|
||||
return (
|
||||
<View style={[styles.policies, {flexDirection: 'row'}]}>
|
||||
<View
|
||||
style={[
|
||||
styles.errorIcon,
|
||||
{borderColor: pal.colors.text, marginTop: 1},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[pal.textLight, s.pl5, s.flex1]}>
|
||||
<Trans>
|
||||
This service has not provided terms of service or a privacy policy.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const els = []
|
||||
if (tos) {
|
||||
els.push(
|
||||
<TextLink
|
||||
key="tos"
|
||||
href={tos}
|
||||
text={_(msg`Terms of Service`)}
|
||||
style={[pal.link, s.underline]}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
if (pp) {
|
||||
els.push(
|
||||
<TextLink
|
||||
key="pp"
|
||||
href={pp}
|
||||
text={_(msg`Privacy Policy`)}
|
||||
style={[pal.link, s.underline]}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
if (els.length === 2) {
|
||||
els.splice(
|
||||
1,
|
||||
0,
|
||||
<Text key="and" style={pal.textLight}>
|
||||
{' '}
|
||||
and{' '}
|
||||
</Text>,
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={styles.policies}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>By creating an account you agree to the {els}.</Trans>
|
||||
</Text>
|
||||
{needsGuardian && (
|
||||
<Text style={[pal.textLight, s.bold]}>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -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 (
|
||||
<View>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
|
||||
/>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Hosting provider</Trans>
|
||||
</Text>
|
||||
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
|
||||
<View
|
||||
style={[
|
||||
pal.borderDark,
|
||||
{flexDirection: 'row', alignItems: 'center'},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, {marginLeft: 14}]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="selectServiceButton"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={onPressSelectService}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.text,
|
||||
{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
paddingRight: 12,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
]}>
|
||||
{toNiceDomain(uiState.serviceUrl)}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
pal.btn,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 8,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!uiState.serviceDescription ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<>
|
||||
{uiState.isInviteCodeRequired && (
|
||||
<View style={s.pb20}>
|
||||
<Text type="md-medium" style={[pal.text, s.mb2]}>
|
||||
<Trans>Invite code</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="inviteCodeInput"
|
||||
icon="ticket"
|
||||
placeholder={_(msg`Required for this provider`)}
|
||||
value={uiState.inviteCode}
|
||||
editable
|
||||
onChange={value => 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}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!uiState.isInviteCodeRequired || uiState.inviteCode ? (
|
||||
<>
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="email">
|
||||
<Trans>Email address</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="emailInput"
|
||||
icon="envelope"
|
||||
placeholder={_(msg`Enter your email address`)}
|
||||
value={uiState.email}
|
||||
editable
|
||||
onChange={value => 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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="password">
|
||||
<Trans>Password</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="passwordInput"
|
||||
icon="lock"
|
||||
placeholder={_(msg`Choose your password`)}
|
||||
value={uiState.password}
|
||||
editable
|
||||
secureTextEntry
|
||||
onChange={value => uiDispatch({type: 'set-password', value})}
|
||||
accessibilityLabel={_(msg`Password`)}
|
||||
accessibilityHint={_(msg`Set password`)}
|
||||
accessibilityLabelledBy="password"
|
||||
autoCapitalize="none"
|
||||
autoComplete="new-password"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
type="md-medium"
|
||||
style={[pal.text, s.mb2]}
|
||||
nativeID="birthDate">
|
||||
<Trans>Your birth date</Trans>
|
||||
</Text>
|
||||
<DateInput
|
||||
handleAsUTC
|
||||
testID="birthdayInput"
|
||||
value={birthDate}
|
||||
onChange={value =>
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{uiState.serviceDescription && (
|
||||
<Policies
|
||||
serviceDescription={uiState.serviceDescription}
|
||||
needsGuardian={!is18(uiState)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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'}),
|
||||
},
|
||||
})
|
||||
@@ -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<IsValidHandle>({
|
||||
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 (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
|
||||
<View style={s.pb10}>
|
||||
<View style={s.mb20}>
|
||||
<TextInput
|
||||
testID="handleInput"
|
||||
icon="at"
|
||||
placeholder="e.g. alice"
|
||||
value={uiState.handle}
|
||||
editable
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChange={onHandleChange}
|
||||
// TODO: Add explicit text label
|
||||
accessibilityLabel={_(msg`User handle`)}
|
||||
accessibilityHint={_(msg`Input your user handle`)}
|
||||
/>
|
||||
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
|
||||
<Trans>Your full handle will be</Trans>{' '}
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
@{createFullHandle(uiState.handle, uiState.userDomain)}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{uiState.error ? (
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={false} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
{uiState.error}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon valid={validCheck.handleChars} />
|
||||
<Text style={[t.atoms.text, a.text_md, a.flex]}>
|
||||
<Trans>May only contain letters and numbers</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<IsValidIcon
|
||||
valid={validCheck.frontLength && validCheck.totalLength}
|
||||
/>
|
||||
{!validCheck.totalLength ? (
|
||||
<Text style={[t.atoms.text]}>
|
||||
<Trans>May not be longer than 253 characters</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[t.atoms.text, a.text_md]}>
|
||||
<Trans>Must be at least 3 characters</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function IsValidIcon({valid}: {valid: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!valid) {
|
||||
return <Times size="md" style={{color: t.palette.negative_500}} />
|
||||
}
|
||||
|
||||
return <Check size="md" style={{color: t.palette.positive_700}} />
|
||||
}
|
||||
@@ -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 (
|
||||
<View>
|
||||
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
|
||||
<View style={[styles.container, completed && styles.center]}>
|
||||
{!completed ? (
|
||||
<CaptchaWebView
|
||||
url={url}
|
||||
stateParam={stateParam}
|
||||
uiState={uiState}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<ActivityIndicator size="large" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{uiState.error ? (
|
||||
<ErrorMessage message={uiState.error} style={styles.error} />
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View>
|
||||
<Text type="lg" style={[pal.textLight]}>
|
||||
{uiState.step === 3 ? (
|
||||
<Trans>Last step!</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Step {uiState.step} of {numSteps}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text style={[pal.text]} type="title-xl">
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<TouchableOpacity
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[pal.view, pal.border, styles.account]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
|
||||
accessibilityHint={_(msg`Double tap to sign in`)}>
|
||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<View style={s.p10}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={30}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.accountText}>
|
||||
<Text type="lg-bold" style={pal.text}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text type="lg" style={[pal.textLight]}>
|
||||
{account.handle}
|
||||
</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
size={16}
|
||||
style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
size={16}
|
||||
style={[pal.text, s.mr10]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
|
||||
<Text
|
||||
type="2xl-medium"
|
||||
style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
|
||||
<Trans>Sign in as...</Trans>
|
||||
</Text>
|
||||
{accounts.map(account => (
|
||||
<AccountItem
|
||||
key={account.did}
|
||||
account={account}
|
||||
onSelect={onSelect}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
testID="chooseNewAccountBtn"
|
||||
style={[pal.view, pal.border, styles.account, styles.accountLast]}
|
||||
onPress={() => onSelectAccount(undefined)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Login to account that is not listed`)}
|
||||
accessibilityHint="">
|
||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<Text style={[styles.accountText, styles.accountTextOther]}>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Other account</Trans>
|
||||
</Text>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
size={16}
|
||||
style={[pal.text, s.mr10]}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
const [email, setEmail] = useState<string>('')
|
||||
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 (
|
||||
<>
|
||||
<View>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
<Trans>Reset password</Trans>
|
||||
</Text>
|
||||
<Text type="md" style={[pal.text, styles.instructions]}>
|
||||
<Trans>
|
||||
Enter the email you used to create your account. We'll send you a
|
||||
"reset code" so you can set a new password.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
testID="forgotPasswordView"
|
||||
style={[pal.borderDark, pal.view, styles.group]}>
|
||||
<TouchableOpacity
|
||||
testID="forgotPasswordSelectServiceButton"
|
||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
|
||||
onPress={onPressSelectService}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Hosting provider`)}
|
||||
accessibilityHint={_(
|
||||
msg`Sets hosting provider for password reset`,
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<Text style={[pal.text, styles.textInput]} numberOfLines={1}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={[pal.borderDark, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="envelope"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="forgotPasswordEmail"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder={_(msg`Email address`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
editable={!isProcessing}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(msg`Sets email for password reset`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{error ? (
|
||||
<View style={styles.error}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{!serviceDescription || isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : !email ? (
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
testID="newPasswordButton"
|
||||
onPress={onPressNext}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Go to next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!serviceDescription || isProcessing ? (
|
||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||
<Trans>Processing...</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
s.flexRow,
|
||||
s.alignCenter,
|
||||
s.mt20,
|
||||
s.mb20,
|
||||
pal.border,
|
||||
s.borderBottom1,
|
||||
{alignSelf: 'center', width: '90%'},
|
||||
]}
|
||||
/>
|
||||
<View style={[s.flexRow, s.justifyCenter]}>
|
||||
<TouchableOpacity
|
||||
testID="skipSendEmailButton"
|
||||
onPress={onEmailSent}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Go to next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
||||
<Text type="xl" style={[pal.link, s.pr5]}>
|
||||
<Trans>Already have a code?</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<string>('')
|
||||
const [serviceUrl, setServiceUrl] = useState<string>(
|
||||
requestedAccount?.service || DEFAULT_SERVICE,
|
||||
)
|
||||
const [initialHandle, setInitialHandle] = useState<string>(
|
||||
requestedAccount?.handle || '',
|
||||
)
|
||||
const [currentForm, setCurrentForm] = useState<Forms>(
|
||||
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 (
|
||||
<KeyboardAvoidingView testID="signIn" behavior="padding" style={pal.view}>
|
||||
{currentForm === Forms.Login ? (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Sign in`)}
|
||||
description={_(msg`Enter your username and password`)}>
|
||||
<LoginForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
serviceDescription={serviceDescription}
|
||||
initialHandle={initialHandle}
|
||||
setError={setError}
|
||||
setServiceUrl={setServiceUrl}
|
||||
onPressBack={onPressBack}
|
||||
onPressForgotPassword={onPressForgotPassword}
|
||||
onPressRetryConnect={onPressRetryConnect}
|
||||
/>
|
||||
</LoggedOutLayout>
|
||||
) : undefined}
|
||||
{currentForm === Forms.ChooseAccount ? (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Sign in as...`)}
|
||||
description={_(msg`Select from an existing account`)}>
|
||||
<ChooseAccountForm
|
||||
onSelectAccount={onSelectAccount}
|
||||
onPressBack={onPressBack}
|
||||
/>
|
||||
</LoggedOutLayout>
|
||||
) : undefined}
|
||||
{currentForm === Forms.ForgotPassword ? (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Forgot Password`)}
|
||||
description={_(msg`Let's get your password reset!`)}>
|
||||
<ForgotPasswordForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
serviceDescription={serviceDescription}
|
||||
setError={setError}
|
||||
setServiceUrl={setServiceUrl}
|
||||
onPressBack={gotoForm(Forms.Login)}
|
||||
onEmailSent={gotoForm(Forms.SetNewPassword)}
|
||||
/>
|
||||
</LoggedOutLayout>
|
||||
) : undefined}
|
||||
{currentForm === Forms.SetNewPassword ? (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Forgot Password`)}
|
||||
description={_(msg`Let's get your password reset!`)}>
|
||||
<SetNewPasswordForm
|
||||
error={error}
|
||||
serviceUrl={serviceUrl}
|
||||
setError={setError}
|
||||
onPressBack={gotoForm(Forms.ForgotPassword)}
|
||||
onPasswordSet={gotoForm(Forms.PasswordUpdated)}
|
||||
/>
|
||||
</LoggedOutLayout>
|
||||
) : undefined}
|
||||
{currentForm === Forms.PasswordUpdated ? (
|
||||
<LoggedOutLayout
|
||||
leadin=""
|
||||
title={_(msg`Password updated`)}
|
||||
description={_(msg`You can now sign in with your new password.`)}>
|
||||
<PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
|
||||
</LoggedOutLayout>
|
||||
) : undefined}
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const passwordInputRef = useRef<TextInput>(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 (
|
||||
<View testID="loginForm">
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={setServiceUrl}
|
||||
/>
|
||||
|
||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||
<Trans>Sign into</Trans>
|
||||
</Text>
|
||||
<View style={[pal.borderDark, styles.group]}>
|
||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="globe"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="loginSelectServiceButton"
|
||||
style={styles.textBtn}
|
||||
onPress={onPressSelectService}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Select service`)}
|
||||
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
|
||||
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
|
||||
{toNiceDomain(serviceUrl)}
|
||||
</Text>
|
||||
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
|
||||
<Trans>Account</Trans>
|
||||
</Text>
|
||||
<View style={[pal.borderDark, styles.group]}>
|
||||
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="at"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="loginUsernameInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder={_(msg`Username or email address`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="username"
|
||||
returnKeyType="next"
|
||||
textContentType="username"
|
||||
onSubmitEditing={() => {
|
||||
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`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[pal.borderDark, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="lock"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="loginPasswordInput"
|
||||
ref={passwordInputRef}
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder={_(msg`Password`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
returnKeyType="done"
|
||||
enablesReturnKeyAutomatically={true}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
secureTextEntry={true}
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
onSubmitEditing={onPressNext}
|
||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||
editable={!isProcessing}
|
||||
accessibilityLabel={_(msg`Password`)}
|
||||
accessibilityHint={
|
||||
identifier === ''
|
||||
? _(msg`Input your password`)
|
||||
: _(msg`Input the password tied to ${identifier}`)
|
||||
}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
testID="forgotPasswordButton"
|
||||
style={styles.textInputInnerBtn}
|
||||
onPress={onPressForgotPassword}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Forgot password`)}
|
||||
accessibilityHint={_(msg`Opens password reset form`)}>
|
||||
<Text style={pal.link}>
|
||||
<Trans>Forgot</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{error ? (
|
||||
<View style={styles.error}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{!serviceDescription && error ? (
|
||||
<TouchableOpacity
|
||||
testID="loginRetryButton"
|
||||
onPress={onPressRetryConnect}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Retry`)}
|
||||
accessibilityHint={_(msg`Retries login`)}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Retry</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : !serviceDescription ? (
|
||||
<>
|
||||
<ActivityIndicator />
|
||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||
<Trans>Connecting...</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : isReady ? (
|
||||
<TouchableOpacity
|
||||
testID="loginNextButton"
|
||||
onPress={onPressNext}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Go to next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : undefined}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<View>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
<Trans>Password updated!</Trans>
|
||||
</Text>
|
||||
<Text type="lg" style={[pal.text, styles.instructions]}>
|
||||
<Trans>You can now sign in with your new password.</Trans>
|
||||
</Text>
|
||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity
|
||||
onPress={onPressNext}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Close alert`)}
|
||||
accessibilityHint={_(msg`Closes password update alert`)}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Okay</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
const [resetCode, setResetCode] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
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 (
|
||||
<>
|
||||
<View>
|
||||
<Text type="title-lg" style={[pal.text, styles.screenTitle]}>
|
||||
<Trans>Set new password</Trans>
|
||||
</Text>
|
||||
<Text type="lg" style={[pal.text, styles.instructions]}>
|
||||
<Trans>
|
||||
You will receive an email with a "reset code." Enter that code here,
|
||||
then enter your new password.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
testID="newPasswordView"
|
||||
style={[pal.view, pal.borderDark, styles.group]}>
|
||||
<View
|
||||
style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
|
||||
<FontAwesomeIcon
|
||||
icon="ticket"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="resetCodeInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder={_(msg`Reset code`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
autoComplete="off"
|
||||
value={resetCode}
|
||||
onChangeText={setResetCode}
|
||||
onFocus={() => setError('')}
|
||||
onBlur={onBlur}
|
||||
editable={!isProcessing}
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Reset code`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input code sent to your email for password reset`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[pal.borderDark, styles.groupContent]}>
|
||||
<FontAwesomeIcon
|
||||
icon="lock"
|
||||
style={[pal.textLight, styles.groupContentIcon]}
|
||||
/>
|
||||
<TextInput
|
||||
testID="newPasswordInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder={_(msg`New password`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="new-password"
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
editable={!isProcessing}
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Password`)}
|
||||
accessibilityHint={_(msg`Input new password`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{error ? (
|
||||
<View style={styles.error}>
|
||||
<View style={styles.errorIcon}>
|
||||
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
<Text style={[s.white, s.bold]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
|
||||
<TouchableOpacity onPress={onPressBack} accessibilityRole="button">
|
||||
<Text type="xl" style={[pal.link, s.pl5]}>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator />
|
||||
) : !resetCode || !password ? (
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
testID="setNewPasswordButton"
|
||||
// Check the code before running the callback
|
||||
onPress={onPressNext}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Go to next`)}
|
||||
accessibilityHint={_(msg`Navigates to the next screen`)}>
|
||||
<Text type="xl-bold" style={[pal.link, s.pr5]}>
|
||||
<Trans>Next</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<Text type="xl" style={[pal.textLight, s.pl10]}>
|
||||
<Trans>Updating...</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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%']
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
BottomSheetScrollView as ScrollView,
|
||||
BottomSheetTextInput as TextInput,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
} from '@discord/bottom-sheet/src'
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={!preferences || !thread}
|
||||
isLoading={(!preferences || !thread) && !error}
|
||||
isError={!!error}
|
||||
onRetry={refetch}
|
||||
errorTitle={error?.title}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {TouchableWithoutFeedback} from 'react-native'
|
||||
import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
|
||||
import Animated, {
|
||||
Extrapolate,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {BottomSheetBackdropProps} from '@discord/bottom-sheet/src'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
|
||||
@@ -2814,6 +2814,13 @@
|
||||
pino "^8.11.0"
|
||||
pino-http "^8.3.3"
|
||||
|
||||
"@discord/bottom-sheet@https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1":
|
||||
version "4.6.1"
|
||||
resolved "https://github.com/bluesky-social/react-native-bottom-sheet.git#54dc2e0e318b0524a2d2d8fb817f6c48101bb0b1"
|
||||
dependencies:
|
||||
"@gorhom/portal" "1.0.14"
|
||||
invariant "^2.2.4"
|
||||
|
||||
"@discoveryjs/json-ext@^0.5.0":
|
||||
version "0.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
|
||||
@@ -3602,14 +3609,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
|
||||
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
|
||||
|
||||
"@gorhom/bottom-sheet@^4.5.1":
|
||||
version "4.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-4.5.1.tgz#1ac4b234a80e7dff263f0b7ac207f92e41562849"
|
||||
integrity sha512-4Qy6hzvN32fXu2hDxDXOIS0IBGBT6huST7J7+K1V5bXemZ08KIx5ZffyLgwhCUl+CnyeG2KG6tqk6iYLkIwi7Q==
|
||||
dependencies:
|
||||
"@gorhom/portal" "1.0.14"
|
||||
invariant "^2.2.4"
|
||||
|
||||
"@gorhom/portal@1.0.14":
|
||||
version "1.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@gorhom/portal/-/portal-1.0.14.tgz#1953edb76aaba80fb24021dc774550194a18e111"
|
||||
@@ -7629,6 +7628,11 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/invariant@^2.2.37":
|
||||
version "2.2.37"
|
||||
resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.37.tgz#1709741e534364d653c87dff22fc76fa94aa7bc0"
|
||||
integrity sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
|
||||
@@ -11533,6 +11537,11 @@ eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.30.1, eslint-plugin-react@^7
|
||||
semver "^6.3.1"
|
||||
string.prototype.matchall "^4.0.8"
|
||||
|
||||
eslint-plugin-simple-import-sort@^12.0.0:
|
||||
version "12.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.0.0.tgz#3cfa05d74509bd4dc329a956938823812194dbb6"
|
||||
integrity sha512-8o0dVEdAkYap0Cn5kNeklaKcT1nUsa3LITWEuFk3nJifOoD+5JQGoyDUW2W/iPWwBsNBJpyJS9y4je/BgxLcyQ==
|
||||
|
||||
eslint-plugin-testing-library@^5.0.1:
|
||||
version "5.11.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.1.tgz#5b46cdae96d4a78918711c0b4792f90088e62d20"
|
||||
|
||||
Reference in New Issue
Block a user