New navigation model (#1)

* Flatten all routing into a single stack

* Replace router with custom implementation

* Add shell header and titles

* Add tab selector

* Add back/forward history menus on longpress

* Fix: don't modify state during render

* Add refresh() to navigation and reroute navigations to the current location to refresh instead of add to history

* Cache screens during navigation to maintain scroll position and improve load-time for renders
zio/stable
Paul Frazee 2022-08-31 14:36:50 -05:00 committed by GitHub
parent d1470bad66
commit 97f52b6a03
57 changed files with 1382 additions and 1159 deletions

View File

@ -23,10 +23,6 @@
"@gorhom/bottom-sheet": "^4", "@gorhom/bottom-sheet": "^4",
"@react-native-async-storage/async-storage": "^1.17.6", "@react-native-async-storage/async-storage": "^1.17.6",
"@react-native-clipboard/clipboard": "^1.10.0", "@react-native-clipboard/clipboard": "^1.10.0",
"@react-navigation/bottom-tabs": "^6.3.1",
"@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2",
"@react-navigation/stack": "^6.2.1",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"lodash.omit": "^4.5.0", "lodash.omit": "^4.5.0",

View File

@ -5,7 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {whenWebCrypto} from './platform/polyfills.native' import {whenWebCrypto} from './platform/polyfills.native'
import * as view from './view/index' import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state' import {RootStoreModel, setupState, RootStoreProvider} from './state'
import * as Routes from './view/routes' import {MobileShell} from './view/shell/mobile'
function App() { function App() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@ -31,7 +31,7 @@ function App() {
<GestureHandlerRootView style={{flex: 1}}> <GestureHandlerRootView style={{flex: 1}}>
<RootSiblingParent> <RootSiblingParent>
<RootStoreProvider value={rootStore}> <RootStoreProvider value={rootStore}>
<Routes.Root /> <MobileShell />
</RootStoreProvider> </RootStoreProvider>
</RootSiblingParent> </RootSiblingParent>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@ -1,7 +1,7 @@
import React, {useState, useEffect} from 'react' import React, {useState, useEffect} from 'react'
import * as view from './view/index' import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state' import {RootStoreModel, setupState, RootStoreProvider} from './state'
import * as Routes from './view/routes' import {DesktopWebShell} from './view/shell/desktop-web'
import Toast from './view/com/util/Toast' import Toast from './view/com/util/Toast'
function App() { function App() {
@ -22,7 +22,7 @@ function App() {
return ( return (
<RootStoreProvider value={rootStore}> <RootStoreProvider value={rootStore}>
<Routes.Root /> <DesktopWebShell />
<Toast.ToastContainer /> <Toast.ToastContainer />
</RootStoreProvider> </RootStoreProvider>
) )

View File

@ -0,0 +1,251 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from '../lib/type-guards'
let __tabId = 0
function genTabId() {
return ++__tabId
}
interface HistoryItem {
url: string
ts: number
title?: string
}
export class NavigationTabModel {
id = genTabId()
history: HistoryItem[] = [{url: '/', ts: Date.now()}]
index = 0
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
// accessors
// =
get current() {
return this.history[this.index]
}
get canGoBack() {
return this.index > 0
}
get canGoForward() {
return this.index < this.history.length - 1
}
getBackList(n: number) {
const start = Math.max(this.index - n, 0)
const end = Math.min(this.index, n)
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
}))
}
get backTen() {
return this.getBackList(10)
}
getForwardList(n: number) {
const start = Math.min(this.index + 1, this.history.length)
const end = Math.min(this.index + n, this.history.length)
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
}))
}
get forwardTen() {
return this.getForwardList(10)
}
// navigation
// =
navigate(url: string, title?: string) {
if (this.current?.url === url) {
this.refresh()
} else {
if (this.index < this.history.length - 1) {
this.history.length = this.index + 1
}
this.history.push({url, title, ts: Date.now()})
this.index = this.history.length - 1
}
}
refresh() {
this.history = [
...this.history.slice(0, this.index),
{url: this.current.url, title: this.current.title, ts: Date.now()},
...this.history.slice(this.index + 1),
]
}
goBack() {
if (this.canGoBack) {
this.index--
}
}
goForward() {
if (this.canGoForward) {
this.index++
}
}
goToIndex(index: number) {
if (index >= 0 && index <= this.history.length - 1) {
this.index = index
}
}
setTitle(title: string) {
this.current.title = title
}
// persistence
// =
serialize(): unknown {
return {
history: this.history,
index: this.index,
}
}
hydrate(v: unknown) {
this.history = []
this.index = 0
if (isObj(v)) {
if (hasProp(v, 'history') && Array.isArray(v.history)) {
for (const item of v.history) {
if (
isObj(item) &&
hasProp(item, 'url') &&
typeof item.url === 'string'
) {
let copy: HistoryItem = {
url: item.url,
ts:
hasProp(item, 'ts') && typeof item.ts === 'number'
? item.ts
: Date.now(),
}
if (hasProp(item, 'title') && typeof item.title === 'string') {
copy.title = item.title
}
this.history.push(copy)
}
}
}
if (hasProp(v, 'index') && typeof v.index === 'number') {
this.index = v.index
}
if (this.index >= this.history.length - 1) {
this.index = this.history.length - 1
}
}
}
}
export class NavigationModel {
tabs: NavigationTabModel[] = [new NavigationTabModel()]
tabIndex = 0
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
// accessors
// =
get tab() {
return this.tabs[this.tabIndex]
}
isCurrentScreen(tabId: number, index: number) {
return this.tab.id === tabId && this.tab.index === index
}
// navigation
// =
navigate(url: string, title?: string) {
this.tab.navigate(url, title)
}
refresh() {
this.tab.refresh()
}
setTitle(title: string) {
this.tab.setTitle(title)
}
// tab management
// =
newTab(url: string, title?: string) {
const tab = new NavigationTabModel()
tab.navigate(url, title)
this.tabs.push(tab)
this.tabIndex = this.tabs.length - 1
}
setActiveTab(tabIndex: number) {
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
}
closeTab(tabIndex: number) {
this.tabs = [
...this.tabs.slice(0, tabIndex),
...this.tabs.slice(tabIndex + 1),
]
if (this.tabs.length === 0) {
this.newTab('/')
} else if (this.tabIndex >= this.tabs.length) {
this.tabIndex = this.tabs.length - 1
}
}
// persistence
// =
serialize(): unknown {
return {
tabs: this.tabs.map(t => t.serialize()),
tabIndex: this.tabIndex,
}
}
hydrate(v: unknown) {
this.tabs.length = 0
this.tabIndex = 0
if (isObj(v)) {
if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
for (const tab of v.tabs) {
const copy = new NavigationTabModel()
copy.hydrate(tab)
if (copy.history.length) {
this.tabs.push(copy)
}
}
}
if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
this.tabIndex = v.tabIndex
}
}
}
}

View File

@ -7,12 +7,14 @@ import {adx, AdxClient} from '@adxp/mock-api'
import {createContext, useContext} from 'react' import {createContext, useContext} from 'react'
import {isObj, hasProp} from '../lib/type-guards' import {isObj, hasProp} from '../lib/type-guards'
import {SessionModel} from './session' import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {MeModel} from './me' import {MeModel} from './me'
import {FeedViewModel} from './feed-view' import {FeedViewModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view' import {NotificationsViewModel} from './notifications-view'
export class RootStoreModel { export class RootStoreModel {
session = new SessionModel() session = new SessionModel()
nav = new NavigationModel()
me = new MeModel(this) me = new MeModel(this)
homeFeed = new FeedViewModel(this, {}) homeFeed = new FeedViewModel(this, {})
notesFeed = new NotificationsViewModel(this, {}) notesFeed = new NotificationsViewModel(this, {})
@ -35,6 +37,7 @@ export class RootStoreModel {
serialize(): unknown { serialize(): unknown {
return { return {
session: this.session.serialize(), session: this.session.serialize(),
nav: this.nav.serialize(),
} }
} }
@ -43,6 +46,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, 'nav')) {
this.nav.hydrate(v.nav)
}
} }
} }
} }

View File

