about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-12-05 13:25:04 -0600
committerPaul Frazee <pfrazee@gmail.com>2022-12-05 13:25:04 -0600
commitf27e32e54c3e6a6f7d156cf6c23c11778a7dd316 (patch)
tree484dbd281d938082c0de8ec3ba346ed8af839429 /src
parent59363181e1c72dcec3a86cf155f12a556e569b8f (diff)
downloadvoidsky-f27e32e54c3e6a6f7d156cf6c23c11778a7dd316.tar.zst
Ensure the UI always renders, even in bad network conditions (close #6)
Diffstat (limited to 'src')
-rw-r--r--src/lib/errors.ts4
-rw-r--r--src/lib/strings.ts3
-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
-rw-r--r--src/view/com/util/ViewHeader.tsx147
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/lib/styles.ts4
-rw-r--r--src/view/screens/Login.tsx45
-rw-r--r--src/view/shell/desktop-web/index.tsx2
-rw-r--r--src/view/shell/mobile/index.tsx2
13 files changed, 259 insertions, 72 deletions
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
new file mode 100644
index 000000000..c14a2dbe4
--- /dev/null
+++ b/src/lib/errors.ts
@@ -0,0 +1,4 @@
+export function isNetworkError(e: unknown) {
+  const str = String(e)
+  return str.includes('Aborted') || str.includes('Network request failed')
+}
diff --git a/src/lib/strings.ts b/src/lib/strings.ts
index 66dd59708..0474c330d 100644
--- a/src/lib/strings.ts
+++ b/src/lib/strings.ts
@@ -1,6 +1,7 @@
 import {AtUri} from '../third-party/uri'
 import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
 import {PROD_SERVICE} from '../state'
+import {isNetworkError} from './errors'
 import TLDs from 'tlds'
 
 export const MAX_DISPLAY_NAME = 64
@@ -201,7 +202,7 @@ export function enforceLen(str: string, len: number): string {
 }
 
 export function cleanError(str: string): string {
-  if (str.includes('Network request failed')) {
+  if (isNetworkError(str)) {
     return 'Unable to connect. Please check your internet connection and try again.'
   }
   if (str.startsWith('Error: ')) {
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)
       })
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 5d0ec2995..12aa86a4f 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,14 +1,21 @@
 import React from 'react'
-import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  StyleSheet,
+  Text,
+  TouchableOpacity,
+  View,
+} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {colors} from '../../lib/styles'
+import {s, colors} from '../../lib/styles'
 import {MagnifyingGlassIcon} from '../../lib/icons'
 import {useStores} from '../../../state'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
 
