Compare commits

...

59 Commits

Author SHA1 Message Date
Jason Frerich
af8f7fcccf Merge branch 'main' into MM-47655-add-people-screen-main 2023-02-03 10:57:34 -06:00
Jason Frerich
794b16b327 fix indentation 2023-02-02 07:05:58 -06:00
Jason Frerich
052e2c66f5 Merge branch 'main' into MM-47655-add-people-screen-main 2023-02-02 07:00:59 -06:00
Jason Frerich
d2ebfb1a9f Use one-liners to reduce lines of code 2023-01-24 14:16:55 -06:00
Jason Frerich
cd97f29a2e Merge branch 'main' into MM-47655-add-people-screen-main 2023-01-24 14:08:17 -06:00
Jason Frerich
76a2c277aa Merge branch 'main' into MM-47655-add-people-screen-main 2023-01-24 07:51:30 -06:00
Jason Frerich
48dd301b86 Account for different locations to open this screen so that we know
which screen / bottom sheet to pop back to.
2023-01-09 17:04:34 -06:00
Jason Frerich
de48d2c152 copy older version of bottom sheet button for use in this component. 2023-01-09 09:40:00 -06:00
Jason Frerich
966ecb5508 Correct default message for plurization. The closing bracket was in the
wrong position
2023-01-06 14:32:32 -06:00
Jason Frerich
0e6da10728 Add locations for opening add members screen 2023-01-05 16:38:30 -06:00
Jason Frerich
77fdb1858f Merge branch 'main' into MM-47655-add-people-screen-main 2023-01-05 16:24:44 -06:00
Jason Frerich
c6efab0315 Merge branch 'main' into MM-47655-add-people-screen-main 2023-01-04 09:05:42 -06:00
Jason Frerich
78ceab9e07 add pack back in after removed during merge 2023-01-03 17:22:12 -06:00
Jason Frerich
0406619bbc Merge branch 'main' into MM-47655-add-people-screen-main 2023-01-03 17:07:27 -06:00
Jason Frerich
1c9ea94867 nit 2022-12-12 08:36:27 -06:00
Jason Frerich
a7f8226eb9 order according to npm run i18n-extract 2022-12-10 15:04:20 -06:00
Jason Frerich
b6fe56fc76 use plural option so only one intl id is necessary 2022-12-10 15:01:20 -06:00
Jason Frerich
1f73625078 Merge branch 'main' into MM-47655-add-people-screen-main 2022-12-10 13:52:28 -06:00
Jason Frerich
49a171c92a move edges to a const 2022-12-04 22:16:32 -06:00
Jason Frerich
944a1dc3a8 rename const s/style/styles/
move all formatted message into defineMessages func
sort props
2022-12-04 22:09:29 -06:00
Jason Frerich
1e5a32cd1c remove unused component. This was moved to selected_users in another pr
(CreateDirectMessage)
2022-12-04 21:54:41 -06:00
Jason Frerich
f9ff339133 deconstruct CHANNEL_ADD_PEOPLE from screens
use defineMessages function
2022-12-04 21:47:16 -06:00
Jason Frerich
da8f13283f deconstruct formatMessage from useIntl 2022-12-04 21:37:44 -06:00
Jason Frerich
6e89d4458e no need to pass in ChannelModel. already have the channelId. just add
the displayName as a prop
2022-12-04 21:35:24 -06:00
Jason Frerich
4c00e58c4c s/style/styles/ 2022-12-04 21:06:16 -06:00
Jason Frerich
05f4bd963a add missing ids to en.json 2022-12-04 21:04:23 -06:00
Jason Frerich
5b1334d6c2 update en.json 2022-12-04 20:59:45 -06:00
Jason Frerich
7a4b6e9512 update en.json for plural members added 2022-12-04 20:50:15 -06:00
Jason Frerich
f31d6c0d9b update en.json 2022-12-04 20:48:07 -06:00
Jason Frerich
10718989dd Add ability to pass values to snackbar messages 2022-12-04 20:46:40 -06:00
Jason Frerich
419a5e8488 sort props
add dependency
2022-12-03 14:16:09 -06:00
Jason Frerich
772e6a24fd only observe fields from channel and don't pass in entire channelModel 2022-12-03 13:54:08 -06:00
Jason Frerich
02f5d8fa72 remove unused dependencies 2022-12-03 13:42:31 -06:00
Jason Frerich
76facd2f2c define emtpy profiles and ids as consts 2022-12-03 13:23:49 -06:00
Jason Frerich
84a6443042 Add selected users panel 2022-12-03 13:12:36 -06:00
Jason Frerich
205fe2dae9 Merge branch 'main' into MM-47655-add-people-screen-main 2022-12-03 10:07:45 -06:00
Jason Frerich
40444118a5 Merge branch 'main' into MM-47655-add-people-screen-main 2022-12-02 16:44:08 -06:00
Jason Frerich
b9d40b59e3 check that users a present before trying to store to the database 2022-11-30 11:57:53 -06:00
Jason Frerich
86c13f2be1 Merge branch 'main' into MM-47655-add-people-screen-main 2022-11-30 11:22:58 -06:00
Jason Frerich
2c3564623d Merge branch 'main' into MM-47655-add-people-screen-main 2022-11-29 20:13:28 -06:00
Jason Frerich
f920a5b5b2 Merge branch 'main' into MM-47655-add-people-screen-main 2022-11-26 22:10:52 -06:00
Jason Frerich
2317c0e1b8 when searching, set loading to true so that empty results don't show 2022-11-19 07:37:48 -06:00
Jason Frerich
bbee41de31 revers to original 2022-11-19 07:26:32 -06:00
Jason Frerich
9c4d9c659b when searching for people, restrict to only people not in the channel 2022-11-19 07:16:07 -06:00
Jason Frerich
4d9ab6de8a - use ternary to determine which noResults component to show
- set loading true when start a user search
2022-11-19 06:37:42 -06:00
Jason Frerich
57187326b9 nit 2022-11-19 05:53:01 -06:00
Jason Frerich
b2395ab5ef add empty profiles component for when there are no more users to add to
the channel (all team members already in the channel)
2022-11-18 19:58:50 -06:00
Jason Frerich
0c5d1b267e - combine try blocks
- user getServerDatabaseAndOperator
2022-11-18 07:04:42 -06:00
Jason Frerich
b25a375762 remove @app prefix from imports 2022-11-18 06:54:17 -06:00
Jason Frerich
a5c4bc9b6a use useDidUpdate to omit calling updateNavigationButtons twice 2022-11-17 14:11:49 -06:00
Jason Frerich
6503bbb6ef rename action from start conversation start add people 2022-11-17 14:07:55 -06:00
Jason Frerich
f6db4c9cc9 Merge branch 'MM-47653-manage-members-screen-off-gekidou-branch' into MM-47655-Add-People-Screen 2022-11-17 13:58:39 -06:00
Jason Frerich
ff5e6d7892 Merge branch 'main' into MM-47655-Add-People-Screen 2022-11-17 13:35:08 -06:00
Jason Frerich
23a29a05a8 Merge branch 'main' into MM-47653-manage-members-screen-off-gekidou-branch 2022-11-17 13:33:55 -06:00
Jason Frerich
7be1abebfd - remove selectedUsers panel
- remvoe reduce
- remove checking if user.id is current user
2022-11-17 13:27:31 -06:00
Jason Frerich
b261d0dc16 don't close modal after adding members, pop to previous screen 2022-11-16 14:29:48 -06:00
Jason Frerich
f54bb59aef remove close button from navbar 2022-11-16 14:23:15 -06:00
Jason Frerich
2d10f99a94 pass channelId in to the AddPeopleBox component 2022-11-16 10:27:19 -06:00
Jason Frerich
a6b51436ac implement add members to channel screen
add channel name to add people screen nav bar
2022-11-15 14:08:07 -06:00
21 changed files with 770 additions and 114 deletions

