diff options
-rw-r--r-- | src/state/models/navigation.ts | 19 | ||||
-rw-r--r-- | src/state/models/session.ts | 5 | ||||
-rw-r--r-- | src/view/lib/icons.tsx | 32 | ||||
-rw-r--r-- | src/view/routes.ts | 2 | ||||
-rw-r--r-- | src/view/screens/Menu.tsx | 246 | ||||
-rw-r--r-- | src/view/shell/mobile/MainMenu.tsx | 354 | ||||
-rw-r--r-- | src/view/shell/mobile/index.tsx | 21 |
7 files changed, 300 insertions, 379 deletions
diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts index 6eb13a62a..93aab9d4f 100644 --- a/src/state/models/navigation.ts +++ b/src/state/models/navigation.ts @@ -17,8 +17,11 @@ export type HistoryPtr = [number, number] export class NavigationTabModel { id = genId() - history: HistoryItem[] = [{url: '/', ts: Date.now(), id: genId()}] - index = 0 + history: HistoryItem[] = [ + {url: '/menu', ts: Date.now(), id: genId()}, + {url: '/', ts: Date.now(), id: genId()}, + ] + index = 1 isNewTab = false constructor() { @@ -107,9 +110,15 @@ export class NavigationTabModel { } } - goBackToZero() { - if (this.canGoBack) { - this.index = 0 + resetTo(path: string) { + if (this.index >= 1 && this.history[1]?.url === path) { + // 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 } } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 069e3db32..1537d1316 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -138,7 +138,10 @@ export class SessionModel { } async connect(): Promise<void> { - this._connectPromise ??= this._connect() + if (this._connectPromise) { + return this._connectPromise + } + this._connectPromise = this._connect() await this._connectPromise this._connectPromise = undefined } diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx index 7e3313597..31869810d 100644 --- a/src/view/lib/icons.tsx +++ b/src/view/lib/icons.tsx @@ -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. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function UserGroupIcon({ diff --git a/src/view/routes.ts b/src/view/routes.ts index 272a1b096..e662e2cca 100644 --- a/src/view/routes.ts +++ b/src/view/routes.ts @@ -1,6 +1,7 @@ import React, {MutableRefObject} from 'react' import {FlatList} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {Menu} from './screens/Menu' import {Home} from './screens/Home' import {Contacts} from './screens/Contacts' import {Search} from './screens/Search' @@ -33,6 +34,7 @@ export type MatchResult = { const r = (pattern: string) => new RegExp('^' + pattern + '([?]|$)', 'i') export const routes: Route[] = [ + [Menu, 'Menu', 'bars', r('/menu')], [Home, 'Home', 'house', r('/')], [Contacts, 'Contacts', ['far', 'circle-user'], r('/contacts')], [Search, 'Search', 'magnifying-glass', r('/search')], diff --git a/src/view/screens/Menu.tsx b/src/view/screens/Menu.tsx new file mode 100644 index 000000000..d226ea02b --- /dev/null +++ b/src/view/screens/Menu.tsx @@ -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, + }, +}) diff --git a/src/view/shell/mobile/MainMenu.tsx b/src/view/shell/mobile/MainMenu.tsx deleted file mode 100644 index 8a7264612..000000000 --- a/src/view/shell/mobile/MainMenu.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index e3e30decc..6bb111877 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -33,7 +33,6 @@ import {match, MatchResult} from '../../routes' import {Login} from '../../screens/Login' import {Onboard} from '../../screens/Onboard' import {Modal} from '../../com/modals/Modal' -import {MainMenu} from './MainMenu' import {TabsSelector} from './TabsSelector' import {Composer} from './Composer' import {s, colors} from '../../lib/styles' @@ -118,7 +117,6 @@ const Btn = ({ export const MobileShell: React.FC = observer(() => { const store = useStores() - const [isMainMenuActive, setMainMenuActive] = useState(false) const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) const scrollElRef = useRef<FlatList | undefined>() const winDim = useWindowDimensions() @@ -134,16 +132,10 @@ export const MobileShell: React.FC = observer(() => { if (store.nav.tab.current.url === '/') { scrollElRef.current?.scrollToOffset({offset: 0}) } else { - if (store.nav.tab.canGoBack) { - // sanity check - store.nav.tab.goBackToZero() - } else { - store.nav.navigate('/') - } + store.nav.tab.resetTo('/') } } - const onPressMenu = () => setMainMenuActive(true) - const onPressNotifications = () => store.nav.navigate('/notifications') + const onPressNotifications = () => store.nav.tab.resetTo('/notifications') const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive) const doNewTab = (url: string) => () => store.nav.newTab(url) @@ -337,16 +329,7 @@ export const MobileShell: React.FC = observer(() => { onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} notificationCount={store.me.notificationCount} /> - <Btn - icon={isMainMenuActive ? 'menu-solid' : 'menu'} - onPress={onPressMenu} - /> </View> - <MainMenu - active={isMainMenuActive} - insetBottom={clamp(safeAreaInsets.bottom, 15, 40)} - onClose={() => setMainMenuActive(false)} - /> <Modal /> <Composer active={store.shell.isComposerActive} |