-export function ViewHeader({
+export const ViewHeader = observer(function ViewHeader({
   title,
   subtitle,
   onPost,
@@ -27,43 +34,91 @@ export function ViewHeader({
   const onPressSearch = () => {
     store.nav.navigate(`/search`)
   }
+  const onPressReconnect = () => {
+    store.session.connect().catch(e => {
+      // log for debugging but ignore otherwise
+      console.log(e)
+    })
+  }
   return (
-    <View style={styles.header}>
-      {store.nav.tab.canGoBack ? (
+    <>
+      <View style={styles.header}>
+        {store.nav.tab.canGoBack ? (
+          <TouchableOpacity
+            onPress={onPressBack}
+            hitSlop={BACK_HITSLOP}
+            style={styles.backIcon}>
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              style={{marginTop: 6}}
+            />
+          </TouchableOpacity>
+        ) : undefined}
+        <View style={styles.titleContainer} pointerEvents="none">
+          <Text style={styles.title}>{title}</Text>
+          {subtitle ? (
+            <Text style={styles.subtitle} numberOfLines={1}>
+              {subtitle}
+            </Text>
+          ) : undefined}
+        </View>
         <TouchableOpacity
-          onPress={onPressBack}
-          hitSlop={BACK_HITSLOP}
-          style={styles.backIcon}>
-          <FontAwesomeIcon size={18} icon="angle-left" style={{marginTop: 6}} />
+          onPress={onPressCompose}
+          hitSlop={HITSLOP}
+          style={styles.btn}>
+          <FontAwesomeIcon size={18} icon="plus" />
+        </TouchableOpacity>
+        <TouchableOpacity
+          onPress={onPressSearch}
+          hitSlop={HITSLOP}
+          style={[styles.btn, {marginLeft: 8}]}>
+          <MagnifyingGlassIcon
+            size={18}
+            strokeWidth={3}
+            style={styles.searchBtnIcon}
+          />
         </TouchableOpacity>
-      ) : undefined}
-      <View style={styles.titleContainer} pointerEvents="none">
-        <Text style={styles.title}>{title}</Text>
-        {subtitle ? (
-          <Text style={styles.subtitle} numberOfLines={1}>
-            {subtitle}
-          </Text>
-        ) : undefined}
       </View>
-      <TouchableOpacity
-        onPress={onPressCompose}
-        hitSlop={HITSLOP}
-        style={styles.btn}>
-        <FontAwesomeIcon size={18} icon="plus" />
-      </TouchableOpacity>
-      <TouchableOpacity
-        onPress={onPressSearch}
-        hitSlop={HITSLOP}
-        style={[styles.btn, {marginLeft: 8}]}>
-        <MagnifyingGlassIcon
-          size={18}
-          strokeWidth={3}
-          style={styles.searchBtnIcon}
-        />
-      </TouchableOpacity>
-    </View>
+      {!store.session.online ? (
+        <TouchableOpacity style={styles.offline} onPress={onPressReconnect}>
+          {store.session.attemptingConnect ? (
+            <>
+              <ActivityIndicator />
+              <Text style={[s.gray1, s.bold, s.flex1, s.pl5, s.pt5, s.pb5]}>
+                Connecting...
+              </Text>
+            </>
+          ) : (
+            <>
+              <FontAwesomeIcon icon="signal" style={[s.gray2]} size={18} />
+              <FontAwesomeIcon
+                icon="x"
+                style={[
+                  s.red4,
+                  {
+                    backgroundColor: colors.gray6,
+                    position: 'relative',
+                    left: -4,
+                    top: 6,
+                  },
+                ]}
+                border
+                size={12}
+              />
+              <Text style={[s.gray1, s.bold, s.flex1, s.pl2]}>
+                Unable to connect
+              </Text>
+              <View style={styles.offlineBtn}>
+                <Text style={styles.offlineBtnText}>Try again</Text>
+              </View>
+            </>
+          )}
+        </TouchableOpacity>
+      ) : undefined}
+    </>
   )
-}
+})
 
 const styles = StyleSheet.create({
   header: {
@@ -108,4 +163,26 @@ const styles = StyleSheet.create({
     position: 'relative',
     top: -1,
   },
+
+  offline: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    backgroundColor: colors.gray6,
+    paddingLeft: 15,
+    paddingRight: 10,
+    paddingVertical: 8,
+    borderRadius: 8,
+    marginHorizontal: 4,
+    marginTop: 4,
+  },
+  offlineBtn: {
+    backgroundColor: colors.gray5,
+    borderRadius: 5,
+    paddingVertical: 5,
+    paddingHorizontal: 10,
+  },
+  offlineBtnText: {
+    color: colors.white,
+    fontWeight: 'bold',
+  },
 })
diff --git a/src/view/index.ts b/src/view/index.ts
index bd0e33cbe..65779bf99 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -45,6 +45,7 @@ import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
 import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
 import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
+import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
 import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
 import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
 import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
@@ -110,6 +111,7 @@ export function setup() {
     faShare,
     faShareFromSquare,
     faShield,
+    faSignal,
     faUser,
     faUsers,
     faUserCheck,
diff --git a/src/view/lib/styles.ts b/src/view/lib/styles.ts
index 1ac6283a2..d3fc8c70f 100644
--- a/src/view/lib/styles.ts
+++ b/src/view/lib/styles.ts
@@ -10,6 +10,9 @@ export const colors = {
   gray3: '#c1b9b9',
   gray4: '#968d8d',
   gray5: '#645454',
+  gray6: '#423737',
+  gray7: '#2D2626',
+  gray8: '#131010',
 
   blue0: '#bfe1ff',
   blue1: '#8bc7fd',
@@ -131,6 +134,7 @@ export const s = StyleSheet.create({
   flexRow: {flexDirection: 'row'},
   flexCol: {flexDirection: 'column'},
   flex1: {flex: 1},
+  alignCenter: {alignItems: 'center'},
 
   // position
   absolute: {position: 'absolute'},
diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx
index abd5274da..4175e0a34 100644
--- a/src/view/screens/Login.tsx
+++ b/src/view/screens/Login.tsx
@@ -25,6 +25,7 @@ import {useStores, DEFAULT_SERVICE} from '../../state'
 import {ServiceDescription} from '../../state/models/session'
 import {ServerInputModel} from '../../state/models/shell-ui'
 import {ComAtprotoAccountCreate} from '../../third-party/api/index'
+import {isNetworkError} from '../../lib/errors'
 
 enum ScreenState {
   SigninOrCreateAccount,
@@ -186,7 +187,7 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
       setIsProcessing(false)
       if (errMsg.includes('Authentication Required')) {
         setError('Invalid username or password')
-      } else if (errMsg.includes('Network request failed')) {
+      } else if (isNetworkError(e)) {
         setError(
           'Unable to contact your service. Please check your Internet connection.',
         )
@@ -210,16 +211,6 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
           </Text>
           <FontAwesomeIcon icon="pen" size={10} style={styles.groupTitleIcon} />
         </TouchableOpacity>
-        {error ? (
-          <View style={styles.error}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-            </View>
-            <View style={s.flex1}>
-              <Text style={[s.white, s.bold]}>{error}</Text>
-            </View>
-          </View>
-        ) : undefined}
         <View style={styles.groupContent}>
           <FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
           <TextInput
@@ -249,18 +240,31 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
           />
         </View>
       </View>
-      <View style={[s.flexRow, s.pl20, s.pr20]}>
+      {error ? (
+        <View style={styles.error}>
+          <View style={styles.errorIcon}>
+            <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+          </View>
+          <View style={s.flex1}>
+            <Text style={[s.white, s.bold]}>{error}</Text>
+          </View>
+        </View>
+      ) : undefined}
+      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
         <TouchableOpacity onPress={onPressBack}>
           <Text style={[s.white, s.f18, s.pl5]}>Back</Text>
         </TouchableOpacity>
         <View style={s.flex1} />
         <TouchableOpacity onPress={onPressNext}>
-          {isProcessing ? (
+          {!serviceDescription || isProcessing ? (
             <ActivityIndicator color="#fff" />
           ) : (
             <Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
           )}
         </TouchableOpacity>
+        {!serviceDescription || isProcessing ? (
+          <Text style={[s.white, s.f18, s.pl10]}>Connecting...</Text>
+        ) : undefined}
       </View>
     </KeyboardAvoidingView>
   )
@@ -689,18 +693,19 @@ const styles = StyleSheet.create({
     color: colors.white,
   },
   error: {
-    borderTopWidth: 1,
-    borderTopColor: colors.blue1,
+    borderWidth: 1,
+    borderColor: colors.red5,
+    backgroundColor: colors.red4,
     flexDirection: 'row',
     alignItems: 'center',
-    marginTop: 5,
-    backgroundColor: colors.blue2,
+    marginTop: -5,
+    marginHorizontal: 20,
+    marginBottom: 15,
+    borderRadius: 8,
     paddingHorizontal: 8,
-    paddingVertical: 5,
+    paddingVertical: 8,
   },
   errorFloating: {
-    borderWidth: 1,
-    borderColor: colors.blue1,
     marginBottom: 20,
     marginHorizontal: 20,
     borderRadius: 8,
diff --git a/src/view/shell/desktop-web/index.tsx b/src/view/shell/desktop-web/index.tsx
index 13acbbfed..194954349 100644
--- a/src/view/shell/desktop-web/index.tsx
+++ b/src/view/shell/desktop-web/index.tsx
@@ -9,7 +9,7 @@ export const DesktopWebShell: React.FC = observer(({children}) => {
   const store = useStores()
   return (
     <View style={styles.outerContainer}>
-      {store.session.isAuthed ? (
+      {store.session.hasSession ? (
         <>
           <DesktopLeftColumn />
           <View style={styles.innerContainer}>{children}</View>
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index d653944d1..e3e30decc 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -231,7 +231,7 @@ export const MobileShell: React.FC = observer(() => {
     transform: [{scale: newTabInterp.value}],
   }))
 
-  if (!store.session.isAuthed) {
+  if (!store.session.hasSession) {
     return (
       <LinearGradient
         colors={['#007CFF', '#00BCFF']}