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 <nahumhbl@gmail.com>
This commit is contained in:
Suneet Srivastava
2022-02-16 18:47:57 +05:30
committed by GitHub
parent ea54a8dff3
commit 03d5ac083c
15 changed files with 303 additions and 284 deletions

View File

@@ -20,176 +20,6 @@ exports[`components/channel_list should match snapshot 1`] = `
}
}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"opacity": 1,
}
}
>
<View
animatedStyle={
Object {
"value": Object {
"marginLeft": 0,
},
}
}
collapsable={false}
style={
Object {
"marginLeft": 0,
}
}
>
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<Text
style={
Object {
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
}
}
>
Test Team!
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"marginLeft": 4,
}
}
>
<Icon
name="chevron-down"
style={
Object {
"color": "rgba(255,255,255,0.8)",
"fontSize": 24,
}
}
/>
</View>
</View>
</View>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.08)",
"borderRadius": 14,
"height": 28,
"justifyContent": "center",
"width": 28,
}
}
>
<Icon
name="plus"
onPress={[Function]}
style={
Object {
"color": "rgba(255,255,255,0.8)",
"fontSize": 18,
}
}
/>
</View>
</View>
</View>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
}
}
>
</Text>
</View>
</View>
</RNGestureHandlerButton>
<View
style={
Object {

View File

@@ -26,51 +26,45 @@ exports[`components/channel_list/header Channel List Header Component should mat
}
>
<View
accessible={true}
collapsable={false}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
"opacity": 1,
}
}
>
<Text
<View
style={
Object {
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
Test!
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<Text
style={
Object {
"color": "#ffffff",
"fontFamily": "Metropolis-SemiBold",
"fontSize": 28,
"fontWeight": "600",
"lineHeight": 36,
}
}
>
Test!
</Text>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"marginLeft": 4,
@@ -90,51 +84,37 @@ exports[`components/channel_list/header Channel List Header Component should mat
</View>
</View>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.08)",
"borderRadius": 14,
"height": 28,
"justifyContent": "center",
"opacity": 1,
"width": 28,
}
}
>
<View
accessible={true}
focusable={false}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
<Icon
name="plus"
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.08)",
"borderRadius": 14,
"height": 28,
"justifyContent": "center",
"width": 28,
"color": "rgba(255,255,255,0.8)",
"fontSize": 18,
}
}
>
<Icon
name="plus"
onPress={[Function]}
style={
Object {
"color": "rgba(255,255,255,0.8)",
"fontSize": 18,
}
}
/>
</View>
/>
</View>
</View>
<Text

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import {renderWithIntl} from '@test/intl-test-helper';
@@ -10,7 +11,13 @@ import Header from './header';
describe('components/channel_list/header', () => {
it('Channel List Header Component should match snapshot', () => {
const {toJSON} = renderWithIntl(
<Header displayName={'Test!'}/>,
<SafeAreaProvider>
<Header
canCreateChannels={true}
canJoinChannels={true}
displayName={'Test!'}
/>
</SafeAreaProvider>,
);
expect(toJSON()).toMatchSnapshot();

View File

@@ -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 (
<PlusMenu
canCreateChannels={canCreateChannels}
canJoinChannels={canJoinChannels}
/>
);
};
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 (
<Animated.View style={animatedStyle}>
{Boolean(displayName) &&
<View style={styles.headerRow}>
<View style={styles.headerRow}>
<Text style={styles.headingStyles}>
{displayName}
</Text>
<TouchableWithFeedback style={styles.chevronButton}>
<CompassIcon
style={styles.chevronIcon}
name={'chevron-down'}
/>
</TouchableWithFeedback>
</View>
<TouchableWithFeedback style={styles.plusButton}>
<TouchableWithFeedback
onPress={onHeaderPress}
type='opacity'
>
<View style={styles.headerRow}>
<Text style={styles.headingStyles}>
{displayName}
</Text>
<View style={styles.chevronButton}>
<CompassIcon
style={styles.chevronIcon}
name={'chevron-down'}
/>
</View>
</View>
</TouchableWithFeedback>
<TouchableWithFeedback
onPress={onPress}
style={styles.plusButton}
type='opacity'
>
<CompassIcon
style={styles.plusIcon}
name={'plus'}
onPress={async () => {
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,
});
}}
/>
</TouchableWithFeedback>
</View>

View File

@@ -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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
const team = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_TEAM_ID).pipe(
switchMap((id) => database.get<TeamModel>(TEAM).findAndObserve(id.value)),
catchError(() => of$({displayName: ''})),
);
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe(
switchMap(({value}) => database.get<UserModel>(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)),
),

View File

@@ -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 &&
<PlusMenuItem
pickerAction='browseChannels'
onPress={browseChannels}
/>
}
{canCreateChannels &&
<PlusMenuItem
pickerAction='createNewChannel'
onPress={createNewChannel}
/>
}
<PlusMenuItem
pickerAction='openDirectMessage'
onPress={openDirectMessage}
/>
</>
);
};
export default PlusMenuList;

View File

@@ -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 (
<View>
<SlideUpPanelItem
text={itemType.text}
icon={itemType.icon}
onPress={onPress}
/>
</View>
);
};
export default PlusMenuItem;

View File

@@ -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(
<ChannelsList
isTablet={false}
teamsCount={1}
/>,
<SafeAreaProvider>
<ChannelsList
isTablet={false}
teamsCount={1}
/>
</SafeAreaProvider>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();

View File

@@ -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 (
<Animated.View style={[styles.container, tabletStyle]}>
<TouchableOpacity onPress={() => setShowCats(!showCats)}>
<ChannelListHeader
iconPad={iconPad}
/>
</TouchableOpacity>
<ChannelListHeader
iconPad={iconPad}
onHeaderPress={() => setShowCats(!showCats)}
/>
{content}
</Animated.View>
);

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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);

View File

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