From 97f52b6a03ab36dcbf21256cc0137b550b10f174 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 31 Aug 2022 14:36:50 -0500 Subject: [PATCH] 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 --- package.json | 4 - src/App.native.tsx | 4 +- src/App.web.tsx | 4 +- src/state/models/navigation.ts | 251 ++++++++++++++++++ src/state/models/root-store.ts | 6 + src/view/com/feed/Feed.tsx | 15 +- src/view/com/feed/FeedItem.tsx | 26 +- src/view/com/modals/SharePost.native.tsx | 89 ++----- src/view/com/notifications/Feed.tsx | 5 +- src/view/com/notifications/FeedItem.tsx | 26 +- src/view/com/post-thread/PostLikedBy.tsx | 24 +- src/view/com/post-thread/PostRepostedBy.tsx | 18 +- src/view/com/post-thread/PostThread.tsx | 29 +- src/view/com/post-thread/PostThreadItem.tsx | 33 +-- src/view/com/post/Post.tsx | 22 +- src/view/com/profile/ProfileFollowers.tsx | 20 +- src/view/com/profile/ProfileFollows.tsx | 20 +- src/view/com/profile/ProfileHeader.tsx | 7 +- .../com/util/BottomSheetCustomBackdrop.tsx | 36 +++ src/view/index.ts | 24 +- src/view/lib/navigation.ts | 12 + src/view/routes.ts | 64 +++++ src/view/routes/index.tsx | 208 --------------- src/view/routes/types.ts | 47 ---- src/view/screens/Composer.tsx | 43 +++ src/view/screens/Home.tsx | 65 +++++ src/view/screens/{tabroots => }/Login.tsx | 12 +- src/view/screens/NotFound.tsx | 13 + src/view/screens/Notifications.tsx | 65 +++++ src/view/screens/PostLikedBy.tsx | 26 ++ src/view/screens/PostRepostedBy.tsx | 26 ++ src/view/screens/PostThread.tsx | 32 +++ src/view/screens/Profile.tsx | 58 ++++ src/view/screens/ProfileFollowers.tsx | 24 ++ src/view/screens/ProfileFollows.tsx | 24 ++ src/view/screens/Search.tsx | 11 + src/view/screens/{tabroots => }/Signup.tsx | 12 +- src/view/screens/stacks/Composer.tsx | 50 ---- src/view/screens/stacks/PostLikedBy.tsx | 38 --- src/view/screens/stacks/PostRepostedBy.tsx | 41 --- src/view/screens/stacks/PostThread.tsx | 38 --- src/view/screens/stacks/Profile.tsx | 71 ----- src/view/screens/stacks/ProfileFollowers.tsx | 39 --- src/view/screens/stacks/ProfileFollows.tsx | 39 --- src/view/screens/tabroots/Home.tsx | 69 ----- src/view/screens/tabroots/Menu.tsx | 16 -- src/view/screens/tabroots/NotFound.tsx | 15 -- src/view/screens/tabroots/Notifications.tsx | 71 ----- src/view/screens/tabroots/Search.tsx | 14 - .../desktop-web/{shell.tsx => index.tsx} | 0 src/view/shell/desktop-web/left-column.tsx | 6 +- src/view/shell/index.tsx | 12 - src/view/shell/mobile/history-menu.tsx | 99 +++++++ src/view/shell/mobile/index.tsx | 235 ++++++++++++++++ src/view/shell/mobile/tabs-selector.tsx | 158 +++++++++++ todos.txt | 5 +- yarn.lock | 120 +-------- 57 files changed, 1382 insertions(+), 1159 deletions(-) create mode 100644 src/state/models/navigation.ts create mode 100644 src/view/com/util/BottomSheetCustomBackdrop.tsx create mode 100644 src/view/lib/navigation.ts create mode 100644 src/view/routes.ts delete mode 100644 src/view/routes/index.tsx delete mode 100644 src/view/routes/types.ts create mode 100644 src/view/screens/Composer.tsx create mode 100644 src/view/screens/Home.tsx rename src/view/screens/{tabroots => }/Login.tsx (63%) create mode 100644 src/view/screens/NotFound.tsx create mode 100644 src/view/screens/Notifications.tsx create mode 100644 src/view/screens/PostLikedBy.tsx create mode 100644 src/view/screens/PostRepostedBy.tsx create mode 100644 src/view/screens/PostThread.tsx create mode 100644 src/view/screens/Profile.tsx create mode 100644 src/view/screens/ProfileFollowers.tsx create mode 100644 src/view/screens/ProfileFollows.tsx create mode 100644 src/view/screens/Search.tsx rename src/view/screens/{tabroots => }/Signup.tsx (66%) delete mode 100644 src/view/screens/stacks/Composer.tsx delete mode 100644 src/view/screens/stacks/PostLikedBy.tsx delete mode 100644 src/view/screens/stacks/PostRepostedBy.tsx delete mode 100644 src/view/screens/stacks/PostThread.tsx delete mode 100644 src/view/screens/stacks/Profile.tsx delete mode 100644 src/view/screens/stacks/ProfileFollowers.tsx delete mode 100644 src/view/screens/stacks/ProfileFollows.tsx delete mode 100644 src/view/screens/tabroots/Home.tsx delete mode 100644 src/view/screens/tabroots/Menu.tsx delete mode 100644 src/view/screens/tabroots/NotFound.tsx delete mode 100644 src/view/screens/tabroots/Notifications.tsx delete mode 100644 src/view/screens/tabroots/Search.tsx rename src/view/shell/desktop-web/{shell.tsx => index.tsx} (100%) delete mode 100644 src/view/shell/index.tsx create mode 100644 src/view/shell/mobile/history-menu.tsx create mode 100644 src/view/shell/mobile/index.tsx create mode 100644 src/view/shell/mobile/tabs-selector.tsx diff --git a/package.json b/package.json index e27e0695..1368a089 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,6 @@ "@gorhom/bottom-sheet": "^4", "@react-native-async-storage/async-storage": "^1.17.6", "@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", "base64-js": "^1.5.1", "lodash.omit": "^4.5.0", diff --git a/src/App.native.tsx b/src/App.native.tsx index 4309fa3c..a220fab3 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -5,7 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler' import {whenWebCrypto} from './platform/polyfills.native' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' -import * as Routes from './view/routes' +import {MobileShell} from './view/shell/mobile' function App() { const [rootStore, setRootStore] = useState( @@ -31,7 +31,7 @@ function App() { - + diff --git a/src/App.web.tsx b/src/App.web.tsx index 9a6fedd5..06da5e4e 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,7 +1,7 @@ import React, {useState, useEffect} from 'react' import * as view from './view/index' 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' function App() { @@ -22,7 +22,7 @@ function App() { return ( - + ) diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts new file mode 100644 index 00000000..d5338ac0 --- /dev/null +++ b/src/state/models/navigation.ts @@ -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 + } + } + } +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index e05c8638..d1e73132 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -7,12 +7,14 @@ import {adx, AdxClient} from '@adxp/mock-api' import {createContext, useContext} from 'react' import {isObj, hasProp} from '../lib/type-guards' import {SessionModel} from './session' +import {NavigationModel} from './navigation' import {MeModel} from './me' import {FeedViewModel} from './feed-view' import {NotificationsViewModel} from './notifications-view' export class RootStoreModel { session = new SessionModel() + nav = new NavigationModel() me = new MeModel(this) homeFeed = new FeedViewModel(this, {}) notesFeed = new NotificationsViewModel(this, {}) @@ -35,6 +37,7 @@ export class RootStoreModel { serialize(): unknown { return { session: this.session.serialize(), + nav: this.nav.serialize(), } } @@ -43,6 +46,9 @@ export class RootStoreModel { if (hasProp(v, 'session')) { this.session.hydrate(v.session) } + if (hasProp(v, 'nav')) { + this.nav.hydrate(v.nav) + } } } } diff --git a/src/view/com/feed/Feed.tsx b/src/view/com/feed/Feed.tsx index 6787b51a..7c7fea58 100644 --- a/src/view/com/feed/Feed.tsx +++ b/src/view/com/feed/Feed.tsx @@ -1,18 +1,11 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' import {Text, View, FlatList} from 'react-native' -import {OnNavigateContent} from '../../routes/types' import {FeedViewModel, FeedViewItemModel} from '../../../state/models/feed-view' import {FeedItem} from './FeedItem' import {ShareModal} from '../modals/SharePost' -export const Feed = observer(function Feed({ - feed, - onNavigateContent, -}: { - feed: FeedViewModel - onNavigateContent: OnNavigateContent -}) { +export const Feed = observer(function Feed({feed}: {feed: FeedViewModel}) { const shareSheetRef = useRef<{open: (_uri: string) => void}>() const onPressShare = (uri: string) => { @@ -23,11 +16,7 @@ export const Feed = observer(function Feed({ // renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc const renderItem = ({item}: {item: FeedViewItemModel}) => ( - + ) const onRefresh = () => { feed.refresh().catch(err => console.error('Failed to refresh', err)) diff --git a/src/view/com/feed/FeedItem.tsx b/src/view/com/feed/FeedItem.tsx index e79c1532..a63fb7a2 100644 --- a/src/view/com/feed/FeedItem.tsx +++ b/src/view/com/feed/FeedItem.tsx @@ -3,39 +3,31 @@ import {observer} from 'mobx-react-lite' import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {bsky, AdxUri} from '@adxp/mock-api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {OnNavigateContent} from '../../routes/types' import {FeedViewItemModel} from '../../../state/models/feed-view' import {s} from '../../lib/styles' import {ago} from '../../lib/strings' import {AVIS} from '../../lib/assets' +import {useStores} from '../../../state' export const FeedItem = observer(function FeedItem({ item, - onNavigateContent, onPressShare, }: { item: FeedViewItemModel - onNavigateContent: OnNavigateContent onPressShare: (_uri: string) => void }) { + const store = useStores() const record = item.record as unknown as bsky.Post.Record const onPressOuter = () => { const urip = new AdxUri(item.uri) - onNavigateContent('PostThread', { - name: item.author.name, - recordKey: urip.recordKey, - }) + store.nav.navigate(`/profile/${item.author.name}/post/${urip.recordKey}`) } const onPressAuthor = () => { - onNavigateContent('Profile', { - name: item.author.name, - }) + store.nav.navigate(`/profile/${item.author.name}`) } const onPressReply = () => { - onNavigateContent('Composer', { - replyTo: item.uri, - }) + store.nav.navigate('/composer') } const onPressToggleRepost = () => { item @@ -137,8 +129,11 @@ export const FeedItem = observer(function FeedItem({ const styles = StyleSheet.create({ outer: { - borderTopWidth: 1, - borderTopColor: '#e8e8e8', + // borderWidth: 1, + // borderColor: '#e8e8e8', + borderRadius: 10, + margin: 2, + marginBottom: 0, backgroundColor: '#fff', padding: 10, }, @@ -175,6 +170,7 @@ const styles = StyleSheet.create({ }, postText: { paddingBottom: 5, + fontFamily: 'Helvetica Neue', }, ctrls: { flexDirection: 'row', diff --git a/src/view/com/modals/SharePost.native.tsx b/src/view/com/modals/SharePost.native.tsx index 0e99bd4d..6fc1d1ad 100644 --- a/src/view/com/modals/SharePost.native.tsx +++ b/src/view/com/modals/SharePost.native.tsx @@ -1,27 +1,10 @@ -import React, { - forwardRef, - useState, - 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 React, {forwardRef, useState, useImperativeHandle, useRef} from 'react' +import {Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native' +import BottomSheet from '@gorhom/bottom-sheet' import Toast from '../util/Toast' import Clipboard from '@react-native-clipboard/clipboard' import {s} from '../../lib/styles' +import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' export const ShareModal = forwardRef(function ShareModal({}: {}, ref) { const [isOpen, setIsOpen] = useState(false) @@ -33,14 +16,13 @@ export const ShareModal = forwardRef(function ShareModal({}: {}, ref) { console.log('sharing', uri) setUri(uri) setIsOpen(true) + bottomSheetRef.current?.expand() }, })) const onPressCopy = () => { Clipboard.setString(uri) console.log('showing') - console.log(Toast) - console.log(Toast.show) Toast.show('Link copied', { position: Toast.positions.TOP, }) @@ -55,50 +37,25 @@ export const ShareModal = forwardRef(function ShareModal({}: {}, ref) { 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 ( - <> - {isOpen && ( - - - Share this post - {uri} -