Ensure the UI always renders, even in bad network conditions (close #6)

This commit is contained in:
Paul Frazee 2022-12-05 13:25:04 -06:00
parent 59363181e1
commit f27e32e54c
13 changed files with 259 additions and 72 deletions

View file

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

View file

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

View file

@ -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({

View file

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

View file

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