From 03d5ac083c853ab4dacdd578e44ee6a76763cdf0 Mon Sep 17 00:00:00 2001 From: Suneet Srivastava Date: Wed, 16 Feb 2022 18:47:57 +0530 Subject: [PATCH] MM-39716: Added + button bottomsheet layout (#5957) * [gekidou] feat: MM-39716 Added + Button Bottom sheet layout * feat: Added on click to the channel header listener * chore: Ran i18n command and did the requested changes * chore: updated test snapshot * Refactor PlusMenu & fix Browse Channels * Fix snapshot tests * feedback review Co-authored-by: Elias Nahum --- .../__snapshots__/index.test.tsx.snap | 170 ------------------ .../header/__snapshots__/header.test.tsx.snap | 116 +++++------- .../channel_list/header/header.test.tsx | 9 +- app/components/channel_list/header/header.tsx | 89 ++++++--- app/components/channel_list/header/index.ts | 33 +++- .../channel_list/header/plus_menu/index.tsx | 64 +++++++ .../channel_list/header/plus_menu/item.tsx | 46 +++++ app/components/channel_list/index.test.tsx | 11 +- app/components/channel_list/index.tsx | 10 +- app/screens/bottom_sheet/content.tsx | 5 +- .../browse_channels/channel_dropdown.tsx | 7 +- app/utils/role/index.ts | 16 ++ assets/base/i18n/en.json | 4 + test/setup.ts | 3 + types/modules/mock-safe-area-context.d.ts | 4 + 15 files changed, 303 insertions(+), 284 deletions(-) create mode 100644 app/components/channel_list/header/plus_menu/index.tsx create mode 100644 app/components/channel_list/header/plus_menu/item.tsx create mode 100644 types/modules/mock-safe-area-context.d.ts diff --git a/app/components/channel_list/__snapshots__/index.test.tsx.snap b/app/components/channel_list/__snapshots__/index.test.tsx.snap index dc232fdca8..d0c7d83478 100644 --- a/app/components/channel_list/__snapshots__/index.test.tsx.snap +++ b/app/components/channel_list/__snapshots__/index.test.tsx.snap @@ -20,176 +20,6 @@ exports[`components/channel_list should match snapshot 1`] = ` } } > - - - - - - - Test Team! - - - - - - - - - - - - - - - - - - - - - Test! - - + + Test! + - - - + /> { it('Channel List Header Component should match snapshot', () => { const {toJSON} = renderWithIntl( -
, + +
+ , ); expect(toJSON()).toMatchSnapshot(); diff --git a/app/components/channel_list/header/header.tsx b/app/components/channel_list/header/header.tsx index a7f2a7d5cd..3f8cacba95 100644 --- a/app/components/channel_list/header/header.tsx +++ b/app/components/channel_list/header/header.tsx @@ -1,23 +1,30 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; +import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {Screens} from '@constants'; import {useServerDisplayName} from '@context/server'; import {useTheme} from '@context/theme'; -import {showModal} from '@screens/navigation'; +import {useIsTablet} from '@hooks/device'; +import {bottomSheet} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +import PlusMenu from './plus_menu'; + type Props = { + canCreateChannels: boolean; + canJoinChannels: boolean; displayName: string; iconPad?: boolean; + onHeaderPress?: () => void; } const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -55,8 +62,11 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const ChannelListHeader = ({displayName, iconPad}: Props) => { +const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, iconPad, onHeaderPress}: Props) => { const theme = useTheme(); + const isTablet = useIsTablet(); + const intl = useIntl(); + const insets = useSafeAreaInsets(); const serverDisplayName = useServerDisplayName(); const marginLeft = useSharedValue(iconPad ? 44 : 0); const styles = getStyles(theme); @@ -64,38 +74,67 @@ const ChannelListHeader = ({displayName, iconPad}: Props) => { marginLeft: withTiming(marginLeft.value, {duration: 350}), }), []); - const intl = useIntl(); - useEffect(() => { marginLeft.value = iconPad ? 44 : 0; }, [iconPad]); + const onPress = useCallback(() => { + const renderContent = () => { + return ( + + ); + }; + + const closeButtonId = 'close-plus-menu'; + let items = 1; + if (canCreateChannels) { + items += 1; + } + + if (canJoinChannels) { + items += 1; + } + + bottomSheet({ + closeButtonId, + renderContent, + snapPoints: [(items * ITEM_HEIGHT) + (insets.bottom * 2), 10], + theme, + title: intl.formatMessage({id: 'home.header.plus_menu', defaultMessage: 'Options'}), + }); + }, [intl, insets, isTablet, theme]); + return ( {Boolean(displayName) && - - - {displayName} - - - - - - + + + + {displayName} + + + + + + + { - const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'More Channels'}); - const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); - showModal(Screens.BROWSE_CHANNELS, title, { - closeButton, - }); - }} /> diff --git a/app/components/channel_list/header/index.ts b/app/components/channel_list/header/index.ts index fd8f25167e..2e55756f26 100644 --- a/app/components/channel_list/header/index.ts +++ b/app/components/channel_list/header/index.ts @@ -3,25 +3,52 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {catchError, of as of$} from 'rxjs'; +import {catchError, combineLatest, of as of$, from as from$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {Permissions} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {hasPermissionForTeam} from '@utils/role'; -const {SERVER: {SYSTEM, TEAM}} = MM_TABLES; import ChannelListHeader from './header'; import type {WithDatabaseArgs} from '@typings/database/database'; import type SystemModel from '@typings/database/models/servers/system'; import type TeamModel from '@typings/database/models/servers/team'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {SYSTEM, TEAM, USER}} = MM_TABLES; +const {CURRENT_TEAM_ID, CURRENT_USER_ID} = SYSTEM_IDENTIFIERS; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { - const team = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + const team = database.get(SYSTEM).findAndObserve(CURRENT_TEAM_ID).pipe( switchMap((id) => database.get(TEAM).findAndObserve(id.value)), catchError(() => of$({displayName: ''})), ); + const currentUser = database.get(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe( + switchMap(({value}) => database.get(USER).findAndObserve(value)), + ); + + const canJoinChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (('id' in t) ? from$(hasPermissionForTeam(t, u, Permissions.JOIN_PUBLIC_CHANNELS, true)) : of$(false))), + ); + + const canCreatePublicChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (('id' in t) ? from$(hasPermissionForTeam(t, u, Permissions.CREATE_PUBLIC_CHANNEL, true)) : of$(false))), + ); + + const canCreatePrivateChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (('id' in t) ? from$(hasPermissionForTeam(t, u, Permissions.CREATE_PRIVATE_CHANNEL, false)) : of$(false))), + ); + + const canCreateChannels = combineLatest([canCreatePublicChannels, canCreatePrivateChannels]).pipe( + switchMap(([open, priv]) => of$(open || priv)), + ); + return { + canCreateChannels, + canJoinChannels, displayName: team.pipe( switchMap((t) => of$(t.displayName)), ), diff --git a/app/components/channel_list/header/plus_menu/index.tsx b/app/components/channel_list/header/plus_menu/index.tsx new file mode 100644 index 0000000000..4832d30a72 --- /dev/null +++ b/app/components/channel_list/header/plus_menu/index.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import CompassIcon from '@components/compass_icon'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {dismissBottomSheet, showModal} from '@screens/navigation'; + +import PlusMenuItem from './item'; + +type Props = { + canCreateChannels: boolean; + canJoinChannels: boolean; +} + +const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + + const browseChannels = useCallback(async () => { + await dismissBottomSheet(); + + const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'More Channels'}); + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + showModal(Screens.BROWSE_CHANNELS, title, { + closeButton, + }); + }, [intl, theme]); + + const createNewChannel = useCallback(async () => { + // To be added + }, [intl, theme]); + + const openDirectMessage = useCallback(async () => { + // To be added + }, [intl, theme]); + + return ( + <> + {canJoinChannels && + + } + {canCreateChannels && + + } + + + ); +}; + +export default PlusMenuList; diff --git a/app/components/channel_list/header/plus_menu/item.tsx b/app/components/channel_list/header/plus_menu/item.tsx new file mode 100644 index 0000000000..c026dd3b58 --- /dev/null +++ b/app/components/channel_list/header/plus_menu/item.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; + +import SlideUpPanelItem from '@components/slide_up_panel_item'; + +type PlusMenuItemProps = { + pickerAction: 'browseChannels' | 'createNewChannel' | 'openDirectMessage'; + onPress: () => void; +}; + +const PlusMenuItem = ({pickerAction, onPress}: PlusMenuItemProps) => { + const intl = useIntl(); + + const menuItems = { + browseChannels: { + icon: 'globe', + text: intl.formatMessage({id: 'plus_menu.browse_channels.title', defaultMessage: 'Browse Channels'}), + }, + + createNewChannel: { + icon: 'plus', + text: intl.formatMessage({id: 'plus_menu.create_new_channel.title', defaultMessage: 'Create New Channel'}), + }, + + openDirectMessage: { + icon: 'account-outline', + text: intl.formatMessage({id: 'plus_menu.open_direct_message.title', defaultMessage: 'Open a Direct Message'}), + }, + }; + const itemType = menuItems[pickerAction]; + return ( + + + + ); +}; + +export default PlusMenuItem; diff --git a/app/components/channel_list/index.test.tsx b/app/components/channel_list/index.test.tsx index 4933e40067..fdccc6eb72 100644 --- a/app/components/channel_list/index.test.tsx +++ b/app/components/channel_list/index.test.tsx @@ -3,6 +3,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; +import {SafeAreaProvider} from 'react-native-safe-area-context'; import {MM_TABLES} from '@constants/database'; import {TeamModel} from '@database/models/server'; @@ -27,10 +28,12 @@ describe('components/channel_list', () => { it('should match snapshot', () => { const wrapper = renderWithEverything( - , + + + , {database}, ); expect(wrapper.toJSON()).toMatchSnapshot(); diff --git a/app/components/channel_list/index.tsx b/app/components/channel_list/index.tsx index c1e2b67f5a..0dc6495894 100644 --- a/app/components/channel_list/index.tsx +++ b/app/components/channel_list/index.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React, {useEffect, useState} from 'react'; -import {TouchableOpacity} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view'; @@ -82,11 +81,10 @@ const ChannelList = ({currentTeamId, iconPad, isTablet, teamsCount}: ChannelList return ( - setShowCats(!showCats)}> - - + setShowCats(!showCats)} + /> {content} ); diff --git a/app/screens/bottom_sheet/content.tsx b/app/screens/bottom_sheet/content.tsx index f6a2aeb2b0..1479f3df05 100644 --- a/app/screens/bottom_sheet/content.tsx +++ b/app/screens/bottom_sheet/content.tsx @@ -26,10 +26,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { container: { flex: 1, }, - titleContainer: { - marginTop: 4, - marginBottom: 4, - }, + titleContainer: {marginVertical: 4}, titleText: { color: theme.centerChannelColor, lineHeight: 30, diff --git a/app/screens/browse_channels/channel_dropdown.tsx b/app/screens/browse_channels/channel_dropdown.tsx index 8174b8a1c2..21bf38a031 100644 --- a/app/screens/browse_channels/channel_dropdown.tsx +++ b/app/screens/browse_channels/channel_dropdown.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {useIntl} from 'react-intl'; import {View, Text} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; @@ -39,8 +40,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const BOTTOM_SHEET_HEIGHT_BASE = 55; // Magic number - export default function ChannelDropdown({ typeOfChannels, onPress, @@ -48,6 +47,7 @@ export default function ChannelDropdown({ sharedChannelsEnabled, }: Props) { const intl = useIntl(); + const insets = useSafeAreaInsets(); const theme = useTheme(); const style = getStyleFromTheme(theme); @@ -72,10 +72,11 @@ export default function ChannelDropdown({ items += 1; } + const itemsSnap = ((items + 1) * ITEM_HEIGHT) + (insets.bottom * 2) + TITLE_HEIGHT; bottomSheet({ title: intl.formatMessage({id: 'browse_channels.dropdownTitle', defaultMessage: 'Show'}), renderContent, - snapPoints: [(items * ITEM_HEIGHT) + TITLE_HEIGHT + BOTTOM_SHEET_HEIGHT_BASE, 10], + snapPoints: [itemsSnap, 10], closeButtonId: 'close', theme, }); diff --git a/app/utils/role/index.ts b/app/utils/role/index.ts index 3da72a4c02..8061afbb6a 100644 --- a/app/utils/role/index.ts +++ b/app/utils/role/index.ts @@ -48,6 +48,22 @@ export async function hasPermissionForChannel(channel: ChannelModel, user: UserM return defaultValue; } +export async function hasPermissionForTeam(team: TeamModel, user: UserModel, permission: string, defaultValue: boolean) { + const rolesArray = [...user.roles.split(' ')]; + + const myTeam = await team.myTeam.fetch() as MyTeamModel | undefined; + if (myTeam) { + rolesArray.push(...myTeam.roles.split(' ')); + } + + if (rolesArray.length) { + const roles = await user.collections.get(MM_TABLES.SERVER.ROLE).query(Q.where('name', Q.oneOf(rolesArray))).fetch() as RoleModel[]; + return hasPermission(roles, permission, defaultValue); + } + + return defaultValue; +} + export async function hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) { const channel = await post.channel.fetch() as ChannelModel | undefined; if (channel) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index a5d889023b..2f06323aec 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -120,6 +120,7 @@ "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", "file_upload.fileAbove": "Files must be less than {max}", + "home.header.plus_menu": "Options", "get_post_link_modal.title": "Copy Link", "intro.add_people": "Add People", "intro.channel_details": "Details", @@ -365,6 +366,9 @@ "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", "permalink.show_dialog_warn.join": "Join", "permalink.show_dialog_warn.title": "Join private channel", + "plus_menu.browse_channels.title": "Browse Channels", + "plus_menu.create_new_channel.title": "Create New Channel", + "plus_menu.open_direct_message.title": "Open a Direct Message", "post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.", "post_body.check_for_out_of_channel_mentions.link.and": " and ", "post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel", diff --git a/test/setup.ts b/test/setup.ts index 0f50ce14a3..19a06d4470 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -5,6 +5,7 @@ import MockAsyncStorage from 'mock-async-storage'; import * as ReactNative from 'react-native'; +import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import 'react-native-gesture-handler/jestSetup'; require('react-native-reanimated/lib/reanimated2/jestUtils').setUpTests(); @@ -310,6 +311,8 @@ jest.mock('@screens/navigation', () => ({ jest.mock('@mattermost/react-native-emm'); +jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); + declare const global: {requestAnimationFrame: (callback: any) => void}; global.requestAnimationFrame = (callback) => { setTimeout(callback, 0); diff --git a/types/modules/mock-safe-area-context.d.ts b/types/modules/mock-safe-area-context.d.ts new file mode 100644 index 0000000000..96b39e2bdf --- /dev/null +++ b/types/modules/mock-safe-area-context.d.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +declare module 'react-native-safe-area-context/jest/mock';