@ -1,18 +1,11 @@
import React, {useRef} from 'react' import React, {useRef} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Text, View, FlatList} from 'react-native' import {Text, View, FlatList} from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view' import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {ShareModal} from '../modals/SharePost' import {ShareModal} from '../modals/SharePost'
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) {
feed,
onNavigateContent,
}: {
feed: FeedViewModel
onNavigateContent: OnNavigateContent
}) {
const shareSheetRef = useRef<{open: (_uri: string) => void}>() const shareSheetRef = useRef<{open: (_uri: string) => void}>()
const onPressShare = (uri: string) => { const onPressShare = (uri: string) => {
@ -23,11 +16,7 @@ export const Feed = observer(function Feed({
// renderItem function renders components that follow React performance best practices // renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc // like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: FeedViewItemModel}) => ( const renderItem = ({item}: {item: FeedViewItemModel}) => (
<FeedItem <FeedItem item={item} onPressShare={onPressShare} />
item={item}
onNavigateContent={onNavigateContent}
onPressShare={onPressShare}
/>
) )
const onRefresh = () => { const onRefresh = () => {
feed.refresh().catch(err => console.error('Failed to refresh', err)) feed.refresh().catch(err => console.error('Failed to refresh', err))

View File

@ -3,39 +3,31 @@ import {observer} from 'mobx-react-lite'
import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {bsky, AdxUri} from '@adxp/mock-api' import {bsky, AdxUri} from '@adxp/mock-api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {FeedViewItemModel} from '../../../state/models/feed-view' import {FeedViewItemModel} from '../../../state/models/feed-view'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {ago} from '../../lib/strings' import {ago} from '../../lib/strings'
import {AVIS} from '../../lib/assets' import {AVIS} from '../../lib/assets'
import {useStores} from '../../../state'
export const FeedItem = observer(function FeedItem({ export const FeedItem = observer(function FeedItem({
item, item,
onNavigateContent,
onPressShare, onPressShare,
}: { }: {
item: FeedViewItemModel item: FeedViewItemModel
onNavigateContent: OnNavigateContent
onPressShare: (_uri: string) => void onPressShare: (_uri: string) => void
}) { }) {
const store = useStores()
const record = item.record as unknown as bsky.Post.Record const record = item.record as unknown as bsky.Post.Record
const onPressOuter = () => { const onPressOuter = () => {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostThread', { store.nav.navigate(`/profile/${item.author.name}/post/${urip.recordKey}`)
name: item.author.name,
recordKey: urip.recordKey,
})
} }
const onPressAuthor = () => { const onPressAuthor = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.author.name}`)
name: item.author.name,
})
} }
const onPressReply = () => { const onPressReply = () => {
onNavigateContent('Composer', { store.nav.navigate('/composer')
replyTo: item.uri,
})
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item item
@ -137,8 +129,11 @@ export const FeedItem = observer(function FeedItem({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: 1, // borderWidth: 1,
borderTopColor: '#e8e8e8', // borderColor: '#e8e8e8',
borderRadius: 10,
margin: 2,
marginBottom: 0,
backgroundColor: '#fff', backgroundColor: '#fff',
padding: 10, padding: 10,
}, },
@ -175,6 +170,7 @@ const styles = StyleSheet.create({
}, },
postText: { postText: {
paddingBottom: 5, paddingBottom: 5,
fontFamily: 'Helvetica Neue',
}, },
ctrls: { ctrls: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -1,27 +1,10 @@
import React, { import React, {forwardRef, useState, useImperativeHandle, useRef} from 'react'
forwardRef, import {Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
useState, import BottomSheet from '@gorhom/bottom-sheet'
useMemo,
useImperativeHandle,
useRef,
} from 'react'
import {
Button,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import BottomSheet, {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
import Animated, {
Extrapolate,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated'
import Toast from '../util/Toast' import Toast from '../util/Toast'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
export const ShareModal = forwardRef(function ShareModal({}: {}, ref) { export const ShareModal = forwardRef(function ShareModal({}: {}, ref) {
const [isOpen, setIsOpen] = useState<boolean>(false) const [isOpen, setIsOpen] = useState<boolean>(false)
@ -33,14 +16,13 @@ export const ShareModal = forwardRef(function ShareModal({}: {}, ref) {
console.log('sharing', uri) console.log('sharing', uri)
setUri(uri) setUri(uri)
setIsOpen(true) setIsOpen(true)
bottomSheetRef.current?.expand()
}, },
})) }))
const onPressCopy = () => { const onPressCopy = () => {
Clipboard.setString(uri) Clipboard.setString(uri)
console.log('showing') console.log('showing')
console.log(Toast)
console.log(Toast.show)
Toast.show('Link copied', { Toast.show('Link copied', {
position: Toast.positions.TOP, position: Toast.positions.TOP,
}) })
@ -55,36 +37,13 @@ export const ShareModal = forwardRef(function ShareModal({}: {}, ref) {
bottomSheetRef.current?.close() bottomSheetRef.current?.close()
} }
const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
// animated variables
const opacity = useAnimatedStyle(() => ({
opacity: interpolate(
animatedIndex.value, // current snap index
[-1, 0], // input range
[0, 0.5], // output range
Extrapolate.CLAMP,
),
}))
const containerStyle = useMemo(
() => [style, {backgroundColor: '#000'}, opacity],
[style, opacity],
)
return ( return (
<TouchableWithoutFeedback onPress={onClose}>
<Animated.View style={containerStyle} />
</TouchableWithoutFeedback>
)
}
return (
<>
{isOpen && (
<BottomSheet <BottomSheet
ref={bottomSheetRef} ref={bottomSheetRef}
index={-1}
snapPoints={['50%']} snapPoints={['50%']}
enablePanDownToClose enablePanDownToClose
backdropComponent={CustomBackdrop} backdropComponent={isOpen ? createCustomBackdrop(onClose) : undefined}
onChange={onShareBottomSheetChange}> onChange={onShareBottomSheetChange}>
<View> <View>
<Text style={[s.textCenter, s.bold, s.mb10]}>Share this post</Text> <Text style={[s.textCenter, s.bold, s.mb10]}>Share this post</Text>
@ -97,8 +56,6 @@ export const ShareModal = forwardRef(function ShareModal({}: {}, ref) {
</View> </View>
</View> </View>
</BottomSheet> </BottomSheet>
)}
</>
) )
}) })

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Text, View, FlatList} from 'react-native' import {Text, View, FlatList} from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import { import {
NotificationsViewModel, NotificationsViewModel,
NotificationsViewItemModel, NotificationsViewItemModel,
@ -10,17 +9,15 @@ import {FeedItem} from './FeedItem'
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
view, view,
onNavigateContent,
}: { }: {
view: NotificationsViewModel view: NotificationsViewModel
onNavigateContent: OnNavigateContent
}) { }) {
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your // VirtualizedList: You have a large list that is slow to update - make sure your
// renderItem function renders components that follow React performance best practices // renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc // like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: NotificationsViewItemModel}) => ( const renderItem = ({item}: {item: NotificationsViewItemModel}) => (
<FeedItem item={item} onNavigateContent={onNavigateContent} /> <FeedItem item={item} />
) )
const onRefresh = () => { const onRefresh = () => {
view.refresh().catch(err => console.error('Failed to refresh', err)) view.refresh().catch(err => console.error('Failed to refresh', err))

View File

@ -3,44 +3,34 @@ import {observer} from 'mobx-react-lite'
import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {AdxUri} from '@adxp/mock-api' import {AdxUri} from '@adxp/mock-api'
import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {NotificationsViewItemModel} from '../../../state/models/notifications-view' import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {ago} from '../../lib/strings' import {ago} from '../../lib/strings'
import {AVIS} from '../../lib/assets' import {AVIS} from '../../lib/assets'
import {PostText} from '../post/PostText' import {PostText} from '../post/PostText'
import {Post} from '../post/Post' import {Post} from '../post/Post'
import {useStores} from '../../../state'
export const FeedItem = observer(function FeedItem({ export const FeedItem = observer(function FeedItem({
item, item,
onNavigateContent,
}: { }: {
item: NotificationsViewItemModel item: NotificationsViewItemModel
onNavigateContent: OnNavigateContent
}) { }) {
const store = useStores()
const onPressOuter = () => { const onPressOuter = () => {
if (item.isLike || item.isRepost) { if (item.isLike || item.isRepost) {
const urip = new AdxUri(item.subjectUri) const urip = new AdxUri(item.subjectUri)
onNavigateContent('PostThread', { store.nav.navigate(`/profile/${urip.host}/post/${urip.recordKey}`)
name: urip.host,
recordKey: urip.recordKey,
})
} else if (item.isFollow) { } else if (item.isFollow) {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.author.name}`)
name: item.author.name,
})
} else if (item.isReply) { } else if (item.isReply) {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostThread', { store.nav.navigate(`/profile/${urip.host}/post/${urip.recordKey}`)
name: urip.host,
recordKey: urip.recordKey,
})
} }
} }
const onPressAuthor = () => { const onPressAuthor = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.author.name}`)
name: item.author.name,
})
} }
let action = '' let action = ''
@ -92,7 +82,7 @@ export const FeedItem = observer(function FeedItem({
</View> </View>
{item.isReply ? ( {item.isReply ? (
<View style={s.pt5}> <View style={s.pt5}>
<Post uri={item.uri} onNavigateContent={onNavigateContent} /> <Post uri={item.uri} />
</View> </View>
) : ( ) : (
<></> <></>

View File

@ -9,7 +9,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import { import {
LikedByViewModel, LikedByViewModel,
LikedByViewItemModel, LikedByViewItemModel,
@ -18,13 +17,7 @@ import {useStores} from '../../../state'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {AVIS} from '../../lib/assets' import {AVIS} from '../../lib/assets'
export const PostLikedBy = observer(function PostLikedBy({ export const PostLikedBy = observer(function PostLikedBy({uri}: {uri: string}) {
uri,
onNavigateContent,
}: {
uri: string
onNavigateContent: OnNavigateContent
}) {
const store = useStores() const store = useStores()
const [view, setView] = useState<LikedByViewModel | undefined>() const [view, setView] = useState<LikedByViewModel | undefined>()
@ -66,7 +59,7 @@ export const PostLikedBy = observer(function PostLikedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: LikedByViewItemModel}) => ( const renderItem = ({item}: {item: LikedByViewItemModel}) => (
<LikedByItem item={item} onNavigateContent={onNavigateContent} /> <LikedByItem item={item} />
) )
return ( return (
<View> <View>
@ -79,17 +72,10 @@ export const PostLikedBy = observer(function PostLikedBy({
) )
}) })
const LikedByItem = ({ const LikedByItem = ({item}: {item: LikedByViewItemModel}) => {
item, const store = useStores()
onNavigateContent,
}: {
item: LikedByViewItemModel
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => { const onPressOuter = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.name}`)
name: item.name,
})
} }
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>

View File

