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
This commit is contained in:
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

@ -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}) => (
<FeedItem
item={item}
onNavigateContent={onNavigateContent}
onPressShare={onPressShare}
/>
<FeedItem item={item} onPressShare={onPressShare} />
)
const onRefresh = () => {
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 {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',

View file

@ -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<boolean>(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 (
<TouchableWithoutFeedback onPress={onClose}>
<Animated.View style={containerStyle} />
</TouchableWithoutFeedback>
)
}
return (
<>
{isOpen && (
<BottomSheet
ref={bottomSheetRef}
snapPoints={['50%']}
enablePanDownToClose
backdropComponent={CustomBackdrop}
onChange={onShareBottomSheetChange}>
<View>
<Text style={[s.textCenter, s.bold, s.mb10]}>Share this post</Text>
<Text style={[s.textCenter, s.mb10]}>{uri}</Text>
<Button title="Copy to clipboard" onPress={onPressCopy} />
<View style={s.p10}>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Text style={s.textCenter}>Close</Text>
</TouchableOpacity>
</View>
</View>
</BottomSheet>
)}
</>
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={['50%']}
enablePanDownToClose
backdropComponent={isOpen ? createCustomBackdrop(onClose) : undefined}
onChange={onShareBottomSheetChange}>
<View>
<Text style={[s.textCenter, s.bold, s.mb10]}>Share this post</Text>
<Text style={[s.textCenter, s.mb10]}>{uri}</Text>
<Button title="Copy to clipboard" onPress={onPressCopy} />
<View style={s.p10}>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Text style={s.textCenter}>Close</Text>
</TouchableOpacity>
</View>
</View>
</BottomSheet>
)
})

View file

