import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {
Animated,
Easing,
GestureResponderEvent,
StatusBar,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
useColorScheme,
useWindowDimensions,
View,
} from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {TABS_ENABLED} from 'lib/build-flags'
import {useStores} from 'state/index'
import {
NavigationModel,
TabPurpose,
TabPurposeMainPath,
} from 'state/models/navigation'
import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login'
import {Menu} from './Menu'
import {Onboard} from '../../screens/Onboard'
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
import {ModalsContainer} from '../../com/modals/Modal'
import {Lightbox} from '../../com/lightbox/Lightbox'
import {Text} from '../../com/util/text/Text'
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
import {TabsSelector} from './TabsSelector'
import {Composer} from './Composer'
import {s, colors} from 'lib/styles'
import {clamp} from 'lib/numbers'
import {
GridIcon,
GridIconSolid,
HomeIcon,
HomeIconSolid,
MagnifyingGlassIcon,
BellIcon,
BellIconSolid,
} from 'lib/icons'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
const Btn = ({
icon,
notificationCount,
tabCount,
onPress,
onLongPress,
}: {
icon:
| IconProp
| 'menu'
| 'menu-solid'
| 'home'
| 'home-solid'
| 'search'
| 'search-solid'
| 'bell'
| 'bell-solid'
notificationCount?: number
tabCount?: number
onPress?: (event: GestureResponderEvent) => void
onLongPress?: (event: GestureResponderEvent) => void
}) => {
const pal = usePalette('default')
let iconEl
if (icon === 'menu') {
iconEl =
} else if (icon === 'menu-solid') {
iconEl =
} else if (icon === 'home') {
iconEl =
} else if (icon === 'home-solid') {
iconEl =
} else if (icon === 'search') {
iconEl = (
)
} else if (icon === 'search-solid') {
iconEl = (
)
} else if (icon === 'bell') {
iconEl = (
)
} else if (icon === 'bell-solid') {
iconEl = (
)
} else {
iconEl = (
)
}
return (
{notificationCount ? (
{notificationCount}
) : undefined}
{tabCount && tabCount > 1 ? (
{tabCount}
) : undefined}
{iconEl}
)
}
export const MobileShell: React.FC = observer(() => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const [isTabsSelectorActive, setTabsSelectorActive] = useState(false)
const winDim = useWindowDimensions()
const [menuSwipingDirection, setMenuSwipingDirection] = useState(0)
const swipeGestureInterp = useAnimatedValue(0)
const minimalShellInterp = useAnimatedValue(0)
const tabMenuInterp = useAnimatedValue(0)
const newTabInterp = useAnimatedValue(0)
const [isRunningNewTabAnim, setIsRunningNewTabAnim] = useState(false)
const colorScheme = useColorScheme()
const safeAreaInsets = useSafeAreaInsets()
const screenRenderDesc = constructScreenRenderDesc(store.nav)
const {track} = useAnalytics()
const onPressHome = () => {
track('MobileShell:HomeButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Default, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}
const onPressSearch = () => {
track('MobileShell:SearchButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Search, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}
const onPressNotifications = () => {
track('MobileShell:NotificationsButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
if (!store.nav.tab.canGoBack) {
store.emitScreenSoftReset()
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Notifs, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}
const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive)
const doNewTab = (url: string) => () => store.nav.newTab(url)
// minimal shell animation
// =
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)}],
}
// tab selector animation
// =
const toggleTabsMenu = (active: boolean) => {
if (active) {
// will trigger the animation below
setTabsSelectorActive(true)
} else {
Animated.timing(tabMenuInterp, {
toValue: 0,
duration: 100,
useNativeDriver: false,
}).start(() => {
// hide once the animation has finished
setTabsSelectorActive(false)
})
}
}
useEffect(() => {
if (isTabsSelectorActive) {
// trigger the animation once the tabs selector is rendering
Animated.timing(tabMenuInterp, {
toValue: 1,
duration: 100,
useNativeDriver: false,
}).start()
}
}, [tabMenuInterp, isTabsSelectorActive])
// new tab animation
// =
useEffect(() => {
if (screenRenderDesc.hasNewTab && !isRunningNewTabAnim) {
setIsRunningNewTabAnim(true)
}
}, [isRunningNewTabAnim, screenRenderDesc.hasNewTab])
useEffect(() => {
if (isRunningNewTabAnim) {
const reset = () => {
store.nav.tab.setIsNewTab(false)
setIsRunningNewTabAnim(false)
}
Animated.timing(newTabInterp, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.exp),
useNativeDriver: false,
}).start(() => {
reset()
})
} else {
newTabInterp.setValue(0)
}
}, [newTabInterp, store.nav.tab, isRunningNewTabAnim])
// navigation swipes
// =
const isMenuActive = store.shell.isMainMenuOpen
const canSwipeLeft = store.nav.tab.canGoBack || !isMenuActive
const canSwipeRight = isMenuActive
const onNavSwipeStartDirection = (dx: number) => {
if (dx < 0 && !store.nav.tab.canGoBack) {
setMenuSwipingDirection(dx)
} else if (dx > 0 && isMenuActive) {
setMenuSwipingDirection(dx)
} else {
setMenuSwipingDirection(0)
}
}
const onNavSwipeEnd = (dx: number) => {
if (dx < 0) {
if (store.nav.tab.canGoBack) {
store.nav.tab.goBack()
} else {
store.shell.setMainMenuOpen(true)
}
} else if (dx > 0) {
if (isMenuActive) {
store.shell.setMainMenuOpen(false)
}
}
setMenuSwipingDirection(0)
}
const swipeTranslateX = Animated.multiply(
swipeGestureInterp,
winDim.width * -1,
)
const swipeTransform = store.nav.tab.canGoBack
? {transform: [{translateX: swipeTranslateX}]}
: undefined
let shouldRenderMenu = false
let menuTranslateX
const menuDrawerWidth = winDim.width - 100
if (isMenuActive) {
// menu is active, interpret swipes as closes
menuTranslateX = Animated.multiply(swipeGestureInterp, menuDrawerWidth * -1)
shouldRenderMenu = true
} else if (!store.nav.tab.canGoBack) {
// at back of history, interpret swipes as opens
menuTranslateX = Animated.subtract(
menuDrawerWidth * -1,
Animated.multiply(swipeGestureInterp, menuDrawerWidth),
)
shouldRenderMenu = true
}
const menuSwipeTransform = menuTranslateX
? {
transform: [{translateX: menuTranslateX}],
}
: undefined
const swipeOpacity = {
opacity: swipeGestureInterp.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0.6, 0],
}),
}
const menuSwipeOpacity =
menuSwipingDirection !== 0
? {
opacity: swipeGestureInterp.interpolate({
inputRange: menuSwipingDirection > 0 ? [0, 1] : [-1, 0],
outputRange: [0.6, 0],
}),
}
: undefined
// TODO
// const tabMenuTransform = {
// transform: [{translateY: Animated.multiply(tabMenuInterp, -320)}],
// }
// const newTabTransform = {
// transform: [{scale: newTabInterp}],
// }
if (store.hackUpgradeNeeded) {
return (
Update required
Please update your app to the latest version. If no update is
available yet, please check the App Store in a day or so.
What's happening?
We're in the final stages of the AT Protocol's v1 development. To
make sure everything works as well as possible, we're making final
breaking changes to the APIs.
If we didn't botch this process, a new version of the app should
be available now.
)
}
if (!store.session.hasSession) {
return (
)
}
if (store.onboard.isOnboarding) {
return (
)
}
const isAtHome =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
const isAtSearch =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
const isAtNotifications =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
const screenBg = {
backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1,
}
return (
{screenRenderDesc.screens.map(
({Com, navIdx, params, key, current, previous}) => {
if (isMenuActive) {
// HACK menu is active, treat current as previous
if (previous) {
previous = false
} else if (current) {
current = false
previous = true
}
}
return (
)
},
)}
{isMenuActive || menuSwipingDirection !== 0 ? (
store.shell.setMainMenuOpen(false)}>
) : undefined}
{shouldRenderMenu && (
)}
{isTabsSelectorActive ? (
) : undefined}
toggleTabsMenu(false)}
/>
{TABS_ENABLED ? (
) : undefined}
store.shell.closeComposer()}
winHeight={winDim.height}
replyTo={store.shell.composerOpts?.replyTo}
imagesOpen={store.shell.composerOpts?.imagesOpen}
onPost={store.shell.composerOpts?.onPost}
/>
)
})
/**
* This method produces the information needed by the shell to
* render the current screens with screen-caching behaviors.
*/
type ScreenRenderDesc = MatchResult & {
key: string
navIdx: string
current: boolean
previous: boolean
isNewTab: boolean
}
function constructScreenRenderDesc(nav: NavigationModel): {
icon: IconProp
hasNewTab: boolean
screens: ScreenRenderDesc[]
} {
let hasNewTab = false
let icon: IconProp = 'magnifying-glass'
let screens: ScreenRenderDesc[] = []
for (const tab of nav.tabs) {
const tabScreens = [
...tab.getBackList(5),
Object.assign({}, tab.current, {index: tab.index}),
]
const parsedTabScreens = tabScreens.map(screen => {
const isCurrent = nav.isCurrentScreen(tab.id, screen.index)
const isPrevious = nav.isCurrentScreen(tab.id, screen.index + 1)
const matchRes = match(screen.url)
if (isCurrent) {
icon = matchRes.icon
}
hasNewTab = hasNewTab || tab.isNewTab
return Object.assign(matchRes, {
key: `t${tab.id}-s${screen.index}`,
navIdx: `${tab.id}-${screen.id}`,
current: isCurrent,
previous: isPrevious,
isNewTab: tab.isNewTab,
}) as ScreenRenderDesc
})
screens = screens.concat(parsedTabScreens)
}
return {
icon,
hasNewTab,
screens,
}
}
const styles = StyleSheet.create({
outerContainer: {
height: '100%',
},
innerContainer: {
height: '100%',
},
screenContainer: {
height: '100%',
},
screenMask: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#000',
opacity: 0.6,
},
menuDrawer: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 100,
},
topBarProtector: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 50, // will be overwritten by insets
backgroundColor: colors.white,
},
topBarProtectorDark: {
backgroundColor: colors.black,
},
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flexDirection: 'row',
borderTopWidth: 1,
paddingLeft: 5,
paddingRight: 25,
},
ctrl: {
flex: 1,
paddingTop: 12,
paddingBottom: 5,
},
notificationCount: {
position: 'absolute',
left: '60%',
top: 10,
backgroundColor: colors.red3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 8,
},
notificationCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.white,
},
tabCount: {
position: 'absolute',
left: 46,
top: 30,
},
tabCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.black,
},
ctrlIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
inactive: {
color: colors.gray3,
},
bumpUpOnePixel: {
position: 'relative',
top: -1,
},
})