@ -9,7 +9,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import { import {
RepostedByViewModel, RepostedByViewModel,
RepostedByViewItemModel, RepostedByViewItemModel,
@ -20,10 +19,8 @@ import {AVIS} from '../../lib/assets'
export const PostRepostedBy = observer(function PostRepostedBy({ export const PostRepostedBy = observer(function PostRepostedBy({
uri, uri,
onNavigateContent,
}: { }: {
uri: string uri: string
onNavigateContent: OnNavigateContent
}) { }) {
const store = useStores() const store = useStores()
const [view, setView] = useState<RepostedByViewModel | undefined>() const [view, setView] = useState<RepostedByViewModel | undefined>()
@ -68,7 +65,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: RepostedByViewItemModel}) => ( const renderItem = ({item}: {item: RepostedByViewItemModel}) => (
<RepostedByItem item={item} onNavigateContent={onNavigateContent} /> <RepostedByItem item={item} />
) )
return ( return (
<View> <View>
@ -81,17 +78,10 @@ export const PostRepostedBy = observer(function PostRepostedBy({
) )
}) })
const RepostedByItem = ({ const RepostedByItem = ({item}: {item: RepostedByViewItemModel}) => {
item, const store = useStores()
onNavigateContent,
}: {
item: RepostedByViewItemModel
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => { const onPressOuter = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.name}`)
name: item.name,
})
} }
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>

View File

@ -1,8 +1,6 @@
import React, {useState, useEffect, useRef} from 'react' import React, {useState, useEffect, useRef} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, Text, View} from 'react-native' import {ActivityIndicator, FlatList, Text, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {OnNavigateContent} from '../../routes/types'
import { import {
PostThreadViewModel, PostThreadViewModel,
PostThreadViewPostModel, PostThreadViewPostModel,
@ -14,13 +12,7 @@ import {s} from '../../lib/styles'
const UPDATE_DELAY = 2e3 // wait 2s before refetching the thread for updates const UPDATE_DELAY = 2e3 // wait 2s before refetching the thread for updates
export const PostThread = observer(function PostThread({ export const PostThread = observer(function PostThread({uri}: {uri: string}) {
uri,
onNavigateContent,
}: {
uri: string
onNavigateContent: OnNavigateContent
}) {
const store = useStores() const store = useStores()
const [view, setView] = useState<PostThreadViewModel | undefined>() const [view, setView] = useState<PostThreadViewModel | undefined>()
const [lastUpdate, setLastUpdate] = useState<number>(Date.now()) const [lastUpdate, setLastUpdate] = useState<number>(Date.now())
@ -37,12 +29,13 @@ export const PostThread = observer(function PostThread({
newView.setup().catch(err => console.error('Failed to fetch thread', err)) newView.setup().catch(err => console.error('Failed to fetch thread', err))
}, [uri, view?.params.uri, store]) }, [uri, view?.params.uri, store])
useFocusEffect(() => { // TODO
if (Date.now() - lastUpdate > UPDATE_DELAY) { // useFocusEffect(() => {
view?.update() // if (Date.now() - lastUpdate > UPDATE_DELAY) {
setLastUpdate(Date.now()) // view?.update()
} // setLastUpdate(Date.now())
}) // }
// })
const onPressShare = (uri: string) => { const onPressShare = (uri: string) => {
shareSheetRef.current?.open(uri) shareSheetRef.current?.open(uri)
@ -79,11 +72,7 @@ export const PostThread = observer(function PostThread({
// = // =
const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
<PostThreadItem <PostThreadItem item={item} onPressShare={onPressShare} />
item={item}
onNavigateContent={onNavigateContent}
onPressShare={onPressShare}
/>
) )
return ( return (
<View style={s.h100pct}> <View style={s.h100pct}>

View File

@ -3,11 +3,11 @@ import {observer} from 'mobx-react-lite'
import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {bsky, AdxUri} from '@adxp/mock-api' import {bsky, AdxUri} from '@adxp/mock-api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {ago, pluralize} from '../../lib/strings' import {ago, pluralize} from '../../lib/strings'
import {AVIS} from '../../lib/assets' import {AVIS} from '../../lib/assets'
import {useStores} from '../../../state'
function iter<T>(n: number, fn: (_i: number) => T): Array<T> { function iter<T>(n: number, fn: (_i: number) => T): Array<T> {
const arr: T[] = [] const arr: T[] = []
@ -19,46 +19,36 @@ function iter<T>(n: number, fn: (_i: number) => T): Array<T> {
export const PostThreadItem = observer(function PostThreadItem({ export const PostThreadItem = observer(function PostThreadItem({
item, item,
onNavigateContent,
onPressShare, onPressShare,
}: { }: {
item: PostThreadViewPostModel item: PostThreadViewPostModel
onNavigateContent: OnNavigateContent
onPressShare: (_uri: string) => void onPressShare: (_uri: string) => void
}) { }) {
const store = useStores()
const record = item.record as unknown as bsky.Post.Record const record = item.record as unknown as bsky.Post.Record
const hasEngagement = item.likeCount || item.repostCount const hasEngagement = item.likeCount || item.repostCount
const onPressOuter = () => { const onPressOuter = () => {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostThread', { store.nav.navigate(`/profile/${item.author.name}/post/${urip.recordKey}`)
name: item.author.name,
recordKey: urip.recordKey,
})
} }
const onPressAuthor = () => { const onPressAuthor = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.author.name}`)
name: item.author.name,
})
} }
const onPressLikes = () => { const onPressLikes = () => {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostLikedBy', { store.nav.navigate(
name: item.author.name, `/profile/${item.author.name}/post/${urip.recordKey}/liked-by`,
recordKey: urip.recordKey, )
})
} }
const onPressReposts = () => { const onPressReposts = () => {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostRepostedBy', { store.nav.navigate(
name: item.author.name, `/profile/${item.author.name}/post/${urip.recordKey}/reposted-by`,
recordKey: urip.recordKey, )
})
} }
const onPressReply = () => { const onPressReply = () => {
onNavigateContent('Composer', { store.nav.navigate(`/composer?replyTo=${item.uri}`)
replyTo: item.uri,
})
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item item
@ -227,6 +217,7 @@ const styles = StyleSheet.create({
}, },
postText: { postText: {
paddingBottom: 5, paddingBottom: 5,
fontFamily: 'Helvetica Neue',
}, },
expandedInfo: { expandedInfo: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -10,20 +10,13 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {PostThreadViewModel} from '../../../state/models/post-thread-view' import {PostThreadViewModel} from '../../../state/models/post-thread-view'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {ago} from '../../lib/strings' import {ago} from '../../lib/strings'
import {AVIS} from '../../lib/assets' import {AVIS} from '../../lib/assets'
export const Post = observer(function Post({ export const Post = observer(function Post({uri}: {uri: string}) {
uri,
onNavigateContent,
}: {
uri: string
onNavigateContent: OnNavigateContent
}) {
const store = useStores() const store = useStores()
const [view, setView] = useState<PostThreadViewModel | undefined>() const [view, setView] = useState<PostThreadViewModel | undefined>()
@ -63,20 +56,13 @@ export const Post = observer(function Post({
const onPressOuter = () => { const onPressOuter = () => {
const urip = new AdxUri(item.uri) const urip = new AdxUri(item.uri)
onNavigateContent('PostThread', { store.nav.navigate(`/profile/${item.author.name}/post/${urip.recordKey}`)
name: item.author.name,
recordKey: urip.recordKey,
})
} }
const onPressAuthor = () => { const onPressAuthor = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.author.name}`)
name: item.author.name,
})
} }
const onPressReply = () => { const onPressReply = () => {
onNavigateContent('Composer', { store.nav.navigate(`/composer?replyTo=${item.uri}`)
replyTo: item.uri,
})
} }
const onPressToggleRepost = () => { const onPressToggleRepost = () => {
item item

View File

@ -9,7 +9,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import { import {
UserFollowersViewModel, UserFollowersViewModel,
FollowerItem, FollowerItem,
@ -20,10 +19,8 @@ import {AVIS} from '../../lib/assets'
export const ProfileFollowers = observer(function ProfileFollowers({ export const ProfileFollowers = observer(function ProfileFollowers({
name, name,
onNavigateContent,
}: { }: {
name: string name: string
onNavigateContent: OnNavigateContent
}) { }) {
const store = useStores() const store = useStores()
const [view, setView] = useState<UserFollowersViewModel | undefined>() const [view, setView] = useState<UserFollowersViewModel | undefined>()
@ -67,9 +64,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowerItem}) => ( const renderItem = ({item}: {item: FollowerItem}) => <User item={item} />
<User item={item} onNavigateContent={onNavigateContent} />
)
return ( return (
<View> <View>
<FlatList <FlatList
@ -81,17 +76,10 @@ export const ProfileFollowers = observer(function ProfileFollowers({
) )
}) })
const User = ({ const User = ({item}: {item: FollowerItem}) => {
item, const store = useStores()
onNavigateContent,
}: {
item: FollowerItem
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => { const onPressOuter = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.name}`)
name: item.name,
})
} }
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>

View File

@ -9,7 +9,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import { import {
UserFollowsViewModel, UserFollowsViewModel,
FollowItem, FollowItem,
@ -20,10 +19,8 @@ import {AVIS} from '../../lib/assets'
export const ProfileFollows = observer(function ProfileFollows({ export const ProfileFollows = observer(function ProfileFollows({
name, name,
onNavigateContent,
}: { }: {
name: string name: string
onNavigateContent: OnNavigateContent
}) { }) {
const store = useStores() const store = useStores()
const [view, setView] = useState<UserFollowsViewModel | undefined>() const [view, setView] = useState<UserFollowsViewModel | undefined>()
@ -67,9 +64,7 @@ export const ProfileFollows = observer(function ProfileFollows({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowItem}) => ( const renderItem = ({item}: {item: FollowItem}) => <User item={item} />
<User item={item} onNavigateContent={onNavigateContent} />
)
return ( return (
<View> <View>
<FlatList <FlatList
@ -81,17 +76,10 @@ export const ProfileFollows = observer(function ProfileFollows({
) )
}) })
const User = ({ const User = ({item}: {item: FollowItem}) => {
item, const store = useStores()
onNavigateContent,
}: {
item: FollowItem
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => { const onPressOuter = () => {
onNavigateContent('Profile', { store.nav.navigate(`/profile/${item.name}`)
name: item.name,
})
} }
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>

View File

@ -9,7 +9,6 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import {ProfileViewModel} from '../../../state/models/profile-view' import {ProfileViewModel} from '../../../state/models/profile-view'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {pluralize} from '../../lib/strings' import {pluralize} from '../../lib/strings'
@ -19,10 +18,8 @@ import Toast from '../util/Toast'
export const ProfileHeader = observer(function ProfileHeader({ export const ProfileHeader = observer(function ProfileHeader({
user, user,
onNavigateContent,
}: { }: {
user: string user: string
onNavigateContent: OnNavigateContent
}) { }) {
const store = useStores() const store = useStores()
const [view, setView] = useState<ProfileViewModel | undefined>() const [view, setView] = useState<ProfileViewModel | undefined>()
@ -55,10 +52,10 @@ export const ProfileHeader = observer(function ProfileHeader({
) )
} }
const onPressFollowers = () => { const onPressFollowers = () => {
onNavigateContent('ProfileFollowers', {name: user}) store.nav.navigate(`/profile/${user}/followers`)
} }
const onPressFollows = () => { const onPressFollows = () => {
onNavigateContent('ProfileFollows', {name: user}) store.nav.navigate(`/profile/${user}/follows`)
} }
// loading // loading

View File

@ -0,0 +1,36 @@
import React, {useMemo} from 'react'
import {GestureResponderEvent, TouchableWithoutFeedback} from 'react-native'
import {BottomSheetBackdropProps} from '@gorhom/bottom-sheet'
import Animated, {
Extrapolate,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated'
export function createCustomBackdrop(
onClose?: ((event: GestureResponderEvent) => void) | undefined,
): React.FC<BottomSheetBackdropProps> {
const CustomBackdrop = ({animatedIndex, style}: BottomSheetBackdropProps) => {
// animated variables
const opacity = useAnimatedStyle(() => ({
opacity: interpolate(
animatedIndex.value, // current snap index
[-1, 0], // input range
[0, 0.5], // output range
Extrapolate.CLAMP,
),
}))
const containerStyle = useMemo(
() => [style, {backgroundColor: '#000'}, opacity],
[style, opacity],
)
return (
<TouchableWithoutFeedback onPress={onClose}>
<Animated.View style={containerStyle} />
</TouchableWithoutFeedback>
)
}
return CustomBackdrop
}

View File

@ -1,33 +1,55 @@
import {library} from '@fortawesome/fontawesome-svg-core' import {library} from '@fortawesome/fontawesome-svg-core'
import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft' import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck' import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
import {faClone} from '@fortawesome/free-regular-svg-icons/faClone'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
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 {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet' import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faX} from '@fortawesome/free-solid-svg-icons/faX'
export function setup() { export function setup() {
library.add( library.add(
faAngleLeft,
faAngleRight,
faArrowLeft, faArrowLeft,
faBars, faBars,
faBell, faBell,
farBell,
faBookmark,
farBookmark,
faCheck, faCheck,
faClone,
faComment, faComment,
faEllipsis,
faHeart, faHeart,
fasHeart, fasHeart,
faHouse, faHouse,
faPlus,
faMagnifyingGlass, faMagnifyingGlass,
faMessage,
faPenNib,
faPlus,
faRetweet, faRetweet,
faShareFromSquare, faShareFromSquare,
faUser,
faUsers,
faX, faX,
) )
} }

View File

@ -0,0 +1,12 @@
import {useEffect} from 'react'
import {useStores} from '../../state'
type CB = () => void
/**
* This custom effect hook will trigger on every "navigation"
* Use this in screens to handle any loading behaviors needed
*/
export function useLoadEffect(cb: CB, deps: any[] = []) {
const store = useStores()
useEffect(cb, [store.nav.tab, ...deps])
}

64
src/view/routes.ts 100644
View File

@ -0,0 +1,64 @@
import React from 'react'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Home} from './screens/Home'
import {Search} from './screens/Search'
import {Notifications} from './screens/Notifications'
import {Login} from './screens/Login'
import {Signup} from './screens/Signup'
import {NotFound} from './screens/NotFound'
import {Composer} from './screens/Composer'
import {PostThread} from './screens/PostThread'
import {PostLikedBy} from './screens/PostLikedBy'
import {PostRepostedBy} from './screens/PostRepostedBy'
import {Profile} from './screens/Profile'
import {ProfileFollowers} from './screens/ProfileFollowers'
import {ProfileFollows} from './screens/ProfileFollows'
export type ScreenParams = {
params: Record<string, any>
}
export type Route = [React.FC<ScreenParams>, IconProp, RegExp]
export type MatchResult = {
Com: React.FC<ScreenParams>
icon: IconProp
params: Record<string, any>
}
const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i')
export const routes: Route[] = [
[Home, 'house', r('/')],
[Search, 'magnifying-glass', r('/search')],
[Notifications, 'bell', r('/notifications')],
[Profile, ['far', 'user'], r('/profile/(?<name>[^/]+)')],
[ProfileFollowers, 'users', r('/profile/(?<name>[^/]+)/followers')],
[ProfileFollows, 'users', r('/profile/(?<name>[^/]+)/follows')],
[
PostThread,
['far', 'message'],
r('/profile/(?<name>[^/]+)/post/(?<recordKey>[^/]+)'),
],
[
PostLikedBy,
'heart',
r('/profile/(?<name>[^/]+)/post/(?<recordKey>[^/]+)/liked-by'),
],
[
PostRepostedBy,
'retweet',
r('/profile/(?<name>[^/]+)/post/(?<recordKey>[^/]+)/reposted-by'),
],
[Composer, 'pen-nib', r('/compose')],
[Login, ['far', 'user'], r('/login')],
[Signup, ['far', 'user'], r('/signup')],
]
export function match(url: string): MatchResult {
for (const [Com, icon, pattern] of routes) {
const res = pattern.exec(url)
if (res) {
// TODO: query params
return {Com, icon, params: res.groups || {}}
}
}
return {Com: NotFound, icon: 'magnifying-glass', params: {}}
}

View File

@ -1,208 +0,0 @@
import React, {useEffect} from 'react'
import {Linking, Text} from 'react-native'
import {
NavigationContainer,
LinkingOptions,
RouteProp,
ParamListBase,
} from '@react-navigation/native'
import {createNativeStackNavigator} from '@react-navigation/native-stack'
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import type {RootTabsParamList} from './types'
import {useStores} from '../../state'
import * as platform from '../../platform/detection'
import {Home} from '../screens/tabroots/Home'
import {Search} from '../screens/tabroots/Search'
import {Notifications} from '../screens/tabroots/Notifications'
import {Menu} from '../screens/tabroots/Menu'
import {Login} from '../screens/tabroots/Login'
import {Signup} from '../screens/tabroots/Signup'
import {NotFound} from '../screens/tabroots/NotFound'
import {Composer} from '../screens/stacks/Composer'
import {PostThread} from '../screens/stacks/PostThread'
import {PostLikedBy} from '../screens/stacks/PostLikedBy'
import {PostRepostedBy} from '../screens/stacks/PostRepostedBy'
import {Profile} from '../screens/stacks/Profile'
import {ProfileFollowers} from '../screens/stacks/ProfileFollowers'
import {ProfileFollows} from '../screens/stacks/ProfileFollows'
const linking: LinkingOptions<RootTabsParamList> = {
prefixes: [
'http://localhost:3000', // local dev
'https://pubsq.pfrazee.com', // test server (universal links only)
'pubsqapp://', // custom protocol (ios)
'pubsq://app', // custom protocol (android)
],
config: {
screens: {
HomeTab: '',
SearchTab: 'search',
NotificationsTab: 'notifications',
MenuTab: 'menu',
Profile: 'profile/:name',
ProfileFollowers: 'profile/:name/followers',
ProfileFollows: 'profile/:name/follows',
PostThread: 'profile/:name/post/:recordKey',
PostLikedBy: 'profile/:name/post/:recordKey/liked-by',
PostRepostedBy: 'profile/:name/post/:recordKey/reposted-by',
Composer: 'compose',
Login: 'login',
Signup: 'signup',
NotFound: '*',
},
},
}
export const RootTabs = createBottomTabNavigator<RootTabsParamList>()
export const HomeTabStack = createNativeStackNavigator()
export const SearchTabStack = createNativeStackNavigator()
export const NotificationsTabStack = createNativeStackNavigator()
const tabBarScreenOptions = ({
route,
}: {
route: RouteProp<ParamListBase, string>
}) => ({
headerShown: false,
tabBarShowLabel: false,
tabBarIcon: (state: {focused: boolean; color: string; size: number}) => {
switch (route.name) {
case 'HomeTab':
return <FontAwesomeIcon icon="house" style={{color: state.color}} />
case 'SearchTab':
return (
<FontAwesomeIcon
icon="magnifying-glass"
style={{color: state.color}}
/>
)
case 'NotificationsTab':
return <FontAwesomeIcon icon="bell" style={{color: state.color}} />
case 'MenuTab':
return <FontAwesomeIcon icon="bars" style={{color: state.color}} />
default:
return <FontAwesomeIcon icon="bars" style={{color: state.color}} />
}
},
})
const HIDE_HEADER = {headerShown: false}
const HIDE_TAB = {tabBarButton: () => null}
function HomeStackCom() {
return (
<HomeTabStack.Navigator>
<HomeTabStack.Screen name="Home" component={Home} />
<HomeTabStack.Screen name="Composer" component={Composer} />
<HomeTabStack.Screen name="Profile" component={Profile} />
<HomeTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<HomeTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
<HomeTabStack.Screen name="PostThread" component={PostThread} />
<HomeTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
<HomeTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
</HomeTabStack.Navigator>
)
}
function SearchStackCom() {
return (
<SearchTabStack.Navigator>
<SearchTabStack.Screen
name="Search"
component={Search}
options={HIDE_HEADER}
/>
<SearchTabStack.Screen name="Profile" component={Profile} />
<SearchTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<SearchTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
<SearchTabStack.Screen name="PostThread" component={PostThread} />
<SearchTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
<SearchTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
</SearchTabStack.Navigator>
)
}
function NotificationsStackCom() {
return (
<NotificationsTabStack.Navigator>
<NotificationsTabStack.Screen
name="Notifications"
component={Notifications}
/>
<NotificationsTabStack.Screen name="Profile" component={Profile} />
<NotificationsTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<NotificationsTabStack.Screen
name="ProfileFollows"
component={ProfileFollows}
/>
<NotificationsTabStack.Screen name="PostThread" component={PostThread} />
<NotificationsTabStack.Screen
name="PostLikedBy"
component={PostLikedBy}
/>
<NotificationsTabStack.Screen
name="PostRepostedBy"
component={PostRepostedBy}
/>
</NotificationsTabStack.Navigator>
)
}
export const Root = observer(() => {
const store = useStores()
useEffect(() => {
console.log('Initial link setup')
Linking.getInitialURL().then((url: string | null) => {
console.log('Initial url', url)
})
Linking.addEventListener('url', ({url}) => {
console.log('Deep link opened with', url)
})
}, [])
// hide the tabbar on desktop web
const tabBar = platform.isDesktopWeb ? () => null : undefined
return (
<NavigationContainer linking={linking} fallback={<Text>Loading...</Text>}>
<RootTabs.Navigator
initialRouteName={store.session.isAuthed ? 'HomeTab' : 'Login'}
screenOptions={tabBarScreenOptions}
tabBar={tabBar}>
{store.session.isAuthed ? (
<>
<RootTabs.Screen name="HomeTab" component={HomeStackCom} />
<RootTabs.Screen name="SearchTab" component={SearchStackCom} />
<RootTabs.Screen
name="NotificationsTab"
component={NotificationsStackCom}
/>
<RootTabs.Screen name="MenuTab" component={Menu} />
</>
) : (
<>
<RootTabs.Screen name="Login" component={Login} />
<RootTabs.Screen name="Signup" component={Signup} />
</>
)}
<RootTabs.Screen
name="NotFound"
component={NotFound}
options={HIDE_TAB}
/>
</RootTabs.Navigator>
</NavigationContainer>
)
})

View File

@ -1,47 +0,0 @@
import type {StackScreenProps} from '@react-navigation/stack'
export type RootTabsParamList = {
HomeTab: undefined
SearchTab: undefined
NotificationsTab: undefined
MenuTab: undefined
Profile: {name: string}
ProfileFollowers: {name: string}
ProfileFollows: {name: string}
PostThread: {name: string; recordKey: string}
PostLikedBy: {name: string; recordKey: string}
PostRepostedBy: {name: string; recordKey: string}
Composer: {replyTo?: string}
Login: undefined
Signup: undefined
NotFound: undefined
}
export type RootTabsScreenProps<T extends keyof RootTabsParamList> =
StackScreenProps<RootTabsParamList, T>
export type OnNavigateContent = (
screen: string,
params: Record<string, string>,
) => void
/*
NOTE
this is leftover from a nested nav implementation
keeping it around for future reference
-prf
import type {NavigatorScreenParams} from '@react-navigation/native'
import type {CompositeScreenProps} from '@react-navigation/native'
import type {BottomTabScreenProps} from '@react-navigation/bottom-tabs'
Container: NavigatorScreenParams<PrimaryStacksParamList>
export type PrimaryStacksParamList = {
Home: undefined
Profile: {name: string}
}
export type PrimaryStacksScreenProps<T extends keyof PrimaryStacksParamList> =
CompositeScreenProps<
BottomTabScreenProps<PrimaryStacksParamList, T>,
RootTabsScreenProps<keyof RootTabsParamList>
>
*/

View File

@ -0,0 +1,43 @@
import React, {useLayoutEffect, useRef} from 'react'
// import {Text, TouchableOpacity} from 'react-native'
// import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Composer as ComposerComponent} from '../com/composer/Composer'
import {ScreenParams} from '../routes'
export const Composer = ({params}: ScreenParams) => {
const {replyTo} = params
const ref = useRef<{publish: () => Promise<boolean>}>()
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: replyTo ? 'Reply' : 'New Post',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="x" />
// </TouchableOpacity>
// ),
// headerRight: () => (
// <TouchableOpacity
// onPress={() => {
// if (!ref.current) {
// return
// }
// ref.current.publish().then(
// posted => {
// if (posted) {
// navigation.goBack()
// }
// },
// err => console.error('Failed to create post', err),
// )
// }}>
// <Text>Post</Text>
// </TouchableOpacity>
// ),
// })
// }, [navigation, replyTo, ref])
return <ComposerComponent ref={ref} replyTo={replyTo} />
}

View File

@ -0,0 +1,65 @@
import React, {useState, useEffect, useLayoutEffect} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Feed} from '../com/feed/Feed'
import {useStores} from '../../state'
import {useLoadEffect} from '../lib/navigation'
import {AVIS} from '../lib/assets'
import {ScreenParams} from '../routes'
export function Home({params}: ScreenParams) {
const [hasSetup, setHasSetup] = useState<boolean>(false)
const store = useStores()
useLoadEffect(() => {
store.nav.setTitle('Home')
console.log('Fetching home feed')
store.homeFeed.setup().then(() => setHasSetup(true))
}, [store.nav, store.homeFeed])
// TODO
// useEffect(() => {
// return navigation.addListener('focus', () => {
// if (hasSetup) {
// console.log('Updating home feed')
// store.homeFeed.update()
// }
// })
// }, [navigation, store.homeFeed, hasSetup])
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'V I B E',
// headerLeft: () => (
// <TouchableOpacity
// onPress={() => navigation.push('Profile', {name: 'alice.com'})}>
// <Image source={AVIS['alice.com']} style={styles.avi} />
// </TouchableOpacity>
// ),
// headerRight: () => (
// <TouchableOpacity
// onPress={() => {
// navigation.push('Composer', {})
// }}>
// <FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return (
<View>
<Feed feed={store.homeFeed} />
</View>
)
}
const styles = StyleSheet.create({
avi: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
})

View File

@ -1,15 +1,12 @@
import React from 'react' import React from 'react'
import {Text, View} from 'react-native' import {Text, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Shell} from '../../shell'
// import type {RootTabsScreenProps} from '../routes/types'
// import {useStores} from '../../state' // import {useStores} from '../../state'
export const Login = observer( export const Login = observer(
(/*{navigation}: RootTabsScreenProps<'Login'>*/) => { (/*{navigation}: RootTabsScreenProps<'Login'>*/) => {
// const store = useStores() // const store = useStores()
return ( return (
<Shell>
<View style={{justifyContent: 'center', alignItems: 'center'}}> <View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text> <Text style={{fontSize: 20, fontWeight: 'bold'}}>Sign In</Text>
{/*store.session.uiError && <Text>{store.session.uiError}</Text>} {/*store.session.uiError && <Text>{store.session.uiError}</Text>}
@ -25,7 +22,6 @@ export const Login = observer(
<ActivityIndicator /> <ActivityIndicator />
)*/} )*/}
</View> </View>
</Shell>
) )
}, },
) )

View File

@ -0,0 +1,13 @@
import React from 'react'
import {Text, Button, View} from 'react-native'
import {useStores} from '../../state'
export const NotFound = () => {
const stores = useStores()
return (
<View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Page not found</Text>
<Button title="Home" onPress={() => stores.nav.navigate('/')} />
</View>
)
}

View File

@ -0,0 +1,65 @@
import React, {useState, useEffect, useLayoutEffect} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Feed} from '../com/notifications/Feed'
import {useStores} from '../../state'
import {AVIS} from '../lib/assets'
import {ScreenParams} from '../routes'
import {useLoadEffect} from '../lib/navigation'
export const Notifications = ({params}: ScreenParams) => {
const [hasSetup, setHasSetup] = useState<boolean>(false)
const store = useStores()
useLoadEffect(() => {
store.nav.setTitle('Notifications')
console.log('Fetching notifications feed')
store.notesFeed.setup().then(() => setHasSetup(true))
}, [store.notesFeed])
// TODO
// useEffect(() => {
// return navigation.addListener('focus', () => {
// if (hasSetup) {
// console.log('Updating notifications feed')
// store.notesFeed.update()
// }
// })
// }, [navigation, store.notesFeed, hasSetup])
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Notifications',
// headerLeft: () => (
// <TouchableOpacity
// onPress={() => navigation.push('Profile', {name: 'alice.com'})}>
// <Image source={AVIS['alice.com']} style={styles.avi} />
// </TouchableOpacity>
// ),
// headerRight: () => (
// <TouchableOpacity
// onPress={() => {
// navigation.push('Composer', {})
// }}>
// <FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return (
<View>
<Feed view={store.notesFeed} />
</View>
)
}
const styles = StyleSheet.create({
avi: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
})

View File

@ -0,0 +1,26 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../lib/strings'
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
import {ScreenParams} from '../routes'
export const PostLikedBy = ({params}: ScreenParams) => {
const {name, recordKey} = params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Liked By',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="arrow-left" />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return <PostLikedByComponent uri={uri} />
}

View File

@ -0,0 +1,26 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../lib/strings'
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
import {ScreenParams} from '../routes'
export const PostRepostedBy = ({params}: ScreenParams) => {
const {name, recordKey} = params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Reposted By',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="arrow-left" />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return <PostRepostedByComponent uri={uri} />
}

View File

@ -0,0 +1,32 @@
import React, {useEffect, useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../lib/strings'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ScreenParams} from '../routes'
import {useStores} from '../../state'
import {useLoadEffect} from '../lib/navigation'
export const PostThread = ({params}: ScreenParams) => {
const store = useStores()
const {name, recordKey} = params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
useLoadEffect(() => {
store.nav.setTitle(`Post by ${name}`)
}, [store.nav, name])
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Thread',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="arrow-left" />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return <PostThreadComponent uri={uri} />
}

View File

@ -0,0 +1,58 @@
import React, {useState, useEffect} from 'react'
import {View, StyleSheet} from 'react-native'
import {FeedViewModel} from '../../state/models/feed-view'
import {useStores} from '../../state'
import {ProfileHeader} from '../com/profile/ProfileHeader'
import {Feed} from '../com/feed/Feed'
import {ScreenParams} from '../routes'
import {useLoadEffect} from '../lib/navigation'
export const Profile = ({params}: ScreenParams) => {
const store = useStores()
const [hasSetup, setHasSetup] = useState<string>('')
const [feedView, setFeedView] = useState<FeedViewModel | undefined>()
useLoadEffect(() => {
const author = params.name
if (feedView?.params.author === author) {
return // no change needed? or trigger refresh?
}
console.log('Fetching profile feed', author)
const newFeedView = new FeedViewModel(store, {author})
setFeedView(newFeedView)
newFeedView
.setup()
.catch(err => console.error('Failed to fetch feed', err))
.then(() => {
setHasSetup(author)
store.nav.setTitle(author)
})
}, [params.name, feedView?.params.author, store])
// TODO
// useEffect(() => {
// return navigation.addListener('focus', () => {
// if (hasSetup === feedView?.params.author) {
// console.log('Updating profile feed', hasSetup)
// feedView?.update()
// }
// })
// }, [navigation, feedView, hasSetup])
return (
<View style={styles.container}>
<ProfileHeader user={params.name} />
<View style={styles.feed}>{feedView && <Feed feed={feedView} />}</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
height: '100%',
},
feed: {
flex: 1,
},
})

View File

@ -0,0 +1,24 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {ScreenParams} from '../routes'
export const ProfileFollowers = ({params}: ScreenParams) => {
const {name} = params
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Followers',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="arrow-left" />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return <ProfileFollowersComponent name={name} />
}

View File

@ -0,0 +1,24 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {ScreenParams} from '../routes'
export const ProfileFollows = ({params}: ScreenParams) => {
const {name} = params
// TODO
// useLayoutEffect(() => {
// navigation.setOptions({
// headerShown: true,
// headerTitle: 'Following',
// headerLeft: () => (
// <TouchableOpacity onPress={() => navigation.goBack()}>
// <FontAwesomeIcon icon="arrow-left" />
// </TouchableOpacity>
// ),
// })
// }, [navigation])
return <ProfileFollowsComponent name={name} />
}

View File

@ -0,0 +1,11 @@
import React from 'react'
import {Text, View} from 'react-native'
import {ScreenParams} from '../routes'
export const Search = ({params}: ScreenParams) => {
return (
<View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Search</Text>
</View>
)
}

View File

@ -1,15 +1,12 @@
import React from 'react' import React from 'react'
import {Text, View} from 'react-native' import {Text, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Shell} from '../../shell'
// import type {RootTabsScreenProps} from '../routes/types'
// import {useStores} from '../../state' // import {useStores} from '../../state'
export const Signup = observer( export const Signup = observer(
(/*{navigation}: RootTabsScreenProps<'Signup'>*/) => { (/*{navigation}: RootTabsScreenProps<'Signup'>*/) => {
// const store = useStores() // const store = useStores()
return ( return (
<Shell>
<View style={{justifyContent: 'center', alignItems: 'center'}}> <View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Create Account</Text> <Text style={{fontSize: 20, fontWeight: 'bold'}}>Create Account</Text>
{/*store.session.uiError ?? <Text>{store.session.uiError}</Text>} {/*store.session.uiError ?? <Text>{store.session.uiError}</Text>}
@ -28,7 +25,6 @@ export const Signup = observer(
<ActivityIndicator /> <ActivityIndicator />
)*/} )*/}
</View> </View>
</Shell>
) )
}, },
) )

View File

@ -1,50 +0,0 @@
import React, {useLayoutEffect, useRef} from 'react'
import {Text, TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {Composer as ComposerComponent} from '../../com/composer/Composer'
export const Composer = ({
navigation,
route,
}: RootTabsScreenProps<'Composer'>) => {
const {replyTo} = route.params
const ref = useRef<{publish: () => Promise<boolean>}>()
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: replyTo ? 'Reply' : 'New Post',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="x" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => {
if (!ref.current) {
return
}
ref.current.publish().then(
posted => {
if (posted) {
navigation.goBack()
}
},
err => console.error('Failed to create post', err),
)
}}>
<Text>Post</Text>
</TouchableOpacity>
),
})
}, [navigation, replyTo, ref])
return (
<Shell>
<ComposerComponent ref={ref} replyTo={replyTo} />
</Shell>
)
}

View File

@ -1,38 +0,0 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../../lib/strings'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {PostLikedBy as PostLikedByComponent} from '../../com/post-thread/PostLikedBy'
export const PostLikedBy = ({
navigation,
route,
}: RootTabsScreenProps<'PostLikedBy'>) => {
const {name, recordKey} = route.params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Liked By',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<PostLikedByComponent uri={uri} onNavigateContent={onNavigateContent} />
</Shell>
)
}

View File

@ -1,41 +0,0 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../../lib/strings'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {PostRepostedBy as PostRepostedByComponent} from '../../com/post-thread/PostRepostedBy'
export const PostRepostedBy = ({
navigation,
route,
}: RootTabsScreenProps<'PostRepostedBy'>) => {
const {name, recordKey} = route.params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Reposted By',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<PostRepostedByComponent
uri={uri}
onNavigateContent={onNavigateContent}
/>
</Shell>
)
}

View File

@ -1,38 +0,0 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {makeRecordUri} from '../../lib/strings'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {PostThread as PostThreadComponent} from '../../com/post-thread/PostThread'
export const PostThread = ({
navigation,
route,
}: RootTabsScreenProps<'PostThread'>) => {
const {name, recordKey} = route.params
const uri = makeRecordUri(name, 'blueskyweb.xyz:Posts', recordKey)
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Thread',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<PostThreadComponent uri={uri} onNavigateContent={onNavigateContent} />
</Shell>
)
}

View File

@ -1,71 +0,0 @@
import React, {useState, useEffect} from 'react'
import {View, StyleSheet} from 'react-native'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {FeedViewModel} from '../../../state/models/feed-view'
import {useStores} from '../../../state'
import {ProfileHeader} from '../../com/profile/ProfileHeader'
import {Feed} from '../../com/feed/Feed'
export const Profile = ({
navigation,
route,
}: RootTabsScreenProps<'Profile'>) => {
const store = useStores()
const [hasSetup, setHasSetup] = useState<string>('')
const [feedView, setFeedView] = useState<FeedViewModel | undefined>()
useEffect(() => {
const author = route.params.name
if (feedView?.params.author === author) {
return // no change needed? or trigger refresh?
}
console.log('Fetching profile feed', author)
const newFeedView = new FeedViewModel(store, {author})
setFeedView(newFeedView)
newFeedView
.setup()
.catch(err => console.error('Failed to fetch feed', err))
.then(() => setHasSetup(author))
}, [route.params.name, feedView?.params.author, store])
useEffect(() => {
return navigation.addListener('focus', () => {
if (hasSetup === feedView?.params.author) {
console.log('Updating profile feed', hasSetup)
feedView?.update()
}
})
}, [navigation, feedView, hasSetup])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<View style={styles.container}>
<ProfileHeader
user={route.params.name}
onNavigateContent={onNavigateContent}
/>
<View style={styles.feed}>
{feedView && (
<Feed feed={feedView} onNavigateContent={onNavigateContent} />
)}
</View>
</View>
</Shell>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
height: '100%',
},
feed: {
flex: 1,
},
})

