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/index.ts16
-rw-r--r--src/state/lib/api.ts9
-rw-r--r--src/state/models/me.ts42
-rw-r--r--src/state/models/root-store.ts15
-rw-r--r--src/state/models/session.ts40
5 files changed, 108 insertions, 14 deletions
diff --git a/src/state/index.ts b/src/state/index.ts
index 32efea3f3..739742d4a 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -27,10 +27,19 @@ export async function setupState() {
     console.error('Failed to load state from storage', e)
   }
 
-  await rootStore.session.setup()
+  console.log('Initial hydrate', rootStore.me)
+  rootStore.session
+    .connect()
+    .then(() => {
+      console.log('Session connected', rootStore.me)
+      return rootStore.fetchStateUpdate()
+    })
+    .catch(e => {
+      console.log('Failed initial connect', e)
+    })
   // @ts-ignore .on() is correct -prf
   api.sessionManager.on('session', () => {
-    if (!api.sessionManager.session && rootStore.session.isAuthed) {
+    if (!api.sessionManager.session && rootStore.session.hasSession) {
       // reset session
       rootStore.session.clear()
     } else if (api.sessionManager.session) {
@@ -44,9 +53,6 @@ export async function setupState() {
     storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
   })
 
-  await rootStore.fetchStateUpdate()
-  console.log(rootStore.me)
-
   // periodic state fetch
   setInterval(() => {
     rootStore.fetchStateUpdate()
diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts
index 842905d1d..f17d0337c 100644
--- a/src/state/lib/api.ts
+++ b/src/state/lib/api.ts
@@ -12,6 +12,8 @@ import {APP_BSKY_GRAPH} from '../../third-party/api'
 import {RootStoreModel} from '../models/root-store'
 import {extractEntities} from '../../lib/strings'
 
+const TIMEOUT = 10e3 // 10s
+
 export function doPolyfill() {
   AtpApi.xrpc.fetch = fetchHandler
 }
@@ -175,10 +177,14 @@ async function fetchHandler(
     reqBody = JSON.stringify(reqBody)
   }
 
+  const controller = new AbortController()
+  const to = setTimeout(() => controller.abort(), TIMEOUT)
+
   const res = await fetch(reqUri, {
     method: reqMethod,
     headers: reqHeaders,
     body: reqBody,
+    signal: controller.signal,
   })
 
   const resStatus = res.status
@@ -197,6 +203,9 @@ async function fetchHandler(
       throw new Error('TODO: non-textual response body')
     }
   }
+
+  clearTimeout(to)
+
   return {
     status: resStatus,
     headers: resHeaders,
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index e3405b80d..fde387ebe 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {RootStoreModel} from './root-store'
 import {MembershipsViewModel} from './memberships-view'
 import {NotificationsViewModel} from './notifications-view'
+import {isObj, hasProp} from '../lib/type-guards'
 
 export class MeModel {
   did?: string
@@ -13,7 +14,11 @@ export class MeModel {
   notifications: NotificationsViewModel
 
   constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {rootStore: false}, {autoBind: true})
+    makeAutoObservable(
+      this,
+      {rootStore: false, serialize: false, hydrate: false},
+      {autoBind: true},
+    )
     this.notifications = new NotificationsViewModel(this.rootStore, {})
   }
 
@@ -26,9 +31,42 @@ export class MeModel {
     this.memberships = undefined
   }
 
+  serialize(): unknown {
+    return {
+      did: this.did,
+      handle: this.handle,
+      displayName: this.displayName,
+      description: this.description,
+    }
+  }
+
+  hydrate(v: unknown) {
+    if (isObj(v)) {
+      let did, handle, displayName, description
+      if (hasProp(v, 'did') && typeof v.did === 'string') {
+        did = v.did
+      }
+      if (hasProp(v, 'handle') && typeof v.handle === 'string') {
+        handle = v.handle
+      }
+      if (hasProp(v, 'displayName') && typeof v.displayName === 'string') {
+        displayName = v.displayName
+      }
+      if (hasProp(v, 'description') && typeof v.description === 'string') {
+        description = v.description
+      }
+      if (did && handle) {
+        this.did = did
+        this.handle = handle
+        this.displayName = displayName
+        this.description = description
+      }
+    }
+  }
+
   async load() {
     const sess = this.rootStore.session
-    if (sess.isAuthed && sess.data) {
+    if (sess.hasSession && sess.data) {
       this.did = sess.data.did || ''
       this.handle = sess.data.handle
       const profile = await this.rootStore.api.app.bsky.actor.getProfile({
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index af79ccc1e..ad306ee9f 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -14,6 +14,7 @@ import {ProfilesViewModel} from './profiles-view'
 import {LinkMetasViewModel} from './link-metas-view'
 import {MeModel} from './me'
 import {OnboardModel} from './onboard'
+import {isNetworkError} from '../../lib/errors'
 
 export class RootStoreModel {
   session = new SessionModel(this)
@@ -45,12 +46,18 @@ export class RootStoreModel {
   }
 
   async fetchStateUpdate() {
-    if (!this.session.isAuthed) {
+    if (!this.session.hasSession) {
       return
     }
     try {
+      if (!this.session.online) {
+        await this.session.connect()
+      }
       await this.me.fetchStateUpdate()
-    } catch (e) {
+    } catch (e: unknown) {
+      if (isNetworkError(e)) {
+        this.session.setOnline(false) // connection lost
+      }
       console.error('Failed to fetch latest state', e)
     }
   }
@@ -58,6 +65,7 @@ export class RootStoreModel {
   serialize(): unknown {
     return {
       session: this.session.serialize(),
+      me: this.me.serialize(),
       nav: this.nav.serialize(),
       onboard: this.onboard.serialize(),
     }
@@ -68,6 +76,9 @@ export class RootStoreModel {
       if (hasProp(v, 'session')) {
         this.session.hydrate(v.session)
       }
+      if (hasProp(v, 'me')) {
+        this.me.hydrate(v.me)
+      }
       if (hasProp(v, 'nav')) {
         this.nav.hydrate(v.nav)
       }
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 0f1faeaba..069e3db32 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -7,6 +7,7 @@ import type {
 import type * as GetAccountsConfig from '../../third-party/api/src/client/types/com/atproto/server/getAccountsConfig'
 import {isObj, hasProp} from '../lib/type-guards'
 import {RootStoreModel} from './root-store'
+import {isNetworkError} from '../../lib/errors'
 
 export type ServiceDescription = GetAccountsConfig.OutputSchema
 
@@ -20,16 +21,20 @@ interface SessionData {
 
 export class SessionModel {
   data: SessionData | null = null
+  online = false
+  attemptingConnect = false
+  private _connectPromise: Promise<void> | undefined
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
       rootStore: false,
       serialize: false,
       hydrate: false,
+      _connectPromise: false,
     })
   }
 
-  get isAuthed() {
+  get hasSession() {
     return this.data !== null
   }
 
@@ -91,6 +96,13 @@ export class SessionModel {
     this.data = data
   }
 
+  setOnline(online: boolean, attemptingConnect?: boolean) {
+    this.online = online
+    if (typeof attemptingConnect === 'boolean') {
+      this.attemptingConnect = attemptingConnect
+    }
+  }
+
   updateAuthTokens(session: Session) {
     if (this.data) {
       this.setState({
@@ -125,7 +137,14 @@ export class SessionModel {
     return true
   }
 
-  async setup(): Promise<void> {
+  async connect(): Promise<void> {
+    this._connectPromise ??= this._connect()
+    await this._connectPromise
+    this._connectPromise = undefined
+  }
+
+  private async _connect(): Promise<void> {
+    this.attemptingConnect = true
     if (!this.configureApi()) {
       return
     }
@@ -133,14 +152,25 @@ export class SessionModel {
     try {
       const sess = await this.rootStore.api.com.atproto.session.get()
       if (sess.success && this.data && this.data.did === sess.data.did) {
+        this.setOnline(true, false)
+        if (this.rootStore.me.did !== sess.data.did) {
+          this.rootStore.me.clear()
+        }
         this.rootStore.me.load().catch(e => {
           console.error('Failed to fetch local user information', e)
         })
         return // success
       }
-    } catch (e: any) {}
+    } catch (e: any) {
+      if (isNetworkError(e)) {
+        this.setOnline(false, false) // connection issue
+        return
+      } else {
+        this.clear() // invalid session cached
+      }
+    }
 
-    this.clear() // invalid session cached
+    this.setOnline(false, false)
   }
 
   async describeService(service: string): Promise<ServiceDescription> {
@@ -212,7 +242,7 @@ export class SessionModel {
   }
 
   async logout() {
-    if (this.isAuthed) {
+    if (this.hasSession) {
       this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
         console.error('(Minor issue) Failed to delete session on the server', e)
       })