Replace the navigation model with react-navigation

This commit is contained in:
Paul Frazee
2023-03-10 01:01:51 -06:00
parent bf1f743382
commit 3b0d72b776
22 changed files with 249 additions and 776 deletions
-1
View File
@@ -1,2 +1 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false
+2 -1
View File
@@ -16,7 +16,8 @@ export function init(store: RootStoreModel) {
store.log.debug('Notifee foreground event', {type})
if (type === EventType.PRESS) {
store.log.debug('User pressed a notifee, opening notifications')
store.nav.switchTo(TabPurpose.Notifs, true)
// TODO
// store.nav.switchTo(TabPurpose.Notifs, true)
}
})
notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent
+16 -2
View File
@@ -1,4 +1,18 @@
import {State} from './types'
import {State, NavigationProp} from './types'
// TODO needed?
// export function getCurrentTabName(
// navigator: NavigationProp | undefined,
// ): string {
// if (!navigator) {
// throw new Error('Failed to get current tab')
// }
// const state = navigator.getState()
// if (state.type !== 'tab') {
// return getCurrentTabName(navigator.getParent())
// }
// return state.routes[state.index].name
// }
export function getCurrentRoute(state: State) {
let node = state.routes[state.index]
@@ -15,7 +29,7 @@ export function isTab(current: string, route: string) {
// -prf
return (
current === route ||
current === `${route}Stack` ||
current === `${route}Tab` ||
current === `${route}Inner`
)
}
+20 -3
View File
@@ -1,4 +1,5 @@
import {NavigationState, PartialState} from '@react-navigation/native'
import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -13,15 +14,31 @@ export type CommonNavigatorParams = {
Debug: undefined
Log: undefined
}
export type HomeStackNavigatorParams = CommonNavigatorParams & {
export type HomeTabNavigatorParams = CommonNavigatorParams & {
Home: undefined
}
export type NotificationsStackNavigatorParams = CommonNavigatorParams & {
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
}
export type SearchStackNavigatorParams = CommonNavigatorParams & {
export type SearchTabNavigatorParams = CommonNavigatorParams & {
Search: undefined
}
// NOTE
// this isn't strictly correct but it should be close enough
// a TS wizard might be able to get this 100%
// -prf
export type NavigationProp = NativeStackNavigationProp<
CommonNavigatorParams & {
HomeTab: undefined
NotificationsTab: undefined
SearchTab: undefined
}
>
export type State =
| NavigationState
| Omit<PartialState<NavigationState>, 'stale'>
-434
View File
@@ -1,434 +0,0 @@
import {RootStoreModel} from './root-store'
import {makeAutoObservable} from 'mobx'
import {TABS_ENABLED} from 'lib/build-flags'
import * as analytics from 'lib/analytics'
import {isNative} from 'platform/detection'
let __id = 0
function genId() {
return String(++__id)
}
// NOTE
// this model was originally built for a freeform "tabs" concept like a browser
// we've since decided to pause that idea and do something more traditional
// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
// - Tab 0: The "Default" tab
// - Tab 1: The "Search" tab
// - Tab 2: The "Notifications" tab
// These tabs always retain the first item in their history.
// -prf
export enum TabPurpose {
Default = 0,
Search = 1,
Notifs = 2,
}
export const TabPurposeMainPath: Record<TabPurpose, string> = {
[TabPurpose.Default]: '/',
[TabPurpose.Search]: '/search',
[TabPurpose.Notifs]: '/notifications',
}
interface HistoryItem {
url: string
ts: number
title?: string
id: string
}
export type HistoryPtr = string // `{tabId}-{historyId}`
export class NavigationTabModel {
id = genId()
history: HistoryItem[]
index = 0
isNewTab = false
constructor(public fixedTabPurpose: TabPurpose) {
this.history = [
{url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
]
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 = this.index
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
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 + 1, this.history.length)
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
get forwardTen() {
return this.getForwardList(10)
}
// navigation
// =
navigate(url: string, title?: string) {
try {
const path = url.split('/')[1]
analytics.track('Navigation', {
path,
})
} catch (error) {}
if (this.current?.url === url) {
this.refresh()
} else {
if (this.index < this.history.length - 1) {
this.history.length = this.index + 1
}
// TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) {
const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
}
this.history.push({url, title, ts: Date.now(), id: genId()})
this.index = this.history.length - 1
if (!isNative) {
window.history.pushState({hindex: this.index, hurl: url}, '', url)
}
}
}
refresh() {
this.history = [
...this.history.slice(0, this.index),
{
url: this.current.url,
title: this.current.title,
ts: Date.now(),
id: this.current.id,
},
...this.history.slice(this.index + 1),
]
}
goBack() {
if (this.canGoBack) {
this.index--
if (!isNative) {
window.history.back()
}
}
}
// TEMP
// a helper to bring the tab back to its base state
// -prf
fixedTabReset() {
this.index = 0
}
goForward() {
if (this.canGoForward) {
this.index++
if (!isNative) {
window.history.forward()
}
}
}
goToIndex(index: number) {
if (index >= 0 && index <= this.history.length - 1) {
const delta = index - this.index
this.index = index
if (!isNative) {
window.history.go(delta)
}
}
}
setTitle(id: string, title: string) {
this.history = this.history.map(h => {
if (h.id === id) {
return {...h, title}
}
return h
})
}
setIsNewTab(v: boolean) {
this.isNewTab = v
}
// browser only
// =
resetTo(url: string) {
this.index = 0
this.history.push({url, title: '', ts: Date.now(), id: genId()})
this.index = this.history.length - 1
}
// persistence
// =
serialize(): unknown {
return {
history: this.history,
index: this.index,
}
}
hydrate(_v: unknown) {
// TODO fixme
// 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[] = isNative
? [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
: [new NavigationTabModel(TabPurpose.Default)]
tabIndex = 0
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
})
}
/**
* Used only in the web build to sync with browser history state
*/
bindWebNavigation() {
if (!isNative) {
window.addEventListener('popstate', e => {
const {hindex, hurl} = e.state
if (hindex >= 0 && hindex <= this.tab.history.length - 1) {
this.tab.index = hindex
}
if (this.tab.current.url !== hurl) {
// desynced because they went back to an old tab session-
// do a reset to match that
this.tab.resetTo(hurl)
}
// sanity check
if (this.tab.current.url !== window.location.pathname) {
// state has completely desynced, reload
window.location.reload()
}
})
}
}
clear() {
this.tabs = isNative
? [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
: [new NavigationTabModel(TabPurpose.Default)]
this.tabIndex = 0
}
// accessors
// =
get tab() {
return this.tabs[this.tabIndex]
}
get tabCount() {
return this.tabs.length
}
isCurrentScreen(tabId: string, index: number) {
return this.tab.id === tabId && this.tab.index === index
}
// navigation
// =
navigate(url: string, title?: string) {
this.rootStore.emitNavigation()
this.tab.navigate(url, title)
}
refresh() {
this.tab.refresh()
}
setTitle(ptr: HistoryPtr, title: string) {
const [tid, hid] = ptr.split('-')
this.tabs.find(t => t.id === tid)?.setTitle(hid, title)
}
handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
this.navigate(path)
}
// tab management
// =
// TEMP
// fixed tab helper function
// -prf
switchTo(purpose: TabPurpose, reset: boolean) {
this.rootStore.emitNavigation()
switch (purpose) {
case TabPurpose.Notifs:
this.tabIndex = 2
break
case TabPurpose.Search:
this.tabIndex = 1
break
default:
this.tabIndex = 0
}
if (reset) {
this.tab.fixedTabReset()
}
}
newTab(url: string, title?: string) {
if (!TABS_ENABLED) {
return this.navigate(url)
}
const tab = new NavigationTabModel(TabPurpose.Default)
tab.navigate(url, title)
tab.isNewTab = true
this.tabs.push(tab)
this.tabIndex = this.tabs.length - 1
}
setActiveTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
}
closeTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
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) {
// TODO fixme
this.clear()
/*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
}
}*/
}
}
+2 -8
View File
@@ -11,7 +11,6 @@ import {z} from 'zod'
import {isObj, hasProp} from 'lib/type-guards'
import {LogModel} from './log'
import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {ShellUiModel} from './shell-ui'
import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view'
@@ -31,7 +30,6 @@ export class RootStoreModel {
appInfo?: AppInfo
log = new LogModel()
session = new SessionModel(this)
nav = new NavigationModel(this)
shell = new ShellUiModel(this)
me = new MeModel(this)
profiles = new ProfilesViewModel(this)
@@ -82,7 +80,6 @@ export class RootStoreModel {
log: this.log.serialize(),
session: this.session.serialize(),
me: this.me.serialize(),
nav: this.nav.serialize(),
shell: this.shell.serialize(),
}
}
@@ -101,9 +98,6 @@ export class RootStoreModel {
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'nav')) {
this.nav.hydrate(v.nav)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
@@ -144,7 +138,7 @@ export class RootStoreModel {
*/
async handleSessionDrop() {
this.log.debug('RootStoreModel:handleSessionDrop')
this.nav.clear()
// this.nav.clear() TODO
this.me.clear()
this.emitSessionDropped()
}
@@ -155,7 +149,7 @@ export class RootStoreModel {
clearAllSessionState() {
this.log.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
this.nav.clear()
// this.nav.clear() TODO
this.me.clear()
}
+1 -1
View File
@@ -46,7 +46,7 @@ export function Component({}: {}) {
token: confirmCode,
})
Toast.show('Your account has been deleted')
store.nav.tab.fixedTabReset()
// store.nav.tab.fixedTabReset() TODO
store.session.clear()
store.shell.closeModal()
} catch (e: any) {
+10 -4
View File
@@ -7,6 +7,7 @@ import {
StyleSheet,
ViewStyle,
} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {CenteredView, FlatList} from '../util/Views'
@@ -18,10 +19,10 @@ import {FeedModel} from 'state/models/feed-view'
import {FeedItem} from './FeedItem'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
@@ -47,9 +48,9 @@ export const Feed = observer(function Feed({
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const store = useStores()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const data = React.useMemo(() => {
let feedItems: any[] = []
@@ -112,7 +113,12 @@ export const Feed = observer(function Feed({
<Button
type="inverted"
style={styles.emptyBtn}
onPress={() => store.nav.navigate('/search')}>
onPress={
() =>
navigation.navigate(
'SearchTab',
) /* TODO make sure it goes to root of the tab */
}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts
</Text>
@@ -134,7 +140,7 @@ export const Feed = observer(function Feed({
}
return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
},
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav],
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
)
const FeedFooter = React.useCallback(
+26 -23
View File
@@ -12,6 +12,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {BlurView} from '../util/BlurView'
import {ProfileViewModel} from 'state/models/profile-view'
import {useStores} from 'state/index'
@@ -28,6 +29,7 @@ import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30}
@@ -40,16 +42,17 @@ export const ProfileHeader = observer(function ProfileHeader({
}) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressAvi = () => {
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressAvi = React.useCallback(() => {
if (view.avatar) {
store.shell.openLightbox(new ProfileImageLightbox(view))
}
}
const onPressToggleFollow = () => {
}, [store, view])
const onPressToggleFollow = React.useCallback(() => {
view?.toggleFollowing().then(
() => {
Toast.show(
@@ -60,28 +63,28 @@ export const ProfileHeader = observer(function ProfileHeader({
},
err => store.log.error('Failed to toggle follow', err),
)
}
const onPressEditProfile = () => {
}, [view, store])
const onPressEditProfile = React.useCallback(() => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal({
name: 'edit-profile',
profileView: view,
onUpdate: onRefreshAll,
})
}
const onPressFollowers = () => {
}, [track, store, view, onRefreshAll])
const onPressFollowers = React.useCallback(() => {
track('ProfileHeader:FollowersButtonClicked')
store.nav.navigate(`/profile/${view.handle}/followers`)
}
const onPressFollows = () => {
navigation.push('ProfileFollowers', {name: view.handle})
}, [track, navigation, view])
const onPressFollows = React.useCallback(() => {
track('ProfileHeader:FollowsButtonClicked')
store.nav.navigate(`/profile/${view.handle}/follows`)
}
const onPressShare = () => {
navigation.push('ProfileFollows', {name: view.handle})
}, [track, navigation, view])
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
Share.share({url: toShareUrl(`/profile/${view.handle}`)})
}
const onPressMuteAccount = async () => {
}, [track, view])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await view.muteAccount()
@@ -90,8 +93,8 @@ export const ProfileHeader = observer(function ProfileHeader({
store.log.error('Failed to mute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}
const onPressUnmuteAccount = async () => {
}, [track, view, store])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await view.unmuteAccount()
@@ -100,14 +103,14 @@ export const ProfileHeader = observer(function ProfileHeader({
store.log.error('Failed to unmute account', e)
Toast.show(`There was an issue! ${e.toString()}`)
}
}
const onPressReportAccount = () => {
}, [track, view, store])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal({
name: 'report-account',
did: view.did,
})
}
}, [track, store, view])
// loading
// =
+77 -37
View File
@@ -2,6 +2,8 @@ import React from 'react'
import {observer} from 'mobx-react-lite'
import {
Linking,
GestureResponderEvent,
Platform,
StyleProp,
TouchableWithoutFeedback,
TouchableOpacity,
@@ -9,11 +11,18 @@ import {
View,
ViewStyle,
} from 'react-native'
import {useLinkProps, useNavigation} from '@react-navigation/native'
import {Text} from './text/Text'
import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types'
import {matchPath} from 'view/screens'
import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
| GestureResponderEvent
export const Link = observer(function Link({
style,
href,
@@ -27,35 +36,30 @@ export const Link = observer(function Link({
children?: React.ReactNode
noFeedback?: boolean
}) {
let {...props} = useLinkProps({to: href})
const store = useStores()
const onPress = () => {
if (href) {
handleLink(store, href, false)
}
}
const onLongPress = () => {
if (href) {
handleLink(store, href, true)
}
}
const navigation = useNavigation<NavigationProp>()
props.onPress = React.useCallback(
(e?: Event) => {
if (typeof href === 'string') {
return onPressInner(store, navigation, href, e)
}
},
[store, navigation, href],
)
if (noFeedback) {
return (
<TouchableWithoutFeedback
onPress={onPress}
onLongPress={onLongPress}
delayPressIn={50}>
<View style={style}>
<TouchableWithoutFeedback delayPressIn={50} {...props}>
<View style={style} {...props}>
{children ? children : <Text>{title || 'link'}</Text>}
</View>
</TouchableWithoutFeedback>
)
}
return (
<TouchableOpacity
onPress={onPress}
onLongPress={onLongPress}
delayPressIn={50}
style={style}>
<TouchableOpacity delayPressIn={50} style={style} {...props}>
{children ? children : <Text>{title || 'link'}</Text>}
</TouchableOpacity>
)
@@ -72,29 +76,65 @@ export const TextLink = observer(function TextLink({
href: string
text: string
}) {
const {...props} = useLinkProps({to: href})
const store = useStores()
const onPress = () => {
handleLink(store, href, false)
}
const onLongPress = () => {
handleLink(store, href, true)
}
const navigation = useNavigation<NavigationProp>()
props.onPress = React.useCallback(
(e?: Event) => {
return onPressInner(store, navigation, href, e)
},
[store, navigation, href],
)
return (
<Text type={type} style={style} onPress={onPress} onLongPress={onLongPress}>
<Text type={type} style={style} {...props}>
{text}
</Text>
)
})
function handleLink(store: RootStoreModel, href: string, longPress: boolean) {
href = convertBskyAppUrlIfNeeded(href)
if (href.startsWith('http')) {
Linking.openURL(href)
} else if (longPress) {
store.shell.closeModal() // close any active modals
store.nav.newTab(href)
} else {
store.shell.closeModal() // close any active modals
store.nav.navigate(href)
// NOTE
// we can't use the onPress given by useLinkProps because it will
// match most paths to the HomeTab routes while we actually want to
// preserve the tab the app is currently in
//
// we also have some additional behaviors - closing the current modal,
// converting bsky urls, and opening http/s links in the system browser
//
// this method copies from the onPress implementation but adds our
// needed customizations
// -prf
function onPressInner(
store: RootStoreModel,
navigation: NavigationProp,
href: string,
e?: Event,
) {
let shouldHandle = false
if (Platform.OS !== 'web' || !e) {
shouldHandle = e ? !e.defaultPrevented : true
} else if (
!e.defaultPrevented && // onPress prevented default
!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys
(e.button == null || e.button === 0) && // ignore everything but left clicks
[undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc.
) {
e.preventDefault()
shouldHandle = true
}
if (shouldHandle) {
href = convertBskyAppUrlIfNeeded(href)
if (href.startsWith('http')) {
Linking.openURL(href)
} else {
store.shell.closeModal() // close any active modals
const {name, params} = matchPath(href)
// @ts-ignore we're not able to type check on this one -prf
navigation.push(name, params)
}
}
}
+14 -7
View File
@@ -2,6 +2,7 @@ import React from 'react'
import {observer} from 'mobx-react-lite'
import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation, DrawerActions} from '@react-navigation/native'
import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text'
import {useStores} from 'state/index'
@@ -9,6 +10,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useAnalytics} from 'lib/analytics'
import {isDesktopWeb} from '../../../platform/detection'
import {NavigationProp} from 'lib/routes/types'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -23,17 +25,22 @@ export const ViewHeader = observer(function ViewHeader({
}) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressMenu = () => {
const onPressBack = React.useCallback(() => {
navigation.goBack()
}, [navigation])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
store.shell.setMainMenuOpen(true)
}
navigation.dispatch(DrawerActions.openDrawer())
}, [track, navigation])
if (typeof canGoBack === 'undefined') {
canGoBack = store.nav.tab.canGoBack
canGoBack = navigation.canGoBack()
}
if (isDesktopWeb) {
return <></>
}
@@ -17,7 +17,6 @@ import {Button, ButtonType} from './Button'
import {colors} from 'lib/styles'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useStores} from 'state/index'
import {TABS_ENABLED} from 'lib/build-flags'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
@@ -138,15 +137,6 @@ export function PostDropdownBtn({
const store = useStores()
const dropdownItems: DropdownItem[] = [
TABS_ENABLED
? {
icon: ['far', 'clone'],
label: 'Open in new tab',
onPress() {
store.nav.newTab(itemHref)
},
}
: undefined,
{
icon: 'language',
label: 'Translate...',
+48 -46
View File
@@ -5,9 +5,9 @@ import {createDrawerNavigator} from '@react-navigation/drawer'
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
import {
HomeStackNavigatorParams,
NotificationsStackNavigatorParams,
SearchStackNavigatorParams,
HomeTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
State,
} from 'lib/routes/types'
@@ -28,12 +28,12 @@ import {DebugScreen} from './screens/Debug'
import {LogScreen} from './screens/Log'
const HomeDrawer = createDrawerNavigator()
const HomeStack = createNativeStackNavigator<HomeStackNavigatorParams>()
const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
const SearchDrawer = createDrawerNavigator()
const SearchStack = createNativeStackNavigator<SearchStackNavigatorParams>()
const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
const NotificationsDrawer = createDrawerNavigator()
const NotificationsStack =
createNativeStackNavigator<NotificationsStackNavigatorParams>()
const NotificationsTab =
createNativeStackNavigator<NotificationsTabNavigatorParams>()
const Tab = createBottomTabNavigator()
type RouteParams = Record<string, string>
@@ -82,7 +82,21 @@ const ROUTES: Record<string, Route> = {
Log: r('/sys/log'),
}
const LINKING = {
export function matchPath(path: string): {name: string; params: RouteParams} {
let name = 'Home' // TODO should be not found
let params: RouteParams = {}
for (const [screenName, matcher] of Object.entries(ROUTES)) {
const res = matcher.match(path)
if (res) {
name = screenName
params = res.params
break
}
}
return {name, params}
}
export const LINKING = {
prefixes: ['bsky://', 'https://bsky.app'],
getPathFromState(state: State) {
@@ -101,26 +115,14 @@ const LINKING = {
},
getStateFromPath(path: string) {
// match the route
let match = 'Home' // TODO should be not found
let params: RouteParams = {}
for (const [name, matcher] of Object.entries(ROUTES)) {
const res = matcher.match(path)
if (res) {
match = name
params = res.params
break
}
const {name, params} = matchPath(path)
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
}
// build the state object
if (match === 'Search') {
return buildStateObject('SearchStack', 'Search', params)
if (name === 'Notifications') {
return buildStateObject('NotificationsTab', 'Notifications', params)
}
if (match === 'Notifications') {
return buildStateObject('NotificationsStack', 'Notifications', params)
}
return buildStateObject('HomeStack', match, params)
return buildStateObject('HomeTab', name, params)
},
}
@@ -167,17 +169,17 @@ function HomeDrawerNavigator() {
)
}
function HomeStackNavigator() {
function HomeTabNavigator() {
return (
<HomeStack.Navigator
<HomeTab.Navigator
screenOptions={{
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
}}>
<HomeStack.Screen name="Home" component={HomeDrawerNavigator} />
{commonScreens(HomeStack)}
</HomeStack.Navigator>
<HomeTab.Screen name="Home" component={HomeDrawerNavigator} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
}
@@ -195,20 +197,20 @@ function NotificationsDrawerNavigator() {
)
}
function NotificationsStackNavigator() {
function NotificationsTabNavigator() {
return (
<NotificationsStack.Navigator
<NotificationsTab.Navigator
screenOptions={{
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
}}>
<NotificationsStack.Screen
<NotificationsTab.Screen
name="Notifications"
component={NotificationsDrawerNavigator}
/>
{commonScreens(NotificationsStack)}
</NotificationsStack.Navigator>
{commonScreens(NotificationsTab)}
</NotificationsTab.Navigator>
)
}
@@ -223,17 +225,17 @@ function SearchDrawerNavigator() {
)
}
function SearchStackNavigator() {
function SearchTabNavigator() {
return (
<SearchStack.Navigator
<SearchTab.Navigator
screenOptions={{
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
}}>
<SearchStack.Screen name="Search" component={SearchDrawerNavigator} />
{commonScreens(SearchStack)}
</SearchStack.Navigator>
<SearchTab.Screen name="Search" component={SearchDrawerNavigator} />
{commonScreens(SearchTab)}
</SearchTab.Navigator>
)
}
@@ -241,16 +243,16 @@ function TabsNavigator() {
const tabBar = React.useCallback(props => <BottomBar {...props} />, [])
return (
<Tab.Navigator
initialRouteName="HomeStack"
initialRouteName="HomeTab"
backBehavior="initialRoute"
screenOptions={{headerShown: false}}
tabBar={tabBar}>
<Tab.Screen name="HomeStack" component={HomeStackNavigator} />
<Tab.Screen name="HomeTab" component={HomeTabNavigator} />
<Tab.Screen
name="NotificationsStack"
component={NotificationsStackNavigator}
name="NotificationsTab"
component={NotificationsTabNavigator}
/>
<Tab.Screen name="SearchStack" component={SearchStackNavigator} />
<Tab.Screen name="SearchTab" component={SearchTabNavigator} />
</Tab.Navigator>
)
}
+2 -5
View File
@@ -3,10 +3,7 @@ import {FlatList, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
import {
NativeStackScreenProps,
HomeStackNavigatorParams,
} from 'lib/routes/types'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/posts/Feed'
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
@@ -20,7 +17,7 @@ import {ComposeIcon2} from 'lib/icons'
const HEADER_HEIGHT = 42
type Props = NativeStackScreenProps<HomeStackNavigatorParams, 'Home'>
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = observer(function Home({}: Props) {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
+9 -3
View File
@@ -1,11 +1,17 @@
import React from 'react'
import {Button, StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {NavigationProp} from 'lib/routes/types'
export const NotFound = () => {
const stores = useStores()
const navigation = useNavigation<NavigationProp>()
const onPressHome = React.useCallback(() => {
navigation.navigate('HomeTab') // TODO go fully home
}, [navigation])
return (
<View testID="notFoundView">
<ViewHeader title="Page not found" />
@@ -14,7 +20,7 @@ export const NotFound = () => {
<Button
testID="navigateHomeButton"
title="Home"
onPress={() => stores.nav.navigate('/')}
onPress={onPressHome}
/>
</View>
</View>
+2 -2
View File
@@ -4,7 +4,7 @@ import {useFocusEffect} from '@react-navigation/native'
import useAppState from 'react-native-appstate-hook'
import {
NativeStackScreenProps,
NotificationsStackNavigatorParams,
NotificationsTabNavigatorParams,
} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
@@ -16,7 +16,7 @@ import {useAnalytics} from 'lib/analytics'
const NOTIFICATIONS_POLL_INTERVAL = 15e3
export const NotificationsScreen = ({}: NativeStackScreenProps<
NotificationsStackNavigatorParams,
NotificationsTabNavigatorParams,
'Notifications'
>) => {
const store = useStores()
+2 -2
View File
@@ -15,7 +15,7 @@ import {
import {ScrollView} from '../com/util/Views'
import {
NativeStackScreenProps,
SearchStackNavigatorParams,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {UserAvatar} from '../com/util/UserAvatar'
@@ -34,7 +34,7 @@ import {useAnalytics} from 'lib/analytics'
const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
const FIVE_MIN = 5 * 60 * 1e3
type Props = NativeStackScreenProps<SearchStackNavigatorParams, 'Search'>
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
+2 -2
View File
@@ -5,7 +5,7 @@ import {ScrollView} from '../com/util/Views'
import {observer} from 'mobx-react-lite'
import {
NativeStackScreenProps,
SearchStackNavigatorParams,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
@@ -16,7 +16,7 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
const FIVE_MIN = 5 * 60 * 1e3
type Props = NativeStackScreenProps<SearchStackNavigatorParams, 'Search'>
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
+11 -3
View File
@@ -5,7 +5,11 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {
useFocusEffect,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@@ -26,6 +30,7 @@ import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
export const SettingsScreen = observer(
function Settings({}: NativeStackScreenProps<
@@ -35,6 +40,7 @@ export const SettingsScreen = observer(
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {screen, track} = useAnalytics()
const [isSwitching, setIsSwitching] = React.useState(false)
@@ -50,13 +56,15 @@ export const SettingsScreen = observer(
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
store.nav.tab.fixedTabReset()
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
}
setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
store.nav.tab.fixedTabReset()
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressAddAccount = () => {
+1 -1
View File
@@ -66,7 +66,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
} else if (isTab(state.routes[state.index].name, tab)) {
navigation.dispatch(StackActions.popToTop())
} else {
navigation.navigate(`${tab}Stack`)
navigation.navigate(`${tab}Tab`)
}
},
[store, track, navigation],
+1 -1
View File
@@ -68,7 +68,7 @@ export const Drawer = observer(({navigation}: DrawerContentComponentProps) => {
} else {
// wait for drawer anim to finish
setTimeout(() => {
navigation.navigate(`${tab}Stack`)
navigation.navigate(`${tab}Tab`)
}, 250)
}
},
+3 -180
View File
@@ -1,30 +1,13 @@
import React, {useState} from 'react'
import React from 'react'
import {observer} from 'mobx-react-lite'
import {
Animated,
StatusBar,
StyleSheet,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {StatusBar, StyleSheet, useWindowDimensions, View} from 'react-native'
import {useStores} from 'state/index'
import {NavigationModel} from 'state/models/navigation'
import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login'
import {Menu} from './Menu'
import {BottomBar} from './BottomBar'
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
import {ModalsContainer} from '../../com/modals/Modal'
import {Lightbox} from '../../com/lightbox/Lightbox'
import {Text} from '../../com/util/text/Text'
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
import {Composer} from './Composer'
import {s, colors} from 'lib/styles'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
@@ -35,81 +18,6 @@ export const MobileShell: React.FC = observer(() => {
const pal = usePalette('default')
const store = useStores()
const winDim = useWindowDimensions()
const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
const swipeGestureInterp = useAnimatedValue(0)
const safeAreaInsets = useSafeAreaInsets()
const screenRenderDesc = constructScreenRenderDesc(store.nav)
// navigation swipes
// =
const isMenuActive = store.shell.isMainMenuOpen
const canSwipeLeft = store.nav.tab.canGoBack || !isMenuActive
const canSwipeRight = isMenuActive
const onNavSwipeStartDirection = (dx: number) => {
if (dx < 0 && !store.nav.tab.canGoBack) {
setMenuSwipingDirection(dx)
} else if (dx > 0 && isMenuActive) {
setMenuSwipingDirection(dx)
} else {
setMenuSwipingDirection(0)
}
}
const onNavSwipeEnd = (dx: number) => {
if (dx < 0) {
if (store.nav.tab.canGoBack) {
store.nav.tab.goBack()
} else {
store.shell.setMainMenuOpen(true)
}
} else if (dx > 0) {
if (isMenuActive) {
store.shell.setMainMenuOpen(false)
}
}
setMenuSwipingDirection(0)
}
const swipeTranslateX = Animated.multiply(
swipeGestureInterp,
winDim.width * -1,
)
const swipeTransform = store.nav.tab.canGoBack
? {transform: [{translateX: swipeTranslateX}]}
: undefined
let shouldRenderMenu = false
let menuTranslateX
const menuDrawerWidth = winDim.width - 100
if (isMenuActive) {
// menu is active, interpret swipes as closes
menuTranslateX = Animated.multiply(swipeGestureInterp, menuDrawerWidth * -1)
shouldRenderMenu = true
} else if (!store.nav.tab.canGoBack) {
// at back of history, interpret swipes as opens
menuTranslateX = Animated.subtract(
menuDrawerWidth * -1,
Animated.multiply(swipeGestureInterp, menuDrawerWidth),
)
shouldRenderMenu = true
}
const menuSwipeTransform = menuTranslateX
? {
transform: [{translateX: menuTranslateX}],
}
: undefined
const swipeOpacity = {
opacity: swipeGestureInterp.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0.6, 0],
}),
}
const menuSwipeOpacity =
menuSwipingDirection !== 0
? {
opacity: swipeGestureInterp.interpolate({
inputRange: menuSwipingDirection > 0 ? [0, 1] : [-1, 0],
outputRange: [0.6, 0],
}),
}
: undefined
if (store.hackUpgradeNeeded) {
return (
@@ -158,9 +66,6 @@ export const MobileShell: React.FC = observer(() => {
)
}
const screenBg = {
backgroundColor: theme.colorScheme === 'dark' ? colors.black : colors.gray1,
}
return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar
@@ -184,90 +89,8 @@ export const MobileShell: React.FC = observer(() => {
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {
key: string
navIdx: string
current: boolean
previous: boolean
isNewTab: boolean
}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
hasNewTab: boolean
screens: ScreenRenderDesc[]
} {
let hasNewTab = false
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 isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
hasNewTab = hasNewTab || tab.isNewTab
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
navIdx: `${tab.id}-${screen.id}`,
current: isCurrent,
previous: isPrevious,
isNewTab: tab.isNewTab,
}) as ScreenRenderDesc
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
hasNewTab,
screens,
}
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
innerContainer: {
height: '100%',
},
screenContainer: {
height: '100%',
},
screenMask: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#000',
opacity: 0.6,
},
menuDrawer: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 100,
},
topBarProtector: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 50, // will be overwritten by insets
backgroundColor: colors.white,
},
topBarProtectorDark: {
backgroundColor: colors.black,
},
})