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
4
src/lib/errors.ts
Normal file
4
src/lib/errors.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export function isNetworkError(e: unknown) {
|
||||||
|
const str = String(e)
|
||||||
|
return str.includes('Aborted') || str.includes('Network request failed')
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import {AtUri} from '../third-party/uri'
|
import {AtUri} from '../third-party/uri'
|
||||||
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
|
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
|
||||||
import {PROD_SERVICE} from '../state'
|
import {PROD_SERVICE} from '../state'
|
||||||
|
import {isNetworkError} from './errors'
|
||||||
import TLDs from 'tlds'
|
import TLDs from 'tlds'
|
||||||
|
|
||||||
export const MAX_DISPLAY_NAME = 64
|
export const MAX_DISPLAY_NAME = 64
|
||||||
|
@ -201,7 +202,7 @@ export function enforceLen(str: string, len: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanError(str: string): 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.'
|
return 'Unable to connect. Please check your internet connection and try again.'
|
||||||
}
|
}
|
||||||
if (str.startsWith('Error: ')) {
|
if (str.startsWith('Error: ')) {
|
||||||
|
|
|
@ -27,10 +27,19 @@ export async function setupState() {
|
||||||
console.error('Failed to load state from storage', e)
|
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
|
// @ts-ignore .on() is correct -prf
|
||||||
api.sessionManager.on('session', () => {
|
api.sessionManager.on('session', () => {
|
||||||
if (!api.sessionManager.session && rootStore.session.isAuthed) {
|
if (!api.sessionManager.session && rootStore.session.hasSession) {
|
||||||
// reset session
|
// reset session
|
||||||
rootStore.session.clear()
|
rootStore.session.clear()
|
||||||
} else if (api.sessionManager.session) {
|
} else if (api.sessionManager.session) {
|
||||||
|
@ -44,9 +53,6 @@ export async function setupState() {
|
||||||
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
|
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
|
||||||
})
|
})
|
||||||
|
|
||||||
await rootStore.fetchStateUpdate()
|
|
||||||
console.log(rootStore.me)
|
|
||||||
|
|
||||||
// periodic state fetch
|
// periodic state fetch
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
rootStore.fetchStateUpdate()
|
rootStore.fetchStateUpdate()
|
||||||
|
|
|
@ -12,6 +12,8 @@ import {APP_BSKY_GRAPH} from '../../third-party/api'
|
||||||
import {RootStoreModel} from '../models/root-store'
|
import {RootStoreModel} from '../models/root-store'
|
||||||
import {extractEntities} from '../../lib/strings'
|
import {extractEntities} from '../../lib/strings'
|
||||||
|
|
||||||
|
const TIMEOUT = 10e3 // 10s
|
||||||
|
|
||||||
export function doPolyfill() {
|
export function doPolyfill() {
|
||||||
AtpApi.xrpc.fetch = fetchHandler
|
AtpApi.xrpc.fetch = fetchHandler
|
||||||
}
|
}
|
||||||
|
@ -175,10 +177,14 @@ async function fetchHandler(
|
||||||
reqBody = JSON.stringify(reqBody)
|
reqBody = JSON.stringify(reqBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const to = setTimeout(() => controller.abort(), TIMEOUT)
|
||||||
|
|
||||||
const res = await fetch(reqUri, {
|
const res = await fetch(reqUri, {
|
||||||
method: reqMethod,
|
method: reqMethod,
|
||||||
headers: reqHeaders,
|
headers: reqHeaders,
|
||||||
body: reqBody,
|
body: reqBody,
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
const resStatus = res.status
|
const resStatus = res.status
|
||||||
|
@ -197,6 +203,9 @@ async function fetchHandler(
|
||||||
throw new Error('TODO: non-textual response body')
|
throw new Error('TODO: non-textual response body')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearTimeout(to)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: resStatus,
|
status: resStatus,
|
||||||
headers: resHeaders,
|
headers: resHeaders,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {MembershipsViewModel} from './memberships-view'
|
import {MembershipsViewModel} from './memberships-view'
|
||||||
import {NotificationsViewModel} from './notifications-view'
|
import {NotificationsViewModel} from './notifications-view'
|
||||||
|
import {isObj, hasProp} from '../lib/type-guards'
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
did?: string
|
did?: string
|
||||||
|
@ -13,7 +14,11 @@ export class MeModel {
|
||||||
notifications: NotificationsViewModel
|
notifications: NotificationsViewModel
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
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, {})
|
this.notifications = new NotificationsViewModel(this.rootStore, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,9 +31,42 @@ export class MeModel {
|
||||||
this.memberships = undefined
|
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() {
|
async load() {
|
||||||
const sess = this.rootStore.session
|
const sess = this.rootStore.session
|
||||||
if (sess.isAuthed && sess.data) {
|
if (sess.hasSession && sess.data) {
|
||||||
this.did = sess.data.did || ''
|
this.did = sess.data.did || ''
|
||||||
this.handle = sess.data.handle
|
this.handle = sess.data.handle
|
||||||
const profile = await this.rootStore.api.app.bsky.actor.getProfile({
|
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 {LinkMetasViewModel} from './link-metas-view'
|
||||||
import {MeModel} from './me'
|
import {MeModel} from './me'
|
||||||
import {OnboardModel} from './onboard'
|
import {OnboardModel} from './onboard'
|
||||||
|
import {isNetworkError} from '../../lib/errors'
|
||||||
|
|
||||||
export class RootStoreModel {
|
export class RootStoreModel {
|
||||||
session = new SessionModel(this)
|
session = new SessionModel(this)
|
||||||
|
@ -45,12 +46,18 @@ export class RootStoreModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchStateUpdate() {
|
async fetchStateUpdate() {
|
||||||
if (!this.session.isAuthed) {
|
if (!this.session.hasSession) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (!this.session.online) {
|
||||||
|
await this.session.connect()
|
||||||
|
}
|
||||||
await this.me.fetchStateUpdate()
|
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)
|
console.error('Failed to fetch latest state', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,6 +65,7 @@ export class RootStoreModel {
|
||||||
serialize(): unknown {
|
serialize(): unknown {
|
||||||
return {
|
return {
|
||||||
session: this.session.serialize(),
|
session: this.session.serialize(),
|
||||||
|
me: this.me.serialize(),
|
||||||
nav: this.nav.serialize(),
|
nav: this.nav.serialize(),
|
||||||
onboard: this.onboard.serialize(),
|
onboard: this.onboard.serialize(),
|
||||||
}
|
}
|
||||||
|
@ -68,6 +76,9 @@ export class RootStoreModel {
|
||||||
if (hasProp(v, 'session')) {
|
if (hasProp(v, 'session')) {
|
||||||
this.session.hydrate(v.session)
|
this.session.hydrate(v.session)
|
||||||
}
|
}
|
||||||
|
if (hasProp(v, 'me')) {
|
||||||
|
this.me.hydrate(v.me)
|
||||||
|
}
|
||||||
if (hasProp(v, 'nav')) {
|
if (hasProp(v, 'nav')) {
|
||||||
this.nav.hydrate(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 type * as GetAccountsConfig from '../../third-party/api/src/client/types/com/atproto/server/getAccountsConfig'
|
||||||
import {isObj, hasProp} from '../lib/type-guards'
|
import {isObj, hasProp} from '../lib/type-guards'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
|
import {isNetworkError} from '../../lib/errors'
|
||||||
|
|
||||||
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
export type ServiceDescription = GetAccountsConfig.OutputSchema
|
||||||
|
|
||||||
|
@ -20,16 +21,20 @@ interface SessionData {
|
||||||
|
|
||||||
export class SessionModel {
|
export class SessionModel {
|
||||||
data: SessionData | null = null
|
data: SessionData | null = null
|
||||||
|
online = false
|
||||||
|
attemptingConnect = false
|
||||||
|
private _connectPromise: Promise<void> | undefined
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
rootStore: false,
|
rootStore: false,
|
||||||
serialize: false,
|
serialize: false,
|
||||||
hydrate: false,
|
hydrate: false,
|
||||||
|
_connectPromise: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get isAuthed() {
|
get hasSession() {
|
||||||
return this.data !== null
|
return this.data !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +96,13 @@ export class SessionModel {
|
||||||
this.data = data
|
this.data = data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOnline(online: boolean, attemptingConnect?: boolean) {
|
||||||
|
this.online = online
|
||||||
|
if (typeof attemptingConnect === 'boolean') {
|
||||||
|
this.attemptingConnect = attemptingConnect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateAuthTokens(session: Session) {
|
updateAuthTokens(session: Session) {
|
||||||
if (this.data) {
|
if (this.data) {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -125,7 +137,14 @@ export class SessionModel {
|
||||||
return true
|
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()) {
|
if (!this.configureApi()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -133,14 +152,25 @@ export class SessionModel {
|
||||||
try {
|
try {
|
||||||
const sess = await this.rootStore.api.com.atproto.session.get()
|
const sess = await this.rootStore.api.com.atproto.session.get()
|
||||||
if (sess.success && this.data && this.data.did === sess.data.did) {
|
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 => {
|
this.rootStore.me.load().catch(e => {
|
||||||
console.error('Failed to fetch local user information', e)
|
console.error('Failed to fetch local user information', e)
|
||||||
})
|
})
|
||||||
return // success
|
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> {
|
async describeService(service: string): Promise<ServiceDescription> {
|
||||||
|
@ -212,7 +242,7 @@ export class SessionModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
if (this.isAuthed) {
|
if (this.hasSession) {
|
||||||
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
|
||||||
console.error('(Minor issue) Failed to delete session on the server', e)
|
console.error('(Minor issue) Failed to delete session on the server', e)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
import React from 'react'
|
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {colors} from '../../lib/styles'
|
import {s, colors} from '../../lib/styles'
|
||||||
import {MagnifyingGlassIcon} from '../../lib/icons'
|
import {MagnifyingGlassIcon} from '../../lib/icons'
|
||||||
import {useStores} from '../../../state'
|
import {useStores} from '../../../state'
|
||||||
|
|
||||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||||
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
||||||
|
|
||||||
export function ViewHeader({
|
export const ViewHeader = observer(function ViewHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
onPost,
|
onPost,
|
||||||
|
@ -27,43 +34,91 @@ export function ViewHeader({
|
||||||
const onPressSearch = () => {
|
const onPressSearch = () => {
|
||||||
store.nav.navigate(`/search`)
|
store.nav.navigate(`/search`)
|
||||||
}
|
}
|
||||||
|
const onPressReconnect = () => {
|
||||||
|
store.session.connect().catch(e => {
|
||||||
|
// log for debugging but ignore otherwise
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
return (
|
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
|
<TouchableOpacity
|
||||||
onPress={onPressBack}
|
onPress={onPressCompose}
|
||||||
hitSlop={BACK_HITSLOP}
|
hitSlop={HITSLOP}
|
||||||
style={styles.backIcon}>
|
style={styles.btn}>
|
||||||
<FontAwesomeIcon size={18} icon="angle-left" style={{marginTop: 6}} />
|
<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>
|
</TouchableOpacity>
|
||||||
) : undefined}
|
) : 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>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: {
|
header: {
|
||||||
|
@ -108,4 +163,26 @@ const styles = StyleSheet.create({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
top: -1,
|
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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -45,6 +45,7 @@ import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
|
||||||
import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
|
import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
|
||||||
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
|
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
|
||||||
import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
|
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 {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
|
||||||
import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
|
import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
|
||||||
import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
|
import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
|
||||||
|
@ -110,6 +111,7 @@ export function setup() {
|
||||||
faShare,
|
faShare,
|
||||||
faShareFromSquare,
|
faShareFromSquare,
|
||||||
faShield,
|
faShield,
|
||||||
|
faSignal,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers,
|
faUsers,
|
||||||
faUserCheck,
|
faUserCheck,
|
||||||
|
|
|
@ -10,6 +10,9 @@ export const colors = {
|
||||||
gray3: '#c1b9b9',
|
gray3: '#c1b9b9',
|
||||||
gray4: '#968d8d',
|
gray4: '#968d8d',
|
||||||
gray5: '#645454',
|
gray5: '#645454',
|
||||||
|
gray6: '#423737',
|
||||||
|
gray7: '#2D2626',
|
||||||
|
gray8: '#131010',
|
||||||
|
|
||||||
blue0: '#bfe1ff',
|
blue0: '#bfe1ff',
|
||||||
blue1: '#8bc7fd',
|
blue1: '#8bc7fd',
|
||||||
|
@ -131,6 +134,7 @@ export const s = StyleSheet.create({
|
||||||
flexRow: {flexDirection: 'row'},
|
flexRow: {flexDirection: 'row'},
|
||||||
flexCol: {flexDirection: 'column'},
|
flexCol: {flexDirection: 'column'},
|
||||||
flex1: {flex: 1},
|
flex1: {flex: 1},
|
||||||
|
alignCenter: {alignItems: 'center'},
|
||||||
|
|
||||||
// position
|
// position
|
||||||
absolute: {position: 'absolute'},
|
absolute: {position: 'absolute'},
|
||||||
|
|
|
@ -25,6 +25,7 @@ import {useStores, DEFAULT_SERVICE} from '../../state'
|
||||||
import {ServiceDescription} from '../../state/models/session'
|
import {ServiceDescription} from '../../state/models/session'
|
||||||
import {ServerInputModel} from '../../state/models/shell-ui'
|
import {ServerInputModel} from '../../state/models/shell-ui'
|
||||||
import {ComAtprotoAccountCreate} from '../../third-party/api/index'
|
import {ComAtprotoAccountCreate} from '../../third-party/api/index'
|
||||||
|
import {isNetworkError} from '../../lib/errors'
|
||||||
|
|
||||||
enum ScreenState {
|
enum ScreenState {
|
||||||
SigninOrCreateAccount,
|
SigninOrCreateAccount,
|
||||||
|
@ -186,7 +187,7 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
if (errMsg.includes('Authentication Required')) {
|
if (errMsg.includes('Authentication Required')) {
|
||||||
setError('Invalid username or password')
|
setError('Invalid username or password')
|
||||||
} else if (errMsg.includes('Network request failed')) {
|
} else if (isNetworkError(e)) {
|
||||||
setError(
|
setError(
|
||||||
'Unable to contact your service. Please check your Internet connection.',
|
'Unable to contact your service. Please check your Internet connection.',
|
||||||
)
|
)
|
||||||
|
@ -210,16 +211,6 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
</Text>
|
</Text>
|
||||||
<FontAwesomeIcon icon="pen" size={10} style={styles.groupTitleIcon} />
|
<FontAwesomeIcon icon="pen" size={10} style={styles.groupTitleIcon} />
|
||||||
</TouchableOpacity>
|
</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}>
|
<View style={styles.groupContent}>
|
||||||
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
|
<FontAwesomeIcon icon="at" style={styles.groupContentIcon} />
|
||||||
<TextInput
|
<TextInput
|
||||||
|
@ -249,18 +240,31 @@ const Signin = ({onPressBack}: {onPressBack: () => void}) => {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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}>
|
<TouchableOpacity onPress={onPressBack}>
|
||||||
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
<Text style={[s.white, s.f18, s.pl5]}>Back</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<TouchableOpacity onPress={onPressNext}>
|
<TouchableOpacity onPress={onPressNext}>
|
||||||
{isProcessing ? (
|
{!serviceDescription || isProcessing ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
<Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
{!serviceDescription || isProcessing ? (
|
||||||
|
<Text style={[s.white, s.f18, s.pl10]}>Connecting...</Text>
|
||||||
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
)
|
)
|
||||||
|
@ -689,18 +693,19 @@ const styles = StyleSheet.create({
|
||||||
color: colors.white,
|
color: colors.white,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
borderTopWidth: 1,
|
borderWidth: 1,
|
||||||
borderTopColor: colors.blue1,
|
borderColor: colors.red5,
|
||||||
|
backgroundColor: colors.red4,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 5,
|
marginTop: -5,
|
||||||
backgroundColor: colors.blue2,
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 15,
|
||||||
|
borderRadius: 8,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 5,
|
paddingVertical: 8,
|
||||||
},
|
},
|
||||||
errorFloating: {
|
errorFloating: {
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.blue1,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const DesktopWebShell: React.FC = observer(({children}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
return (
|
return (
|
||||||
<View style={styles.outerContainer}>
|
<View style={styles.outerContainer}>
|
||||||
{store.session.isAuthed ? (
|
{store.session.hasSession ? (
|
||||||
<>
|
<>
|
||||||
<DesktopLeftColumn />
|
<DesktopLeftColumn />
|
||||||
<View style={styles.innerContainer}>{children}</View>
|
<View style={styles.innerContainer}>{children}</View>
|
||||||
|
|
|
@ -231,7 +231,7 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
transform: [{scale: newTabInterp.value}],
|
transform: [{scale: newTabInterp.value}],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (!store.session.isAuthed) {
|
if (!store.session.hasSession) {
|
||||||
return (
|
return (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#007CFF', '#00BCFF']}
|
colors={['#007CFF', '#00BCFF']}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue