Rework the 'main menu' to be a screen that's always in history

zio/stable
Paul Frazee 2022-12-06 10:11:32 -06:00
parent 70cfae56e2
commit 474c4f9b5d
7 changed files with 300 additions and 379 deletions

View File

@ -17,8 +17,11 @@ export type HistoryPtr = [number, number]
export class NavigationTabModel { export class NavigationTabModel {
id = genId() id = genId()
history: HistoryItem[] = [{url: '/', ts: Date.now(), id: genId()}] history: HistoryItem[] = [
index = 0 {url: '/menu', ts: Date.now(), id: genId()},
{url: '/', ts: Date.now(), id: genId()},
]
index = 1
isNewTab = false isNewTab = false
constructor() { constructor() {
@ -107,9 +110,15 @@ export class NavigationTabModel {
} }
} }
goBackToZero() { resetTo(path: string) {
if (this.canGoBack) { if (this.index >= 1 && this.history[1]?.url === path) {
this.index = 0 // fall back in history to target
if (this.index > 1) {
this.index = 1
}
} else {
this.history = [this.history[0], {url: path, ts: Date.now(), id: genId()}]
this.index = 1
} }
} }

View File

@ -138,7 +138,10 @@ export class SessionModel {
} }
async connect(): Promise<void> { async connect(): Promise<void> {
this._connectPromise ??= this._connect() if (this._connectPromise) {
return this._connectPromise
}
this._connectPromise = this._connect()
await this._connectPromise await this._connectPromise
this._connectPromise = undefined this._connectPromise = undefined
} }

View File

@ -166,6 +166,38 @@ export function BellIconSolid({
) )
} }
export function CogIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc. // Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserGroupIcon({ export function UserGroupIcon({

View File

@ -1,6 +1,7 @@
import React, {MutableRefObject} from 'react' import React, {MutableRefObject} from 'react'
import {FlatList} from 'react-native' import {FlatList} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Menu} from './screens/Menu'
import {Home} from './screens/Home' import {Home} from './screens/Home'
import {Contacts} from './screens/Contacts' import {Contacts} from './screens/Contacts'
import {Search} from './screens/Search' import {Search} from './screens/Search'
@ -33,6 +34,7 @@ export type MatchResult = {
const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i') const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i')
export const routes: Route[] = [ export const routes: Route[] = [
[Menu, 'Menu', 'bars', r('/menu')],
[Home, 'Home', 'house', r('/')], [Home, 'Home', 'house', r('/')],
[Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')], [Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')],
[Search, 'Search', 'magnifying-glass', r('/search')], [Search, 'Search', 'magnifying-glass', r('/search')],

View File

@ -0,0 +1,246 @@
import React, {useEffect} from 'react'
import {
StyleProp,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {colors} from '../lib/styles'
import {ScreenParams} from '../routes'
import {useStores} from '../../state'
import {
HomeIcon,
UserGroupIcon,
BellIcon,
CogIcon,
MagnifyingGlassIcon,
} from '../lib/icons'
import {UserAvatar} from '../com/util/UserAvatar'
import {ViewHeader} from '../com/util/ViewHeader'
import {CreateSceneModel} from '../../state/models/shell-ui'
export const Menu = ({navIdx, visible}: ScreenParams) => {
const store = useStores()
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, 'Menu')
// trigger a refresh in case memberships have changed recently
store.me.refreshMemberships()
}
}, [store, visible])
// events
// =
const onNavigate = (url: string) => {
store.nav.navigate(url)
}
const onPressCreateScene = () => {
store.shell.openModal(new CreateSceneModel())
}
// rendering
// =
const MenuItem = ({
icon,
label,
count,
url,
bold,
onPress,
}: {
icon: JSX.Element
label: string
count?: number
url?: string
bold?: boolean
onPress?: () => void
}) => (
<TouchableOpacity
style={styles.menuItem}
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View style={styles.menuItemCount}>
<Text style={styles.menuItemCountLabel}>{count}</Text>
</View>
) : undefined}
</View>
<Text
style={[
styles.menuItemLabel,
bold ? styles.menuItemLabelBold : undefined,
]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
/*TODO <MenuItem icon={['far', 'compass']} label="Discover" url="/" />*/
return (
<View style={styles.view}>
<ViewHeader title="Bluesky" subtitle="Private Beta" />
<TouchableOpacity
style={styles.searchBtn}
onPress={() => onNavigate('/search')}>
<MagnifyingGlassIcon
style={{color: colors.gray5} as StyleProp<ViewStyle>}
size={21}
/>
<Text style={styles.searchBtnLabel}>Search</Text>
</TouchableOpacity>
<View style={styles.section}>
<MenuItem
icon={
<UserAvatar
size={24}
displayName={store.me.displayName}
handle={store.me.handle}
/>
}
label={store.me.displayName || store.me.handle}
bold
url={`/profile/${store.me.handle}`}
/>
<MenuItem
icon={
<HomeIcon
style={{color: colors.gray5} as StyleProp<ViewStyle>}
size="24"
/>
}
label="Home"
url="/"
/>
<MenuItem
icon={
<BellIcon
style={{color: colors.gray5} as StyleProp<ViewStyle>}
size="24"
/>
}
label="Notifications"
url="/notifications"
count={store.me.notificationCount}
/>
<MenuItem
icon={
<CogIcon
style={{color: colors.gray6} as StyleProp<ViewStyle>}
size="24"
strokeWidth={2}
/>
}
label="Settings"
url="/settings"
count={store.me.notificationCount}
/>
</View>
<View style={styles.section}>
<Text style={styles.heading}>Scenes</Text>
<MenuItem
icon={
<UserGroupIcon
style={{color: colors.gray6} as StyleProp<ViewStyle>}
size="24"
/>
}
label="Create a scene"
onPress={onPressCreateScene}
/>
{store.me.memberships
? store.me.memberships.memberships.map((membership, i) => (
<MenuItem
key={i}
icon={
<UserAvatar
size={24}
displayName={membership.displayName}
handle={membership.handle}
/>
}
label={membership.displayName || membership.handle}
url={`/profile/${membership.handle}`}
/>
))
: undefined}
</View>
</View>
)
}
const styles = StyleSheet.create({
view: {
flex: 1,
backgroundColor: colors.white,
},
section: {
paddingHorizontal: 10,
paddingTop: 10,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: colors.gray1,
},
heading: {
fontSize: 16,
fontWeight: 'bold',
paddingVertical: 8,
paddingHorizontal: 4,
},
searchBtn: {
flexDirection: 'row',
backgroundColor: colors.gray1,
borderRadius: 8,
margin: 10,
marginBottom: 0,
paddingVertical: 10,
paddingHorizontal: 12,
},
searchBtnLabel: {
marginLeft: 8,
fontSize: 18,
color: colors.gray6,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 2,
},
menuItemIconWrapper: {
width: 30,
height: 30,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
menuItemLabel: {
fontSize: 17,
color: colors.gray7,
},
menuItemLabelBold: {
fontWeight: 'bold',
},
menuItemCount: {
position: 'absolute',
right: -6,
top: -2,
backgroundColor: colors.red3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 6,
},
menuItemCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.white,
},
})

View File

