diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-14 13:03:43 -0500 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2023-03-14 13:03:43 -0500 |
commit | acf0f80de2a7a96ad8ee58dbf6aa8cb59859c9e8 (patch) | |
tree | ea84f4665976b847ea7d32190324f47c1e0b414f | |
parent | d55780f5c333208eaac1d5240929959b131e8787 (diff) | |
download | voidsky-acf0f80de2a7a96ad8ee58dbf6aa8cb59859c9e8.tar.zst |
Rework account creation and login views
22 files changed, 1266 insertions, 66 deletions
diff --git a/package.json b/package.json index 41f251921..f7264d321 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "js-sha256": "^0.9.0", "lodash.chunk": "^4.2.0", "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "lodash.omit": "^4.5.0", "lodash.samplesize": "^4.2.0", @@ -122,6 +123,7 @@ "@types/jest": "^29.4.0", "@types/lodash.chunk": "^4.2.7", "@types/lodash.clonedeep": "^4.5.7", + "@types/lodash.debounce": "^4.0.7", "@types/lodash.isequal": "^4.5.6", "@types/lodash.omit": "^4.5.7", "@types/lodash.samplesize": "^4.2.7", diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index e194e7a87..fd233f99c 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -801,3 +801,30 @@ export function SquarePlusIcon({ </Svg> ) } + +export function InfoCircleIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth} + stroke="currentColor" + width={size} + height={size} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" + /> + </Svg> + ) +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 328229f46..5d7f7f82d 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -64,6 +64,7 @@ export const s = StyleSheet.create({ footerSpacer: {height: 100}, contentContainer: {paddingBottom: 200}, contentContainerExtra: {paddingBottom: 300}, + border0: {borderWidth: 0}, border1: {borderWidth: 1}, borderTop1: {borderTopWidth: 1}, borderRight1: {borderRightWidth: 1}, diff --git a/src/state/index.ts b/src/state/index.ts index 61b85e51d..f0713efeb 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -6,7 +6,7 @@ import * as apiPolyfill from 'lib/api/api-polyfill' import * as storage from 'lib/storage' export const LOCAL_DEV_SERVICE = - Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583' + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' export const PROD_SERVICE = 'https://bsky.social' export const DEFAULT_SERVICE = PROD_SERVICE diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts new file mode 100644 index 000000000..a212fe05e --- /dev/null +++ b/src/state/models/ui/create-account.ts @@ -0,0 +1,192 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' +import {ServiceDescription} from '../session' +import {DEFAULT_SERVICE} from 'state/index' +import {ComAtprotoAccountCreate} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {createFullHandle} from 'lib/strings/handles' +import {cleanError} from 'lib/strings/errors' + +export class CreateAccountModel { + step: number = 1 + isProcessing = false + isFetchingServiceDescription = false + didServiceDescriptionFetchFail = false + error = '' + + serviceUrl = DEFAULT_SERVICE + serviceDescription: ServiceDescription | undefined = undefined + userDomain = '' + inviteCode = '' + email = '' + password = '' + handle = '' + is13 = false + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, {}, {autoBind: true}) + } + + // form state controls + // = + + next() { + this.error = '' + this.step++ + } + + back() { + this.error = '' + this.step-- + } + + setStep(v: number) { + this.step = v + } + + async fetchServiceDescription() { + this.setError('') + this.setIsFetchingServiceDescription(true) + this.setDidServiceDescriptionFetchFail(false) + this.setServiceDescription(undefined) + if (!this.serviceUrl) { + return + } + try { + const desc = await this.rootStore.session.describeService(this.serviceUrl) + this.setServiceDescription(desc) + this.setUserDomain(desc.availableUserDomains[0]) + } catch (err: any) { + this.rootStore.log.warn( + `Failed to fetch service description for ${this.serviceUrl}`, + err, + ) + this.setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + this.setDidServiceDescriptionFetchFail(true) + } finally { + this.setIsFetchingServiceDescription(false) + } + } + + async submit() { + if (!this.email) { + this.setStep(2) + return this.setError('Please enter your email.') + } + if (!EmailValidator.validate(this.email)) { + this.setStep(2) + return this.setError('Your email appears to be invalid.') + } + if (!this.password) { + this.setStep(2) + return this.setError('Please choose your password.') + } + if (!this.handle) { + this.setStep(3) + return this.setError('Please choose your handle.') + } + this.setError('') + this.setIsProcessing(true) + try { + await this.rootStore.session.createAccount({ + service: this.serviceUrl, + email: this.email, + handle: createFullHandle(this.handle, this.userDomain), + password: this.password, + inviteCode: this.inviteCode, + }) + } catch (e: any) { + let errMsg = e.toString() + if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + errMsg = + 'Invite code not accepted. Check that you input it correctly and try again.' + } + this.rootStore.log.error('Failed to create account', e) + this.setIsProcessing(false) + this.setError(cleanError(errMsg)) + throw e + } + } + + // form state accessors + // = + + get canBack() { + return this.step > 1 + } + + get canNext() { + if (this.step === 1) { + return !!this.serviceDescription + } else if (this.step === 2) { + return ( + (!this.isInviteCodeRequired || this.inviteCode) && + !!this.email && + !!this.password && + this.is13 + ) + } + return !!this.handle + } + + get isServiceDescribed() { + return !!this.serviceDescription + } + + get isInviteCodeRequired() { + return this.serviceDescription?.inviteCodeRequired + } + + // setters + // = + + setIsProcessing(v: boolean) { + this.isProcessing = v + } + + setIsFetchingServiceDescription(v: boolean) { + this.isFetchingServiceDescription = v + } + + setDidServiceDescriptionFetchFail(v: boolean) { + this.didServiceDescriptionFetchFail = v + } + + setError(v: string) { + this.error = v + } + + setServiceUrl(v: string) { + this.serviceUrl = v + } + + setServiceDescription(v: ServiceDescription | undefined) { + this.serviceDescription = v + } + + setUserDomain(v: string) { + this.userDomain = v + } + + setInviteCode(v: string) { + this.inviteCode = v + } + + setEmail(v: string) { + this.email = v + } + + setPassword(v: string) { + this.password = v + } + + setHandle(v: string) { + this.handle = v + } + + setIs13(v: boolean) { + this.is13 = v + } +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 47dd51d9c..5d4b9451f 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,8 +1,8 @@ import React from 'react' import {SafeAreaView} from 'react-native' import {observer} from 'mobx-react-lite' -import {Signin} from 'view/com/auth/Signin' -import {CreateAccount} from 'view/com/auth/CreateAccount' +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' @@ -12,8 +12,8 @@ import {SplashScreen} from './SplashScreen' import {CenteredView} from '../util/Views' enum ScreenState { - S_SigninOrCreateAccount, - S_Signin, + S_LoginOrCreateAccount, + S_Login, S_CreateAccount, } @@ -22,7 +22,7 @@ export const LoggedOut = observer(() => { const store = useStores() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState<ScreenState>( - ScreenState.S_SigninOrCreateAccount, + ScreenState.S_LoginOrCreateAccount, ) React.useEffect(() => { @@ -32,11 +32,11 @@ export const LoggedOut = observer(() => { if ( store.session.isResumingSession || - screenState === ScreenState.S_SigninOrCreateAccount + screenState === ScreenState.S_LoginOrCreateAccount ) { return ( <SplashScreen - onPressSignin={() => setScreenState(ScreenState.S_Signin)} + onPressSignin={() => setScreenState(ScreenState.S_Login)} onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} /> ) @@ -46,17 +46,17 @@ export const LoggedOut = observer(() => { <CenteredView style={[s.hContentRegion, pal.view]}> <SafeAreaView testID="noSessionView" style={s.hContentRegion}> <ErrorBoundary> - {screenState === ScreenState.S_Signin ? ( - <Signin + {screenState === ScreenState.S_Login ? ( + <Login onPressBack={() => - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( <CreateAccount onPressBack={() => - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 27943f64d..f98bed120 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,11 +1,9 @@ import React from 'react' import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' -import Image, {Source as ImageSource} from 'view/com/util/images/Image' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {colors} from 'lib/styles' +import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {CLOUD_SPLASH} from 'lib/assets' import {CenteredView} from '../util/Views' export const SplashScreen = ({ @@ -17,29 +15,29 @@ export const SplashScreen = ({ }) => { const pal = usePalette('default') return ( - <CenteredView style={styles.container}> - <Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} /> + <CenteredView style={[styles.container, pal.view]}> <SafeAreaView testID="noSessionView" style={styles.container}> <ErrorBoundary> <View style={styles.hero}> - <View style={styles.heroText}> - <Text style={styles.title}>Bluesky</Text> - </View> + <Text style={[styles.title, pal.link]}>Bluesky</Text> + <Text style={[styles.subtitle, pal.textLight]}> + See what's next + </Text> </View> <View testID="signinOrCreateAccount" style={styles.btns}> <TouchableOpacity testID="createAccountButton" - style={[pal.view, styles.btn]} + style={[styles.btn, {backgroundColor: colors.blue3}]} onPress={onPressCreateAccount}> - <Text style={[pal.link, styles.btnLabel]}> + <Text style={[s.white, styles.btnLabel]}> Create a new account </Text> </TouchableOpacity> <TouchableOpacity testID="signInButton" - style={[pal.view, styles.btn]} + style={[styles.btn, pal.btn]} onPress={onPressSignin}> - <Text style={[pal.link, styles.btnLabel]}>Sign in</Text> + <Text style={[pal.text, styles.btnLabel]}>Sign in</Text> </TouchableOpacity> </View> </ErrorBoundary> @@ -56,37 +54,27 @@ const styles = StyleSheet.create({ flex: 2, justifyContent: 'center', }, - bgImg: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - }, - heroText: { - backgroundColor: colors.white, - paddingTop: 10, - paddingBottom: 20, - }, btns: { paddingBottom: 40, }, title: { textAlign: 'center', - color: colors.blue3, fontSize: 68, fontWeight: 'bold', }, + subtitle: { + textAlign: 'center', + fontSize: 42, + fontWeight: 'bold', + }, btn: { - borderRadius: 4, + borderRadius: 32, paddingVertical: 16, marginBottom: 20, marginHorizontal: 20, - backgroundColor: colors.blue3, }, btnLabel: { textAlign: 'center', fontSize: 21, - color: colors.white, }, }) diff --git a/src/view/com/auth/CreateAccount.tsx b/src/view/com/auth/create/Backup.tsx index a24dc4e35..c0693605f 100644 --- a/src/view/com/auth/CreateAccount.tsx +++ b/src/view/com/auth/create/Backup.tsx @@ -17,10 +17,10 @@ import {ComAtprotoAccountCreate} from '@atproto/api' import * as EmailValidator from 'email-validator' import {sha256} from 'js-sha256' import {useAnalytics} from 'lib/analytics' -import {LogoTextHero} from './Logo' -import {Picker} from '../util/Picker' -import {TextLink} from '../util/Link' -import {Text} from '../util/text/Text' +import {LogoTextHero} from '../Logo' +import {Picker} from '../../util/Picker' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' import {s, colors} from 'lib/styles' import {makeValidHandle, createFullHandle} from 'lib/strings/handles' import {toNiceDomain} from 'lib/strings/url-helpers' diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx new file mode 100644 index 000000000..93773665d --- /dev/null +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {sha256} from 'js-sha256' +import {useAnalytics} from 'lib/analytics' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {useStores} from 'state/index' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' + +import {Step1} from './Step1' +import {Step2} from './Step2' +import {Step3} from './Step3' + +export const CreateAccount = observer( + ({onPressBack}: {onPressBack: () => void}) => { + const {track, screen, identify} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const model = React.useMemo(() => new CreateAccountModel(store), [store]) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + model.fetchServiceDescription() + }, [model]) + + const onPressRetryConnect = React.useCallback( + () => model.fetchServiceDescription(), + [model], + ) + + const onPressBackInner = React.useCallback(() => { + if (model.canBack) { + console.log('?') + model.back() + } else { + onPressBack() + } + }, [model, onPressBack]) + + const onPressNext = React.useCallback(async () => { + if (!model.canNext) { + return + } + if (model.step < 3) { + model.next() + } else { + try { + await model.submit() + const email_hashed = sha256(model.email) + identify(email_hashed, {email_hashed}) + track('Create Account') + } catch { + // dont need to handle here + } + } + }, [model, identify, track]) + + return ( + <ScrollView testID="createAccount" style={pal.view}> + <KeyboardAvoidingView behavior="padding"> + <View style={styles.stepContainer}> + {model.step === 1 && <Step1 model={model} />} + {model.step === 2 && <Step2 model={model} />} + {model.step === 3 && <Step3 model={model} />} + </View> + <View style={[s.flexRow, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBackInner}> + <Text type="xl" style={pal.link}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {model.canNext ? ( + <TouchableOpacity + testID="createAccountButton" + onPress={onPressNext}> + {model.isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + )} + </TouchableOpacity> + ) : model.didServiceDescriptionFetchFail ? ( + <TouchableOpacity + testID="registerRetryButton" + onPress={onPressRetryConnect}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Retry + </Text> + </TouchableOpacity> + ) : model.isFetchingServiceDescription ? ( + <> + <ActivityIndicator color="#fff" /> + <Text type="xl" style={[pal.text, s.pr5]}> + Connecting... + </Text> + </> + ) : undefined} + </View> + <View style={s.footerSpacer} /> + </KeyboardAvoidingView> + </ScrollView> + ) + }, +) + +const styles = StyleSheet.create({ + stepContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + + noTopBorder: { + borderTopWidth: 0, + }, + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + 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, + }, + textBtnFakeInnerBtnIcon: { + marginRight: 4, + }, + picker: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + borderRadius: 10, + }, + pickerLabel: { + fontSize: 17, + }, + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + checkboxFilled: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorFloating: { + marginBottom: 20, + marginHorizontal: 20, + borderRadius: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx new file mode 100644 index 000000000..4ba6a5406 --- /dev/null +++ b/src/view/com/auth/create/Policies.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {ServiceDescription} from 'state/models/session' +import {usePalette} from 'lib/hooks/usePalette' + +export const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return <View /> + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + <View style={styles.policies}> + <View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}> + <FontAwesomeIcon + icon="exclamation" + style={pal.textLight as FontAwesomeIconStyle} + size={10} + /> + </View> + <Text style={[pal.textLight, s.pl5, s.flex1]}> + This service has not provided terms of service or a privacy policy. + </Text> + </View> + ) + } + const els = [] + if (tos) { + els.push( + <TextLink + key="tos" + href={tos} + text="Terms of Service" + style={[pal.link, s.underline]} + />, + ) + } + if (pp) { + els.push( + <TextLink + key="pp" + href={pp} + text="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}> + By creating an account you agree to the {els}. + </Text> + </View> + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx new file mode 100644 index 000000000..0a628f9d0 --- /dev/null +++ b/src/view/com/auth/create/Step1.tsx @@ -0,0 +1,187 @@ +import React from 'react' +import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import debounce from 'lodash.debounce' +import {Text} from 'view/com/util/text/Text' +import {StepHeader} from './StepHeader' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {HelpTip} from '../util/HelpTip' +import {TextInput} from '../util/TextInput' +import {Button} from 'view/com/util/forms/Button' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' + +export const Step1 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + + const onPressDefault = React.useCallback(() => { + setIsDefaultSelected(true) + model.setServiceUrl(PROD_SERVICE) + model.fetchServiceDescription() + }, [setIsDefaultSelected, model]) + + const onPressOther = React.useCallback(() => { + setIsDefaultSelected(false) + model.setServiceUrl('https://') + model.setServiceDescription(undefined) + }, [setIsDefaultSelected, model]) + + const fetchServiceDesription = React.useMemo( + () => debounce(() => model.fetchServiceDescription(), 1e3), + [model], + ) + + const onChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + fetchServiceDesription() + }, + [model, fetchServiceDesription], + ) + + const onDebugChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + model.fetchServiceDescription() + }, + [model], + ) + + return ( + <View> + <StepHeader step="1" title="Your hosting provider" /> + <Text style={[pal.text, s.mb10]}> + This is the company that keeps you online. + </Text> + <Option + isSelected={isDefaultSelected} + label="Bluesky" + help=" (default)" + onPress={onPressDefault} + /> + <Option + isSelected={!isDefaultSelected} + label="Other" + onPress={onPressOther}> + <View style={styles.otherForm}> + <Text style={[pal.text, s.mb5]}> + Enter the address of your provider: + </Text> + <TextInput + icon="globe" + placeholder="Hosting provider address" + value={model.serviceUrl} + editable + onChange={onChangeServiceUrl} + /> + {LOGIN_INCLUDE_DEV_SERVERS && ( + <View style={[s.flexRow, s.mt10]}> + <Button + type="default" + style={s.mr5} + label="Staging" + onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + /> + <Button + type="default" + label="Dev Server" + onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + /> + </View> + )} + </View> + </Option> + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : ( + <HelpTip text="You can change hosting providers at any time." /> + )} + </View> + ) +}) + +function Option({ + children, + isSelected, + label, + help, + onPress, +}: React.PropsWithChildren<{ + isSelected: boolean + label: string + help?: string + onPress: () => void +}>) { + const theme = useTheme() + const pal = usePalette('default') + const circleFillStyle = React.useMemo( + () => ({ + backgroundColor: theme.palette.primary.background, + }), + [theme], + ) + + return ( + <View style={[styles.option, pal.border]}> + <TouchableWithoutFeedback onPress={onPress}> + <View style={styles.optionHeading}> + <View style={[styles.circle, pal.border]}> + {isSelected ? ( + <View style={[circleFillStyle, styles.circleFill]} /> + ) : undefined} + </View> + <Text type="xl" style={pal.text}> + {label} + {help ? ( + <Text type="xl" style={pal.textLight}> + {help} + </Text> + ) : undefined} + </Text> + </View> + </TouchableWithoutFeedback> + {isSelected && children} + </View> + ) +} + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + }, + + option: { + borderWidth: 1, + borderRadius: 6, + marginBottom: 10, + }, + optionHeading: { + flexDirection: 'row', + alignItems: 'center', + padding: 10, + }, + circle: { + width: 26, + height: 26, + borderRadius: 15, + padding: 4, + borderWidth: 1, + marginRight: 10, + }, + circleFill: { + width: 16, + height: 16, + borderRadius: 10, + }, + + otherForm: { + paddingBottom: 10, + paddingHorizontal: 12, + }, +}) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx new file mode 100644 index 000000000..1f3162880 --- /dev/null +++ b/src/view/com/auth/create/Step2.tsx @@ -0,0 +1,275 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {Text} from 'view/com/util/text/Text' +import {TextLink} from 'view/com/util/Link' +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' + +export const Step2 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + return ( + <View> + <StepHeader step="2" title="Your account" /> + + {model.isInviteCodeRequired && ( + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Invite code + </Text> + <TextInput + icon="ticket" + placeholder="Required for this provider" + value={model.inviteCode} + editable + onChange={model.setInviteCode} + /> + </View> + )} + + {!model.inviteCode && model.isInviteCodeRequired ? ( + <Text> + Don't have an invite code?{' '} + <TextLink text="Join the waitlist" href="#" style={pal.link} /> to try + the beta before it's publicly available. + </Text> + ) : ( + <> + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Email address + </Text> + <TextInput + icon="envelope" + placeholder="Enter your email address" + value={model.email} + editable + onChange={model.setEmail} + /> + </View> + + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Password + </Text> + <TextInput + icon="lock" + placeholder="Choose your password" + value={model.password} + editable + secureTextEntry + onChange={model.setPassword} + /> + </View> + + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Legal check + </Text> + <TouchableOpacity + testID="registerIs13Input" + style={[styles.toggleBtn, pal.border]} + onPress={() => model.setIs13(!model.is13)}> + <View style={[pal.borderDark, styles.checkbox]}> + {model.is13 && ( + <FontAwesomeIcon icon="check" style={s.blue3} size={16} /> + )} + </View> + <Text type="md" style={[pal.text, styles.toggleBtnLabel]}> + I am 13 years old or older + </Text> + </TouchableOpacity> + </View> + + {model.serviceDescription && ( + <Policies serviceDescription={model.serviceDescription} /> + )} + </> + )} + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : undefined} + </View> + ) +}) + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + marginTop: 10, + }, + + toggleBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 10, + borderRadius: 6, + }, + toggleBtnLabel: { + flex: 1, + paddingHorizontal: 10, + }, + + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, +}) + +/* + +<View style={[pal.borderDark, styles.group]}> +{serviceDescription?.inviteCodeRequired ? ( + <View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + style={[pal.text, styles.textInput]} + placeholder="Invite code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoFocus + keyboardAppearance={theme.colorScheme} + value={inviteCode} + onChangeText={setInviteCode} + onBlur={onBlurInviteCode} + editable={!isProcessing} + /> + </View> +) : undefined} +<View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerEmailInput" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + /> +</View> +<View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="Choose your password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> +</View> +</View> +</> +) : undefined} +{serviceDescription ? ( +<> +<View style={styles.groupLabel}> +<Text type="sm-bold" style={pal.text}> + Choose your username +</Text> +</View> +<View style={[pal.border, styles.group]}> +<View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerHandleInput" + style={[pal.text, styles.textInput]} + placeholder="eg alice" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + value={handle} + onChangeText={v => setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> +</View> +{serviceDescription.availableUserDomains.length > 1 && ( + <View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="globe" + style={styles.groupContentIcon} + /> + <Picker + style={[pal.text, styles.picker]} + labelStyle={styles.pickerLabel} + iconStyle={pal.textLight as FontAwesomeIconStyle} + value={userDomain} + items={serviceDescription.availableUserDomains.map(d => ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + </View> +)} +<View style={[pal.border, styles.groupContent]}> + <Text style={[pal.textLight, s.p10]}> + Your full username will be{' '} + <Text type="md-bold" style={pal.textLight}> + @{createFullHandle(handle, userDomain)} + </Text> + </Text> +</View> +</View> +<View style={styles.groupLabel}> +<Text type="sm-bold" style={pal.text}> + Legal +</Text> +</View> +<View style={[pal.border, styles.group]}> +<View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <TouchableOpacity + testID="registerIs13Input" + style={styles.textBtn} + onPress={() => setIs13(!is13)}> + <View + style={[ + pal.border, + is13 ? styles.checkboxFilled : styles.checkbox, + ]}> + {is13 && ( + <FontAwesomeIcon icon="check" style={s.blue3} size={14} /> + )} + </View> + <Text style={[pal.text, styles.textBtnLabel]}> + I am 13 years old or older + </Text> + </TouchableOpacity> +</View> +</View>*/ diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx new file mode 100644 index 000000000..652591171 --- /dev/null +++ b/src/view/com/auth/create/Step3.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {Text} from 'view/com/util/text/Text' +import {StepHeader} from './StepHeader' +import {s} from 'lib/styles' +import {TextInput} from '../util/TextInput' +import {createFullHandle} from 'lib/strings/handles' +import {usePalette} from 'lib/hooks/usePalette' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +export const Step3 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + return ( + <View> + <StepHeader step="3" title="Your user handle" /> + <View style={s.pb10}> + <TextInput + icon="at" + placeholder="eg alice" + value={model.handle} + editable + onChange={model.setHandle} + /> + <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> + Your full handle will be{' '} + <Text type="lg-bold" style={pal.text}> + @{createFullHandle(model.handle, model.userDomain)} + </Text> + </Text> + </View> + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : undefined} + </View> + ) +}) + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + }, +}) diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx new file mode 100644 index 000000000..8c852b640 --- /dev/null +++ b/src/view/com/auth/create/StepHeader.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export function StepHeader({step, title}: {step: string; title: string}) { + const pal = usePalette('default') + return ( + <View style={styles.container}> + <Text type="lg" style={pal.textLight}> + {step === '3' ? 'Last step!' : <>Step {step} of 3</>} + </Text> + <Text type="title-xl">{title}</Text> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 20, + }, +}) diff --git a/src/view/com/auth/Signin.tsx b/src/view/com/auth/login/Login.tsx index 6faf5ff12..f99e72daa 100644 --- a/src/view/com/auth/Signin.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -15,9 +15,8 @@ import { import * as EmailValidator from 'email-validator' import AtpAgent from '@atproto/api' import {useAnalytics} from 'lib/analytics' -import {LogoTextHero} from './Logo' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' +import {Text} from '../../util/text/Text' +import {UserAvatar} from '../../util/UserAvatar' import {s, colors} from 'lib/styles' import {createFullHandle} from 'lib/strings/handles' import {toNiceDomain} from 'lib/strings/url-helpers' @@ -37,7 +36,7 @@ enum Forms { PasswordUpdated, } -export const Signin = ({onPressBack}: {onPressBack: () => void}) => { +export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() @@ -100,7 +99,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { } return ( - <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}> + <KeyboardAvoidingView + testID="signIn" + behavior="padding" + style={[pal.view, s.pt10]}> {currentForm === Forms.Login ? ( <LoginForm store={store} @@ -164,9 +166,9 @@ const ChooseAccountForm = ({ const pal = usePalette('default') const [isProcessing, setIsProcessing] = React.useState(false) - // React.useEffect(() => { - screen('Choose Account') - // }, [screen]) + React.useEffect(() => { + screen('Choose Account') + }, [screen]) const onTryAccount = async (account: AccountData) => { if (account.accessJwt && account.refreshJwt) { @@ -183,15 +185,16 @@ const ChooseAccountForm = ({ return ( <View testID="chooseAccountForm"> - <LogoTextHero /> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Text + type="2xl-medium" + style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> Sign in as... </Text> {store.session.accounts.map(account => ( <TouchableOpacity testID={`chooseAccountBtn-${account.handle}`} key={account.did} - style={[pal.borderDark, styles.group, s.mb5]} + style={[pal.view, pal.border, styles.account]} onPress={() => onTryAccount(account)}> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> @@ -216,7 +219,7 @@ const ChooseAccountForm = ({ ))} <TouchableOpacity testID="chooseNewAccountBtn" - style={[pal.borderDark, styles.group]} + style={[pal.view, pal.border, styles.account, styles.accountLast]} onPress={() => onSelectAccount(undefined)}> <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <Text style={[styles.accountText, styles.accountTextOther]}> @@ -336,7 +339,6 @@ const LoginForm = ({ const isReady = !!serviceDescription && !!identifier && !!password return ( <View testID="loginForm"> - <LogoTextHero /> <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> Sign into </Text> @@ -523,7 +525,6 @@ const ForgotPasswordForm = ({ return ( <> - <LogoTextHero /> <View> <Text type="title-lg" style={[pal.text, styles.screenTitle]}> Reset password @@ -669,7 +670,6 @@ const SetNewPasswordForm = ({ return ( <> - <LogoTextHero /> <View> <Text type="title-lg" style={[pal.text, styles.screenTitle]}> Set new password @@ -774,7 +774,6 @@ const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { const pal = usePalette('default') return ( <> - <LogoTextHero /> <View> <Text type="title-lg" style={[pal.text, styles.screenTitle]}> Password updated! @@ -825,6 +824,16 @@ const styles = StyleSheet.create({ groupContentIcon: { marginLeft: 10, }, + account: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 4, + }, + accountLast: { + borderBottomWidth: 1, + marginBottom: 20, + paddingVertical: 8, + }, textInput: { flex: 1, width: '100%', diff --git a/src/view/com/auth/util/HelpTip.tsx b/src/view/com/auth/util/HelpTip.tsx new file mode 100644 index 000000000..3ea4437df --- /dev/null +++ b/src/view/com/auth/util/HelpTip.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {InfoCircleIcon} from 'lib/icons' +import {s, colors} from 'lib/styles' +import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' + +export function HelpTip({text}: {text: string}) { + const bg = useColorSchemeStyle( + {backgroundColor: colors.gray1}, + {backgroundColor: colors.gray8}, + ) + const fg = useColorSchemeStyle({color: colors.gray5}, {color: colors.gray4}) + return ( + <View style={[styles.helptip, bg]}> + <InfoCircleIcon size={18} style={fg} strokeWidth={1.5} /> + <Text type="xs-medium" style={[fg, s.ml5]}> + {text} + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + helptip: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 8, + }, +}) diff --git a/src/view/com/auth/util/TextInput.tsx b/src/view/com/auth/util/TextInput.tsx new file mode 100644 index 000000000..934bf2acf --- /dev/null +++ b/src/view/com/auth/util/TextInput.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import {StyleSheet, TextInput as RNTextInput, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' + +export function TextInput({ + testID, + icon, + value, + placeholder, + editable, + secureTextEntry, + onChange, +}: { + testID?: string + icon: IconProp + value: string + placeholder: string + editable: boolean + secureTextEntry?: boolean + onChange: (v: string) => void +}) { + const theme = useTheme() + const pal = usePalette('default') + return ( + <View style={[pal.border, styles.container]}> + <FontAwesomeIcon icon={icon} style={[pal.textLight, styles.icon]} /> + <RNTextInput + testID={testID} + style={[pal.text, styles.textInput]} + placeholder={placeholder} + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + secureTextEntry={secureTextEntry} + value={value} + onChangeText={v => onChange(v)} + editable={editable} + /> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + borderWidth: 1, + borderRadius: 6, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 4, + }, + icon: { + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 10, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, +}) diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 62fa9f386..23cd9eb82 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -5,7 +5,7 @@ import { TouchableOpacity, View, } from 'react-native' -import {BottomSheetTextInput} from '@gorhom/bottom-sheet' +import {TextInput} from './util' import LinearGradient from 'react-native-linear-gradient' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' @@ -116,7 +116,7 @@ export function Component({}: {}) { Check your inbox for an email with the confirmation code to enter below: </Text> - <BottomSheetTextInput + <TextInput style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} placeholder="Confirmation code" placeholderTextColor={pal.textLight.color} @@ -127,7 +127,7 @@ export function Component({}: {}) { <Text type="lg" style={styles.description}> Please enter your password as well: </Text> - <BottomSheetTextInput + <TextInput style={[styles.textInput, pal.borderDark, pal.text]} placeholder="Password" placeholderTextColor={pal.textLight.color} diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index dd9a3aa65..0627fa9b6 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,6 +10,7 @@ import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' import * as ChangeHandleModal from './ChangeHandle' @@ -61,6 +62,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ReportAccountModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> + } else if (modal.name === 'delete-account') { + element = <DeleteAccountModal.Component /> } else if (modal.name === 'repost') { element = <RepostModal.Component {...modal} /> } else if (modal.name === 'change-handle') { diff --git a/src/view/com/util/WelcomeBanner.tsx b/src/view/com/util/WelcomeBanner.tsx index 9e360f725..428a30764 100644 --- a/src/view/com/util/WelcomeBanner.tsx +++ b/src/view/com/util/WelcomeBanner.tsx @@ -10,6 +10,7 @@ import {useStores} from 'state/index' import {SUGGESTED_FOLLOWS} from 'lib/constants' // @ts-ignore no type definition -prf import ProgressBar from 'react-native-progress/Bar' +import {CenteredView} from './Views' export const WelcomeBanner = observer(() => { const pal = usePalette('default') @@ -39,7 +40,7 @@ export const WelcomeBanner = observer(() => { }, [store]) return ( - <View + <CenteredView testID="welcomeBanner" style={[pal.view, styles.container, pal.border]}> <Text @@ -76,7 +77,7 @@ export const WelcomeBanner = observer(() => { </View> </> )} - </View> + </CenteredView> ) }) diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index e6e27fac0..cc0df1b59 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -38,7 +38,7 @@ export function ErrorMessage({ /> </View> <Text - type="sm" + type="sm-medium" style={[styles.message, pal.text]} numberOfLines={numberOfLines}> {message} diff --git a/yarn.lock b/yarn.lock index c9032b71a..43600fa97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3508,6 +3508,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.debounce@^4.0.7": + version "4.0.7" + resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.7.tgz#0285879defb7cdb156ae633cecd62d5680eded9f" + integrity sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA== + dependencies: + "@types/lodash" "*" + "@types/lodash.isequal@^4.5.6": version "4.5.6" resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b" |