View File

@ -1,39 +0,0 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {ProfileFollowers as ProfileFollowersComponent} from '../../com/profile/ProfileFollowers'
export const ProfileFollowers = ({
navigation,
route,
}: RootTabsScreenProps<'ProfileFollowers'>) => {
const {name} = route.params
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Followers',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<ProfileFollowersComponent
name={name}
onNavigateContent={onNavigateContent}
/>
</Shell>
)
}

View File

@ -1,39 +0,0 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {ProfileFollows as ProfileFollowsComponent} from '../../com/profile/ProfileFollows'
export const ProfileFollows = ({
navigation,
route,
}: RootTabsScreenProps<'ProfileFollows'>) => {
const {name} = route.params
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Following',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<ProfileFollowsComponent
name={name}
onNavigateContent={onNavigateContent}
/>
</Shell>
)
}

View File

@ -1,69 +0,0 @@
import React, {useState, useEffect, useLayoutEffect} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import {Feed} from '../../com/feed/Feed'
import type {RootTabsScreenProps} from '../../routes/types'
import {useStores} from '../../../state'
import {AVIS} from '../../lib/assets'
export function Home({navigation}: RootTabsScreenProps<'HomeTab'>) {
const [hasSetup, setHasSetup] = useState<boolean>(false)
const store = useStores()
useEffect(() => {
console.log('Fetching home feed')
store.homeFeed.setup().then(() => setHasSetup(true))
}, [store.homeFeed])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.navigate(screen, props)
}
useEffect(() => {
return navigation.addListener('focus', () => {
if (hasSetup) {
console.log('Updating home feed')
store.homeFeed.update()
}
})
}, [navigation, store.homeFeed, hasSetup])
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'V I B E',
headerLeft: () => (
<TouchableOpacity
onPress={() => navigation.push('Profile', {name: 'alice.com'})}>
<Image source={AVIS['alice.com']} style={styles.avi} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => {
navigation.push('Composer', {})
}}>
<FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} />
</TouchableOpacity>
),
})
}, [navigation])
return (
<Shell>
<View>
<Feed feed={store.homeFeed} onNavigateContent={onNavigateContent} />
</View>
</Shell>
)
}
const styles = StyleSheet.create({
avi: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
})

