Mobile Web (#427)

* WIP

* WIP

* Fix header offset on web

* Remove debug

* Fix web mobile feed and FAB layout

* Fix modals on mobile web

* Remove dead code

* Remove ios config that shouldnt be committed now

* Move bottom bar into its own folder

* Fix web drawer navigation and state behaviors

* Remove dark mode toggle from web drawer for now

* Fix search on mobile web

* Fix the logged out splash screen on mobile web

* Fixes to detox simulator

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
John Fawcett 2023-04-12 20:27:55 -05:00 committed by GitHub
parent 2fed6c4021
commit f6769b283f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 4343 additions and 319 deletions

View File

@ -14,14 +14,14 @@ module.exports = {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/bluesky.app',
build:
'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
'xcodebuild -workspace ios/Bluesky.xcworkspace -scheme Bluesky -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/bluesky.app',
build:
'xcodebuild -workspace ios/bluesky.xcworkspace -scheme bluesky -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
'xcodebuild -workspace ios/Bluesky.xcworkspace -scheme Bluesky -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',

View File

@ -93,7 +93,7 @@ describe('Profile screen', () => {
// have to wait for the toast to clear
await waitFor(element(by.id('searchTextInput')))
.toBeVisible()
.withTimeout(2000)
.withTimeout(5000)
await element(by.id('searchTextInput')).typeText('bob')
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
await expect(element(by.id('profileView'))).toBeVisible()

View File

@ -117,6 +117,7 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.18.11",
"react-native-web-linear-gradient": "^1.1.2",
"react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
@ -143,6 +144,7 @@
"@types/lodash.shuffle": "^4.2.7",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-native": "^0.67.3",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",

File diff suppressed because it is too large Load Diff

View File

@ -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'

View File

@ -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}
}

View File

@ -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,
}
})
}

View File

@ -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',
}
})
}

View File

@ -0,0 +1,8 @@
import {useMediaQuery} from 'react-responsive'
export function useWebMediaQueries() {
const isDesktop = useMediaQuery({
query: '(min-width: 1230px)',
})
return {isDesktop}
}

View File

@ -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,7 +39,6 @@ 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"
@ -57,7 +55,6 @@ export const SplashScreen = ({
<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: {

View File

@ -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'

View File

@ -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

View File

@ -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,
},
})

View File

@ -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',
},
})

View File

@ -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 />
}

View File

@ -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',

View File

@ -0,0 +1 @@
export {FABInner as FAB} from './FABInner'

View File

@ -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 />
}

View File

@ -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',
},
})

View File

@ -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) => {

View File

@ -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'

View File

@ -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"

View File

@ -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,
},
})

View File

@ -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',
},
})

View File

@ -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,6 +55,10 @@ export const DrawerContent = observer(() => {
track('Menu:ItemClicked', {url: tab})
const state = navigation.getState()
store.shell.closeDrawer()
if (isWeb) {
// @ts-ignore must be Home, Search, or Notifications
navigation.navigate(tab)
} else {
const tabState = getTabState(state, tab)
if (tabState === TabState.InsideAtRoot) {
store.emitScreenSoftReset()
@ -75,6 +68,7 @@ export const DrawerContent = observer(() => {
// @ts-ignore must be Home, Search, or Notifications
navigation.navigate(`${tab}Tab`)
}
}
},
[store, track, navigation],
)
@ -240,6 +234,7 @@ export const DrawerContent = observer(() => {
</View>
<View style={s.flex1} />
<View style={styles.footer}>
{!isWeb && (
<TouchableOpacity
onPress={onDarkmodePress}
style={[
@ -254,6 +249,7 @@ export const DrawerContent = observer(() => {
strokeWidth={2}
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={onPressFeedback}
style={[

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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>
)
}

View File

@ -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>
{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%',
},
})

View File

@ -4615,6 +4615,13 @@
dependencies:
"@types/react" "^17"
"@types/react-responsive@^8.0.5":
version "8.0.5"
resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.5.tgz#77769862d2a0711434feb972be08e3e6c334440a"
integrity sha512-k3gQJgI87oP5IrVZe//3LKJFnAeFaqqWmmtl5eoYL2H3HqFcIhUaE30kRK1CsW3DHdojZxcVj4ZNc2ClsEu2PA==
dependencies:
"@types/react" "*"
"@types/react-test-renderer@^17.0.1":
version "17.0.2"
resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.2.tgz#5f800a39b12ac8d2a2149e7e1885215bcf4edbbf"
@ -6836,6 +6843,11 @@ css-loader@^6.5.1:
postcss-value-parser "^4.2.0"
semver "^7.3.8"
css-mediaquery@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0"
integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==
css-minimizer-webpack-plugin@^3.2.0, css-minimizer-webpack-plugin@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz#ab78f781ced9181992fe7b6e4f3422e76429878f"
@ -9627,7 +9639,7 @@ humps@^2.0.1:
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
integrity sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==
hyphenate-style-name@^1.0.3:
hyphenate-style-name@^1.0.0, hyphenate-style-name@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
@ -11896,6 +11908,13 @@ markdown-it@^13.0.1:
mdurl "^1.0.1"
uc.micro "^1.0.5"
matchmediaquery@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7"
integrity sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==
dependencies:
css-mediaquery "^0.1.2"
md5-file@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/md5-file/-/md5-file-3.2.3.tgz#f9bceb941eca2214a4c0727f5e700314e770f06f"
@ -14235,7 +14254,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.0, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@*, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@*, prop-types@^15.6.1, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -14869,6 +14888,16 @@ react-refresh@^0.4.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.4.3.tgz#966f1750c191672e76e16c2efa569150cc73ab53"
integrity sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA==
react-responsive@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/react-responsive/-/react-responsive-9.0.2.tgz#34531ca77a61e7a8775714016d21241df7e4205c"
integrity sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==
dependencies:
hyphenate-style-name "^1.0.0"
matchmediaquery "^0.3.0"
prop-types "^15.6.1"
shallow-equal "^1.2.1"
react-scripts@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003"
@ -15676,6 +15705,11 @@ shallow-clone@^3.0.0:
dependencies:
kind-of "^6.0.2"
shallow-equal@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da"
integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==
sharp@^0.31.2:
version "0.31.3"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.31.3.tgz#60227edc5c2be90e7378a210466c99aefcf32688"