about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/models/media/image.ts13
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/models/session.ts16
-rw-r--r--src/state/models/ui/reminders.ts65
-rw-r--r--src/state/models/ui/shell.ts22
5 files changed, 118 insertions, 4 deletions
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index 844ecb778..10aef0ff4 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -8,6 +8,7 @@ import {openCropper} from 'lib/media/picker'
 import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
 import {Position} from 'react-avatar-editor'
 import {Dimensions} from 'lib/media/types'
+import {isIOS} from 'platform/detection'
 
 export interface ImageManipulationAttributes {
   aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
@@ -164,8 +165,13 @@ export class ImageModel implements Omit<RNImage, 'size'> {
   // Mobile
   async crop() {
     try {
-      // openCropper requires an output width and height hence
-      // getting upload dimensions before cropping is necessary.
+      // NOTE
+      // on ios, react-native-image-cropper gives really bad quality
+      // without specifying width and height. on android, however, the
+      // crop stretches incorrectly if you do specify it. these are
+      // both separate bugs in the library. we deal with that by
+      // providing width & height for ios only
+      // -prf
       const {width, height} = this.getUploadDimensions({
         width: this.width,
         height: this.height,
@@ -175,8 +181,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
         mediaType: 'photo',
         path: this.path,
         freeStyleCropEnabled: true,
-        width,
-        height,
+        ...(isIOS ? {width, height} : {}),
       })
 
       runInAction(() => {
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()
+      }
+    })
+  }
 }