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>
This commit is contained in:
Paul Frazee 2023-09-28 12:08:00 -07:00 committed by GitHub
parent 16763d1d41
commit cd3b0e54fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 855 additions and 15 deletions

View file

@ -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)
}
}
}

View file

@ -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,
)
}
}

View file

@ -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()
}
}

View file

@ -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()
}
})
}
}