Ensure the UI always renders, even in bad network conditions (close #6)
This commit is contained in:
parent
59363181e1
commit
f27e32e54c
13 changed files with 259 additions and 72 deletions
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue