diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-19 18:53:57 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-19 18:53:57 -0500 |
commit | 1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf (patch) | |
tree | de1b244a976e55818f1181e6bf2b727237aff7c2 /src/view/screens | |
parent | c31ffdac1b970d8d51c538f931cc64a942670740 (diff) | |
download | voidsky-1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf.tar.zst |
Add custom feeds selector, rework search, simplify onboarding (#325)
* Get home screen's swipable pager working with the drawer * Add tab bar to pager * Implement popular & following views on home screen * Visual tune-up * Move the feed selector to the footer * Fix to 'new posts' poll * Add the view header as a feed item * Use the native driver on the tabbar indicator to improve perf * Reduce home polling to the currently active page; also reuse some code * Add soft reset on tap selected in tab bar * Remove explicit 'onboarding' flow * Choose good stuff based on service * Add foaf-based follow discovery * Fall back to who to follow * Fix backgrounds * Switch to the off-spec goodstuff route * 1.8 * Fix for dev & staging * Swap the tab bar items and rename suggested to what's hot * Go to whats-hot by default if you have no follows * Implement pager and tabbar for desktop web * Pin deps to make expo happy * Add language filtering to goodstuff
Diffstat (limited to 'src/view/screens')
-rw-r--r-- | src/view/screens/Home.tsx | 144 | ||||
-rw-r--r-- | src/view/screens/Search.tsx | 93 | ||||
-rw-r--r-- | src/view/screens/home/FeedsTabBar.tsx | 72 | ||||
-rw-r--r-- | src/view/screens/home/FeedsTabBar.web.tsx | 22 |
4 files changed, 281 insertions, 50 deletions
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index adc73315c..4950bc0fd 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,26 +1,97 @@ import React from 'react' -import {FlatList, View} from 'react-native' +import {FlatList, View, useWindowDimensions} 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, HomeTabNavigatorParams} from 'lib/routes/types' +import {FeedModel} from 'state/models/feed-view' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/posts/Feed' +import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' -import {WelcomeBanner} from '../com/util/WelcomeBanner' +import {FeedsTabBar} from './home/FeedsTabBar' +import {Pager, RenderTabBarFnProps} from 'view/com/util/pager/Pager' import {FAB} from '../com/util/FAB' import {useStores} from 'state/index' import {s} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' +import {isDesktopWeb} from 'platform/detection' -const HEADER_HEIGHT = 42 +const TAB_BAR_HEIGHT = 82 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired( - observer(function Home(_opts: Props) { +export const HomeScreen = withAuthRequired((_opts: Props) => { + const store = useStores() + const [selectedPage, setSelectedPage] = React.useState(0) + + const algoFeed = React.useMemo(() => { + const feed = new FeedModel(store, 'goodstuff', {}) + feed.setup() + return feed + }, [store]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) + return () => { + store.shell.setIsDrawerSwipeDisabled(false) + } + }, [store, selectedPage]), + ) + + const onPageSelected = React.useCallback( + (index: number) => { + setSelectedPage(index) + store.shell.setIsDrawerSwipeDisabled(index > 0) + }, + [store], + ) + + const onPressSelected = React.useCallback(() => { + store.emitScreenSoftReset() + }, [store]) + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return <FeedsTabBar {...props} onPressSelected={onPressSelected} /> + }, + [onPressSelected], + ) + + const renderFollowingEmptyState = React.useCallback(() => { + return <FollowingEmptyState /> + }, []) + + const initialPage = store.me.follows.isEmpty ? 1 : 0 + return ( + <Pager + onPageSelected={onPageSelected} + renderTabBar={renderTabBar} + tabBarPosition={isDesktopWeb ? 'top' : 'bottom'} + initialPage={initialPage}> + <FeedPage + key="1" + isPageFocused={selectedPage === 0} + feed={store.me.mainFeed} + renderEmptyState={renderFollowingEmptyState} + /> + <FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} /> + </Pager> + ) +}) + +const FeedPage = observer( + ({ + isPageFocused, + feed, + renderEmptyState, + }: { + feed: FeedModel + isPageFocused: boolean + renderEmptyState?: () => JSX.Element + }) => { const store = useStores() const onMainScroll = useOnMainScroll(store) const {screen, track} = useAnalytics() @@ -28,38 +99,51 @@ export const HomeScreen = withAuthRequired( const {appState} = useAppState({ onForeground: () => doPoll(true), }) - const isFocused = useIsFocused() + const isScreenFocused = useIsFocused() + const winDim = useWindowDimensions() + const containerStyle = React.useMemo( + () => ({height: winDim.height - (isDesktopWeb ? 0 : TAB_BAR_HEIGHT)}), + [winDim], + ) const doPoll = React.useCallback( (knownActive = false) => { - if ((!knownActive && appState !== 'active') || !isFocused) { + if ( + (!knownActive && appState !== 'active') || + !isScreenFocused || + !isPageFocused + ) { return } - if (store.me.mainFeed.isLoading) { + if (feed.isLoading) { return } store.log.debug('HomeScreen: Polling for new posts') - store.me.mainFeed.checkForLatest() + feed.checkForLatest() }, - [appState, isFocused, store], + [appState, isScreenFocused, isPageFocused, store, feed], ) const scrollToTop = React.useCallback(() => { - // NOTE: the feed is offset by the height of the collapsing header, - // so we scroll to the negative of that height -prf - scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT}) + scrollElRef.current?.scrollToOffset({offset: 0}) }, [scrollElRef]) + const onSoftReset = React.useCallback(() => { + if (isPageFocused) { + scrollToTop() + } + }, [isPageFocused, scrollToTop]) + useFocusEffect( React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(scrollToTop) - const feedCleanup = store.me.mainFeed.registerListeners() + const softResetSub = store.onScreenSoftReset(onSoftReset) + const feedCleanup = feed.registerListeners() const pollInterval = setInterval(doPoll, 15e3) screen('Feed') store.log.debug('HomeScreen: Updating feed') - if (store.me.mainFeed.hasContent) { - store.me.mainFeed.update() + if (feed.hasContent) { + feed.update() } return () => { @@ -67,7 +151,7 @@ export const HomeScreen = withAuthRequired( softResetSub.remove() feedCleanup() } - }, [store, doPoll, scrollToTop, screen]), + }, [store, doPoll, onSoftReset, screen, feed]), ) const onPressCompose = React.useCallback(() => { @@ -76,32 +160,28 @@ export const HomeScreen = withAuthRequired( }, [store, track]) const onPressTryAgain = React.useCallback(() => { - store.me.mainFeed.refresh() - }, [store]) + feed.refresh() + }, [feed]) const onPressLoadLatest = React.useCallback(() => { - store.me.mainFeed.refresh() + feed.refresh() scrollToTop() - }, [store, scrollToTop]) + }, [feed, scrollToTop]) return ( - <View style={s.hContentRegion}> - {store.shell.isOnboarding && <WelcomeBanner />} + <View style={containerStyle}> <Feed testID="homeFeed" key="default" - feed={store.me.mainFeed} + feed={feed} scrollElRef={scrollElRef} style={s.hContentRegion} showPostFollowBtn onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} - headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT} + renderEmptyState={renderEmptyState} /> - {!store.shell.isOnboarding && ( - <ViewHeader title="Bluesky" canGoBack={false} hideOnScroll /> - )} - {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && ( + {feed.hasNewLatest && !feed.isRefreshing && ( <LoadLatestBtn onPress={onPressLoadLatest} /> )} <FAB @@ -111,5 +191,5 @@ export const HomeScreen = withAuthRequired( /> </View> ) - }), + }, ) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 19535a164..6ae5fba0d 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Keyboard, + RefreshControl, StyleSheet, TextInput, TouchableOpacity, @@ -13,21 +14,23 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from '../com/util/Views' +import {ScrollView} from 'view/com/util/Views' import { NativeStackScreenProps, SearchTabNavigatorParams, } from 'lib/routes/types' import {observer} from 'mobx-react-lite' -import {UserAvatar} from '../com/util/UserAvatar' -import {Text} from '../com/util/text/Text' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {FoafsModel} from 'state/models/discovery/foafs' import {s} from 'lib/styles' import {MagnifyingGlassIcon} from 'lib/icons' -import {WhoToFollow} from '../com/discover/WhoToFollow' -import {SuggestedPosts} from '../com/discover/SuggestedPosts' -import {ProfileCard} from '../com/profile/ProfileCard' +import {WhoToFollow} from 'view/com/discover/WhoToFollow' +import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' +import {ProfileCard} from 'view/com/profile/ProfileCard' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' @@ -53,6 +56,11 @@ export const SearchScreen = withAuthRequired( () => new UserAutocompleteViewModel(store), [store], ) + const foafsView = React.useMemo<FoafsModel>( + () => new FoafsModel(store), + [store], + ) + const [refreshing, setRefreshing] = React.useState(false) const onSoftReset = () => { scrollElRef.current?.scrollTo({x: 0, y: 0}) @@ -71,9 +79,12 @@ export const SearchScreen = withAuthRequired( } store.shell.setMinimalShellMode(false) autocompleteView.setup() + if (!foafsView.hasData) { + foafsView.fetch() + } return cleanup - }, [store, autocompleteView, lastRenderTime, setRenderTime]), + }, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]), ) const onPressMenu = () => { @@ -98,15 +109,18 @@ export const SearchScreen = withAuthRequired( autocompleteView.setActive(false) textInput.current?.blur() } + const onRefresh = React.useCallback(async () => { + setRefreshing(true) + try { + await foafsView.fetch() + } finally { + setRefreshing(false) + } + }, [foafsView, setRefreshing]) return ( <TouchableWithoutFeedback onPress={Keyboard.dismiss}> - <ScrollView - ref={scrollElRef} - testID="searchScrollView" - style={[pal.view, styles.container]} - onScroll={onMainScroll} - scrollEventThrottle={100}> + <View style={[pal.view, styles.container]}> <View style={[pal.view, pal.border, styles.header]}> <TouchableOpacity testID="viewHeaderBackOrMenuBtn" @@ -180,14 +194,53 @@ export const SearchScreen = withAuthRequired( </Text> </View> ) : ( - <ScrollView onScroll={Keyboard.dismiss}> - <WhoToFollow key={`wtf-${lastRenderTime}`} /> - <SuggestedPosts key={`sp-${lastRenderTime}`} /> + <ScrollView + ref={scrollElRef} + testID="searchScrollView" + style={pal.view} + onScroll={onMainScroll} + scrollEventThrottle={100} + refreshControl={ + <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> + }> + {foafsView.isLoading ? ( + <ProfileCardFeedLoadingPlaceholder /> + ) : foafsView.hasContent ? ( + <> + {foafsView.popular.length > 0 && ( + <View style={styles.suggestions}> + <SuggestedFollows + title="In your network" + suggestions={foafsView.popular} + /> + </View> + )} + {foafsView.sources.map((source, i) => { + const item = foafsView.foafs.get(source) + if (!item || item.follows.length === 0) { + return <View key={`sf-${item?.did || i}`} /> + } + return ( + <View key={`sf-${item.did}`} style={styles.suggestions}> + <SuggestedFollows + title={`Followed by ${ + item.displayName || item.handle + }`} + suggestions={item.follows.slice(0, 10)} + /> + </View> + ) + })} + </> + ) : ( + <View style={pal.view}> + <WhoToFollow /> + </View> + )} <View style={s.footerSpacer} /> </ScrollView> )} - <View style={s.footerSpacer} /> - </ScrollView> + </View> </TouchableWithoutFeedback> ) }), @@ -235,4 +288,8 @@ const styles = StyleSheet.create({ textAlign: 'center', paddingTop: 10, }, + + suggestions: { + marginBottom: 8, + }, }) diff --git a/src/view/screens/home/FeedsTabBar.tsx b/src/view/screens/home/FeedsTabBar.tsx new file mode 100644 index 000000000..d34034103 --- /dev/null +++ b/src/view/screens/home/FeedsTabBar.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {Animated, StyleSheet} from 'react-native' +import {observer} from 'mobx-react-lite' +import {TabBar} from 'view/com/util/TabBar' +import {RenderTabBarFnProps} from 'view/com/util/pager/Pager' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {clamp} from 'lodash' + +const BOTTOM_BAR_HEIGHT = 48 + +export const FeedsTabBar = observer( + (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + const store = useStores() + const safeAreaInsets = useSafeAreaInsets() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + const pad = React.useMemo( + () => ({ + paddingBottom: clamp(safeAreaInsets.bottom, 15, 20), + }), + [safeAreaInsets], + ) + + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 0 : 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [ + {translateY: Animated.multiply(interp, -1 * BOTTOM_BAR_HEIGHT)}, + ], + } + + return ( + <Animated.View + style={[pal.view, pal.border, styles.tabBar, pad, transform]}> + <TabBar + {...props} + items={['Following', "What's hot"]} + indicatorPosition="top" + indicatorColor={pal.colors.link} + /> + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + borderTopWidth: 1, + paddingTop: 0, + paddingBottom: 30, + }, + tabBarAvi: { + marginRight: 4, + }, +}) diff --git a/src/view/screens/home/FeedsTabBar.web.tsx b/src/view/screens/home/FeedsTabBar.web.tsx new file mode 100644 index 000000000..59ea42988 --- /dev/null +++ b/src/view/screens/home/FeedsTabBar.web.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {TabBar} from 'view/com/util/TabBar' +import {CenteredView} from 'view/com/util/Views' +import {RenderTabBarFnProps} from 'view/com/util/pager/Pager' +import {usePalette} from 'lib/hooks/usePalette' + +export const FeedsTabBar = observer( + (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + const pal = usePalette('default') + return ( + <CenteredView> + <TabBar + {...props} + items={['Following', "What's hot"]} + indicatorPosition="bottom" + indicatorColor={pal.colors.link} + /> + </CenteredView> + ) + }, +) |