View File

@ -1,16 +0,0 @@
import React from 'react'
import {Shell} from '../../shell'
import {ScrollView, Text, View} from 'react-native'
import type {RootTabsScreenProps} from '../../routes/types'
export const Menu = (_props: RootTabsScreenProps<'MenuTab'>) => {
return (
<Shell>
<ScrollView contentInsetAdjustmentBehavior="automatic">
<View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Menu</Text>
</View>
</ScrollView>
</Shell>
)
}

View File

@ -1,15 +0,0 @@
import React from 'react'
import {Shell} from '../../shell'
import {Text, Button, View} from 'react-native'
import type {RootTabsScreenProps} from '../../routes/types'
export const NotFound = ({navigation}: RootTabsScreenProps<'NotFound'>) => {
return (
<Shell>
<View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Page not found</Text>
<Button title="Home" onPress={() => navigation.navigate('HomeTab')} />
</View>
</Shell>
)
}

View File

@ -1,71 +0,0 @@
import React, {useState, useEffect, useLayoutEffect} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import {Feed} from '../../com/notifications/Feed'
import type {RootTabsScreenProps} from '../../routes/types'
import {useStores} from '../../../state'
import {AVIS} from '../../lib/assets'
export const Notifications = ({
navigation,
}: RootTabsScreenProps<'NotificationsTab'>) => {
const [hasSetup, setHasSetup] = useState<boolean>(false)
const store = useStores()
useEffect(() => {
console.log('Fetching home feed')
store.notesFeed.setup().then(() => setHasSetup(true))
}, [store.notesFeed])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.navigate(screen, props)
}
useEffect(() => {
return navigation.addListener('focus', () => {
if (hasSetup) {
console.log('Updating home feed')
store.notesFeed.update()
}
})
}, [navigation, store.notesFeed, hasSetup])
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Notifications',
headerLeft: () => (
<TouchableOpacity
onPress={() => navigation.push('Profile', {name: 'alice.com'})}>
<Image source={AVIS['alice.com']} style={styles.avi} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => {
navigation.push('Composer', {})
}}>
<FontAwesomeIcon icon="plus" style={{color: '#006bf7'}} />
</TouchableOpacity>
),
})
}, [navigation])
return (
<Shell>
<View>
<Feed view={store.notesFeed} onNavigateContent={onNavigateContent} />
</View>
</Shell>
)
}
const styles = StyleSheet.create({
avi: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
})