@ -1,7 +1,6 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {Text, View, FlatList} from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import {
NotificationsViewModel,
NotificationsViewItemModel,
@ -10,17 +9,15 @@ import {FeedItem} from './FeedItem'
export const Feed = observer(function Feed({
view,
onNavigateContent,
}: {
view: NotificationsViewModel
onNavigateContent: OnNavigateContent
}) {
// 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
// renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: NotificationsViewItemModel}) => (
<FeedItem item={item} onNavigateContent={onNavigateContent} />
<FeedItem item={item} />
)
const onRefresh = () => {
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 {AdxUri} from '@adxp/mock-api'
import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
import {s} from '../../lib/styles'
import {ago} from '../../lib/strings'
import {AVIS} from '../../lib/assets'
import {PostText} from '../post/PostText'
import {Post} from '../post/Post'
import {useStores} from '../../../state'
export const FeedItem = observer(function FeedItem({
item,
onNavigateContent,
}: {
item: NotificationsViewItemModel
onNavigateContent: OnNavigateContent
}) {
const store = useStores()
const onPressOuter = () => {
if (item.isLike || item.isRepost) {
const urip = new AdxUri(item.subjectUri)
onNavigateContent('PostThread', {
name: urip.host,
recordKey: urip.recordKey,
})
store.nav.navigate(`/profile/${urip.host}/post/${urip.recordKey}`)
} else if (item.isFollow) {
onNavigateContent('Profile', {
name: item.author.name,
})
store.nav.navigate(`/profile/${item.author.name}`)
} else if (item.isReply) {
const urip = new AdxUri(item.uri)
onNavigateContent('PostThread', {
name: urip.host,
recordKey: urip.recordKey,
})
store.nav.navigate(`/profile/${urip.host}/post/${urip.recordKey}`)
}
}
const onPressAuthor = () => {
onNavigateContent('Profile', {
name: item.author.name,
})
store.nav.navigate(`/profile/${item.author.name}`)
}
let action = ''
@ -92,7 +82,7 @@ export const FeedItem = observer(function FeedItem({
</View>
{item.isReply ? (
<View style={s.pt5}>
<Post uri={item.uri} onNavigateContent={onNavigateContent} />
<Post uri={item.uri} />
</View>
) : (
<></>

View file

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

View file

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

View file

@ -1,8 +1,6 @@
import React, {useState, useEffect, useRef} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, Text, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {OnNavigateContent} from '../../routes/types'
import {
PostThreadViewModel,
PostThreadViewPostModel,
@ -14,13 +12,7 @@ import {s} from '../../lib/styles'
const UPDATE_DELAY = 2e3 // wait 2s before refetching the thread for updates
export const PostThread = observer(function PostThread({
uri,
onNavigateContent,
}: {
uri: string
onNavigateContent: OnNavigateContent
}) {
export const PostThread = observer(function PostThread({uri}: {uri: string}) {
const store = useStores()
const [view, setView] = useState<PostThreadViewModel | undefined>()
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))
}, [uri, view?.params.uri, store])
useFocusEffect(() => {
if (Date.now() - lastUpdate > UPDATE_DELAY) {
view?.update()
setLastUpdate(Date.now())
}
})
// TODO
// useFocusEffect(() => {
// if (Date.now() - lastUpdate > UPDATE_DELAY) {
// view?.update()
// setLastUpdate(Date.now())
// }
// })
const onPressShare = (uri: string) => {
shareSheetRef.current?.open(uri)
@ -79,11 +72,7 @@ export const PostThread = observer(function PostThread({
// =
const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
<PostThreadItem
item={item}
onNavigateContent={onNavigateContent}
onPressShare={onPressShare}
/>
<PostThreadItem item={item} onPressShare={onPressShare} />
)
return (
<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 {bsky, AdxUri} from '@adxp/mock-api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
import {s} from '../../lib/styles'
import {ago, pluralize} from '../../lib/strings'
import {AVIS} from '../../lib/assets'
import {useStores} from '../../../state'
function iter<T>(n: number, fn: (_i: number) => T): Array<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({
item,
onNavigateContent,
onPressShare,
}: {
item: PostThreadViewPostModel
onNavigateContent: OnNavigateContent
onPressShare: (_uri: string) => void
}) {
const store = useStores()
const record = item.record as unknown as bsky.Post.Record
const hasEngagement = item.likeCount || item.repostCount
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 onPressLikes = () => {
const urip = new AdxUri(item.uri)
onNavigateContent('PostLikedBy', {
name: item.author.name,
recordKey: urip.recordKey,
})
store.nav.navigate(
`/profile/${item.author.name}/post/${urip.recordKey}/liked-by`,
)
}
const onPressReposts = () => {
const urip = new AdxUri(item.uri)
onNavigateContent('PostRepostedBy', {
name: item.author.name,
recordKey: urip.recordKey,
})
store.nav.navigate(
`/profile/${item.author.name}/post/${urip.recordKey}/reposted-by`,
)
}
const onPressReply = () => {
onNavigateContent('Composer', {
replyTo: item.uri,
})
store.nav.navigate(`/composer?replyTo=${item.uri}`)
}
const onPressToggleRepost = () => {
item
@ -227,6 +217,7 @@ const styles = StyleSheet.create({
},
postText: {
paddingBottom: 5,
fontFamily: 'Helvetica Neue',
},
expandedInfo: {
flexDirection: 'row',

View file

@ -10,20 +10,13 @@ import {
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {OnNavigateContent} from '../../routes/types'
import {PostThreadViewModel} from '../../../state/models/post-thread-view'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {ago} from '../../lib/strings'
import {AVIS} from '../../lib/assets'
export const Post = observer(function Post({
uri,
onNavigateContent,
}: {
uri: string
onNavigateContent: OnNavigateContent
}) {
export const Post = observer(function Post({uri}: {uri: string}) {
const store = useStores()
const [view, setView] = useState<PostThreadViewModel | undefined>()
@ -63,20 +56,13 @@ export const Post = observer(function Post({
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?replyTo=${item.uri}`)
}
const onPressToggleRepost = () => {
item

View file

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

View file

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

View file

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