diff options
Diffstat (limited to 'src')
25 files changed, 705 insertions, 314 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index c10a9c249..648859f16 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -14,7 +14,7 @@ import { FlatNavigatorParams, AllNavigatorParams, } from 'lib/routes/types' -import {BottomBar} from './view/shell/BottomBar' +import {BottomBar} from './view/shell/bottom-bar/BottomBar' import {buildStateObject} from 'lib/routes/helpers' import {State, RouteParams} from 'lib/routes/types' import {colors} from 'lib/styles' diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx new file mode 100644 index 000000000..e28a0e884 --- /dev/null +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import {useStores} from 'state/index' +import {Animated} from 'react-native' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' + +export function useMinimalShellMode() { + const store = useStores() + const minimalShellInterp = useAnimatedValue(0) + const footerMinimalShellTransform = { + transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}], + } + + React.useEffect(() => { + if (store.shell.minimalShellMode) { + Animated.timing(minimalShellInterp, { + toValue: 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } else { + Animated.timing(minimalShellInterp, { + toValue: 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } + }, [minimalShellInterp, store.shell.minimalShellMode]) + + return {footerMinimalShellTransform} +} diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts new file mode 100644 index 000000000..8afc799eb --- /dev/null +++ b/src/lib/hooks/useNavigationTabState.ts @@ -0,0 +1,13 @@ +import {useNavigationState} from '@react-navigation/native' +import {getTabState, TabState} from 'lib/routes/helpers' + +export function useNavigationTabState() { + return useNavigationState(state => { + return { + isAtHome: getTabState(state, 'Home') !== TabState.Outside, + isAtSearch: getTabState(state, 'Search') !== TabState.Outside, + isAtNotifications: + getTabState(state, 'Notifications') !== TabState.Outside, + } + }) +} diff --git a/src/lib/hooks/useNavigationTabState.web.ts b/src/lib/hooks/useNavigationTabState.web.ts new file mode 100644 index 000000000..d0173aa0f --- /dev/null +++ b/src/lib/hooks/useNavigationTabState.web.ts @@ -0,0 +1,13 @@ +import {useNavigationState} from '@react-navigation/native' +import {getCurrentRoute} from 'lib/routes/helpers' + +export function useNavigationTabState() { + return useNavigationState(state => { + let currentRoute = state ? getCurrentRoute(state).name : 'Home' + return { + isAtHome: currentRoute === 'Home', + isAtSearch: currentRoute === 'Search', + isAtNotifications: currentRoute === 'Notifications', + } + }) +} diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx new file mode 100644 index 000000000..441585442 --- /dev/null +++ b/src/lib/hooks/useWebMediaQueries.tsx @@ -0,0 +1,8 @@ +import {useMediaQuery} from 'react-responsive' + +export function useWebMediaQueries() { + const isDesktop = useMediaQuery({ + query: '(min-width: 1230px)', + }) + return {isDesktop} +} diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index a20f2d49e..7fac5a8c0 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -7,8 +7,7 @@ import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {CenteredView} from '../util/Views' -import {isDesktopWeb, isMobileWeb} from 'platform/detection' -import {HelpTip} from './util/HelpTip' +import {isMobileWeb} from 'platform/detection' export const SplashScreen = ({ onPressSignin, @@ -40,24 +39,22 @@ export const SplashScreen = ({ <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> See what's next </Text> - {isDesktopWeb && ( - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount}> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin}> - <Text style={[pal.text, styles.btnLabel]}>Sign in</Text> - </TouchableOpacity> - </View> - )} + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount}> + <Text style={[s.white, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin}> + <Text style={[pal.text, styles.btnLabel]}>Sign in</Text> + </TouchableOpacity> + </View> <Text type="xl" style={[styles.notice, pal.textLight]} @@ -70,13 +67,6 @@ export const SplashScreen = ({ </TouchableOpacity>{' '} to try the beta before it's publicly available. </Text> - {isMobileWeb && ( - <> - <View style={[s.p20, s.mt10]}> - <HelpTip text="Beta testers: the mobile web app isn't quite ready yet. Log in on desktop web or using the iPhone app." /> - </View> - </> - )} </ErrorBoundary> </View> <Footer /> @@ -148,7 +138,8 @@ const styles = StyleSheet.create({ paddingBottom: 30, }, btns: { - flexDirection: 'row', + flexDirection: isMobileWeb ? 'column' : 'row', + gap: 20, justifyContent: 'center', paddingBottom: 40, }, @@ -156,7 +147,6 @@ const styles = StyleSheet.create({ borderRadius: 30, paddingHorizontal: 24, paddingVertical: 12, - marginHorizontal: 10, minWidth: 220, }, btnLabel: { diff --git a/src/view/com/pager/FeedsTabBar.tsx b/src/view/com/pager/FeedsTabBar.tsx index 76e0a6fc6..aa0ba7b24 100644 --- a/src/view/com/pager/FeedsTabBar.tsx +++ b/src/view/com/pager/FeedsTabBar.tsx @@ -1,69 +1 @@ -import React from 'react' -import {Animated, StyleSheet, TouchableOpacity} from 'react-native' -import {observer} from 'mobx-react-lite' -import {TabBar} from 'view/com/pager/TabBar' -import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' - -export const FeedsTabBar = observer( - ( - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, - ) => { - const store = useStores() - const pal = usePalette('default') - const interp = useAnimatedValue(0) - - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, -100)}], - } - - const onPressAvi = React.useCallback(() => { - store.shell.openDrawer() - }, [store]) - - return ( - <Animated.View style={[pal.view, styles.tabBar, transform]}> - <TouchableOpacity - testID="viewHeaderDrawerBtn" - style={styles.tabBarAvi} - onPress={onPressAvi}> - <UserAvatar avatar={store.me.avatar} size={30} /> - </TouchableOpacity> - <TabBar - {...props} - items={['Following', "What's hot"]} - indicatorPosition="bottom" - indicatorColor={pal.colors.link} - /> - </Animated.View> - ) - }, -) - -const styles = StyleSheet.create({ - tabBar: { - position: 'absolute', - zIndex: 1, - left: 0, - right: 0, - top: 0, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 18, - }, - tabBarAvi: { - marginTop: 1, - marginRight: 18, - }, -}) +export * from './FeedsTabBarMobile' diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index fc5932883..5cee2fd6d 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -4,10 +4,18 @@ import {TabBar} from 'view/com/pager/TabBar' import {CenteredView} from 'view/com/util/Views' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' export const FeedsTabBar = observer( (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + + if (!isDesktop) { + return <FeedsTabBarMobile {...props} /> + } + return ( <CenteredView> <TabBar diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx new file mode 100644 index 000000000..76e0a6fc6 --- /dev/null +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {Animated, StyleSheet, TouchableOpacity} from 'react-native' +import {observer} from 'mobx-react-lite' +import {TabBar} from 'view/com/pager/TabBar' +import {RenderTabBarFnProps} from 'view/com/pager/Pager' +import {UserAvatar} from '../util/UserAvatar' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' + +export const FeedsTabBar = observer( + ( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, + ) => { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } + + const onPressAvi = React.useCallback(() => { + store.shell.openDrawer() + }, [store]) + + return ( + <Animated.View style={[pal.view, styles.tabBar, transform]}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + style={styles.tabBarAvi} + onPress={onPressAvi}> + <UserAvatar avatar={store.me.avatar} size={30} /> + </TouchableOpacity> + <TabBar + {...props} + items={['Following', "What's hot"]} + indicatorPosition="bottom" + indicatorColor={pal.colors.link} + /> + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + zIndex: 1, + left: 0, + right: 0, + top: 0, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + }, + tabBarAvi: { + marginTop: 1, + marginRight: 18, + }, +}) diff --git a/src/view/com/util/FAB.tsx b/src/view/com/util/FAB.tsx deleted file mode 100644 index 3427d368e..000000000 --- a/src/view/com/util/FAB.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' -import {observer} from 'mobx-react-lite' -import { - Animated, - GestureResponderEvent, - StyleSheet, - TouchableWithoutFeedback, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import {gradients} from 'lib/styles' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {useStores} from 'state/index' - -type OnPress = ((event: GestureResponderEvent) => void) | undefined -export const FAB = observer( - ({ - testID, - icon, - onPress, - }: { - testID?: string - icon: JSX.Element - onPress: OnPress - }) => { - const store = useStores() - const interp = useAnimatedValue(0) - React.useEffect(() => { - Animated.timing(interp, { - toValue: store.shell.minimalShellMode ? 1 : 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - }, [interp, store.shell.minimalShellMode]) - const transform = { - transform: [{translateY: Animated.multiply(interp, 60)}], - } - return ( - <TouchableWithoutFeedback testID={testID} onPress={onPress}> - <Animated.View style={[styles.outer, transform]}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={styles.inner}> - {icon} - </LinearGradient> - </Animated.View> - </TouchableWithoutFeedback> - ) - }, -) - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - zIndex: 1, - right: 28, - bottom: 94, - width: 60, - height: 60, - borderRadius: 30, - }, - inner: { - width: 60, - height: 60, - borderRadius: 30, - justifyContent: 'center', - alignItems: 'center', - }, -}) diff --git a/src/view/com/util/FAB.web.tsx b/src/view/com/util/FAB.web.tsx deleted file mode 100644 index dcffef29e..000000000 --- a/src/view/com/util/FAB.web.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import {GestureResponderEvent, View} from 'react-native' -import {IconProp} from '@fortawesome/fontawesome-svg-core' - -type OnPress = ((event: GestureResponderEvent) => void) | undefined -export const FAB = (_opts: {icon: IconProp; onPress: OnPress}) => { - return <View /> -} diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index bce178b4c..cfde68536 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -55,9 +55,10 @@ export function show(text: string) { const styles = StyleSheet.create({ container: { position: 'absolute', - right: 20, + left: 20, bottom: 20, - width: 350, + width: 'calc(100% - 40px)', + maxWidth: 350, padding: 20, flexDirection: 'row', alignItems: 'center', diff --git a/src/view/com/util/fab/FAB.tsx b/src/view/com/util/fab/FAB.tsx new file mode 100644 index 000000000..b222fe45c --- /dev/null +++ b/src/view/com/util/fab/FAB.tsx @@ -0,0 +1 @@ +export {FABInner as FAB} from './FABInner' diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx new file mode 100644 index 000000000..0a8831fa9 --- /dev/null +++ b/src/view/com/util/fab/FAB.web.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import {View} from 'react-native' +import {FABInner, FABProps} from './FABInner' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' + +export const FAB = (_opts: FABProps) => { + const {isDesktop} = useWebMediaQueries() + + if (!isDesktop) { + return <FABInner {..._opts} /> + } + + return <View /> +} diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx new file mode 100644 index 000000000..3d44c0dd4 --- /dev/null +++ b/src/view/com/util/fab/FABInner.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import { + Animated, + GestureResponderEvent, + StyleSheet, + TouchableWithoutFeedback, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {gradients} from 'lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' +import {isMobileWeb} from 'platform/detection' + +type OnPress = ((event: GestureResponderEvent) => void) | undefined +export interface FABProps { + testID?: string + icon: JSX.Element + onPress: OnPress +} + +export const FABInner = observer(({testID, icon, onPress}: FABProps) => { + const store = useStores() + const interp = useAnimatedValue(0) + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, 60)}], + } + return ( + <TouchableWithoutFeedback testID={testID} onPress={onPress}> + <Animated.View + style={[styles.outer, isMobileWeb && styles.mobileWebOuter, transform]}> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={styles.inner}> + {icon} + </LinearGradient> + </Animated.View> + </TouchableWithoutFeedback> + ) +}) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + zIndex: 1, + right: 28, + bottom: 94, + width: 60, + height: 60, + borderRadius: 30, + }, + mobileWebOuter: { + bottom: 114, + }, + inner: { + width: 60, + height: 60, + borderRadius: 30, + justifyContent: 'center', + alignItems: 'center', + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index fac522c68..23d1e2b9d 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -11,14 +11,15 @@ import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {FAB} from '../com/util/FAB' +import {FAB} from '../com/util/fab/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, isMobileWeb} from 'platform/detection' -const HEADER_OFFSET = 40 +const HEADER_OFFSET = isDesktopWeb ? 0 : isMobileWeb ? 20 : 40 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired((_opts: Props) => { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index e3158a973..dfee6f12a 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -16,7 +16,7 @@ import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' import {Text} from '../com/util/text/Text' -import {FAB} from '../com/util/FAB' +import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index d12cbc1be..92df1d920 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -15,12 +15,15 @@ import { import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import * as Mobile from './SearchMobile' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> export const SearchScreen = withAuthRequired( - observer(({route}: Props) => { + observer(({navigation, route}: Props) => { const pal = usePalette('default') const store = useStores() + const params = route.params || {} const foafs = React.useMemo<FoafsModel>( () => new FoafsModel(store), [store], @@ -30,13 +33,13 @@ export const SearchScreen = withAuthRequired( [store], ) const searchUIModel = React.useMemo<SearchUIModel | undefined>( - () => (route.params.q ? new SearchUIModel(store) : undefined), - [route.params.q, store], + () => (params.q ? new SearchUIModel(store) : undefined), + [params.q, store], ) React.useEffect(() => { - if (route.params.q && searchUIModel) { - searchUIModel.fetch(route.params.q) + if (params.q && searchUIModel) { + searchUIModel.fetch(params.q) } if (!foafs.hasData) { foafs.fetch() @@ -44,12 +47,18 @@ export const SearchScreen = withAuthRequired( if (!suggestedActors.hasLoaded) { suggestedActors.loadMore(true) } - }, [foafs, suggestedActors, searchUIModel, route.params.q]) + }, [foafs, suggestedActors, searchUIModel, params.q]) if (searchUIModel) { return <SearchResults model={searchUIModel} /> } + const {isDesktop} = useWebMediaQueries() + + if (!isDesktop) { + return <Mobile.SearchScreen navigation={navigation} route={route} /> + } + return ( <ScrollView testID="searchScrollView" diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx new file mode 100644 index 000000000..e1fb3ec0a --- /dev/null +++ b/src/view/screens/SearchMobile.tsx @@ -0,0 +1,195 @@ +import React from 'react' +import { + Keyboard, + RefreshControl, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ScrollView} from 'view/com/util/Views' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' +import {observer} from 'mobx-react-lite' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {SearchUIModel} from 'state/models/ui/search' +import {FoafsModel} from 'state/models/discovery/foafs' +import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' +import {HeaderWithInput} from 'view/com/search/HeaderWithInput' +import {Suggestions} from 'view/com/search/Suggestions' +import {SearchResults} from 'view/com/search/SearchResults' +import {s} from 'lib/styles' +import {ProfileCard} from 'view/com/profile/ProfileCard' +import {usePalette} from 'lib/hooks/usePalette' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' + +type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> +export const SearchScreen = withAuthRequired( + observer<Props>(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const scrollElRef = React.useRef<ScrollView>(null) + const onMainScroll = useOnMainScroll(store) + const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>('') + const autocompleteView = React.useMemo<UserAutocompleteModel>( + () => new UserAutocompleteModel(store), + [store], + ) + const foafs = React.useMemo<FoafsModel>( + () => new FoafsModel(store), + [store], + ) + const suggestedActors = React.useMemo<SuggestedActorsModel>( + () => new SuggestedActorsModel(store), + [store], + ) + const [searchUIModel, setSearchUIModel] = React.useState< + SearchUIModel | undefined + >() + const [refreshing, setRefreshing] = React.useState(false) + + const onSoftReset = () => { + scrollElRef.current?.scrollTo({x: 0, y: 0}) + } + + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } + + store.shell.setMinimalShellMode(false) + autocompleteView.setup() + if (!foafs.hasData) { + foafs.fetch() + } + if (!suggestedActors.hasLoaded) { + suggestedActors.loadMore(true) + } + + return cleanup + }, [store, autocompleteView, foafs, suggestedActors]), + ) + + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 0) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(text) + } else { + autocompleteView.setActive(false) + } + }, + [setQuery, autocompleteView], + ) + + const onPressClearQuery = React.useCallback(() => { + setQuery('') + }, [setQuery]) + + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + autocompleteView.setActive(false) + setSearchUIModel(undefined) + store.shell.setIsDrawerSwipeDisabled(false) + }, [setQuery, autocompleteView, store]) + + const onSubmitQuery = React.useCallback(() => { + const model = new SearchUIModel(store) + model.fetch(query) + setSearchUIModel(model) + store.shell.setIsDrawerSwipeDisabled(true) + }, [query, setSearchUIModel, store]) + + const onRefresh = React.useCallback(async () => { + setRefreshing(true) + try { + await foafs.fetch() + } finally { + setRefreshing(false) + } + }, [foafs, setRefreshing]) + + return ( + <TouchableWithoutFeedback onPress={Keyboard.dismiss}> + <View style={[pal.view, styles.container]}> + <HeaderWithInput + isInputFocused={isInputFocused} + query={query} + setIsInputFocused={setIsInputFocused} + onChangeQuery={onChangeQuery} + onPressClearQuery={onPressClearQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + {searchUIModel ? ( + <SearchResults model={searchUIModel} /> + ) : ( + <ScrollView + ref={scrollElRef} + testID="searchScrollView" + style={pal.view} + onScroll={onMainScroll} + scrollEventThrottle={100} + refreshControl={ + <RefreshControl + refreshing={refreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + }> + {query && autocompleteView.searchRes.length ? ( + <> + {autocompleteView.searchRes.map(item => ( + <ProfileCard + key={item.did} + testID={`searchAutoCompleteResult-${item.handle}`} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + /> + ))} + </> + ) : query && !autocompleteView.searchRes.length ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + No results found for {autocompleteView.prefix} + </Text> + </View> + ) : isInputFocused ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + Search for users on the network + </Text> + </View> + ) : ( + <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> + )} + <View style={s.footerSpacer} /> + </ScrollView> + )} + </View> + </TouchableWithoutFeedback> + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + + searchPrompt: { + textAlign: 'center', + paddingTop: 10, + }, +}) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index e3994bb23..ed0450c01 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -4,6 +4,7 @@ import {StyleSheet, View} from 'react-native' import {ComposePost} from '../com/composer/Composer' import {ComposerOpts} from 'state/models/ui/shell' import {usePalette} from 'lib/hooks/usePalette' +import {isMobileWeb} from 'platform/detection' export const Composer = observer( ({ @@ -60,7 +61,7 @@ const styles = StyleSheet.create({ width: '100%', paddingVertical: 0, paddingHorizontal: 2, - borderRadius: 8, + borderRadius: isMobileWeb ? 0 : 8, marginBottom: '10vh', }, }) diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index ebadb2126..de36463e1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -8,11 +8,7 @@ import { View, ViewStyle, } from 'react-native' -import { - useNavigation, - useNavigationState, - StackActions, -} from '@react-navigation/native' +import {useNavigation, StackActions} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, @@ -40,6 +36,8 @@ import {useAnalytics} from 'lib/analytics' import {pluralize} from 'lib/strings/helpers' import {getTabState, TabState} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' +import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' +import {isWeb} from 'platform/detection' export const DrawerContent = observer(() => { const theme = useTheme() @@ -47,16 +45,7 @@ export const DrawerContent = observer(() => { const store = useStores() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState( - state => { - return { - isAtHome: getTabState(state, 'Home') !== TabState.Outside, - isAtSearch: getTabState(state, 'Search') !== TabState.Outside, - isAtNotifications: - getTabState(state, 'Notifications') !== TabState.Outside, - } - }, - ) + const {isAtHome, isAtSearch, isAtNotifications} = useNavigationTabState() // events // = @@ -66,14 +55,19 @@ export const DrawerContent = observer(() => { track('Menu:ItemClicked', {url: tab}) const state = navigation.getState() store.shell.closeDrawer() - const tabState = getTabState(state, tab) - if (tabState === TabState.InsideAtRoot) { - store.emitScreenSoftReset() - } else if (tabState === TabState.Inside) { - navigation.dispatch(StackActions.popToTop()) - } else { + if (isWeb) { // @ts-ignore must be Home, Search, or Notifications - navigation.navigate(`${tab}Tab`) + navigation.navigate(tab) + } else { + const tabState = getTabState(state, tab) + if (tabState === TabState.InsideAtRoot) { + store.emitScreenSoftReset() + } else if (tabState === TabState.Inside) { + navigation.dispatch(StackActions.popToTop()) + } else { + // @ts-ignore must be Home, Search, or Notifications + navigation.navigate(`${tab}Tab`) + } } }, [store, track, navigation], @@ -240,20 +234,22 @@ export const DrawerContent = observer(() => { </View> <View style={s.flex1} /> <View style={styles.footer}> - <TouchableOpacity - onPress={onDarkmodePress} - style={[ - styles.footerBtn, - theme.colorScheme === 'light' - ? pal.btn - : styles.footerBtnDarkMode, - ]}> - <MoonIcon - size={22} - style={pal.text as StyleProp<ViewStyle>} - strokeWidth={2} - /> - </TouchableOpacity> + {!isWeb && ( + <TouchableOpacity + onPress={onDarkmodePress} + style={[ + styles.footerBtn, + theme.colorScheme === 'light' + ? pal.btn + : styles.footerBtnDarkMode, + ]}> + <MoonIcon + size={22} + style={pal.text as StyleProp<ViewStyle>} + strokeWidth={2} + /> + </TouchableOpacity> + )} <TouchableOpacity onPress={onPressFeedback} style={[ diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index b01366b2b..59b21968d 100644 --- a/src/view/shell/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -2,7 +2,6 @@ import React from 'react' import { Animated, GestureResponderEvent, - StyleSheet, TouchableOpacity, View, } from 'react-native' @@ -13,7 +12,6 @@ import {observer} from 'mobx-react-lite' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {clamp} from 'lib/numbers' import { HomeIcon, @@ -24,14 +22,14 @@ import { BellIconSolid, UserIcon, } from 'lib/icons' -import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {getTabState, TabState} from 'lib/routes/helpers' +import {styles} from './BottomBarStyles' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' export const BottomBar = observer(({navigation}: BottomTabBarProps) => { const store = useStores() const pal = usePalette('default') - const minimalShellInterp = useAnimatedValue(0) const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState( @@ -52,26 +50,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { }, ) - React.useEffect(() => { - if (store.shell.minimalShellMode) { - Animated.timing(minimalShellInterp, { - toValue: 1, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } else { - Animated.timing(minimalShellInterp, { - toValue: 0, - duration: 100, - useNativeDriver: true, - isInteraction: false, - }).start() - } - }, [minimalShellInterp, store.shell.minimalShellMode]) - const footerMinimalShellTransform = { - transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}], - } + const {footerMinimalShellTransform} = useMinimalShellMode() const onPressTab = React.useCallback( (tab: string) => { @@ -217,62 +196,3 @@ function Btn({ </TouchableOpacity> ) } - -const styles = StyleSheet.create({ - bottomBar: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - flexDirection: 'row', - borderTopWidth: 1, - paddingLeft: 5, - paddingRight: 10, - }, - ctrl: { - flex: 1, - paddingTop: 13, - paddingBottom: 4, - }, - notificationCount: { - position: 'absolute', - left: '52%', - top: 8, - backgroundColor: colors.blue3, - paddingHorizontal: 4, - paddingBottom: 1, - borderRadius: 6, - zIndex: 1, - }, - notificationCountLight: { - borderColor: colors.white, - }, - notificationCountDark: { - borderColor: colors.gray8, - }, - notificationCountLabel: { - fontSize: 12, - fontWeight: 'bold', - color: colors.white, - fontVariant: ['tabular-nums'], - }, - ctrlIcon: { - marginLeft: 'auto', - marginRight: 'auto', - }, - ctrlIconSizingWrapper: { - height: 27, - }, - homeIcon: { - top: 0, - }, - searchIcon: { - top: -2, - }, - bellIcon: { - top: -2.5, - }, - profileIcon: { - top: -4, - }, -}) diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx new file mode 100644 index 000000000..3d5adbc9e --- /dev/null +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -0,0 +1,61 @@ +import {StyleSheet} from 'react-native' +import {colors} from 'lib/styles' + +export const styles = StyleSheet.create({ + bottomBar: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + flexDirection: 'row', + borderTopWidth: 1, + paddingLeft: 5, + paddingRight: 10, + }, + ctrl: { + flex: 1, + paddingTop: 13, + paddingBottom: 4, + }, + notificationCount: { + position: 'absolute', + left: '52%', + top: 8, + backgroundColor: colors.blue3, + paddingHorizontal: 4, + paddingBottom: 1, + borderRadius: 6, + zIndex: 1, + }, + notificationCountLight: { + borderColor: colors.white, + }, + notificationCountDark: { + borderColor: colors.gray8, + }, + notificationCountLabel: { + fontSize: 12, + fontWeight: 'bold', + color: colors.white, + fontVariant: ['tabular-nums'], + }, + ctrlIcon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + ctrlIconSizingWrapper: { + height: 27, + }, + homeIcon: { + top: 0, + }, + searchIcon: { + top: -2, + }, + bellIcon: { + top: -2.5, + }, + profileIcon: { + top: -4, + }, +}) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx new file mode 100644 index 000000000..b7daac5af --- /dev/null +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {Animated} from 'react-native' +import {useNavigationState} from '@react-navigation/native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {getCurrentRoute, isTab} from 'lib/routes/helpers' +import {styles} from './BottomBarStyles' +import {clamp} from 'lib/numbers' +import { + BellIcon, + BellIconSolid, + HomeIcon, + HomeIconSolid, + MagnifyingGlassIcon2, + MagnifyingGlassIcon2Solid, + UserIcon, +} from 'lib/icons' +import {Link} from 'view/com/util/Link' +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' + +export const BottomBarWeb = observer(() => { + const store = useStores() + const pal = usePalette('default') + const safeAreaInsets = useSafeAreaInsets() + const {footerMinimalShellTransform} = useMinimalShellMode() + + return ( + <Animated.View + style={[ + styles.bottomBar, + pal.view, + pal.border, + {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, + footerMinimalShellTransform, + ]}> + <NavItem routeName="Home" href="/"> + {({isActive}) => { + const Icon = isActive ? HomeIconSolid : HomeIcon + return ( + <Icon + strokeWidth={4} + size={24} + style={[styles.ctrlIcon, pal.text, styles.homeIcon]} + /> + ) + }} + </NavItem> + <NavItem routeName="Search" href="/search"> + {({isActive}) => { + const Icon = isActive + ? MagnifyingGlassIcon2Solid + : MagnifyingGlassIcon2 + return ( + <Icon + size={25} + style={[styles.ctrlIcon, pal.text, styles.searchIcon]} + strokeWidth={1.8} + /> + ) + }} + </NavItem> + <NavItem routeName="Notifications" href="/notifications"> + {({isActive}) => { + const Icon = isActive ? BellIconSolid : BellIcon + return ( + <Icon + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} + /> + ) + }} + </NavItem> + <NavItem routeName="Profile" href={`/profile/${store.me.handle}`}> + {() => ( + <UserIcon + size={28} + strokeWidth={1.5} + style={[styles.ctrlIcon, pal.text, styles.profileIcon]} + /> + )} + </NavItem> + </Animated.View> + ) +}) + +const NavItem: React.FC<{ + children: (props: {isActive: boolean}) => React.ReactChild + href: string + routeName: string +}> = ({children, href, routeName}) => { + const currentRoute = useNavigationState(getCurrentRoute) + const isActive = isTab(currentRoute.name, routeName) + return ( + <Link href={href} style={styles.ctrl}> + {children({isActive})} + </Link> + ) +} diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 96a120642..86d120127 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,6 +1,6 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import {View, StyleSheet} from 'react-native' +import {View, StyleSheet, TouchableOpacity} from 'react-native' import {useStores} from 'state/index' import {DesktopLeftNav} from './desktop/LeftNav' import {DesktopRightNav} from './desktop/RightNav' @@ -11,9 +11,13 @@ import {Composer} from './Composer.web' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {s, colors} from 'lib/styles' import {RoutesContainer, FlatNavigator} from '../../Navigation' +import {DrawerContent} from './Drawer' +import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' +import {BottomBarWeb} from './bottom-bar/BottomBarWeb' const ShellInner = observer(() => { const store = useStores() + const {isDesktop} = useWebMediaQueries() return ( <> @@ -22,10 +26,14 @@ const ShellInner = observer(() => { <FlatNavigator /> </ErrorBoundary> </View> - <DesktopLeftNav /> - <DesktopRightNav /> - <View style={[styles.viewBorder, styles.viewBorderLeft]} /> - <View style={[styles.viewBorder, styles.viewBorderRight]} /> + {isDesktop && ( + <> + <DesktopLeftNav /> + <DesktopRightNav /> + <View style={[styles.viewBorder, styles.viewBorderLeft]} /> + <View style={[styles.viewBorder, styles.viewBorderRight]} /> + </> + )} <Composer active={store.shell.isComposerActive} onClose={() => store.shell.closeComposer()} @@ -34,8 +42,18 @@ const ShellInner = observer(() => { quote={store.shell.composerOpts?.quote} onPost={store.shell.composerOpts?.onPost} /> + {!isDesktop && <BottomBarWeb />} <ModalsContainer /> <Lightbox /> + {!isDesktop && store.shell.isDrawerOpen && ( + <TouchableOpacity + onPress={() => store.shell.closeDrawer()} + style={styles.drawerMask}> + <View style={styles.drawerContainer}> + <DrawerContent /> + </View> + </TouchableOpacity> + )} </> ) }) @@ -71,4 +89,19 @@ const styles = StyleSheet.create({ viewBorderRight: { left: 'calc(50vw + 300px)', }, + drawerMask: { + position: 'absolute', + width: '100%', + height: '100%', + top: 0, + left: 0, + backgroundColor: 'rgba(0,0,0,0.25)', + }, + drawerContainer: { + display: 'flex', + position: 'absolute', + top: 0, + left: 0, + height: '100%', + }, }) |