View File

@ -1,14 +0,0 @@
import React from 'react'
import {Shell} from '../../shell'
import {Text, View} from 'react-native'
import type {RootTabsScreenProps} from '../../routes/types'
export const Search = (_props: RootTabsScreenProps<'SearchTab'>) => {
return (
<Shell>
<View style={{justifyContent: 'center', alignItems: 'center'}}>
<Text style={{fontSize: 20, fontWeight: 'bold'}}>Search</Text>
</View>
</Shell>
)
}

View File

@ -1,13 +1,11 @@
import React from 'react' import React from 'react'
import {Pressable, View, StyleSheet} from 'react-native' import {Pressable, View, StyleSheet} from 'react-native'
import {Link} from '@react-navigation/native'
import {useRoute} from '@react-navigation/native'
export const NavItem: React.FC<{label: string; screen: string}> = ({ export const NavItem: React.FC<{label: string; screen: string}> = ({
label, label,
screen, screen,
}) => { }) => {
const route = useRoute() const Link = <></> // TODO
return ( return (
<View> <View>
<Pressable <Pressable
@ -18,7 +16,7 @@ export const NavItem: React.FC<{label: string; screen: string}> = ({
<Link <Link
style={[ style={[
styles.navItemLink, styles.navItemLink,
route.name === screen && styles.navItemLinkSelected, false /* TODO route.name === screen*/ && styles.navItemLinkSelected,
]} ]}
to={{screen, params: {}}}> to={{screen, params: {}}}>
{label} {label}

View File

@ -1,12 +0,0 @@
import React from 'react'
import {SafeAreaView} from 'react-native'
import {isDesktopWeb} from '../../platform/detection'
import {DesktopWebShell} from './desktop-web/shell'
export const Shell: React.FC = ({children}) => {
return isDesktopWeb ? (
<DesktopWebShell>{children}</DesktopWebShell>
) : (
<SafeAreaView>{children}</SafeAreaView>
)
}

View File

@ -0,0 +1,99 @@
import React from 'react'
import {
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RootSiblings from 'react-native-root-siblings'
import {NavigationTabModel} from '../../../state/models/navigation'
export function createBackMenu(tab: NavigationTabModel): RootSiblings {
const onPressItem = (index: number) => {
sibling.destroy()
tab.goToIndex(index)
}
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<View style={[styles.menu, styles.back]}>
{tab.backTen.map((item, i) => (
<TouchableOpacity
key={item.index}
style={[styles.menuItem, i !== 0 && styles.menuItemBorder]}
onPress={() => onPressItem(item.index)}>
<Text>{item.title || item.url}</Text>
</TouchableOpacity>
))}
</View>
</>
),
)
return sibling
}
export function createForwardMenu(tab: NavigationTabModel): RootSiblings {
const onPressItem = (index: number) => {
sibling.destroy()
tab.goToIndex(index)
}
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<View style={[styles.menu, styles.forward]}>
{tab.forwardTen.reverse().map((item, i) => (
<TouchableOpacity
key={item.index}
style={[styles.menuItem, i !== 0 && styles.menuItemBorder]}
onPress={() => onPressItem(item.index)}>
<Text>{item.title || item.url}</Text>
</TouchableOpacity>
))}
</View>
</>
),
)
return sibling
}
const styles = StyleSheet.create({
bg: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: '#000',
opacity: 0.1,
},
menu: {
position: 'absolute',
bottom: 80,
backgroundColor: '#fff',
borderRadius: 8,
opacity: 1,
},
back: {
left: 10,
},
forward: {
left: 60,
},
menuItem: {
paddingVertical: 10,
paddingLeft: 15,
paddingRight: 30,
},
menuItemBorder: {
borderTopWidth: 1,
borderTopColor: '#ddd',
},
})

View File

@ -0,0 +1,235 @@
import React, {useRef} from 'react'
import {observer} from 'mobx-react-lite'
import {
GestureResponderEvent,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {useStores} from '../../../state'
import {NavigationModel} from '../../../state/models/navigation'
import {match, MatchResult} from '../../routes'
import {TabsSelectorModal} from './tabs-selector'
import {createBackMenu, createForwardMenu} from './history-menu'
const Location = ({icon, title}: {icon: IconProp; title?: string}) => {
return (
<TouchableOpacity style={styles.location}>
{title ? (
<FontAwesomeIcon size={16} style={styles.locationIcon} icon={icon} />
) : (
<FontAwesomeIcon
size={16}
style={styles.locationIconLight}
icon="magnifying-glass"
/>
)}
<Text style={title ? styles.locationText : styles.locationTextLight}>
{title || 'Search'}
</Text>
</TouchableOpacity>
)
}
const Btn = ({
icon,
inactive,
onPress,
onLongPress,
}: {
icon: IconProp
inactive?: boolean
onPress?: (event: GestureResponderEvent) => void
onLongPress?: (event: GestureResponderEvent) => void
}) => {
if (inactive) {
return (
<View style={styles.ctrl}>
<FontAwesomeIcon
size={18}
style={[styles.ctrlIcon, styles.inactive]}
icon={icon}
/>
</View>
)
}
return (
<TouchableOpacity
style={styles.ctrl}
onPress={onPress}
onLongPress={onLongPress}>
<FontAwesomeIcon size={18} style={styles.ctrlIcon} icon={icon} />
</TouchableOpacity>
)
}
export const MobileShell: React.FC = observer(() => {
const stores = useStores()
const tabSelectorRef = useRef<{open: () => void}>()
const screenRenderDesc = constructScreenRenderDesc(stores.nav)
const onPressBack = () => stores.nav.tab.goBack()
const onPressForward = () => stores.nav.tab.goForward()
const onPressHome = () => stores.nav.navigate('/')
const onPressNotifications = () => stores.nav.navigate('/notifications')
const onPressTabs = () => tabSelectorRef.current?.open()
const onLongPressBack = () => createBackMenu(stores.nav.tab)
const onLongPressForward = () => createForwardMenu(stores.nav.tab)
const onNewTab = () => stores.nav.newTab('/')
const onChangeTab = (tabIndex: number) => stores.nav.setActiveTab(tabIndex)
const onCloseTab = (tabIndex: number) => stores.nav.closeTab(tabIndex)
return (
<View style={styles.outerContainer}>
<View style={styles.topBar}>
<Location
icon={screenRenderDesc.icon}
title={stores.nav.tab.current.title}
/>
</View>
<SafeAreaView style={styles.innerContainer}>
<ScreenContainer>
{screenRenderDesc.screens.map(({Com, params, key, activityState}) => (
<Screen
key={key}
style={{backgroundColor: '#fff'}}
activityState={activityState}>
<Com params={params} />
</Screen>
))}
</ScreenContainer>
</SafeAreaView>
<View style={styles.bottomBar}>
<Btn
icon="angle-left"
inactive={!stores.nav.tab.canGoBack}
onPress={onPressBack}
onLongPress={onLongPressBack}
/>
<Btn
icon="angle-right"
inactive={!stores.nav.tab.canGoForward}
onPress={onPressForward}
onLongPress={onLongPressForward}
/>
<Btn icon="house" onPress={onPressHome} />
<Btn icon={['far', 'bell']} onPress={onPressNotifications} />
<Btn icon={['far', 'clone']} onPress={onPressTabs} />
</View>
<TabsSelectorModal
ref={tabSelectorRef}
tabs={stores.nav.tabs}
currentTabIndex={stores.nav.tabIndex}
onNewTab={onNewTab}
onChangeTab={onChangeTab}
onCloseTab={onCloseTab}
/>
</View>
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {key: string; activityState: 0 | 1 | 2}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
screens: ScreenRenderDesc[]
} {
let icon: IconProp = 'magnifying-glass'
let screens: ScreenRenderDesc[] = []
for (const tab of nav.tabs) {
const tabScreens = [
...tab.getBackList(5),
Object.assign({}, tab.current, {index: tab.index}),
]
const parsedTabScreens = tabScreens.map(screen => {
const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
activityState: isCurrent ? 2 : 0,
})
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
screens,
}
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
innerContainer: {
flex: 1,
},
topBar: {
flexDirection: 'row',
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#ccc',
paddingLeft: 10,
paddingRight: 10,
paddingTop: 40,
paddingBottom: 5,
},
location: {
flex: 1,
flexDirection: 'row',
borderRadius: 4,
paddingLeft: 10,
paddingRight: 6,
paddingTop: 6,
paddingBottom: 6,
backgroundColor: '#F8F3F3',
},
locationIcon: {
color: '#DB00FF',
marginRight: 8,
},
locationIconLight: {
color: '#909090',
marginRight: 8,
},
locationText: {
color: '#000',
},
locationTextLight: {
color: '#868788',
},
bottomBar: {
flexDirection: 'row',
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#ccc',
paddingLeft: 5,
paddingRight: 15,
paddingBottom: 20,
},
ctrl: {
flex: 1,
paddingTop: 15,
paddingBottom: 15,
},
ctrlIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
inactive: {
color: '#888',
},
})

View File

@ -0,0 +1,158 @@
import React, {forwardRef, useState, useImperativeHandle, useRef} from 'react'
import {StyleSheet, Text, TouchableWithoutFeedback, View} from 'react-native'
import BottomSheet from '@gorhom/bottom-sheet'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s} from '../../lib/styles'
import {NavigationTabModel} from '../../../state/models/navigation'
import {createCustomBackdrop} from '../../com/util/BottomSheetCustomBackdrop'
import {match} from '../../routes'
const TAB_HEIGHT = 38
const TAB_SPACING = 5
const BOTTOM_MARGIN = 70
export const TabsSelectorModal = forwardRef(function TabsSelectorModal(
{
onNewTab,
onChangeTab,
onCloseTab,
tabs,
currentTabIndex,
}: {
onNewTab: () => void
onChangeTab: (tabIndex: number) => void
onCloseTab: (tabIndex: number) => void
tabs: NavigationTabModel[]
currentTabIndex: number
},
ref,
) {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [snapPoints, setSnapPoints] = useState<number[]>([100])
const bottomSheetRef = useRef<BottomSheet>(null)
useImperativeHandle(ref, () => ({
open() {
setIsOpen(true)
setSnapPoints([
(tabs.length + 1) * (TAB_HEIGHT + TAB_SPACING) + BOTTOM_MARGIN,
])
bottomSheetRef.current?.expand()
},
}))
const onShareBottomSheetChange = (snapPoint: number) => {
if (snapPoint === -1) {
setIsOpen(false)
}
}
const onPressNewTab = () => {
onNewTab()
onClose()
}
const onPressChangeTab = (tabIndex: number) => {
onChangeTab(tabIndex)
onClose()
}
const onClose = () => {
setIsOpen(false)
bottomSheetRef.current?.close()
}
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={isOpen ? createCustomBackdrop(onClose) : undefined}
onChange={onShareBottomSheetChange}>
<View style={s.p10}>
{tabs.map((tab, tabIndex) => {
const {icon} = match(tab.current.url)
const isActive = tabIndex === currentTabIndex
return (
<View
key={tabIndex}
style={[styles.tab, styles.existing, isActive && styles.active]}>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={16} icon={icon} />
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<Text
style={[styles.tabText, isActive && styles.tabTextActive]}>
{tab.current.title || tab.current.url}
</Text>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={() => onCloseTab(tabIndex)}>
<View style={styles.tabClose}>
<FontAwesomeIcon
size={16}
icon="x"
style={styles.tabCloseIcon}
/>
</View>
</TouchableWithoutFeedback>
</View>
)
})}
<TouchableWithoutFeedback onPress={onPressNewTab}>
<View style={[styles.tab, styles.create]}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={16} icon="plus" />
</View>
<Text style={styles.tabText}>New tab</Text>
</View>
</TouchableWithoutFeedback>
</View>
</BottomSheet>
)
})
const styles = StyleSheet.create({
tab: {
flexDirection: 'row',
width: '100%',
borderRadius: 4,
height: TAB_HEIGHT,
marginBottom: TAB_SPACING,
},
existing: {
borderColor: '#000',
borderWidth: 1,
},
create: {
backgroundColor: '#F8F3F3',
},
active: {
backgroundColor: '#faf0f0',
borderColor: '#f00',
borderWidth: 1,
},
tabIcon: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 15,
paddingRight: 10,
},
tabText: {
flex: 1,
paddingTop: 10,
paddingBottom: 10,
},
tabTextActive: {
fontWeight: 'bold',
},
tabClose: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 10,
paddingRight: 15,
},
tabCloseIcon: {
color: '#655',
},
})