View File

@@ -145,29 +145,21 @@ export async function fetchChannelMemberships(serverUrl: string, channelId: stri
}
export async function addMembersToChannel(serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const promises = userIds.map((id) => client.addToChannel(id, channelId, postRootId));
const channelMemberships: ChannelMembership[] = await Promise.all(promises);
const {users} = await fetchUsersByIds(serverUrl, userIds, true);
if (!fetchOnly) {
const modelPromises: Array<Promise<Model[]>> = [];
modelPromises.push(operator.handleUsers({
users,
prepareRecordsOnly: true,
}));
if (users?.length) {
modelPromises.push(operator.handleUsers({
users,
prepareRecordsOnly: true,
}));
}
modelPromises.push(operator.handleChannelMembership({
channelMemberships,
prepareRecordsOnly: true,

View File

@@ -531,6 +531,36 @@ export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, pag
}
};
export const fetchProfilesNotInChannel = async (
serverUrl: string,
teamId: string,
channelId: string,
groupConstrained: boolean,
page = 0,
perPage: number = General.PROFILE_CHUNK_SIZE,
fetchOnly = false,
) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const users = await client.getProfilesNotInChannel(teamId, channelId, groupConstrained, page, perPage);
if (!fetchOnly && users.length) {
const currentUserId = await getCurrentUserId(operator.database);
const toStore = removeUserFromList(currentUserId, users);
await operator.handleUsers({
users: toStore,
prepareRecordsOnly: false,
});
}
return {users};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const searchProfiles = async (serverUrl: string, term: string, options: SearchUserOptions, fetchOnly = false) => {
let client: Client;
try {

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import CompassIcon from '@components/compass_icon';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {goToScreen, showModal} from '@screens/navigation';
import {changeOpacity} from '@utils/theme';
import type {StyleProp, ViewStyle} from 'react-native';
type Props = {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
displayName: string;
inModal?: boolean;
testID?: string;
}
const messages = defineMessages({
title: {
id: t('mobile.channel_add_people.title'),
defaultMessage: 'Add Members',
},
boxText: {
id: t('intro.add_people'),
defaultMessage: 'Add People',
},
});
const {CHANNEL_ADD_PEOPLE} = Screens;
const closeButtonId = 'close-add-people';
const AddPeopleBox = ({channelId, containerStyle, displayName, inModal, testID}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const onAddPeople = useCallback(async () => {
const closeButton = await CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const title = formatMessage(messages.title);
const options = {
topBar: {
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: displayName,
},
leftButtons: inModal ? [] : [{
id: closeButtonId,
icon: closeButton,
testID: 'close.channel_info.button',
}],
},
};
if (inModal) {
goToScreen(CHANNEL_ADD_PEOPLE, title, {channelId}, options);
return;
}
showModal(CHANNEL_ADD_PEOPLE, title, {channelId, closeButtonId}, options);
}, [formatMessage, channelId, inModal]);
return (
<OptionBox
containerStyle={containerStyle}
iconName='account-plus-outline'
onPress={onAddPeople}
testID={testID}
text={formatMessage(messages.boxText)}
/>
);
};
export default AddPeopleBox;

View File

@@ -1,44 +1,28 @@
// 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 {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import OptionBox from '@components/option_box';
import {Screens} from '@constants';
import {dismissBottomSheet, goToScreen, showModal} from '@screens/navigation';
import {observeChannel} from '@queries/servers/channel';
import type {StyleProp, ViewStyle} from 'react-native';
import AddPeopleBox from './add_people_box';
type Props = {
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
inModal?: boolean;
testID?: string;
}
const AddPeopleBox = ({channelId, containerStyle, inModal, testID}: Props) => {
const intl = useIntl();
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
const channel = observeChannel(database, channelId);
const displayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
const onAddPeople = useCallback(async () => {
const title = intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'});
if (inModal) {
goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
return;
}
await dismissBottomSheet();
showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
}, [intl, channelId, inModal]);
return {
displayName,
};
});
return (
<OptionBox
containerStyle={containerStyle}
iconName='account-plus-outline'
onPress={onAddPeople}
testID={testID}
text={intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'})}
/>
);
};
export default AddPeopleBox;
export default withDatabase(enhanced(AddPeopleBox));

View File

@@ -5,6 +5,7 @@ import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';
import ChannelInfoStartButton from '@calls/components/channel_info_start';
import AddPeopleBox from '@components/channel_actions/add_people_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import MutedBox from '@components/channel_actions/mute_box';
@@ -13,8 +14,6 @@ import {useServerUrl} from '@context/server';
import {dismissBottomSheet} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
type Props = {
channelId: string;
channelType?: ChannelType;
@@ -70,7 +69,6 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel
testID={`${testID}.set_header.action`}
/>
}
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{!isDM &&
<AddPeopleBox
channelId={channelId}
@@ -78,7 +76,6 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel
testID={`${testID}.add_people.action`}
/>
}
*/}
{!isDM && !callsEnabled &&
<>
<View style={styles.separator}/>

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {popToRoot} from '@app/screens/navigation';
import {useTheme} from '@context/theme';
import {t} from '@i18n';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const messages = defineMessages({
no_more_members_title: {
id: t('mobile.no_more_members.title'),
defaultMessage: 'No other members to add',
},
no_more_members_subtext: {
id: t('mobile.no_more_members.subtext'),
defaultMessage: 'All team members are already in this channel.',
},
go_back: {
id: t('mobile.no_more_members.go_back'),
defaultMessage: 'Go back',
},
});
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center' as const,
flexGrow: 1,
height: '100%',
justifyContent: 'center' as const,
},
buttonContainer: {
marginTop: 24,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},
subText: {
color: changeOpacity(theme.centerChannelColor, 0.72),
marginTop: 8,
...typography('Body', 200),
},
};
});
const NoResultsWithButton = () => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const {formatMessage} = useIntl();
return (
<View style={styles.container}>
<Text style={styles.title}>{formatMessage(messages.no_more_members_title)}</Text>
<Text style={styles.subText}>{formatMessage(messages.no_more_members_subtext)}</Text>
<View style={styles.buttonContainer}>
<Button
onPress={popToRoot}
icon={'arrow-left'}
text={formatMessage(messages.go_back)}
/>
</View>
</View>
);
};
export default NoResultsWithButton;

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, StyleSheet, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {changeOpacity} from '@utils/theme';
type Props = {
disabled?: boolean;
onPress?: (e: GestureResponderEvent) => void;
icon?: string;
testID?: string;
text?: string;
}
const styles = StyleSheet.create({
button: {
display: 'flex',
flexDirection: 'row',
},
icon_container: {
width: 24,
height: 24,
top: -1,
marginRight: 4,
},
});
export default function Button({disabled = false, onPress, icon, testID, text}: Props) {
const theme = useTheme();
const buttonType = disabled ? 'disabled' : 'default';
const styleButtonText = buttonTextStyle(theme, 'lg', 'primary', buttonType);
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary', buttonType);
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.32) : theme.buttonColor;
return (
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={[styles.button, styleButtonBackground]}
testID={testID}
>
{icon && (
<View style={styles.icon_container}>
<CompassIcon
size={24}
name={icon}
color={iconColor}
/>
</View>
)}
{text && (
<Text
style={styleButtonText}
>{text}</Text>
)}
</TouchableWithFeedback>
);
}

