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