View File

@ -12,3 +12,6 @@ Paul's todo list
- Reposted by - Reposted by
- Followers list - Followers list
- Follows list - Follows list
- Navigation
- Restore all functionality that was disabled during the refactor
- Reduce extraneous triggers of useLoadEffect

120
yarn.lock
View File

@ -2375,65 +2375,6 @@
resolved "https://registry.yarnpkg.com/@react-native/polyfills/-/polyfills-2.0.0.tgz#4c40b74655c83982c8cf47530ee7dc13d957b6aa" resolved "https://registry.yarnpkg.com/@react-native/polyfills/-/polyfills-2.0.0.tgz#4c40b74655c83982c8cf47530ee7dc13d957b6aa"
integrity sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ== integrity sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==
"@react-navigation/bottom-tabs@^6.3.1":
version "6.3.1"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-6.3.1.tgz#1552ccdb789b6c9fc05af0877f8f3a50ab28870c"
integrity sha512-sL9F4WMhhR6I9bE7bpsPVHnK1cN9doaFHAuy5YmD+Sw6OyO0TAmNgQFx4xZWqboA5ZwSkN0tWcRCr6wGXaRRag==
dependencies:
"@react-navigation/elements" "^1.3.3"
color "^3.1.3"
warn-once "^0.1.0"
"@react-navigation/core@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-6.2.1.tgz#90459f9afd25b71a9471b0706ebea2cdd2534fc4"
integrity sha512-3mjS6ujwGnPA/BC11DN9c2c42gFld6B6dQBgDedxP2djceXESpY2kVTTwISDHuqFnF7WjvRjsrDu3cKBX+JosA==
dependencies:
"@react-navigation/routers" "^6.1.0"
escape-string-regexp "^4.0.0"
nanoid "^3.1.23"
query-string "^7.0.0"
react-is "^16.13.0"
"@react-navigation/elements@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.3.tgz#9f56b650a9a1a8263a271628be7342c8121d1788"
integrity sha512-Lv2lR7si5gNME8dRsqz57d54m4FJtrwHRjNQLOyQO546ZxO+g864cSvoLC6hQedQU0+IJnPTsZiEI2hHqfpEpw==
"@react-navigation/native-stack@^6.6.2":
version "6.6.2"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-6.6.2.tgz#09e696ad72299872f4c5c1e5b1ad309869853628"
integrity sha512-pFMuzhxbPml5MBvJVAzHWoaUkQaefAOKpuUnAs/AxNQuHQwwnxRmDit1PQLuIPo7g7DlfwFXagDHE1R0tbnS8Q==
dependencies:
"@react-navigation/elements" "^1.3.3"
warn-once "^0.1.0"
"@react-navigation/native@^6.0.10":
version "6.0.10"
resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-6.0.10.tgz#c58aa176eb0e63f3641c83a65c509faf253e4385"
integrity sha512-H6QhLeiieGxNcAJismIDXIPZgf1myr7Og8v116tezIGmincJTOcWavTd7lPHGnMMXaZg94LlVtbaBRIx9cexqw==
dependencies:
"@react-navigation/core" "^6.2.1"
escape-string-regexp "^4.0.0"
fast-deep-equal "^3.1.3"
nanoid "^3.1.23"
"@react-navigation/routers@^6.1.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-6.1.0.tgz#d5682be88f1eb7809527c48f9cd3dedf4f344e40"
integrity sha512-8xJL+djIzpFdRW/sGlKojQ06fWgFk1c5jER9501HYJ12LF5DIJFr/tqBI2TJ6bk+y+QFu0nbNyeRC80OjRlmkA==
dependencies:
nanoid "^3.1.23"
"@react-navigation/stack@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@react-navigation/stack/-/stack-6.2.1.tgz#2b14473579eced6def5cca06860044d60e59d06e"
integrity sha512-JI7boxtPAMCBXi4VJHVEq61jLVHFW5f3npvbndS+XfOsv7Gf0f91HOVJ28DS5c2Fn4+CO4AByjUozzlN296X+A==
dependencies:
"@react-navigation/elements" "^1.3.3"
color "^3.1.3"
warn-once "^0.1.0"
"@rollup/plugin-babel@^5.2.0": "@rollup/plugin-babel@^5.2.0":
version "5.3.1" version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@ -4691,7 +4632,7 @@ collection-visit@^1.0.0:
map-visit "^1.0.0" map-visit "^1.0.0"
object-visit "^1.0.0" object-visit "^1.0.0"
color-convert@^1.9.0, color-convert@^1.9.3: color-convert@^1.9.0:
version "1.9.3" version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@ -4710,27 +4651,11 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.6.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color@^3.1.3:
version "3.2.1"
resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164"
integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==
dependencies:
color-convert "^1.9.3"
color-string "^1.6.0"
colord@^2.9.1: colord@^2.9.1:
version "2.9.2" version "2.9.2"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1"
@ -6553,11 +6478,6 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
filter-obj@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b"
integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==
finalhandler@1.1.2: finalhandler@1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
@ -7474,11 +7394,6 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-bigint@^1.0.1: is-bigint@^1.0.1:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
@ -9781,7 +9696,7 @@ multiformats@^9.1.2, multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.6
resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.6.5.tgz#f2d894a26664b454a90abf5a8911b7e39195db80" resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.6.5.tgz#f2d894a26664b454a90abf5a8911b7e39195db80"
integrity sha512-vMwf/FUO+qAPvl3vlSZEgEVFY/AxeZq5yg761ScF3CZsXgmTi/HGkicUiNN0CI4PW8FiY2P0OLklOcmQjdQJhw== integrity sha512-vMwf/FUO+qAPvl3vlSZEgEVFY/AxeZq5yg761ScF3CZsXgmTi/HGkicUiNN0CI4PW8FiY2P0OLklOcmQjdQJhw==
nanoid@^3.1.23, nanoid@^3.3.1, nanoid@^3.3.3, nanoid@^3.3.4: nanoid@^3.3.1, nanoid@^3.3.3, nanoid@^3.3.4:
version "3.3.4" version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
@ -11234,16 +11149,6 @@ qs@6.10.3:
dependencies: dependencies:
side-channel "^1.0.4" side-channel "^1.0.4"
query-string@^7.0.0:
version "7.1.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1"
integrity sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w==
dependencies:
decode-uri-component "^0.2.0"
filter-obj "^1.1.0"
split-on-first "^1.0.0"
strict-uri-encode "^2.0.0"
queue-microtask@^1.2.2, queue-microtask@^1.2.3: queue-microtask@^1.2.2, queue-microtask@^1.2.3:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@ -11375,7 +11280,7 @@ react-freeze@^1.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@ -12333,13 +12238,6 @@ simple-plist@^1.1.0:
bplist-parser "0.3.1" bplist-parser "0.3.1"
plist "^3.0.5" plist "^3.0.5"
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==
dependencies:
is-arrayish "^0.3.1"
sisteransi@^1.0.5: sisteransi@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@ -12583,11 +12481,6 @@ spdy@^4.0.2:
select-hose "^2.0.0" select-hose "^2.0.0"
spdy-transport "^3.0.0" spdy-transport "^3.0.0"
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
split-string@^3.0.1, split-string@^3.0.2: split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -12647,11 +12540,6 @@ stream-buffers@2.2.x:
resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4"
integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==
string-hash-64@^1.0.3: string-hash-64@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/string-hash-64/-/string-hash-64-1.0.3.tgz#0deb56df58678640db5c479ccbbb597aaa0de322" resolved "https://registry.yarnpkg.com/string-hash-64/-/string-hash-64-1.0.3.tgz#0deb56df58678640db5c479ccbbb597aaa0de322"