View File

@@ -11,9 +11,9 @@ import Toast from '@components/toast';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Button from './button';
import SelectedUser from './selected_user';
type Props = {

View File

@@ -7,6 +7,7 @@ import {FlatList, Keyboard, ListRenderItemInfo, Platform, SectionList, SectionLi
import {storeProfile} from '@actions/local/user';
import Loading from '@components/loading';
import NoResultsWithButton from '@components/no_results_with_button';
import NoResultsWithTerm from '@components/no_results_with_term';
import UserListRow from '@components/user_list_row';
import {General, Screens} from '@constants';
@@ -183,7 +184,7 @@ export default function UserList({
const serverUrl = useServerUrl();
const style = getStyleFromTheme(theme);
const keyboardHeight = useKeyboardHeight();
const noResutsStyle = useMemo(() => [
const noResultsStyle = useMemo(() => [
style.noResultContainer,
{paddingBottom: keyboardHeight},
], [style, keyboardHeight]);
@@ -261,16 +262,16 @@ export default function UserList({
}, [loading, theme]);
const renderNoResults = useCallback(() => {
if (!showNoResults || !term) {
if (!showNoResults) {
return null;
}
return (
<View style={noResutsStyle}>
<NoResultsWithTerm term={term}/>
<View style={noResultsStyle}>
{term ? <NoResultsWithTerm term={term}/> : <NoResultsWithButton/>}
</View>
);
}, [showNoResults && style, term, noResutsStyle]);
}, [showNoResults && style, term, noResultsStyle]);
const renderSectionHeader = useCallback(({section}: {section: SectionListData<UserProfile>}) => {
return (

View File

@@ -29,6 +29,7 @@ export default {
SHOW_FULLNAME: 'full_name',
},
SPECIAL_MENTIONS: new Set(['all', 'channel', 'here']),
MAX_USERS_ADD_TO_CHANNEL: 25,
MAX_USERS_IN_GM: 7,
MIN_USERS_IN_GM: 3,
MAX_GROUP_CHANNELS_FOR_PROFILES: 50,

View File

@@ -142,6 +142,7 @@ export default {
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
BROWSE_CHANNELS,
CHANNEL_INFO,
CHANNEL_ADD_PEOPLE,
CREATE_DIRECT_MESSAGE,
CREATE_TEAM,
CUSTOM_STATUS,
@@ -171,7 +172,6 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
]);
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
];

View File

@@ -5,6 +5,7 @@ import {t} from '@i18n';
import keyMirror from '@utils/key_mirror';
export const SNACK_BAR_TYPE = keyMirror({
ADD_CHANNEL_MEMBERS: null,
FAVORITE_CHANNEL: null,
LINK_COPIED: null,
MESSAGE_COPIED: null,
@@ -22,6 +23,12 @@ type SnackBarConfig = {
};
export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
ADD_CHANNEL_MEMBERS: {
id: t('snack.bar.channel.members.added'),
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
iconName: 'check',
canUndo: false,
},
FAVORITE_CHANNEL: {
id: t('snack.bar.favorited.channel'),
defaultMessage: 'This channel was favorited',

View File

@@ -4,7 +4,7 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
import AddPeopleBox from '@components/channel_actions/add_people_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import InfoBox from '@components/channel_actions/info_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
@@ -39,10 +39,9 @@ const styles = StyleSheet.create({
},
});
const IntroOptions = ({channelId, header, favorite}: Props) => {
const IntroOptions = ({channelId, header, favorite, people}: Props) => {
return (
<View style={styles.container}>
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{people &&
<AddPeopleBox
channelId={channelId}
@@ -50,7 +49,6 @@ const IntroOptions = ({channelId, header, favorite}: Props) => {
testID='channel_post_list.intro_options.add_people.action'
/>
}
*/}
{header &&
<SetHeaderBox
channelId={channelId}

View File

@@ -0,0 +1,366 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, LayoutChangeEvent, Platform, StyleSheet, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {addMembersToChannel} from '@actions/remote/channel';
import {fetchProfilesNotInChannel, searchProfiles} from '@actions/remote/user';
import useNavButtonPressed from '@app/hooks/navigation_button_pressed';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useModalPosition} from '@hooks/device';
import {t} from '@i18n';
import {dismissModal, popTopScreen} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {alertErrorWithFallback} from '@utils/draft';
import {showAddChannelMembersSnackbar} from '@utils/snack_bar';
import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
import {filterProfilesMatchingTerm} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
const styles = StyleSheet.create({
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
});
const messages = defineMessages({
cancel: {
id: t('mobile.post.cancel'),
defaultMessage: 'Cancel',
},
error: {
id: t('mobile.channel_add_people.error'),
defaultMessage: 'We could not add those users to the channel. Please check your connection and try again.',
},
button: {
id: t('mobile.channel_add_people.title'),
defaultMessage: 'Add Members',
},
search: {
id: t('search_bar.search'),
defaultMessage: 'Search',
},
toastMessage: {
id: t('mobile.channel_add_people.max_limit_reached'),
defaultMessage: 'Max selected users are limited to {maxCount} members',
},
});
type Props = {
channelId: string;
closeButtonId: string;
componentId: AvailableScreens;
currentTeamId: string;
currentUserId: string;
isGroupConstrained: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const MAX_SELECTED_USERS = General.MAX_USERS_ADD_TO_CHANNEL;
const EMPTY: UserProfile[] = [];
const EDGES: Edge[] = ['top', 'left', 'right'];
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
Reflect.deleteProperty(newSelectedIds, id);
return newSelectedIds;
}
export default function ChannelAddPeople({
channelId,
closeButtonId,
componentId,
currentTeamId,
currentUserId,
isGroupConstrained,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const intl = useIntl();
const theme = useTheme();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const [profiles, setProfiles] = useState<UserProfile[]>(EMPTY);
const [searchResults, setSearchResults] = useState<UserProfile[]>(EMPTY);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingAddPeople, setStartingAddPeople] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const [containerHeight, setContainerHeight] = useState(0);
const [showToast, setShowToast] = useState(false);
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const hasProfiles = useMemo(() => Boolean(profiles.length), [profiles]);
const close = () => {
const screens = NavigationStore.getScreensInStack();
if (screens.includes('BottomSheet')) {
// from ...
dismissModal({componentId});
} else {
// from Channel Info Screen
popTopScreen();
}
Keyboard.dismiss();
};
useNavButtonPressed(closeButtonId, componentId, close, [close]);
const loadedProfiles = ({users}: {users: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
setProfiles((prev: UserProfile[]) => [...prev, ...users]);
}
};
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
fetchProfilesNotInChannel(serverUrl,
currentTeamId,
channelId,
isGroupConstrained,
page.current + 1,
General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}, 100), [loading, isSearch, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
setSelectedIds((current) => removeProfileFromList(current, id));
}, [selectedIds]);
const addPeopleToChannel = useCallback(async (ids: string[]): Promise<boolean> => {
const result = await addMembersToChannel(serverUrl, channelId, ids, '', false);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.error);
}
return !result.error;
}, [serverUrl]);
const clearSearch = useCallback(() => {
setLoading(false);
setTerm('');
setSearchResults(EMPTY);
}, []);
const startAddPeople = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingAddPeople) {
return;
}
setStartingAddPeople(true);
const idsToUse = selectedId ? Object.keys(selectedId) : Object.keys(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else {
success = await addPeopleToChannel(idsToUse);
}
if (success) {
close();
showAddChannelMembersSnackbar(idsToUse);
} else {
setStartingAddPeople(false);
}
}, [startingAddPeople, selectedIds, addPeopleToChannel]);
const handleSelectProfile = useCallback((user: UserProfile) => {
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const wasSelected = current[user.id];
if (!wasSelected && selectedCount >= MAX_SELECTED_USERS) {
setShowToast(true);
return current;
}
const newSelectedIds = Object.assign({}, current);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
return newSelectedIds;
});
}, [selectedIds, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
const results = await searchProfiles(serverUrl, lowerCasedTerm, {
team_id: currentTeamId,
not_in_channel_id: channelId,
allow_inactive: true,
});
let data: UserProfile[] = EMPTY;
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [channelId, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onSearch = useCallback((text: string) => {
setLoading(true);
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
useEffect(() => {
mounted.current = true;
getProfiles();
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
setShowToast(selectedCount >= MAX_SELECTED_USERS);
}, [selectedCount >= MAX_SELECTED_USERS]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const data = useMemo(() => {
if (isSearch) {
const exactMatches: UserProfile[] = EMPTY;
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
if (startingAddPeople) {
return (
<View style={styles.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
edges={EDGES}
onLayout={onLayout}
style={styles.container}
testID='add_members.screen'
>
{hasProfiles &&
<View style={styles.searchBar}>
<Search
autoCapitalize='none'
cancelButtonTitle={formatMessage(messages.cancel)}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onCancel={clearSearch}
onChangeText={onSearch}
onSubmitEditing={search}
placeholder={formatMessage(messages.search)}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
testID='add_members.search_bar'
value={term}
/>
</View>
}
<UserList
currentUserId={currentUserId}
fetchMore={getProfiles}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
term={term}
testID='add_members.user_list'
tutorialWatched={tutorialWatched}
/>
<SelectedUsers
buttonIcon={'account-plus-outline'}
buttonText={formatMessage(messages.button)}
containerHeight={containerHeight}
modalPosition={modalPosition}
onPress={startAddPeople}
onRemove={handleRemoveProfile}
selectedIds={selectedIds}
setShowToast={setShowToast}
showToast={showToast}
teammateNameDisplay={teammateNameDisplay}
toastIcon={'check'}
toastMessage={formatMessage(messages.toastMessage, {maxCount: MAX_SELECTED_USERS})}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Tutorial} from '@constants';
import {observeTutorialWatched} from '@queries/app/global';
import {observeCurrentChannel} from '@queries/servers/channel';
import {observeCurrentTeamId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ChannelAddPeople from './channel_add_people';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const channel = observeCurrentChannel(database);
const isGroupConstrained = channel.pipe(switchMap((c) => of$(Boolean(c?.isGroupConstrained))));
const channelId = channel.pipe(switchMap((c) => of$(c?.id)));
return {
channelId,
currentTeamId: observeCurrentTeamId(database),
isGroupConstrained,
teammateNameDisplay: observeTeammateNameDisplay(database),
tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS),
};
});
export default withDatabase(enhanced(ChannelAddPeople));

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, LayoutChangeEvent, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
@@ -59,6 +59,10 @@ type Props = {
tutorialWatched: boolean;
}
const MAX_SELECTED_USERS = General.MAX_USERS_IN_GM;
const EMPTY: UserProfile[] = [];
const EMPTY_IDS = {};
const close = () => {
Keyboard.dismiss();
dismissModal();
@@ -96,15 +100,8 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
const newSelectedIds = Object.assign(EMPTY_IDS, list);
Reflect.deleteProperty(newSelectedIds, id);
return newSelectedIds;
@@ -131,19 +128,19 @@ export default function CreateDirectMessage({
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [profiles, setProfiles] = useState<UserProfile[]>(EMPTY);
const [searchResults, setSearchResults] = useState<UserProfile[]>(EMPTY);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>(EMPTY_IDS);
const [showToast, setShowToast] = useState(false);
const [containerHeight, setContainerHeight] = useState(0);
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
const loadedProfiles = ({users}: {users: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
@@ -151,13 +148,13 @@ export default function CreateDirectMessage({
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
setProfiles((prev: UserProfile[]) => [...prev, ...users]);
}
};
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const exactMatches: UserProfile[] = EMPTY;
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
@@ -250,29 +247,30 @@ export default function CreateDirectMessage({
};
startConversation(selectedId);
} else {
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const wasSelected = current[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
setShowToast(true);
return current;
}
const newSelectedIds = Object.assign({}, current);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
return newSelectedIds;
});
return;
}
}, [currentUserId, clearSearch]);
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const wasSelected = current[user.id];
if (!wasSelected && selectedCount >= MAX_SELECTED_USERS) {
setShowToast(true);
return current;
}
const newSelectedIds = Object.assign(EMPTY_IDS, current);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
return newSelectedIds;
});
}, [currentUserId, clearSearch, selectedCount]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
@@ -285,7 +283,7 @@ export default function CreateDirectMessage({
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let searchData: UserProfile[] = [];
let searchData: UserProfile[] = EMPTY;
if (results.data) {
searchData = results.data;
}
@@ -303,6 +301,7 @@ export default function CreateDirectMessage({
}, []);
const onSearch = useCallback((text: string) => {
setLoading(true);
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
@@ -341,8 +340,8 @@ export default function CreateDirectMessage({
}, []);
useEffect(() => {
setShowToast(selectedCount >= General.MAX_USERS_IN_GM);
}, [selectedCount >= General.MAX_USERS_IN_GM]);
setShowToast(selectedCount >= MAX_SELECTED_USERS);
}, [selectedCount >= MAX_SELECTED_USERS]);
if (startingConversation) {
return (
@@ -393,7 +392,7 @@ export default function CreateDirectMessage({
showToast={showToast}
setShowToast={setShowToast}
toastIcon={'check'}
toastMessage={formatMessage(messages.toastMessage, {maxCount: General.MAX_USERS_IN_GM})}
toastMessage={formatMessage(messages.toastMessage, {maxCount: MAX_SELECTED_USERS})}
selectedIds={selectedIds}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}

View File

@@ -97,6 +97,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase(require('@screens/create_direct_message').default);
break;
case Screens.CHANNEL_ADD_PEOPLE:
screen = withServerDatabase(require('@screens/channel_add_people').default);
break;
case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default);
break;

View File

@@ -28,13 +28,12 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {ShowSnackBarArgs} from '@utils/snack_bar';
type SnackBarProps = {
componentId: AvailableScreens;
onAction?: () => void;
barType: keyof typeof SNACK_BAR_TYPE;
sourceScreen: AvailableScreens;
}
} & ShowSnackBarArgs;
const SNACK_BAR_WIDTH = 96;
const SNACK_BAR_HEIGHT = 56;
@@ -81,9 +80,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const SnackBar = ({barType, componentId, onAction, sourceScreen}: SnackBarProps) => {
const SnackBar = ({barType, componentId, messageValues = {}, onAction, sourceScreen}: SnackBarProps) => {
const [showSnackBar, setShowSnackBar] = useState<boolean | undefined>();
const intl = useIntl();
const {formatMessage} = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
@@ -245,14 +244,17 @@ const SnackBar = ({barType, componentId, onAction, sourceScreen}: SnackBarProps)
<Toast
animatedStyle={snackBarStyle}
iconName={config.iconName}
message={intl.formatMessage({id: config.id, defaultMessage: config.defaultMessage})}
message={formatMessage({
id: config.id,
defaultMessage: config.defaultMessage,
}, messageValues)}
style={[styles.toast, barType === SNACK_BAR_TYPE.LINK_COPIED && {backgroundColor: theme.onlineIndicator}]}
textStyle={styles.text}
>
{config.canUndo && onAction && (
<TouchableOpacity onPress={onUndoPressHandler}>
<Text style={styles.undo}>
{intl.formatMessage({
{formatMessage({
id: 'snack.bar.undo',
defaultMessage: 'Undo',
})}

View File

@@ -6,10 +6,16 @@ import {showOverlay} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
type ShowSnackBarArgs = {
type AddChannelMemberValues = {
numMembers: number;
};
export type ShowSnackBarArgs = {
barType: keyof typeof SNACK_BAR_TYPE;
onAction?: () => void;
sourceScreen?: AvailableScreens;
messageValues?: AddChannelMemberValues | {};
};
export const showSnackBar = (passProps: ShowSnackBarArgs) => {
@@ -31,6 +37,14 @@ export const showFavoriteChannelSnackbar = (favorited: boolean, onAction: () =>
});
};
export const showAddChannelMembersSnackbar = (ids: string[]) => {
return showSnackBar({
barType: SNACK_BAR_TYPE.ADD_CHANNEL_MEMBERS,
sourceScreen: Screens.CHANNEL_ADD_PEOPLE,
messageValues: {numMembers: ids.length},
});
};
export const showRemoveChannelUserSnackbar = () => {
return showSnackBar({
barType: SNACK_BAR_TYPE.REMOVE_CHANNEL_USER,

View File

@@ -457,7 +457,9 @@
"mobile.calls_you": "(you)",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.camera_type.title": "Camera options",
"mobile.channel_add_people.error": "We could not add those users to the channel. Please check your connection and try again.",
"mobile.channel_add_people.max_limit_reached": "Max selected users are limited to {maxCount} members",
"mobile.channel_add_people.title": "Add Members",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
"mobile.channel_list.recent": "Recent",
@@ -562,6 +564,9 @@
"mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}",
"mobile.message_length.message_split_left": "Message exceeds the character limit",
"mobile.message_length.title": "Message Length",
"mobile.no_more_members.go_back": "Go back",
"mobile.no_more_members.subtext": "All team members are already in this channel.",
"mobile.no_more_members.title": "No other members to add",
"mobile.no_results_with_term": "No results for “{term}”",
"mobile.no_results_with_term.files": "No files matching “{term}”",
"mobile.no_results_with_term.messages": "No matches found for “{term}”",
@@ -902,6 +907,7 @@
"skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.",
"skintone_selector.tooltip.title": "Choose your default skin tone",
"smobile.search.recent_title": "Recent searches in {teamName}",
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
"snack.bar.favorited.channel": "This channel was favorited",
"snack.bar.link.copied": "Link copied to clipboard",
"snack.bar.message.copied": "Text copied to clipboard",

View File

@@ -103,6 +103,7 @@ type SearchUserOptions = {
team_id?: string;
not_in_team?: string;
in_channel_id?: string;
not_in_channel_id?: string;
in_group_id?: string;
group_constrained?: boolean;
allow_inactive?: boolean;