@ -1,354 +0,0 @@
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {
StyleSheet,
SafeAreaView,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
} from 'react-native-reanimated'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import _chunk from 'lodash.chunk'
import {HomeIcon, UserGroupIcon, BellIcon} from '../../lib/icons'
import {UserAvatar} from '../../com/util/UserAvatar'
import {useStores} from '../../../state'
import {CreateSceneModel} from '../../../state/models/shell-ui'
import {s, colors} from '../../lib/styles'
export const MainMenu = observer(
({
active,
insetBottom,
onClose,
}: {
active: boolean
insetBottom: number
onClose: () => void
}) => {
const store = useStores()
const initInterp = useSharedValue<number>(0)
useEffect(() => {
if (active) {
// trigger a refresh in case memberships have changed recently
store.me.refreshMemberships()
}
}, [active])
useEffect(() => {
if (active) {
initInterp.value = withTiming(1, {duration: 150})
} else {
initInterp.value = 0
}
}, [initInterp, active])
const wrapperAnimStyle = useAnimatedStyle(() => ({
opacity: interpolate(initInterp.value, [0, 1.0], [0, 1.0]),
}))
const menuItemsAnimStyle = useAnimatedStyle(() => ({
top: interpolate(initInterp.value, [0, 1.0], [15, 0]),
}))
// events
// =
const onNavigate = (url: string) => {
store.nav.navigate(url)
onClose()
}
const onPressCreateScene = () => {
store.shell.openModal(new CreateSceneModel())
onClose()
}
// rendering
// =
const MenuItemBlank = () => (
<View style={[styles.menuItem, styles.menuItemMargin]} />
)
const MenuItem = ({
icon,
label,
count,
url,
onPress,
}: {
icon: IconProp
label: string
count?: number
url?: string
onPress?: () => void
}) => (
<TouchableOpacity
style={[styles.menuItem, styles.menuItemMargin]}
onPress={onPress ? onPress : () => onNavigate(url || '/')}>
<View style={[styles.menuItemIconWrapper]}>
{icon === 'home' ? (
<HomeIcon style={styles.menuItemIcon} size="32" />
) : icon === 'user-group' ? (
<UserGroupIcon style={styles.menuItemIcon} size="36" />
) : icon === 'bell' ? (
<BellIcon style={styles.menuItemIcon} size="32" />
) : (
<FontAwesomeIcon
icon={icon}
style={styles.menuItemIcon}
size={28}
/>
)}
</View>
{count ? (
<View style={styles.menuItemCount}>
<Text style={styles.menuItemCountLabel}>{count}</Text>
</View>
) : undefined}
<Text style={styles.menuItemLabel} numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
const MenuItemActor = ({
label,
url,
count,
}: {
label: string
url: string
count?: number
}) => (
<TouchableOpacity
style={[styles.menuItem, styles.menuItemMargin]}
onPress={() => onNavigate(url)}>
<View style={s.mb5}>
<UserAvatar size={60} displayName={label} handle={label} />
</View>
{count ? (
<View style={styles.menuItemCount}>
<Text style={styles.menuItemCountLabel}>{count}</Text>
</View>
) : undefined}
<Text style={styles.menuItemLabel} numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
if (!active) {
return <View />
}
const MenuItems = ({
children,
}: {
children: (JSX.Element | JSX.Element[])[]
}) => {
const groups = _chunk(children.flat(), 4)
const lastGroup = groups.at(-1)
while (lastGroup && lastGroup.length < 4) {
lastGroup.push(<MenuItemBlank />)
}
return (
<>
{groups.map((group, i) => (
<View key={i} style={[styles.menuItems]}>
{group.map((el, j) => (
<React.Fragment key={j}>{el}</React.Fragment>
))}
</View>
))}
</>
)
}
/*TODO <MenuItem icon={['far', 'compass']} label="Discover" url="/" />*/
return (
<>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<Animated.View
style={[
styles.wrapper,
{bottom: insetBottom + 45},
wrapperAnimStyle,
]}>
<SafeAreaView>
<View style={[styles.topSection]}>
<TouchableOpacity
style={styles.profile}
onPress={() => onNavigate(`/profile/${store.me.handle || ''}`)}>
<View style={styles.profileImage}>
<UserAvatar
size={35}
displayName={store.me.displayName}
handle={store.me.handle || ''}
/>
</View>
<Text style={styles.profileText} numberOfLines={1}>
{store.me.displayName || store.me.handle || 'My profile'}
</Text>
</TouchableOpacity>
<View style={[s.flex1]} />
<TouchableOpacity
style={styles.settings}
onPress={() => onNavigate(`/settings`)}>
<FontAwesomeIcon
icon="gear"
style={styles.settingsIcon}
size={24}
/>
</TouchableOpacity>
</View>
<Animated.View
style={[
styles.section,
styles.menuItemsAnimContainer,
menuItemsAnimStyle,
]}>
<MenuItems>
<MenuItem icon="home" label="Home" url="/" />
<MenuItem
icon="bell"
label="Notifications"
url="/notifications"
count={store.me.notificationCount}
/>
</MenuItems>
<Text style={styles.heading}>Scenes</Text>
<MenuItems>
<MenuItem
icon={'user-group'}
label="Create Scene"
onPress={onPressCreateScene}
/>
{store.me.memberships ? (
store.me.memberships.memberships.map((membership, i) => (
<MenuItemActor
key={i}
label={membership.displayName || membership.handle}
url={`/profile/${membership.handle}`}
/>
))
) : (
<MenuItemBlank />
)}
</MenuItems>
</Animated.View>
</SafeAreaView>
</Animated.View>
</>
)
},
)
const styles = StyleSheet.create({
bg: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
// backgroundColor: '#000',
opacity: 0,
},
wrapper: {
position: 'absolute',
top: 0,
width: '100%',
backgroundColor: '#fff',
},
topSection: {
flexDirection: 'row',
alignItems: 'center',
height: 40,
paddingHorizontal: 10,
marginTop: 12,
marginBottom: 20,
},
section: {
paddingHorizontal: 10,
},
heading: {
fontSize: 21,
fontWeight: 'bold',
paddingHorizontal: 10,
paddingTop: 6,
paddingBottom: 12,
},
profile: {
paddingVertical: 10,
paddingHorizontal: 10,
flexDirection: 'row',
alignItems: 'center',
},
profileImage: {
marginRight: 8,
},
profileText: {
fontSize: 17,
fontWeight: 'bold',
},
settings: {},
settingsIcon: {
color: colors.gray5,
marginRight: 10,
},
menuItemsAnimContainer: {
position: 'relative',
},
menuItems: {
flexDirection: 'row',
marginBottom: 20,
},
menuItem: {
flex: 1,
alignItems: 'center',
},
menuItemMargin: {
marginRight: 10,
},
menuItemIconWrapper: {
borderRadius: 6,
width: 60,
height: 60,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 5,
backgroundColor: colors.gray1,
},
menuItemIcon: {
color: colors.gray5,
},
menuItemLabel: {
fontSize: 13,
textAlign: 'center',
},
menuItemCount: {
position: 'absolute',
left: 48,
top: 10,
backgroundColor: colors.red3,
paddingHorizontal: 4,
paddingBottom: 1,
borderRadius: 6,
},
menuItemCountLabel: {
fontSize: 12,
fontWeight: 'bold',
color: colors.white,
},
})

View File

@ -33,7 +33,6 @@ import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login' import {Login} from '../../screens/Login'
import {Onboard} from '../../screens/Onboard' import {Onboard} from '../../screens/Onboard'
import {Modal} from '../../com/modals/Modal' import {Modal} from '../../com/modals/Modal'
import {MainMenu} from './MainMenu'
import {TabsSelector} from './TabsSelector' import {TabsSelector} from './TabsSelector'
import {Composer} from './Composer' import {Composer} from './Composer'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
@ -118,7 +117,6 @@ const Btn = ({
export const MobileShell: React.FC = observer(() => { export const MobileShell: React.FC = observer(() => {
const store = useStores() const store = useStores()
const [isMainMenuActive, setMainMenuActive] = useState(false)
const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) const [isTabsSelectorActive, setTabsSelectorActive] = useState(false)
const scrollElRef = useRef<FlatList | undefined>() const scrollElRef = useRef<FlatList | undefined>()
const winDim = useWindowDimensions() const winDim = useWindowDimensions()
@ -134,16 +132,10 @@ export const MobileShell: React.FC = observer(() => {
if (store.nav.tab.current.url === '/') { if (store.nav.tab.current.url === '/') {
scrollElRef.current?.scrollToOffset({offset: 0}) scrollElRef.current?.scrollToOffset({offset: 0})
} else { } else {
if (store.nav.tab.canGoBack) { store.nav.tab.resetTo('/')
// sanity check
store.nav.tab.goBackToZero()
} else {
store.nav.navigate('/')
} }
} }
} const onPressNotifications = () => store.nav.tab.resetTo('/notifications')
const onPressMenu = () => setMainMenuActive(true)
const onPressNotifications = () => store.nav.navigate('/notifications')
const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive) const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive)
const doNewTab = (url: string) => () => store.nav.newTab(url) const doNewTab = (url: string) => () => store.nav.newTab(url)
@ -337,16 +329,7 @@ export const MobileShell: React.FC = observer(() => {
onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined}
notificationCount={store.me.notificationCount} notificationCount={store.me.notificationCount}
/> />
<Btn
icon={isMainMenuActive ? 'menu-solid' : 'menu'}
onPress={onPressMenu}
/>
</View> </View>
<MainMenu
active={isMainMenuActive}
insetBottom={clamp(safeAreaInsets.bottom, 15, 40)}
onClose={() => setMainMenuActive(false)}
/>
<Modal /> <Modal />
<Composer <Composer
active={store.shell.isComposerActive} active={store.shell.isComposerActive}