Bluesky app fork with some witchin' additions 💫

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>

authored by

Paul Frazee
Eric Bailey
and committed by
GitHub
cd3b0e54 16763d1d

+855 -15
+1 -1
package.json
··· 25 25 "build:apk": "eas build -p android --profile dev-android-apk" 26 26 }, 27 27 "dependencies": { 28 - "@atproto/api": "^0.6.16", 28 + "@atproto/api": "^0.6.20", 29 29 "@bam.tech/react-native-image-resizer": "^3.0.4", 30 30 "@braintree/sanitize-url": "^6.0.2", 31 31 "@emoji-mart/react": "^1.1.1",
+17
src/lib/strings/helpers.ts
··· 15 15 } 16 16 return str 17 17 } 18 + 19 + // https://stackoverflow.com/a/52171480 20 + export function toHashCode(str: string, seed = 0): number { 21 + let h1 = 0xdeadbeef ^ seed, 22 + h2 = 0x41c6ce57 ^ seed 23 + for (let i = 0, ch; i < str.length; i++) { 24 + ch = str.charCodeAt(i) 25 + h1 = Math.imul(h1 ^ ch, 2654435761) 26 + h2 = Math.imul(h2 ^ ch, 1597334677) 27 + } 28 + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) 29 + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) 30 + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) 31 + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) 32 + 33 + return 4294967296 * (2097151 & h2) + (h1 >>> 0) 34 + }
+6
src/state/models/root-store.ts
··· 21 21 import {resetToTab} from '../../Navigation' 22 22 import {ImageSizesCache} from './cache/image-sizes' 23 23 import {MutedThreads} from './muted-threads' 24 + import {Reminders} from './ui/reminders' 24 25 import {reset as resetNavigation} from '../../Navigation' 25 26 26 27 // TEMPORARY (APP-700) ··· 53 54 linkMetas = new LinkMetasCache(this) 54 55 imageSizes = new ImageSizesCache() 55 56 mutedThreads = new MutedThreads() 57 + reminders = new Reminders(this) 56 58 57 59 constructor(agent: BskyAgent) { 58 60 this.agent = agent ··· 77 79 preferences: this.preferences.serialize(), 78 80 invitedUsers: this.invitedUsers.serialize(), 79 81 mutedThreads: this.mutedThreads.serialize(), 82 + reminders: this.reminders.serialize(), 80 83 } 81 84 } 82 85 ··· 108 111 } 109 112 if (hasProp(v, 'mutedThreads')) { 110 113 this.mutedThreads.hydrate(v.mutedThreads) 114 + } 115 + if (hasProp(v, 'reminders')) { 116 + this.reminders.hydrate(v.reminders) 111 117 } 112 118 } 113 119 }
+16
src/state/models/session.ts
··· 30 30 email: z.string().optional(), 31 31 displayName: z.string().optional(), 32 32 aviUrl: z.string().optional(), 33 + emailConfirmed: z.boolean().optional(), 33 34 }) 34 35 export type AccountData = z.infer<typeof accountData> 35 36 ··· 104 105 105 106 get switchableAccounts() { 106 107 return this.accounts.filter(acct => acct.did !== this.data?.did) 108 + } 109 + 110 + get emailNeedsConfirmation() { 111 + return !this.currentSession?.emailConfirmed 107 112 } 108 113 109 114 get isSandbox() { ··· 217 222 ? addedInfo.displayName 218 223 : existingAccount?.displayName || '', 219 224 aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', 225 + emailConfirmed: session?.emailConfirmed, 220 226 } 221 227 if (!existingAccount) { 222 228 this.accounts.push(newAccount) ··· 246 252 did: acct.did, 247 253 displayName: acct.displayName, 248 254 aviUrl: acct.aviUrl, 255 + email: acct.email, 256 + emailConfirmed: acct.emailConfirmed, 249 257 })) 250 258 } 251 259 ··· 297 305 refreshJwt: account.refreshJwt || '', 298 306 did: account.did, 299 307 handle: account.handle, 308 + email: account.email, 309 + emailConfirmed: account.emailConfirmed, 300 310 }), 301 311 ) 302 312 const addedInfo = await this.loadAccountInfo(agent, account.did) ··· 451 461 }) 452 462 await this.rootStore.me.load() 453 463 } 464 + } 465 + 466 + updateLocalAccountData(changes: Partial<AccountData>) { 467 + this.accounts = this.accounts.map(acct => 468 + acct.did === this.data?.did ? {...acct, ...changes} : acct, 469 + ) 454 470 } 455 471 }
+65
src/state/models/ui/reminders.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import {isObj, hasProp} from 'lib/type-guards' 3 + import {RootStoreModel} from '../root-store' 4 + import {toHashCode} from 'lib/strings/helpers' 5 + 6 + const DAY = 60e3 * 24 * 1 // 1 day (ms) 7 + 8 + export class Reminders { 9 + // NOTE 10 + // by defaulting to the current date, we ensure that the user won't be nagged 11 + // on first run (aka right after creating an account) 12 + // -prf 13 + lastEmailConfirm: Date = new Date() 14 + 15 + constructor(public rootStore: RootStoreModel) { 16 + makeAutoObservable( 17 + this, 18 + {serialize: false, hydrate: false}, 19 + {autoBind: true}, 20 + ) 21 + } 22 + 23 + serialize() { 24 + return { 25 + lastEmailConfirm: this.lastEmailConfirm 26 + ? this.lastEmailConfirm.toISOString() 27 + : undefined, 28 + } 29 + } 30 + 31 + hydrate(v: unknown) { 32 + if ( 33 + isObj(v) && 34 + hasProp(v, 'lastEmailConfirm') && 35 + typeof v.lastEmailConfirm === 'string' 36 + ) { 37 + this.lastEmailConfirm = new Date(v.lastEmailConfirm) 38 + } 39 + } 40 + 41 + get shouldRequestEmailConfirmation() { 42 + const sess = this.rootStore.session.currentSession 43 + if (!sess) { 44 + return false 45 + } 46 + if (sess.emailConfirmed) { 47 + return false 48 + } 49 + const today = new Date() 50 + // shard the users into 2 day of the week buckets 51 + // (this is to avoid a sudden influx of email updates when 52 + // this feature rolls out) 53 + const code = toHashCode(sess.did) % 7 54 + if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { 55 + return false 56 + } 57 + // only ask once a day at most, but because of the bucketing 58 + // this will be more like weekly 59 + return Number(today) - Number(this.lastEmailConfirm) > DAY 60 + } 61 + 62 + setEmailConfirmationRequested() { 63 + this.lastEmailConfirm = new Date() 64 + } 65 + }
+22
src/state/models/ui/shell.ts
··· 24 24 onPressCancel?: () => void | Promise<void> 25 25 confirmBtnText?: string 26 26 confirmBtnStyle?: StyleProp<ViewStyle> 27 + cancelBtnText?: string 27 28 } 28 29 29 30 export interface EditProfileModal { ··· 140 141 name: 'birth-date-settings' 141 142 } 142 143 144 + export interface VerifyEmailModal { 145 + name: 'verify-email' 146 + showReminder?: boolean 147 + } 148 + 149 + export interface ChangeEmailModal { 150 + name: 'change-email' 151 + } 152 + 143 153 export type Modal = 144 154 // Account 145 155 | AddAppPasswordModal ··· 148 158 | EditProfileModal 149 159 | ProfilePreviewModal 150 160 | BirthDateSettingsModal 161 + | VerifyEmailModal 162 + | ChangeEmailModal 151 163 152 164 // Curation 153 165 | ContentFilteringSettingsModal ··· 250 262 }) 251 263 252 264 this.setupClock() 265 + this.setupLoginModals() 253 266 } 254 267 255 268 serialize(): unknown { ··· 374 387 this.tickEveryMinute = Date.now() 375 388 }) 376 389 }, 60_000) 390 + } 391 + 392 + setupLoginModals() { 393 + this.rootStore.onSessionReady(() => { 394 + if (this.rootStore.reminders.shouldRequestEmailConfirmation) { 395 + this.openModal({name: 'verify-email', showReminder: true}) 396 + this.rootStore.reminders.setEmailConfirmationRequested() 397 + } 398 + }) 377 399 } 378 400 }
+280
src/view/com/modals/ChangeEmail.tsx
··· 1 + import React, {useState} from 'react' 2 + import { 3 + ActivityIndicator, 4 + KeyboardAvoidingView, 5 + SafeAreaView, 6 + StyleSheet, 7 + View, 8 + } from 'react-native' 9 + import {ScrollView, TextInput} from './util' 10 + import {observer} from 'mobx-react-lite' 11 + import {Text} from '../util/text/Text' 12 + import {Button} from '../util/forms/Button' 13 + import {ErrorMessage} from '../util/error/ErrorMessage' 14 + import * as Toast from '../util/Toast' 15 + import {useStores} from 'state/index' 16 + import {s, colors} from 'lib/styles' 17 + import {usePalette} from 'lib/hooks/usePalette' 18 + import {isWeb} from 'platform/detection' 19 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 20 + import {cleanError} from 'lib/strings/errors' 21 + 22 + enum Stages { 23 + InputEmail, 24 + ConfirmCode, 25 + Done, 26 + } 27 + 28 + export const snapPoints = ['90%'] 29 + 30 + export const Component = observer(function Component({}: {}) { 31 + const pal = usePalette('default') 32 + const store = useStores() 33 + const [stage, setStage] = useState<Stages>(Stages.InputEmail) 34 + const [email, setEmail] = useState<string>( 35 + store.session.currentSession?.email || '', 36 + ) 37 + const [confirmationCode, setConfirmationCode] = useState<string>('') 38 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 39 + const [error, setError] = useState<string>('') 40 + const {isMobile} = useWebMediaQueries() 41 + 42 + const onRequestChange = async () => { 43 + if (email === store.session.currentSession?.email) { 44 + setError('Enter your new email above') 45 + return 46 + } 47 + setError('') 48 + setIsProcessing(true) 49 + try { 50 + const res = await store.agent.com.atproto.server.requestEmailUpdate() 51 + if (res.data.tokenRequired) { 52 + setStage(Stages.ConfirmCode) 53 + } else { 54 + await store.agent.com.atproto.server.updateEmail({email: email.trim()}) 55 + store.session.updateLocalAccountData({ 56 + email: email.trim(), 57 + emailConfirmed: false, 58 + }) 59 + Toast.show('Email updated') 60 + setStage(Stages.Done) 61 + } 62 + } catch (e) { 63 + let err = cleanError(String(e)) 64 + // TEMP 65 + // while rollout is occuring, we're giving a temporary error message 66 + // you can remove this any time after Oct2023 67 + // -prf 68 + if (err === 'email must be confirmed (temporary)') { 69 + 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.` 70 + } 71 + setError(err) 72 + } finally { 73 + setIsProcessing(false) 74 + } 75 + } 76 + 77 + const onConfirm = async () => { 78 + setError('') 79 + setIsProcessing(true) 80 + try { 81 + await store.agent.com.atproto.server.updateEmail({ 82 + email: email.trim(), 83 + token: confirmationCode.trim(), 84 + }) 85 + store.session.updateLocalAccountData({ 86 + email: email.trim(), 87 + emailConfirmed: false, 88 + }) 89 + Toast.show('Email updated') 90 + setStage(Stages.Done) 91 + } catch (e) { 92 + setError(cleanError(String(e))) 93 + } finally { 94 + setIsProcessing(false) 95 + } 96 + } 97 + 98 + const onVerify = async () => { 99 + store.shell.closeModal() 100 + store.shell.openModal({name: 'verify-email'}) 101 + } 102 + 103 + return ( 104 + <KeyboardAvoidingView 105 + behavior="padding" 106 + style={[pal.view, styles.container]}> 107 + <SafeAreaView style={s.flex1}> 108 + <ScrollView 109 + testID="changeEmailModal" 110 + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> 111 + <View style={styles.titleSection}> 112 + <Text type="title-lg" style={[pal.text, styles.title]}> 113 + {stage === Stages.InputEmail ? 'Change Your Email' : ''} 114 + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} 115 + {stage === Stages.Done ? 'Email Updated' : ''} 116 + </Text> 117 + </View> 118 + 119 + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> 120 + {stage === Stages.InputEmail ? ( 121 + <>Enter your new email address below.</> 122 + ) : stage === Stages.ConfirmCode ? ( 123 + <> 124 + An email has been sent to your previous address,{' '} 125 + {store.session.currentSession?.email || ''}. It includes a 126 + confirmation code which you can enter below. 127 + </> 128 + ) : ( 129 + <> 130 + Your email has been updated but not verified. As a next step, 131 + please verify your new email. 132 + </> 133 + )} 134 + </Text> 135 + 136 + {stage === Stages.InputEmail && ( 137 + <TextInput 138 + testID="emailInput" 139 + style={[styles.textInput, pal.border, pal.text]} 140 + placeholder="alice@mail.com" 141 + placeholderTextColor={pal.colors.textLight} 142 + value={email} 143 + onChangeText={setEmail} 144 + accessible={true} 145 + accessibilityLabel="Email" 146 + accessibilityHint="" 147 + autoCapitalize="none" 148 + autoComplete="email" 149 + autoCorrect={false} 150 + /> 151 + )} 152 + {stage === Stages.ConfirmCode && ( 153 + <TextInput 154 + testID="confirmCodeInput" 155 + style={[styles.textInput, pal.border, pal.text]} 156 + placeholder="XXXXX-XXXXX" 157 + placeholderTextColor={pal.colors.textLight} 158 + value={confirmationCode} 159 + onChangeText={setConfirmationCode} 160 + accessible={true} 161 + accessibilityLabel="Confirmation code" 162 + accessibilityHint="" 163 + autoCapitalize="none" 164 + autoComplete="off" 165 + autoCorrect={false} 166 + /> 167 + )} 168 + 169 + {error ? ( 170 + <ErrorMessage message={error} style={styles.error} /> 171 + ) : undefined} 172 + 173 + <View style={[styles.btnContainer]}> 174 + {isProcessing ? ( 175 + <View style={styles.btn}> 176 + <ActivityIndicator color="#fff" /> 177 + </View> 178 + ) : ( 179 + <View style={{gap: 6}}> 180 + {stage === Stages.InputEmail && ( 181 + <Button 182 + testID="requestChangeBtn" 183 + type="primary" 184 + onPress={onRequestChange} 185 + accessibilityLabel="Request Change" 186 + accessibilityHint="" 187 + label="Request Change" 188 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 189 + labelStyle={[s.f18]} 190 + /> 191 + )} 192 + {stage === Stages.ConfirmCode && ( 193 + <Button 194 + testID="confirmBtn" 195 + type="primary" 196 + onPress={onConfirm} 197 + accessibilityLabel="Confirm Change" 198 + accessibilityHint="" 199 + label="Confirm Change" 200 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 201 + labelStyle={[s.f18]} 202 + /> 203 + )} 204 + {stage === Stages.Done && ( 205 + <Button 206 + testID="verifyBtn" 207 + type="primary" 208 + onPress={onVerify} 209 + accessibilityLabel="Verify New Email" 210 + accessibilityHint="" 211 + label="Verify New Email" 212 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 213 + labelStyle={[s.f18]} 214 + /> 215 + )} 216 + <Button 217 + testID="cancelBtn" 218 + type="default" 219 + onPress={() => store.shell.closeModal()} 220 + accessibilityLabel="Cancel" 221 + accessibilityHint="" 222 + label="Cancel" 223 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 224 + labelStyle={[s.f18]} 225 + /> 226 + </View> 227 + )} 228 + </View> 229 + </ScrollView> 230 + </SafeAreaView> 231 + </KeyboardAvoidingView> 232 + ) 233 + }) 234 + 235 + const styles = StyleSheet.create({ 236 + container: { 237 + flex: 1, 238 + paddingBottom: isWeb ? 0 : 40, 239 + }, 240 + titleSection: { 241 + paddingTop: isWeb ? 0 : 4, 242 + paddingBottom: isWeb ? 14 : 10, 243 + }, 244 + title: { 245 + textAlign: 'center', 246 + fontWeight: '600', 247 + marginBottom: 5, 248 + }, 249 + error: { 250 + borderRadius: 6, 251 + marginTop: 10, 252 + }, 253 + emailContainer: { 254 + flexDirection: 'row', 255 + alignItems: 'center', 256 + gap: 6, 257 + borderWidth: 1, 258 + borderRadius: 6, 259 + paddingHorizontal: 14, 260 + paddingVertical: 12, 261 + }, 262 + textInput: { 263 + borderWidth: 1, 264 + borderRadius: 6, 265 + paddingHorizontal: 14, 266 + paddingVertical: 10, 267 + fontSize: 16, 268 + }, 269 + btn: { 270 + flexDirection: 'row', 271 + alignItems: 'center', 272 + justifyContent: 'center', 273 + borderRadius: 32, 274 + padding: 14, 275 + backgroundColor: colors.blue3, 276 + }, 277 + btnContainer: { 278 + paddingTop: 20, 279 + }, 280 + })
+2 -1
src/view/com/modals/Confirm.tsx
··· 23 23 onPressCancel, 24 24 confirmBtnText, 25 25 confirmBtnStyle, 26 + cancelBtnText, 26 27 }: ConfirmModal) { 27 28 const pal = usePalette('default') 28 29 const store = useStores() ··· 84 85 accessibilityLabel="Cancel" 85 86 accessibilityHint=""> 86 87 <Text type="button-lg" style={pal.textLight}> 87 - Cancel 88 + {cancelBtnText ?? 'Cancel'} 88 89 </Text> 89 90 </TouchableOpacity> 90 91 )}
+8
src/view/com/modals/Modal.tsx
··· 30 30 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 31 31 import * as ModerationDetailsModal from './ModerationDetails' 32 32 import * as BirthDateSettingsModal from './BirthDateSettings' 33 + import * as VerifyEmailModal from './VerifyEmail' 34 + import * as ChangeEmailModal from './ChangeEmail' 33 35 34 36 const DEFAULT_SNAPPOINTS = ['90%'] 35 37 ··· 136 138 } else if (activeModal?.name === 'birth-date-settings') { 137 139 snapPoints = BirthDateSettingsModal.snapPoints 138 140 element = <BirthDateSettingsModal.Component /> 141 + } else if (activeModal?.name === 'verify-email') { 142 + snapPoints = VerifyEmailModal.snapPoints 143 + element = <VerifyEmailModal.Component {...activeModal} /> 144 + } else if (activeModal?.name === 'change-email') { 145 + snapPoints = ChangeEmailModal.snapPoints 146 + element = <ChangeEmailModal.Component /> 139 147 } else { 140 148 return null 141 149 }
+6
src/view/com/modals/Modal.web.tsx
··· 28 28 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 29 29 import * as ModerationDetailsModal from './ModerationDetails' 30 30 import * as BirthDateSettingsModal from './BirthDateSettings' 31 + import * as VerifyEmailModal from './VerifyEmail' 32 + import * as ChangeEmailModal from './ChangeEmail' 31 33 32 34 export const ModalsContainer = observer(function ModalsContainer() { 33 35 const store = useStores() ··· 110 112 element = <ModerationDetailsModal.Component {...modal} /> 111 113 } else if (modal.name === 'birth-date-settings') { 112 114 element = <BirthDateSettingsModal.Component /> 115 + } else if (modal.name === 'verify-email') { 116 + element = <VerifyEmailModal.Component {...modal} /> 117 + } else if (modal.name === 'change-email') { 118 + element = <ChangeEmailModal.Component /> 113 119 } else { 114 120 return null 115 121 }
+296
src/view/com/modals/VerifyEmail.tsx
··· 1 + import React, {useState} from 'react' 2 + import { 3 + ActivityIndicator, 4 + KeyboardAvoidingView, 5 + Pressable, 6 + SafeAreaView, 7 + StyleSheet, 8 + View, 9 + } from 'react-native' 10 + import {ScrollView, TextInput} from './util' 11 + import {observer} from 'mobx-react-lite' 12 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 + import {Text} from '../util/text/Text' 14 + import {Button} from '../util/forms/Button' 15 + import {ErrorMessage} from '../util/error/ErrorMessage' 16 + import * as Toast from '../util/Toast' 17 + import {useStores} from 'state/index' 18 + import {s, colors} from 'lib/styles' 19 + import {usePalette} from 'lib/hooks/usePalette' 20 + import {isWeb} from 'platform/detection' 21 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 22 + import {cleanError} from 'lib/strings/errors' 23 + 24 + export const snapPoints = ['90%'] 25 + 26 + enum Stages { 27 + Reminder, 28 + Email, 29 + ConfirmCode, 30 + } 31 + 32 + export const Component = observer(function Component({ 33 + showReminder, 34 + }: { 35 + showReminder?: boolean 36 + }) { 37 + const pal = usePalette('default') 38 + const store = useStores() 39 + const [stage, setStage] = useState<Stages>( 40 + showReminder ? Stages.Reminder : Stages.Email, 41 + ) 42 + const [confirmationCode, setConfirmationCode] = useState<string>('') 43 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 44 + const [error, setError] = useState<string>('') 45 + const {isMobile} = useWebMediaQueries() 46 + 47 + const onSendEmail = async () => { 48 + setError('') 49 + setIsProcessing(true) 50 + try { 51 + await store.agent.com.atproto.server.requestEmailConfirmation() 52 + setStage(Stages.ConfirmCode) 53 + } catch (e) { 54 + setError(cleanError(String(e))) 55 + } finally { 56 + setIsProcessing(false) 57 + } 58 + } 59 + 60 + const onConfirm = async () => { 61 + setError('') 62 + setIsProcessing(true) 63 + try { 64 + await store.agent.com.atproto.server.confirmEmail({ 65 + email: (store.session.currentSession?.email || '').trim(), 66 + token: confirmationCode.trim(), 67 + }) 68 + store.session.updateLocalAccountData({emailConfirmed: true}) 69 + Toast.show('Email verified') 70 + store.shell.closeModal() 71 + } catch (e) { 72 + setError(cleanError(String(e))) 73 + } finally { 74 + setIsProcessing(false) 75 + } 76 + } 77 + 78 + const onEmailIncorrect = () => { 79 + store.shell.closeModal() 80 + store.shell.openModal({name: 'change-email'}) 81 + } 82 + 83 + return ( 84 + <KeyboardAvoidingView 85 + behavior="padding" 86 + style={[pal.view, styles.container]}> 87 + <SafeAreaView style={s.flex1}> 88 + <ScrollView 89 + testID="verifyEmailModal" 90 + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> 91 + <View style={styles.titleSection}> 92 + <Text type="title-lg" style={[pal.text, styles.title]}> 93 + {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} 94 + {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} 95 + {stage === Stages.Email ? 'Verify Your Email' : ''} 96 + </Text> 97 + </View> 98 + 99 + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> 100 + {stage === Stages.Reminder ? ( 101 + <> 102 + Your email has not yet been verified. This is an important 103 + security step which we recommend. 104 + </> 105 + ) : stage === Stages.Email ? ( 106 + <> 107 + This is important in case you ever need to change your email or 108 + reset your password. 109 + </> 110 + ) : stage === Stages.ConfirmCode ? ( 111 + <> 112 + An email has been sent to{' '} 113 + {store.session.currentSession?.email || ''}. It includes a 114 + confirmation code which you can enter below. 115 + </> 116 + ) : ( 117 + '' 118 + )} 119 + </Text> 120 + 121 + {stage === Stages.Email ? ( 122 + <> 123 + <View style={styles.emailContainer}> 124 + <FontAwesomeIcon 125 + icon="envelope" 126 + color={pal.colors.text} 127 + size={16} 128 + /> 129 + <Text 130 + type="xl-medium" 131 + style={[pal.text, s.flex1, {minWidth: 0}]}> 132 + {store.session.currentSession?.email || ''} 133 + </Text> 134 + </View> 135 + <Pressable 136 + accessibilityRole="link" 137 + accessibilityLabel="Change my email" 138 + accessibilityHint="" 139 + onPress={onEmailIncorrect} 140 + style={styles.changeEmailLink}> 141 + <Text type="lg" style={pal.link}> 142 + Change 143 + </Text> 144 + </Pressable> 145 + </> 146 + ) : stage === Stages.ConfirmCode ? ( 147 + <TextInput 148 + testID="confirmCodeInput" 149 + style={[styles.textInput, pal.border, pal.text]} 150 + placeholder="XXXXX-XXXXX" 151 + placeholderTextColor={pal.colors.textLight} 152 + value={confirmationCode} 153 + onChangeText={setConfirmationCode} 154 + accessible={true} 155 + accessibilityLabel="Confirmation code" 156 + accessibilityHint="" 157 + autoCapitalize="none" 158 + autoComplete="off" 159 + autoCorrect={false} 160 + /> 161 + ) : undefined} 162 + 163 + {error ? ( 164 + <ErrorMessage message={error} style={styles.error} /> 165 + ) : undefined} 166 + 167 + <View style={[styles.btnContainer]}> 168 + {isProcessing ? ( 169 + <View style={styles.btn}> 170 + <ActivityIndicator color="#fff" /> 171 + </View> 172 + ) : ( 173 + <View style={{gap: 6}}> 174 + {stage === Stages.Reminder && ( 175 + <Button 176 + testID="getStartedBtn" 177 + type="primary" 178 + onPress={() => setStage(Stages.Email)} 179 + accessibilityLabel="Get Started" 180 + accessibilityHint="" 181 + label="Get Started" 182 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 183 + labelStyle={[s.f18]} 184 + /> 185 + )} 186 + {stage === Stages.Email && ( 187 + <> 188 + <Button 189 + testID="sendEmailBtn" 190 + type="primary" 191 + onPress={onSendEmail} 192 + accessibilityLabel="Send Confirmation Email" 193 + accessibilityHint="" 194 + label="Send Confirmation Email" 195 + labelContainerStyle={{ 196 + justifyContent: 'center', 197 + padding: 4, 198 + }} 199 + labelStyle={[s.f18]} 200 + /> 201 + <Button 202 + testID="haveCodeBtn" 203 + type="default" 204 + accessibilityLabel="I have a code" 205 + accessibilityHint="" 206 + label="I have a confirmation code" 207 + labelContainerStyle={{ 208 + justifyContent: 'center', 209 + padding: 4, 210 + }} 211 + labelStyle={[s.f18]} 212 + onPress={() => setStage(Stages.ConfirmCode)} 213 + /> 214 + </> 215 + )} 216 + {stage === Stages.ConfirmCode && ( 217 + <Button 218 + testID="confirmBtn" 219 + type="primary" 220 + onPress={onConfirm} 221 + accessibilityLabel="Confirm" 222 + accessibilityHint="" 223 + label="Confirm" 224 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 225 + labelStyle={[s.f18]} 226 + /> 227 + )} 228 + <Button 229 + testID="cancelBtn" 230 + type="default" 231 + onPress={() => store.shell.closeModal()} 232 + accessibilityLabel={ 233 + stage === Stages.Reminder ? 'Not right now' : 'Cancel' 234 + } 235 + accessibilityHint="" 236 + label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} 237 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 238 + labelStyle={[s.f18]} 239 + /> 240 + </View> 241 + )} 242 + </View> 243 + </ScrollView> 244 + </SafeAreaView> 245 + </KeyboardAvoidingView> 246 + ) 247 + }) 248 + 249 + const styles = StyleSheet.create({ 250 + container: { 251 + flex: 1, 252 + paddingBottom: isWeb ? 0 : 40, 253 + }, 254 + titleSection: { 255 + paddingTop: isWeb ? 0 : 4, 256 + paddingBottom: isWeb ? 14 : 10, 257 + }, 258 + title: { 259 + textAlign: 'center', 260 + fontWeight: '600', 261 + marginBottom: 5, 262 + }, 263 + error: { 264 + borderRadius: 6, 265 + marginTop: 10, 266 + }, 267 + emailContainer: { 268 + flexDirection: 'row', 269 + alignItems: 'center', 270 + gap: 6, 271 + paddingHorizontal: 14, 272 + marginTop: 10, 273 + }, 274 + changeEmailLink: { 275 + marginHorizontal: 12, 276 + marginBottom: 12, 277 + }, 278 + textInput: { 279 + borderWidth: 1, 280 + borderRadius: 6, 281 + paddingHorizontal: 14, 282 + paddingVertical: 10, 283 + fontSize: 16, 284 + }, 285 + btn: { 286 + flexDirection: 'row', 287 + alignItems: 'center', 288 + justifyContent: 'center', 289 + borderRadius: 32, 290 + padding: 14, 291 + backgroundColor: colors.blue3, 292 + }, 293 + btnContainer: { 294 + paddingTop: 20, 295 + }, 296 + })
+12 -2
src/view/com/util/forms/Button.tsx
··· 42 42 type = 'primary', 43 43 label, 44 44 style, 45 + labelContainerStyle, 45 46 labelStyle, 46 47 onPress, 47 48 children, ··· 55 56 type?: ButtonType 56 57 label?: string 57 58 style?: StyleProp<ViewStyle> 59 + labelContainerStyle?: StyleProp<ViewStyle> 58 60 labelStyle?: StyleProp<TextStyle> 59 61 onPress?: () => void | Promise<void> 60 62 testID?: string ··· 173 175 } 174 176 175 177 return ( 176 - <View style={styles.labelContainer}> 178 + <View style={[styles.labelContainer, labelContainerStyle]}> 177 179 {label && withLoading && isLoading ? ( 178 180 <ActivityIndicator size={12} color={typeLabelStyle.color} /> 179 181 ) : null} ··· 182 184 </Text> 183 185 </View> 184 186 ) 185 - }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) 187 + }, [ 188 + children, 189 + label, 190 + withLoading, 191 + isLoading, 192 + labelContainerStyle, 193 + typeLabelStyle, 194 + labelStyle, 195 + ]) 186 196 187 197 return ( 188 198 <Pressable
+80 -3
src/view/screens/Settings.tsx
··· 219 219 <View style={[styles.infoLine]}> 220 220 <Text type="lg-medium" style={pal.text}> 221 221 Email:{' '} 222 - <Text type="lg" style={pal.text}> 223 - {store.session.currentSession?.email} 224 - </Text> 225 222 </Text> 223 + {!store.session.emailNeedsConfirmation && ( 224 + <> 225 + <FontAwesomeIcon 226 + icon="check" 227 + size={10} 228 + style={{color: colors.green3, marginRight: 2}} 229 + /> 230 + </> 231 + )} 232 + <Text type="lg" style={pal.text}> 233 + {store.session.currentSession?.email}{' '} 234 + </Text> 235 + <Link 236 + onPress={() => store.shell.openModal({name: 'change-email'})}> 237 + <Text type="lg" style={pal.link}> 238 + Change 239 + </Text> 240 + </Link> 226 241 </View> 227 242 <View style={[styles.infoLine]}> 228 243 <Text type="lg-medium" style={pal.text}> ··· 238 253 </Link> 239 254 </View> 240 255 <View style={styles.spacer20} /> 256 + <EmailConfirmationNotice /> 241 257 </> 242 258 ) : null} 243 259 <View style={[s.flexRow, styles.heading]}> ··· 664 680 </Pressable> 665 681 ) 666 682 } 683 + 684 + const EmailConfirmationNotice = observer( 685 + function EmailConfirmationNoticeImpl() { 686 + const pal = usePalette('default') 687 + const palInverted = usePalette('inverted') 688 + const store = useStores() 689 + const {isMobile} = useWebMediaQueries() 690 + 691 + if (!store.session.emailNeedsConfirmation) { 692 + return null 693 + } 694 + 695 + return ( 696 + <View style={{marginBottom: 20}}> 697 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 698 + Verify email 699 + </Text> 700 + <View 701 + style={[ 702 + { 703 + paddingVertical: isMobile ? 12 : 0, 704 + paddingHorizontal: 18, 705 + }, 706 + pal.view, 707 + ]}> 708 + <View style={{flexDirection: 'row', marginBottom: 8}}> 709 + <Pressable 710 + style={[ 711 + palInverted.view, 712 + { 713 + flexDirection: 'row', 714 + gap: 6, 715 + borderRadius: 6, 716 + paddingHorizontal: 12, 717 + paddingVertical: 10, 718 + alignItems: 'center', 719 + }, 720 + isMobile && {flex: 1}, 721 + ]} 722 + accessibilityRole="button" 723 + accessibilityLabel="Verify my email" 724 + accessibilityHint="" 725 + onPress={() => store.shell.openModal({name: 'verify-email'})}> 726 + <FontAwesomeIcon 727 + icon="envelope" 728 + color={palInverted.colors.text} 729 + size={16} 730 + /> 731 + <Text type="button" style={palInverted.text}> 732 + Verify My Email 733 + </Text> 734 + </Pressable> 735 + </View> 736 + <Text style={pal.textLight}> 737 + Protect your account by verifying your email. 738 + </Text> 739 + </View> 740 + </View> 741 + ) 742 + }, 743 + ) 667 744 668 745 const styles = StyleSheet.create({ 669 746 dimmed: {
+44 -8
yarn.lock
··· 47 47 tlds "^1.234.0" 48 48 typed-emitter "^2.1.0" 49 49 50 - "@atproto/api@^0.6.16": 51 - version "0.6.16" 52 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.16.tgz#0e5f259a8eb8af239b4e77bf70d7e770b33f4eeb" 53 - integrity sha512-DpG994bdwk7NWJSb36Af+0+FRWMFZgzTcrK0rN2tvlsMh6wBF/RdErjHKuoL8wcogGzbI2yp8eOqsA00lyoisw== 50 + "@atproto/api@^0.6.20": 51 + version "0.6.20" 52 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.20.tgz#3a7eda60d73a5d5b6938e2dd016c24a7ba180c83" 53 + integrity sha512-+peoKgkaxbglXQg9qEZcZIvyWm39yj0+syV3TBDrz5cWK4OIsdOyYBg2iISy+jvB5RzEUMe2WvOojP6Nq34mOg== 54 54 dependencies: 55 - "@atproto/common-web" "^0.2.0" 56 - "@atproto/lexicon" "^0.2.1" 57 - "@atproto/syntax" "^0.1.1" 58 - "@atproto/xrpc" "^0.3.1" 55 + "@atproto/common-web" "^0.2.1" 56 + "@atproto/lexicon" "^0.2.2" 57 + "@atproto/syntax" "^0.1.2" 58 + "@atproto/xrpc" "^0.3.2" 59 59 multiformats "^9.9.0" 60 60 tlds "^1.234.0" 61 61 typed-emitter "^2.1.0" ··· 102 102 dependencies: 103 103 graphemer "^1.4.0" 104 104 multiformats "^9.6.4" 105 + uint8arrays "3.0.0" 106 + zod "^3.21.4" 107 + 108 + "@atproto/common-web@^0.2.1": 109 + version "0.2.1" 110 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.1.tgz#97412cb241321fc6c56a2b8c0b2416b3240caf50" 111 + integrity sha512-5AoDKkKz7JhXSiicjhPihA/MJMlSuTQ9Aed9fflPuoTuT6C3aXbxaUZEcqqipSwlCfGpOzPmJmWJjMWWsYr2ew== 112 + dependencies: 113 + graphemer "^1.4.0" 114 + multiformats "^9.9.0" 105 115 uint8arrays "3.0.0" 106 116 zod "^3.21.4" 107 117 ··· 209 219 multiformats "^9.9.0" 210 220 zod "^3.21.4" 211 221 222 + "@atproto/lexicon@^0.2.2": 223 + version "0.2.2" 224 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.2.tgz#938a39482ff41c6a908f4ad43274adba595f3643" 225 + integrity sha512-CvmjaSDavHMOJTuNYE8VjYhL7TVxBYV8QSWh2jHCpzfmj02DvVD9UBIfnoVv67POJkEtWXddjoV9beaIbaq/Xg== 226 + dependencies: 227 + "@atproto/common-web" "^0.2.1" 228 + "@atproto/syntax" "^0.1.2" 229 + iso-datestring-validator "^2.2.2" 230 + multiformats "^9.9.0" 231 + zod "^3.21.4" 232 + 212 233 "@atproto/pds@^0.1.14": 213 234 version "0.1.14" 214 235 resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.14.tgz#7c5a49e412d599d2105bb7ecd019832ab952b19f" ··· 276 297 dependencies: 277 298 "@atproto/common-web" "^0.2.0" 278 299 300 + "@atproto/syntax@^0.1.2": 301 + version "0.1.2" 302 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.2.tgz#417366d36b53ecf29d9d1f6e35179b1f3feef95b" 303 + integrity sha512-n6VSuccMGouwftCvZBq9WNwI0qYCMOH/lTHSV+/dT232lX7pIrqisOlErUSBoOJ49B1Wxy1DjeeBS26ap9SsGQ== 304 + dependencies: 305 + "@atproto/common-web" "^0.2.1" 306 + 279 307 "@atproto/xrpc-server@^0.3.1": 280 308 version "0.3.1" 281 309 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.3.1.tgz#40eeae1dee79fcc835d7a0068ca90f9c91f0ba06" ··· 299 327 integrity sha512-VVoRC/omtXFMIDUyrkFjYkwJ3vevvsGH0L1UW6mTsL40DK3iJpi0GcdJlcqdMkIFJ+QLeAluEKgEcL7bAR5LiQ== 300 328 dependencies: 301 329 "@atproto/lexicon" "^0.2.1" 330 + zod "^3.21.4" 331 + 332 + "@atproto/xrpc@^0.3.2": 333 + version "0.3.2" 334 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.2.tgz#432a364be4b3bf8660a088a07dadecac10209763" 335 + integrity sha512-D9jGjcFnEMHuGQ56v6+78uX3RiytKLrA5ITLq6shy0Qj6Zvt5MqV+/cTFuNPKrNCrnWOtHFeRQwMqyGhNS9qZQ== 336 + dependencies: 337 + "@atproto/lexicon" "^0.2.2" 302 338 zod "^3.21.4" 303 339 304 340 "@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4":