Merge branch 'main' into upload-profile-image

This commit is contained in:
João Ferreiro
2022-12-06 15:11:36 +00:00
26 changed files with 349 additions and 117 deletions
+4
View File
@@ -0,0 +1,4 @@
export function isNetworkError(e: unknown) {
const str = String(e)
return str.includes('Aborted') || str.includes('Network request failed')
}
+2 -1
View File
@@ -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: ')) {
+11 -5
View File
@@ -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()
+9
View File
@@ -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,
+3
View File
@@ -381,6 +381,9 @@ export class FeedModel {
} }
private async _update() { private async _update() {
if (!this.feed.length) {
return
}
this._xLoading() this._xLoading()
let numToFetch = this.feed.length let numToFetch = this.feed.length
let cursor = undefined let cursor = undefined
+40 -2
View File
@@ -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({
+3
View File
@@ -317,6 +317,9 @@ export class NotificationsViewModel {
} }
private async _update() { private async _update() {
if (!this.notifications.length) {
return
}
this._xLoading() this._xLoading()
let numToFetch = this.notifications.length let numToFetch = this.notifications.length
let cursor = undefined let cursor = undefined
+13 -2
View File
@@ -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)
} }
+35 -5
View File
@@ -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)
}) })
+24 -5
View File
@@ -15,7 +15,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
import {Autocomplete} from './Autocomplete' import {Autocomplete} from './Autocomplete'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import ProgressCircle from '../util/ProgressCircle' // @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
@@ -252,10 +255,26 @@ export const ComposePost = observer(function ComposePost({
{MAX_TEXT_LENGTH - text.length} {MAX_TEXT_LENGTH - text.length}
</Text> </Text>
<View> <View>
<ProgressCircle {text.length > DANGER_TEXT_LENGTH ? (
color={progressColor} <ProgressPie
progress={text.length / MAX_TEXT_LENGTH} size={30}
/> borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min(
(text.length - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH,
1,
)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={text.length / MAX_TEXT_LENGTH}
/>
)}
</View> </View>
</View> </View>
<Autocomplete <Autocomplete
+8 -1
View File
@@ -1,6 +1,7 @@
import React, {useMemo, useState} from 'react' import React, {useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, Text, View} from 'react-native' import {StyleSheet, Text, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '../../../third-party/uri' import {AtUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -75,6 +76,10 @@ export const PostThreadItem = observer(function PostThreadItem({
.toggleUpvote() .toggleUpvote()
.catch(e => console.error('Failed to toggle upvote', record, e)) .catch(e => console.error('Failed to toggle upvote', record, e))
} }
const onCopyPostText = () => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}
const onDeletePost = () => { const onDeletePost = () => {
item.delete().then( item.delete().then(
() => { () => {
@@ -130,6 +135,7 @@ export const PostThreadItem = observer(function PostThreadItem({
itemHref={itemHref} itemHref={itemHref}
itemTitle={itemTitle} itemTitle={itemTitle}
isAuthor={item.author.did === store.me.did} isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}> onDeletePost={onDeletePost}>
<FontAwesomeIcon <FontAwesomeIcon
icon="ellipsis-h" icon="ellipsis-h"
@@ -257,6 +263,7 @@ export const PostThreadItem = observer(function PostThreadItem({
authorDisplayName={item.author.displayName} authorDisplayName={item.author.displayName}
timestamp={item.indexedAt} timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did} isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
@@ -356,7 +363,7 @@ const styles = StyleSheet.create({
maxWidth: 240, maxWidth: 240,
}, },
postText: { postText: {
fontFamily: 'Helvetica Neue', fontFamily: 'System',
fontSize: 16, fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px lineHeight: 20.8, // 1.3 of 16px
}, },
+7 -1
View File
@@ -8,6 +8,7 @@ import {
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '../../../third-party/uri' import {AtUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -110,6 +111,10 @@ export const Post = observer(function Post({
.toggleUpvote() .toggleUpvote()
.catch(e => console.error('Failed to toggle upvote', record, e)) .catch(e => console.error('Failed to toggle upvote', record, e))
} }
const onCopyPostText = () => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}
const onDeletePost = () => { const onDeletePost = () => {
item.delete().then( item.delete().then(
() => { () => {
@@ -144,6 +149,7 @@ export const Post = observer(function Post({
authorDisplayName={item.author.displayName} authorDisplayName={item.author.displayName}
timestamp={item.indexedAt} timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did} isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
{replyHref !== '' && ( {replyHref !== '' && (
@@ -206,7 +212,7 @@ const styles = StyleSheet.create({
paddingBottom: 8, paddingBottom: 8,
}, },
postText: { postText: {
fontFamily: 'Helvetica Neue', fontFamily: 'System',
fontSize: 16, fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px lineHeight: 20.8, // 1.3 of 16px
}, },
+7 -1
View File
@@ -1,6 +1,7 @@
import React, {useMemo, useState} from 'react' import React, {useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, Text, View} from 'react-native' import {StyleSheet, Text, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '../../../third-party/uri' import {AtUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -66,6 +67,10 @@ export const FeedItem = observer(function FeedItem({
.toggleUpvote() .toggleUpvote()
.catch(e => console.error('Failed to toggle upvote', record, e)) .catch(e => console.error('Failed to toggle upvote', record, e))
} }
const onCopyPostText = () => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}
const onDeletePost = () => { const onDeletePost = () => {
item.delete().then( item.delete().then(
() => { () => {
@@ -147,6 +152,7 @@ export const FeedItem = observer(function FeedItem({
authorDisplayName={item.author.displayName} authorDisplayName={item.author.displayName}
timestamp={item.indexedAt} timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did} isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost} onDeletePost={onDeletePost}
/> />
) : undefined} ) : undefined}
@@ -249,7 +255,7 @@ const styles = StyleSheet.create({
minHeight: 36, minHeight: 36,
}, },
postText: { postText: {
fontFamily: 'Helvetica Neue', fontFamily: 'System',
fontSize: 16, fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px lineHeight: 20.8, // 1.3 of 16px
}, },
+9
View File
@@ -79,6 +79,7 @@ export function PostDropdownBtn({
itemHref, itemHref,
itemTitle, itemTitle,
isAuthor, isAuthor,
onCopyPostText,
onDeletePost, onDeletePost,
}: { }: {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
@@ -86,6 +87,7 @@ export function PostDropdownBtn({
itemHref: string itemHref: string
itemTitle: string itemTitle: string
isAuthor: boolean isAuthor: boolean
onCopyPostText: () => void
onDeletePost: () => void onDeletePost: () => void
}) { }) {
const store = useStores() const store = useStores()
@@ -100,6 +102,13 @@ export function PostDropdownBtn({
}, },
} }
: undefined, : undefined,
{
icon: ['far', 'paste'],
label: 'Copy post text',
onPress() {
onCopyPostText()
},
},
{ {
icon: 'share', icon: 'share',
label: 'Share...', label: 'Share...',
+2
View File
@@ -14,6 +14,7 @@ interface PostMetaOpts {
authorDisplayName: string | undefined authorDisplayName: string | undefined
timestamp: string timestamp: string
isAuthor: boolean isAuthor: boolean
onCopyPostText: () => void
onDeletePost: () => void onDeletePost: () => void
} }
@@ -40,6 +41,7 @@ export function PostMeta(opts: PostMetaOpts) {
itemHref={opts.itemHref} itemHref={opts.itemHref}
itemTitle={opts.itemTitle} itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor} isAuthor={opts.isAuthor}
onCopyPostText={opts.onCopyPostText}
onDeletePost={opts.onDeletePost}> onDeletePost={opts.onDeletePost}>
<FontAwesomeIcon icon="ellipsis-h" size={14} style={[s.mt2, s.mr5]} /> <FontAwesomeIcon icon="ellipsis-h" size={14} style={[s.mt2, s.mr5]} />
</PostDropdownBtn> </PostDropdownBtn>
@@ -1,3 +0,0 @@
// @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
export default ProgressCircle
-20
View File
@@ -1,20 +0,0 @@
import {View} from 'react-native'
import {CircularProgressbar, buildStyles} from 'react-circular-progressbar'
const ProgressCircle = ({
color,
progress,
}: {
color?: string
progress: number
}) => {
return (
<View style={{width: 20, height: 20}}>
<CircularProgressbar
value={progress * 100}
styles={buildStyles({pathColor: color || '#00f'})}
/>
</View>
)
}
export default ProgressCircle
+7 -10
View File
@@ -23,6 +23,13 @@ export function RichText({
numberOfLines?: number numberOfLines?: number
}) { }) {
if (!entities?.length) { if (!entities?.length) {
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
style = {
fontSize: 26,
lineHeight: 30,
}
return <Text style={style}>{text}</Text>
}
return <Text style={style}>{text}</Text> return <Text style={style}>{text}</Text>
} }
if (!style) style = [] if (!style) style = []
@@ -98,13 +105,3 @@ function* toSegments(text: string, entities: Entity[]) {
yield text.slice(cursor, text.length) yield text.slice(cursor, text.length)
} }
} }
function stripUsername(v: string): string {
return v.trim().replace('@', '')
}
function isSameLink(a: string, b: string) {
a = a.startsWith('http') ? a : `https://${a}`
b = b.startsWith('http') ? b : `https://${b}`
return a === b
}
+112 -35
View File
@@ -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',
},
}) })
+4
View File
@@ -38,6 +38,7 @@ import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky' import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
@@ -45,6 +46,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'
@@ -100,6 +102,7 @@ export function setup() {
faMagnifyingGlass, faMagnifyingGlass,
faMessage, faMessage,
faNoteSticky, faNoteSticky,
faPaste,
faPen, faPen,
faPenNib, faPenNib,
faPenToSquare, faPenToSquare,
@@ -110,6 +113,7 @@ export function setup() {
faShare, faShare,
faShareFromSquare, faShareFromSquare,
faShield, faShield,
faSignal,
faUser, faUser,
faUsers, faUsers,
faUserCheck, faUserCheck,
+4
View File
@@ -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'},
+6 -1
View File
@@ -10,6 +10,8 @@ import {FeedModel} from '../../state/models/feed-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const Home = observer(function Home({ export const Home = observer(function Home({
navIdx, navIdx,
visible, visible,
@@ -95,7 +97,10 @@ export const Home = observer(function Home({
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
/> />
{defaultFeedView.hasNewLatest ? ( {defaultFeedView.hasNewLatest ? (
<TouchableOpacity style={styles.loadLatest} onPress={onPressLoadLatest}> <TouchableOpacity
style={styles.loadLatest}
onPress={onPressLoadLatest}
hitSlop={HITSLOP}>
<FontAwesomeIcon icon="arrow-up" style={{color: colors.white}} /> <FontAwesomeIcon icon="arrow-up" style={{color: colors.white}} />
<Text style={styles.loadLatestText}>Load new posts</Text> <Text style={styles.loadLatestText}>Load new posts</Text>
</TouchableOpacity> </TouchableOpacity>
+25 -20
View File
@@ -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,
+12 -3
View File
@@ -1,5 +1,13 @@
import React, {useEffect, useState, useMemo, useRef} from 'react' import React, {useEffect, useState, useMemo, useRef} from 'react'
import {StyleSheet, Text, TextInput, TouchableOpacity, View} from 'react-native' import {
Keyboard,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {SuggestedFollows} from '../com/discover/SuggestedFollows' import {SuggestedFollows} from '../com/discover/SuggestedFollows'
import {UserAvatar} from '../com/util/UserAvatar' import {UserAvatar} from '../com/util/UserAvatar'
@@ -50,13 +58,14 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
ref={textInput} ref={textInput}
placeholder="Type your query here..." placeholder="Type your query here..."
selectTextOnFocus selectTextOnFocus
returnKeyType="search"
style={styles.input} style={styles.input}
onChangeText={onChangeQuery} onChangeText={onChangeQuery}
/> />
</View> </View>
<View style={styles.outputContainer}> <View style={styles.outputContainer}>
{query ? ( {query ? (
<View> <ScrollView onScroll={Keyboard.dismiss}>
{autocompleteView.searchRes.map((item, i) => ( {autocompleteView.searchRes.map((item, i) => (
<TouchableOpacity <TouchableOpacity
key={i} key={i}
@@ -75,7 +84,7 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </ScrollView>
) : ( ) : (
<SuggestedFollows asLinks /> <SuggestedFollows asLinks />
)} )}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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']}