diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-09-28 12:08:00 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-28 12:08:00 -0700 |
commit | cd3b0e54fbefa6c38ae6ad81198c8d766baee2c5 (patch) | |
tree | 11676f9031d2c5f27e298feec178ce7c2df62262 | |
parent | 16763d1d4118292432678ef256226139c0be73c1 (diff) | |
download | voidsky-cd3b0e54fbefa6c38ae6ad81198c8d766baee2c5.tar.zst |
Email verification and change flows (#1560)
* fix 'Reposted by' text overflow * Add email verification flow * Implement change email flow * Add verify email reminder on load * Bump @atproto/api@0.6.20 * Trim the inputs * Accessibility fixes * Fix typo * Fix: include the day in the sharding check * Update auto behaviors * Update yarn.lock * Temporary error message --------- Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/lib/strings/helpers.ts | 17 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 6 | ||||
-rw-r--r-- | src/state/models/session.ts | 16 | ||||
-rw-r--r-- | src/state/models/ui/reminders.ts | 65 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 22 | ||||
-rw-r--r-- | src/view/com/modals/ChangeEmail.tsx | 280 | ||||
-rw-r--r-- | src/view/com/modals/Confirm.tsx | 3 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 8 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/VerifyEmail.tsx | 296 | ||||
-rw-r--r-- | src/view/com/util/forms/Button.tsx | 14 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 83 | ||||
-rw-r--r-- | yarn.lock | 54 |
14 files changed, 856 insertions, 16 deletions
diff --git a/package.json b/package.json index 32ca3de7a..28e9a6992 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.16", + "@atproto/api": "^0.6.20", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index 183d53e31..ef93a366f 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -15,3 +15,20 @@ export function enforceLen(str: string, len: number, ellipsis = false): string { } return str } + +// https://stackoverflow.com/a/52171480 +export function toHashCode(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i) + h1 = Math.imul(h1 ^ ch, 2654435761) + h2 = Math.imul(h2 ^ ch, 1597334677) + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) + + return 4294967296 * (2097151 & h2) + (h1 >>> 0) +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 1a81072a2..363a81c0f 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' import {MutedThreads} from './muted-threads' +import {Reminders} from './ui/reminders' import {reset as resetNavigation} from '../../Navigation' // TEMPORARY (APP-700) @@ -53,6 +54,7 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() + reminders = new Reminders(this) constructor(agent: BskyAgent) { this.agent = agent @@ -77,6 +79,7 @@ export class RootStoreModel { preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), + reminders: this.reminders.serialize(), } } @@ -109,6 +112,9 @@ export class RootStoreModel { if (hasProp(v, 'mutedThreads')) { this.mutedThreads.hydrate(v.mutedThreads) } + if (hasProp(v, 'reminders')) { + this.reminders.hydrate(v.reminders) + } } } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 1bc722c8c..7cd3c1222 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -30,6 +30,7 @@ export const accountData = z.object({ email: z.string().optional(), displayName: z.string().optional(), aviUrl: z.string().optional(), + emailConfirmed: z.boolean().optional(), }) export type AccountData = z.infer<typeof accountData> @@ -106,6 +107,10 @@ export class SessionModel { return this.accounts.filter(acct => acct.did !== this.data?.did) } + get emailNeedsConfirmation() { + return !this.currentSession?.emailConfirmed + } + get isSandbox() { if (!this.data) { return false @@ -217,6 +222,7 @@ export class SessionModel { ? addedInfo.displayName : existingAccount?.displayName || '', aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', + emailConfirmed: session?.emailConfirmed, } if (!existingAccount) { this.accounts.push(newAccount) @@ -246,6 +252,8 @@ export class SessionModel { did: acct.did, displayName: acct.displayName, aviUrl: acct.aviUrl, + email: acct.email, + emailConfirmed: acct.emailConfirmed, })) } @@ -297,6 +305,8 @@ export class SessionModel { refreshJwt: account.refreshJwt || '', did: account.did, handle: account.handle, + email: account.email, + emailConfirmed: account.emailConfirmed, }), ) const addedInfo = await this.loadAccountInfo(agent, account.did) @@ -452,4 +462,10 @@ export class SessionModel { await this.rootStore.me.load() } } + + updateLocalAccountData(changes: Partial<AccountData>) { + this.accounts = this.accounts.map(acct => + acct.did === this.data?.did ? {...acct, ...changes} : acct, + ) + } } diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts new file mode 100644 index 000000000..f8becdec3 --- /dev/null +++ b/src/state/models/ui/reminders.ts @@ -0,0 +1,65 @@ +import {makeAutoObservable} from 'mobx' +import {isObj, hasProp} from 'lib/type-guards' +import {RootStoreModel} from '../root-store' +import {toHashCode} from 'lib/strings/helpers' + +const DAY = 60e3 * 24 * 1 // 1 day (ms) + +export class Reminders { + // NOTE + // by defaulting to the current date, we ensure that the user won't be nagged + // on first run (aka right after creating an account) + // -prf + lastEmailConfirm: Date = new Date() + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + {serialize: false, hydrate: false}, + {autoBind: true}, + ) + } + + serialize() { + return { + lastEmailConfirm: this.lastEmailConfirm + ? this.lastEmailConfirm.toISOString() + : undefined, + } + } + + hydrate(v: unknown) { + if ( + isObj(v) && + hasProp(v, 'lastEmailConfirm') && + typeof v.lastEmailConfirm === 'string' + ) { + this.lastEmailConfirm = new Date(v.lastEmailConfirm) + } + } + + get shouldRequestEmailConfirmation() { + const sess = this.rootStore.session.currentSession + if (!sess) { + return false + } + if (sess.emailConfirmed) { + return false + } + const today = new Date() + // shard the users into 2 day of the week buckets + // (this is to avoid a sudden influx of email updates when + // this feature rolls out) + const code = toHashCode(sess.did) % 7 + if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { + return false + } + // only ask once a day at most, but because of the bucketing + // this will be more like weekly + return Number(today) - Number(this.lastEmailConfirm) > DAY + } + + setEmailConfirmationRequested() { + this.lastEmailConfirm = new Date() + } +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 647513563..15d92f927 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -24,6 +24,7 @@ export interface ConfirmModal { onPressCancel?: () => void | Promise<void> confirmBtnText?: string confirmBtnStyle?: StyleProp<ViewStyle> + cancelBtnText?: string } export interface EditProfileModal { @@ -140,6 +141,15 @@ export interface BirthDateSettingsModal { name: 'birth-date-settings' } +export interface VerifyEmailModal { + name: 'verify-email' + showReminder?: boolean +} + +export interface ChangeEmailModal { + name: 'change-email' +} + export type Modal = // Account | AddAppPasswordModal @@ -148,6 +158,8 @@ export type Modal = | EditProfileModal | ProfilePreviewModal | BirthDateSettingsModal + | VerifyEmailModal + | ChangeEmailModal // Curation | ContentFilteringSettingsModal @@ -250,6 +262,7 @@ export class ShellUiModel { }) this.setupClock() + this.setupLoginModals() } serialize(): unknown { @@ -375,4 +388,13 @@ export class ShellUiModel { }) }, 60_000) } + + setupLoginModals() { + this.rootStore.onSessionReady(() => { + if (this.rootStore.reminders.shouldRequestEmailConfirmation) { + this.openModal({name: 'verify-email', showReminder: true}) + this.rootStore.reminders.setEmailConfirmationRequested() + } + }) + } } diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx new file mode 100644 index 000000000..c92dabdca --- /dev/null +++ b/src/view/com/modals/ChangeEmail.tsx @@ -0,0 +1,280 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' + +enum Stages { + InputEmail, + ConfirmCode, + Done, +} + +export const snapPoints = ['90%'] + +export const Component = observer(function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [stage, setStage] = useState<Stages>(Stages.InputEmail) + const [email, setEmail] = useState<string>( + store.session.currentSession?.email || '', + ) + const [confirmationCode, setConfirmationCode] = useState<string>('') + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [error, setError] = useState<string>('') + const {isMobile} = useWebMediaQueries() + + const onRequestChange = async () => { + if (email === store.session.currentSession?.email) { + setError('Enter your new email above') + return + } + setError('') + setIsProcessing(true) + try { + const res = await store.agent.com.atproto.server.requestEmailUpdate() + if (res.data.tokenRequired) { + setStage(Stages.ConfirmCode) + } else { + await store.agent.com.atproto.server.updateEmail({email: email.trim()}) + store.session.updateLocalAccountData({ + email: email.trim(), + emailConfirmed: false, + }) + Toast.show('Email updated') + setStage(Stages.Done) + } + } catch (e) { + let err = cleanError(String(e)) + // TEMP + // while rollout is occuring, we're giving a temporary error message + // you can remove this any time after Oct2023 + // -prf + if (err === 'email must be confirmed (temporary)') { + err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.` + } + setError(err) + } finally { + setIsProcessing(false) + } + } + + const onConfirm = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.updateEmail({ + email: email.trim(), + token: confirmationCode.trim(), + }) + store.session.updateLocalAccountData({ + email: email.trim(), + emailConfirmed: false, + }) + Toast.show('Email updated') + setStage(Stages.Done) + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onVerify = async () => { + store.shell.closeModal() + store.shell.openModal({name: 'verify-email'}) + } + + return ( + <KeyboardAvoidingView + behavior="padding" + style={[pal.view, styles.container]}> + <SafeAreaView style={s.flex1}> + <ScrollView + testID="changeEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.InputEmail ? 'Change Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} + {stage === Stages.Done ? 'Email Updated' : ''} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.InputEmail ? ( + <>Enter your new email address below.</> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to your previous address,{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + <> + Your email has been updated but not verified. As a next step, + please verify your new email. + </> + )} + </Text> + + {stage === Stages.InputEmail && ( + <TextInput + testID="emailInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="alice@mail.com" + placeholderTextColor={pal.colors.textLight} + value={email} + onChangeText={setEmail} + accessible={true} + accessibilityLabel="Email" + accessibilityHint="" + autoCapitalize="none" + autoComplete="email" + autoCorrect={false} + /> + )} + {stage === Stages.ConfirmCode && ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + )} + + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.InputEmail && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestChange} + accessibilityLabel="Request Change" + accessibilityHint="" + label="Request Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ConfirmCode && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm Change" + accessibilityHint="" + label="Confirm Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Done && ( + <Button + testID="verifyBtn" + type="primary" + onPress={onVerify} + accessibilityLabel="Verify New Email" + accessibilityHint="" + label="Verify New Email" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Cancel" + accessibilityHint="" + label="Cancel" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + </KeyboardAvoidingView> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + marginTop: 10, + }, + emailContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 12, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 270177182..c1324b1cb 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -23,6 +23,7 @@ export function Component({ onPressCancel, confirmBtnText, confirmBtnStyle, + cancelBtnText, }: ConfirmModal) { const pal = usePalette('default') const store = useStores() @@ -84,7 +85,7 @@ export function Component({ accessibilityLabel="Cancel" accessibilityHint=""> <Text type="button-lg" style={pal.textLight}> - Cancel + {cancelBtnText ?? 'Cancel'} </Text> </TouchableOpacity> )} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index d79c77db3..8590a2698 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -30,6 +30,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' +import * as VerifyEmailModal from './VerifyEmail' +import * as ChangeEmailModal from './ChangeEmail' const DEFAULT_SNAPPOINTS = ['90%'] @@ -136,6 +138,12 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'birth-date-settings') { snapPoints = BirthDateSettingsModal.snapPoints element = <BirthDateSettingsModal.Component /> + } else if (activeModal?.name === 'verify-email') { + snapPoints = VerifyEmailModal.snapPoints + element = <VerifyEmailModal.Component {...activeModal} /> + } else if (activeModal?.name === 'change-email') { + snapPoints = ChangeEmailModal.snapPoints + element = <ChangeEmailModal.Component /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3e87e0e3c..7548fb806 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -28,6 +28,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' +import * as VerifyEmailModal from './VerifyEmail' +import * as ChangeEmailModal from './ChangeEmail' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -110,6 +112,10 @@ function Modal({modal}: {modal: ModalIface}) { element = <ModerationDetailsModal.Component {...modal} /> } else if (modal.name === 'birth-date-settings') { element = <BirthDateSettingsModal.Component /> + } else if (modal.name === 'verify-email') { + element = <VerifyEmailModal.Component {...modal} /> + } else if (modal.name === 'change-email') { + element = <ChangeEmailModal.Component /> } else { return null } diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx new file mode 100644 index 000000000..1b4ddcda4 --- /dev/null +++ b/src/view/com/modals/VerifyEmail.tsx @@ -0,0 +1,296 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + Pressable, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['90%'] + +enum Stages { + Reminder, + Email, + ConfirmCode, +} + +export const Component = observer(function Component({ + showReminder, +}: { + showReminder?: boolean +}) { + const pal = usePalette('default') + const store = useStores() + const [stage, setStage] = useState<Stages>( + showReminder ? Stages.Reminder : Stages.Email, + ) + const [confirmationCode, setConfirmationCode] = useState<string>('') + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [error, setError] = useState<string>('') + const {isMobile} = useWebMediaQueries() + + const onSendEmail = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.requestEmailConfirmation() + setStage(Stages.ConfirmCode) + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onConfirm = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.confirmEmail({ + email: (store.session.currentSession?.email || '').trim(), + token: confirmationCode.trim(), + }) + store.session.updateLocalAccountData({emailConfirmed: true}) + Toast.show('Email verified') + store.shell.closeModal() + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onEmailIncorrect = () => { + store.shell.closeModal() + store.shell.openModal({name: 'change-email'}) + } + + return ( + <KeyboardAvoidingView + behavior="padding" + style={[pal.view, styles.container]}> + <SafeAreaView style={s.flex1}> + <ScrollView + testID="verifyEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} + {stage === Stages.Email ? 'Verify Your Email' : ''} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.Reminder ? ( + <> + Your email has not yet been verified. This is an important + security step which we recommend. + </> + ) : stage === Stages.Email ? ( + <> + This is important in case you ever need to change your email or + reset your password. + </> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + '' + )} + </Text> + + {stage === Stages.Email ? ( + <> + <View style={styles.emailContainer}> + <FontAwesomeIcon + icon="envelope" + color={pal.colors.text} + size={16} + /> + <Text + type="xl-medium" + style={[pal.text, s.flex1, {minWidth: 0}]}> + {store.session.currentSession?.email || ''} + </Text> + </View> + <Pressable + accessibilityRole="link" + accessibilityLabel="Change my email" + accessibilityHint="" + onPress={onEmailIncorrect} + style={styles.changeEmailLink}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Pressable> + </> + ) : stage === Stages.ConfirmCode ? ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + ) : undefined} + + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.Reminder && ( + <Button + testID="getStartedBtn" + type="primary" + onPress={() => setStage(Stages.Email)} + accessibilityLabel="Get Started" + accessibilityHint="" + label="Get Started" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Email && ( + <> + <Button + testID="sendEmailBtn" + type="primary" + onPress={onSendEmail} + accessibilityLabel="Send Confirmation Email" + accessibilityHint="" + label="Send Confirmation Email" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} + labelStyle={[s.f18]} + /> + <Button + testID="haveCodeBtn" + type="default" + accessibilityLabel="I have a code" + accessibilityHint="" + label="I have a confirmation code" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} + labelStyle={[s.f18]} + onPress={() => setStage(Stages.ConfirmCode)} + /> + </> + )} + {stage === Stages.ConfirmCode && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm" + accessibilityHint="" + label="Confirm" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel={ + stage === Stages.Reminder ? 'Not right now' : 'Cancel' + } + accessibilityHint="" + label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + </KeyboardAvoidingView> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + marginTop: 10, + }, + emailContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + marginTop: 10, + }, + changeEmailLink: { + marginHorizontal: 12, + marginBottom: 12, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 076fa1baa..270d98317 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -42,6 +42,7 @@ export function Button({ type = 'primary', label, style, + labelContainerStyle, labelStyle, onPress, children, @@ -55,6 +56,7 @@ export function Button({ type?: ButtonType label?: string style?: StyleProp<ViewStyle> + labelContainerStyle?: StyleProp<ViewStyle> labelStyle?: StyleProp<TextStyle> onPress?: () => void | Promise<void> testID?: string @@ -173,7 +175,7 @@ export function Button({ } return ( - <View style={styles.labelContainer}> + <View style={[styles.labelContainer, labelContainerStyle]}> {label && withLoading && isLoading ? ( <ActivityIndicator size={12} color={typeLabelStyle.color} /> ) : null} @@ -182,7 +184,15 @@ export function Button({ </Text> </View> ) - }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) + }, [ + children, + label, + withLoading, + isLoading, + labelContainerStyle, + typeLabelStyle, + labelStyle, + ]) return ( <Pressable diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 7b17b5347..66b2b8fbb 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -219,10 +219,25 @@ export const SettingsScreen = withAuthRequired( <View style={[styles.infoLine]}> <Text type="lg-medium" style={pal.text}> Email:{' '} - <Text type="lg" style={pal.text}> - {store.session.currentSession?.email} - </Text> </Text> + {!store.session.emailNeedsConfirmation && ( + <> + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3, marginRight: 2}} + /> + </> + )} + <Text type="lg" style={pal.text}> + {store.session.currentSession?.email}{' '} + </Text> + <Link + onPress={() => store.shell.openModal({name: 'change-email'})}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Link> </View> <View style={[styles.infoLine]}> <Text type="lg-medium" style={pal.text}> @@ -238,6 +253,7 @@ export const SettingsScreen = withAuthRequired( </Link> </View> <View style={styles.spacer20} /> + <EmailConfirmationNotice /> </> ) : null} <View style={[s.flexRow, styles.heading]}> @@ -665,6 +681,67 @@ function AccountDropdownBtn({handle}: {handle: string}) { ) } +const EmailConfirmationNotice = observer( + function EmailConfirmationNoticeImpl() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const {isMobile} = useWebMediaQueries() + + if (!store.session.emailNeedsConfirmation) { + return null + } + + return ( + <View style={{marginBottom: 20}}> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Verify email + </Text> + <View + style={[ + { + paddingVertical: isMobile ? 12 : 0, + paddingHorizontal: 18, + }, + pal.view, + ]}> + <View style={{flexDirection: 'row', marginBottom: 8}}> + <Pressable + style={[ + palInverted.view, + { + flexDirection: 'row', + gap: 6, + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: 'center', + }, + isMobile && {flex: 1}, + ]} + accessibilityRole="button" + accessibilityLabel="Verify my email" + accessibilityHint="" + onPress={() => store.shell.openModal({name: 'verify-email'})}> + <FontAwesomeIcon + icon="envelope" + color={palInverted.colors.text} + size={16} + /> + <Text type="button" style={palInverted.text}> + Verify My Email + </Text> + </Pressable> + </View> + <Text style={pal.textLight}> + Protect your account by verifying your email. + </Text> + </View> + </View> + ) + }, +) + const styles = StyleSheet.create({ dimmed: { opacity: 0.5, diff --git a/yarn.lock b/yarn.lock index 7533bb439..7a81e64e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,15 +47,15 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@^0.6.16": - version "0.6.16" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.16.tgz#0e5f259a8eb8af239b4e77bf70d7e770b33f4eeb" - integrity sha512-DpG994bdwk7NWJSb36Af+0+FRWMFZgzTcrK0rN2tvlsMh6wBF/RdErjHKuoL8wcogGzbI2yp8eOqsA00lyoisw== - dependencies: - "@atproto/common-web" "^0.2.0" - "@atproto/lexicon" "^0.2.1" - "@atproto/syntax" "^0.1.1" - "@atproto/xrpc" "^0.3.1" +"@atproto/api@^0.6.20": + version "0.6.20" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.20.tgz#3a7eda60d73a5d5b6938e2dd016c24a7ba180c83" + integrity sha512-+peoKgkaxbglXQg9qEZcZIvyWm39yj0+syV3TBDrz5cWK4OIsdOyYBg2iISy+jvB5RzEUMe2WvOojP6Nq34mOg== + dependencies: + "@atproto/common-web" "^0.2.1" + "@atproto/lexicon" "^0.2.2" + "@atproto/syntax" "^0.1.2" + "@atproto/xrpc" "^0.3.2" multiformats "^9.9.0" tlds "^1.234.0" typed-emitter "^2.1.0" @@ -105,6 +105,16 @@ uint8arrays "3.0.0" zod "^3.21.4" +"@atproto/common-web@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.1.tgz#97412cb241321fc6c56a2b8c0b2416b3240caf50" + integrity sha512-5AoDKkKz7JhXSiicjhPihA/MJMlSuTQ9Aed9fflPuoTuT6C3aXbxaUZEcqqipSwlCfGpOzPmJmWJjMWWsYr2ew== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.21.4" + "@atproto/common@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -209,6 +219,17 @@ multiformats "^9.9.0" zod "^3.21.4" +"@atproto/lexicon@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.2.tgz#938a39482ff41c6a908f4ad43274adba595f3643" + integrity sha512-CvmjaSDavHMOJTuNYE8VjYhL7TVxBYV8QSWh2jHCpzfmj02DvVD9UBIfnoVv67POJkEtWXddjoV9beaIbaq/Xg== + dependencies: + "@atproto/common-web" "^0.2.1" + "@atproto/syntax" "^0.1.2" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.21.4" + "@atproto/pds@^0.1.14": version "0.1.14" resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.14.tgz#7c5a49e412d599d2105bb7ecd019832ab952b19f" @@ -276,6 +297,13 @@ dependencies: "@atproto/common-web" "^0.2.0" +"@atproto/syntax@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.2.tgz#417366d36b53ecf29d9d1f6e35179b1f3feef95b" + integrity sha512-n6VSuccMGouwftCvZBq9WNwI0qYCMOH/lTHSV+/dT232lX7pIrqisOlErUSBoOJ49B1Wxy1DjeeBS26ap9SsGQ== + dependencies: + "@atproto/common-web" "^0.2.1" + "@atproto/xrpc-server@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.3.1.tgz#40eeae1dee79fcc835d7a0068ca90f9c91f0ba06" @@ -301,6 +329,14 @@ "@atproto/lexicon" "^0.2.1" zod "^3.21.4" +"@atproto/xrpc@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.2.tgz#432a364be4b3bf8660a088a07dadecac10209763" + integrity sha512-D9jGjcFnEMHuGQ56v6+78uX3RiytKLrA5ITLq6shy0Qj6Zvt5MqV+/cTFuNPKrNCrnWOtHFeRQwMqyGhNS9qZQ== + dependencies: + "@atproto/lexicon" "^0.2.2" + zod "^3.21.4" + "@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" |