MM-42835_Invite People - add email+user invites

This commit is contained in:
Julian Mondragon
2023-01-09 12:33:51 -05:00
parent 5bf0bbbea0
commit a9a9c00860
14 changed files with 717 additions and 303 deletions

View File

@@ -114,23 +114,55 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
}
}
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[]) {
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
client = NetworkManager.getClient(serverUrl);
EphemeralStore.startAddingToTeam(teamId);
const members = await client.addUsersToTeamGracefully(teamId, userIds);
if (!fetchOnly) {
setTeamLoading(serverUrl, true);
const teamMemberships: TeamMembership[] = [];
const roles: Record<string, boolean> = {};
for (const {member} of members) {
teamMemberships.push(member);
member.roles.split(' ').forEach((role) => {
if (!roles[role]) {
roles[role] = true;
}
});
}
fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles));
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const team = await client.getTeam(teamId);
const models: Model[] = (await Promise.all([
operator.handleTeam({teams: [team], prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}),
])).flat();
await operator.batchRecords(models);
}
setTeamLoading(serverUrl, false);
}
EphemeralStore.finishAddingToTeam(teamId);
return {members};
} catch (error) {
EphemeralStore.finishAddingToTeam(teamId);
if (client) {
EphemeralStore.finishAddingToTeam(teamId);
}
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
@@ -138,13 +170,10 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds:
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
client = NetworkManager.getClient(serverUrl);
const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails);
return {members};
@@ -469,17 +498,46 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
}
}
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[]) {
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
client = NetworkManager.getClient(serverUrl);
const members = await client.getTeamMembersByIds(teamId, userIds);
if (!fetchOnly) {
setTeamLoading(serverUrl, true);
const teamMemberships: TeamMembership[] = [];
const roles: Record<string, boolean> = {};
for (const member of members) {
teamMemberships.push(member);
member.roles.split(' ').forEach((role) => {
if (!roles[role]) {
roles[role] = true;
}
});
}
fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles));
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const team = await client.getTeam(teamId);
const models: Model[] = (await Promise.all([
operator.handleTeam({teams: [team], prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}),
])).flat();
await operator.batchRecords(models);
}
setTeamLoading(serverUrl, false);
}
return {members};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);

View File

@@ -27,7 +27,7 @@ type Props = {
/*
* The user that this component represents.
*/
user: UserProfile|string;
user: UserProfile;
/*
* A handler function that will deselect a user when clicked on.
@@ -61,16 +61,16 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
justifyContent: 'center',
marginLeft: 7,
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
color: theme.centerChannelColor,
},
text: {
marginLeft: 8,
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
},
picture: {
width: 20,
alignItems: 'center',
justifyContent: 'center',
},
};
});
@@ -84,42 +84,36 @@ export default function SelectedUser({
const style = getStyleFromTheme(theme);
const intl = useIntl();
const isProfile = typeof user !== 'string';
const id = isProfile ? user.id : user;
const onPress = useCallback(() => {
onRemove(id);
}, [onRemove, id]);
const userItemTestID = `${testID}.${id}`;
onRemove(user.id);
}, [onRemove, user.id]);
const userItemTestID = `${testID}.${user.id}`;
return (
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
testID={`${userItemTestID}`}
testID={`${testID}.${user.id}`}
>
{isProfile && (
<View style={style.picture}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
)}
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
<Text
style={style.text}
testID={`${userItemTestID}.display_name`}
testID={`${testID}.${user.id}.display_name`}
>
{isProfile ? displayUsername(user, intl.locale, teammateNameDisplay) : id}
{displayUsername(user, intl.locale, teammateNameDisplay)}
</Text>
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${userItemTestID}.remove.button`}
testID={`${testID}.${user.id}.remove.button`}
>
<CompassIcon
name='close-circle'

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useMemo} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
@@ -121,6 +121,10 @@ const UserItem = ({
}
}
const usernameTextStyle = useMemo(() => {
return [style.rowUsername, {flexShrink: rowUsernameFlexShrink}];
}, [user, rowUsernameFlexShrink]);
return (
<View
style={[style.row, containerStyle]}
@@ -158,7 +162,7 @@ const UserItem = ({
}
{Boolean(user) && (
<Text
style={{...style.rowUsername, flexShrink: rowUsernameFlexShrink}}
style={usernameTextStyle}
numberOfLines={1}
testID={`${userItemTestId}.username`}
>

View File

@@ -26,7 +26,7 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro
await dismissBottomSheet();
const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'Browse channels'});
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
showModal(Screens.BROWSE_CHANNELS, title, {
closeButton,
@@ -44,7 +44,7 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro
await dismissBottomSheet();
const title = intl.formatMessage({id: 'create_direct_message.title', defaultMessage: 'Create Direct Message'});
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
showModal(Screens.CREATE_DIRECT_MESSAGE, title, {
closeButton,
});
@@ -54,12 +54,13 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro
await dismissBottomSheet();
const title = intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'});
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-invite';
showModal(
Screens.INVITE,
title,
{closeButton},
{closeButton, closeButtonId},
);
}, [intl, theme]);

View File

@@ -10,7 +10,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {getTeamMembersByIds, addUsersToTeam, sendEmailInvitesToTeam} from '@actions/remote/team';
import {searchProfiles} from '@actions/remote/user';
import Loading from '@components/loading';
import {General, ServerErrors} from '@constants';
import {ServerErrors} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useModalPosition} from '@hooks/device';
@@ -27,6 +27,7 @@ import type {NavButtons} from '@typings/screens/navigation';
const CLOSE_BUTTON_ID = 'close-invite';
const SEND_BUTTON_ID = 'send-invite';
const SEARCH_TIMEOUT_MILLISECONDS = 200;
const makeLeftButton = (icon: ImageResource): OptionsTopBarButton => {
return {
@@ -124,6 +125,7 @@ export default function Invite({
const [result, setResult] = useState<Result>({sent: [], notSent: []});
const [wrapperHeight, setWrapperHeight] = useState(0);
const [stage, setStage] = useState(Stage.SELECTION);
const [sendError, setSendError] = useState('');
const selectedCount = Object.keys(selectedIds).length;
@@ -153,8 +155,8 @@ export default function Invite({
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {allow_inactive: true});
const results: SearchResult[] = data ?? [];
if (isEmail(searchTerm.trim())) {
results.unshift(searchTerm.trim() as EmailInvite);
if (!results.length && isEmail(searchTerm.trim())) {
results.push(searchTerm.trim() as EmailInvite);
}
setSearchResults(results);
@@ -176,7 +178,7 @@ export default function Invite({
searchTimeoutId.current = setTimeout(async () => {
await searchUsers(text);
setLoading(false);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
}, SEARCH_TIMEOUT_MILLISECONDS);
}, [searchUsers]);
const handleSelectItem = useCallback((item: SearchResult) => {
@@ -193,6 +195,12 @@ export default function Invite({
handleClearSearch();
}, [selectedIds, handleClearSearch]);
const handleSendError = () => {
setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Received an unexpected error. Please try again or contact your System Admin for assistance.'}));
setResult({sent: [], notSent: []});
setStage(Stage.RESULT);
};
const handleSend = async () => {
if (!selectedCount) {
return;
@@ -211,11 +219,19 @@ export default function Invite({
}
}
const {members: currentTeamMembers = []} = await getTeamMembersByIds(serverUrl, teamId, userIds);
const currentMemberIds: Record<string, boolean> = {};
for (const {user_id: currentMemberId} of currentTeamMembers) {
currentMemberIds[currentMemberId] = true;
if (userIds.length) {
const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds);
if (getTeamMembersByIdsError) {
handleSendError();
return;
}
for (const {user_id: currentMemberId} of currentTeamMembers) {
currentMemberIds[currentMemberId] = true;
}
}
const sent: InviteResult[] = [];
@@ -233,7 +249,12 @@ export default function Invite({
}
if (usersToAdd.length) {
const {members} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
if (addUsersToTeamError) {
handleSendError();
return;
}
if (members) {
const membersWithError: Record<string, string> = {};
@@ -254,7 +275,12 @@ export default function Invite({
}
if (emails.length) {
const {members} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
if (sendEmailInvitesToTeamError) {
handleSendError();
return;
}
if (members) {
const membersWithError: Record<string, string> = {};
@@ -320,6 +346,7 @@ export default function Invite({
<Summary
result={result}
selectedIds={selectedIds}
error={sendError}
onClose={closeModal}
testID='invite.screen.summary'
/>

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Text, TouchableOpacity} from 'react-native';
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
email: string;
onRemove: (id: string) => void;
testID?: string;
}
export const USER_CHIP_HEIGHT = 32;
export const USER_CHIP_BOTTOM_MARGIN = 8;
const FADE_DURATION = 100;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 16,
height: USER_CHIP_HEIGHT,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: USER_CHIP_BOTTOM_MARGIN,
marginRight: 8,
paddingHorizontal: 7,
},
remove: {
justifyContent: 'center',
marginLeft: 7,
},
text: {
marginLeft: 8,
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
},
};
});
export default function SelectedEmail({
email,
onRemove,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const onPress = useCallback(() => {
onRemove(email);
}, [onRemove, email]);
const selectedEmailTestID = `${testID}.${email}`;
return (
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
testID={`${selectedEmailTestID}`}
>
<Text
style={style.text}
testID={`${selectedEmailTestID}.display_name`}
>
{email}
</Text>
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${selectedEmailTestID}.remove.button`}
>
<CompassIcon
name='close-circle'
size={18}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
</TouchableOpacity>
</Animated.View>
);
}

View File

@@ -2,37 +2,37 @@
// See LICENSE.txt for license information.
import React, {useCallback, useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, View, Text, TouchableOpacity, LayoutChangeEvent, useWindowDimensions, FlatList, ListRenderItemInfo, ScrollView} from 'react-native';
import {
Keyboard,
Platform,
View,
LayoutChangeEvent,
useWindowDimensions,
FlatList,
ListRenderItemInfo,
ScrollView,
} from 'react-native';
import Animated, {useDerivedValue} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Share from 'react-native-share';
import {ShareOptions} from 'react-native-share/lib/typescript/types';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput from '@components/floating_text_input_label';
import FormattedText from '@components/formatted_text';
import SelectedUser from '@components/selected_users/selected_user';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import UserItem from '@components/user_item';
import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete';
import {useServerDisplayName} from '@context/server';
import {useTheme} from '@context/theme';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useIsTablet, useKeyboardHeight} from '@hooks/device';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {SearchResult} from './invite';
import SelectedEmail from './selected_email';
import SelectionSearchBar from './selection_search_bar';
import SelectionTeamBar from './selection_team_bar';
import TextItem, {TextItemType} from './text_item';
const AUTOCOMPLETE_ADJUST = 5;
const BOTTOM_AUTOCOMPLETE_SEPARATION = 10;
const SEARCH_BAR_TITLE_MARGIN_TOP = 24;
const SEARCH_BAR_MARGIN_TOP = 16;
const SEARCH_BAR_BORDER = 2;
const KEYBOARD_HEIGHT_ADJUST = 3;
const INITIAL_BATCH_TO_RENDER = 15;
const SCROLL_EVENT_THROTTLE = 60;
@@ -104,16 +104,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
shareLinkIcon: {
color: theme.buttonBg,
},
searchBarTitleText: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_TITLE_MARGIN_TOP,
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
},
searchBar: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_MARGIN_TOP,
},
searchList: {
left: 20,
right: 20,
@@ -123,10 +113,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
searchListBorder: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
overflow: 'hidden',
borderRadius: 4,
elevation: 3,
},
searchListPadding: {
paddingVertical: 8,
},
searchListShadow: {
shadowColor: '#000',
shadowOpacity: 0.12,
@@ -193,98 +185,31 @@ export default function Selection({
onRemoveItem,
onClose,
}: SelectionProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverDisplayName = useServerDisplayName();
const dimensions = useWindowDimensions();
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const keyboardHeight = useKeyboardHeight();
const [headerFieldHeight, setHeaderFieldHeight] = useState(0);
const [teamBarHeight, setTeamBarHeight] = useState(0);
const [searchBarHeight, setSearchBarHeight] = useState(0);
const [searchBarTitleHeight, setSearchBarTitleHeight] = useState(0);
const onLayoutHeader = useCallback((e: LayoutChangeEvent) => {
setHeaderFieldHeight(e.nativeEvent.layout.height);
const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => {
setTeamBarHeight(e.nativeEvent.layout.height);
}, []);
const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => {
setSearchBarHeight(e.nativeEvent.layout.height);
}, []);
const onLayoutSearchBarTitle = useCallback((e: LayoutChangeEvent) => {
setSearchBarTitleHeight(e.nativeEvent.layout.height);
}, []);
const handleSearchChange = (text: string) => {
onSearchChange(text);
};
const handleOnRemoveItem = (id: string) => {
onRemoveItem(id);
};
const handleOnShareLink = async () => {
const url = `${serverUrl}/signup_user_complete/?id=${teamInviteId}`;
const title = formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: teamDisplayName});
const message = formatMessage({id: 'invite_people_to_team.message', defaultMessage: 'Heres a link to collaborate and communicate with us on Mattermost.'});
const icon = 'data:<data_type>/<file_extension>;base64,<base64_data>';
const options: ShareOptions = Platform.select({
ios: {
activityItemSources: [
{
placeholderItem: {
type: 'url',
content: url,
},
item: {
default: {
type: 'text',
content: `${message} ${url}`,
},
copyToPasteBoard: {
type: 'url',
content: url,
},
},
subject: {
default: title,
},
linkMetadata: {
originalUrl: url,
url,
title,
icon,
},
},
],
},
default: {
title,
subject: title,
url,
showAppsToView: true,
},
});
await onClose();
Share.open(
options,
).catch(() => {
// do nothing
});
};
const handleShareLink = useCallback(preventDoubleTap(() => handleOnShareLink()), []);
const bottomSpace = dimensions.height - wrapperHeight - modalPosition;
const otherElementsSize = headerFieldHeight + searchBarHeight + searchBarTitleHeight +
SEARCH_BAR_MARGIN_TOP + SEARCH_BAR_TITLE_MARGIN_TOP + SEARCH_BAR_BORDER;
const insetsAdjust = keyboardHeight || insets.bottom;
const otherElementsSize = teamBarHeight + searchBarHeight;
const insetsAdjust = (keyboardHeight + KEYBOARD_HEIGHT_ADJUST) || insets.bottom;
const keyboardOverlap = Platform.select({
ios: isTablet ? (
@@ -301,11 +226,11 @@ export default function Selection({
const workingSpace = wrapperHeight - keyboardOverlap;
const spaceOnTop = otherElementsSize - AUTOCOMPLETE_ADJUST;
const spaceOnBottom = workingSpace - (otherElementsSize + BOTTOM_AUTOCOMPLETE_SEPARATION);
const spaceOnBottom = workingSpace - (otherElementsSize + keyboardAdjust);
const autocompletePosition = spaceOnBottom > spaceOnTop ? (
otherElementsSize
) : (
(workingSpace + AUTOCOMPLETE_ADJUST + keyboardAdjust) - otherElementsSize
workingSpace - otherElementsSize
);
const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop;
const isLandscape = dimensions.width > dimensions.height;
@@ -316,7 +241,7 @@ export default function Selection({
const maxHeight = useDerivedValue(() => {
return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight);
}, [defaultMaxHeight]);
}, [animatedAutocompleteAvailableSpace, defaultMaxHeight]);
const searchListContainerStyle = useMemo(() => {
const style = [];
@@ -329,14 +254,22 @@ export default function Selection({
},
);
if (searchResults.length) {
style.push(styles.searchListBorder);
}
if (Platform.OS === 'ios') {
style.push(styles.searchListShadow);
}
return style;
}, [searchResults, styles, animatedAutocompletePosition, maxHeight]);
const searchListFlatListStyle = useMemo(() => {
const style = [];
style.push(styles.searchListFlatList);
if (searchResults.length) {
style.push(styles.searchListBorder, styles.searchListPadding);
}
return style;
}, [searchResults, styles]);
@@ -346,11 +279,13 @@ export default function Selection({
}
return (
<TextItem
text={term}
type={TextItemType.SEARCH_NO_RESULTS}
testID='invite.search_list_no_results'
/>
<View style={[styles.searchListBorder, styles.searchListPadding]}>
<TextItem
text={term}
type={TextItemType.SEARCH_NO_RESULTS}
testID='invite.search_list_no_results'
/>
</View>
);
}, [term, loading]);
@@ -386,15 +321,24 @@ export default function Selection({
const selectedItems = [];
for (const id of Object.keys(selectedIds)) {
selectedItems.push(
const selectedItem = selectedIds[id];
selectedItems.push(typeof selectedItem === 'string' ? (
<SelectedEmail
key={id}
email={selectedItem}
onRemove={handleOnRemoveItem}
testID='invite.selected_item'
/>
) : (
<SelectedUser
key={id}
user={selectedIds[id]}
user={selectedItem}
teammateNameDisplay={teammateNameDisplay}
onRemove={handleOnRemoveItem}
testID='invite.selected_item'
/>,
);
/>
));
}
return selectedItems;
@@ -405,84 +349,20 @@ export default function Selection({
style={styles.container}
testID={testID}
>
<View
style={styles.teamContainer}
onLayout={onLayoutHeader}
>
<View style={styles.iconContainer}>
<TeamIcon
id={teamId}
displayName={teamDisplayName}
lastIconUpdate={teamLastIconUpdate}
selected={false}
textColor={theme.centerChannelColor}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.16)}
testID='invite.team_icon'
/>
</View>
<View style={styles.textContainer}>
<Text
style={styles.teamText}
numberOfLines={1}
testID='invite.team_display_name'
>
{teamDisplayName}
</Text>
<Text
style={styles.serverText}
numberOfLines={1}
testID='invite.server_display_name'
>
{serverDisplayName}
</Text>
</View>
<TouchableOpacity
onPress={handleShareLink}
style={styles.shareLink}
>
<View
style={styles.shareLinkButton}
testID='invite.share_link.button'
>
<CompassIcon
name='export-variant'
size={18}
style={styles.shareLinkIcon}
/>
<FormattedText
id='invite.shareLink'
defaultMessage='Share link'
style={styles.shareLinkText}
/>
</View>
</TouchableOpacity>
</View>
<FormattedText
id='invite.sendInvitationsTo'
defaultMessage='Send invitations to…'
style={styles.searchBarTitleText}
onLayout={onLayoutSearchBarTitle}
testID='invite.search_bar_title'
<SelectionTeamBar
teamId={teamId}
teamDisplayName={teamDisplayName}
teamLastIconUpdate={teamLastIconUpdate}
teamInviteId={teamInviteId}
serverUrl={serverUrl}
onLayoutContainer={onLayoutSelectionTeamBar}
onClose={onClose}
/>
<SelectionSearchBar
term={term}
onSearchChange={onSearchChange}
onLayoutContainer={onLayoutSearchBar}
/>
<View
style={styles.searchBar}
onLayout={onLayoutSearchBar}
>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
placeholder={formatMessage({id: 'invite.searchPlaceholder', defaultMessage: 'Type a name or email address…'})}
onChangeText={handleSearchChange}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
returnKeyType='search'
value={term}
theme={theme}
testID='invite.search_bar_input'
/>
</View>
{Object.keys(selectedIds).length > 0 && (
<ScrollView
style={styles.selectedItems}
@@ -503,8 +383,8 @@ export default function Selection({
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
renderItem={renderItem}
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
style={styles.searchListFlatList}
testID='invite.search_list'
style={searchListFlatListStyle}
/>
</Animated.View>
</View>

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View, TextInput, LayoutChangeEvent} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const SEARCH_BAR_TITLE_MARGIN_TOP = 24;
const SEARCH_BAR_MARGIN_TOP = 16;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
},
searchBarTitleText: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_TITLE_MARGIN_TOP,
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
},
searchBar: {
marginHorizontal: 20,
marginTop: SEARCH_BAR_MARGIN_TOP,
},
searchInput: {
height: 48,
backgroundColor: 'transparent',
...typography('Body', 200, 'Regular'),
lineHeight: 20,
color: theme.centerChannelColor,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
paddingHorizontal: 16,
},
searchInputPlaceholder: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
type SelectionSearchBarProps = {
term: string;
onSearchChange: (text: string) => void;
onLayoutContainer: (e: LayoutChangeEvent) => void;
}
export default function SelectionSearchBar({
term,
onSearchChange,
onLayoutContainer,
}: SelectionSearchBarProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const [isFocused, setIsFocused] = useState(false);
const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => {
onLayoutContainer(e);
}, [onLayoutContainer]);
const onTextInputFocus = () => {
setIsFocused(true);
};
const onTextInputBlur = () => {
setIsFocused(false);
};
const handleSearchChange = (text: string) => {
onSearchChange(text);
};
const searchInputStyle = useMemo(() => {
const style = [];
style.push(styles.searchInput);
if (isFocused) {
style.push({
borderWidth: 2,
borderColor: theme.buttonBg,
});
}
return style;
}, [isFocused, styles]);
return (
<View
style={styles.container}
onLayout={onLayoutSearchBar}
testID='invite.search_bar'
>
<FormattedText
id='invite.sendInvitationsTo'
defaultMessage='Send invitations to…'
style={styles.searchBarTitleText}
testID='invite.search_bar_title'
/>
<View style={styles.searchBar}>
<TextInput
autoCorrect={false}
autoCapitalize={'none'}
autoFocus={true}
blurOnSubmit={false}
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
returnKeyType='search'
style={searchInputStyle}
placeholder={formatMessage({id: 'invite.searchPlaceholder', defaultMessage: 'Type a name or email address…'})}
placeholderTextColor={styles.searchInputPlaceholder.color}
onChangeText={handleSearchChange}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
pointerEvents='auto'
underlineColorAndroid='transparent'
testID='invite.search_bar_input'
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,215 @@
// 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 {
Platform,
View,
Text,
TouchableOpacity,
LayoutChangeEvent,
} from 'react-native';
import Share from 'react-native-share';
import {ShareOptions} from 'react-native-share/lib/typescript/types';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import {useServerDisplayName} from '@context/server';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
width: '100%',
paddingVertical: 16,
paddingHorizontal: 20,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
iconContainer: {
width: 40,
height: 40,
},
textContainer: {
display: 'flex',
flexDirection: 'column',
},
teamText: {
color: theme.centerChannelColor,
marginLeft: 12,
...typography('Body', 200, 'SemiBold'),
},
serverText: {
color: changeOpacity(theme.centerChannelColor, 0.72),
marginLeft: 12,
...typography('Body', 75, 'Regular'),
},
shareLink: {
display: 'flex',
marginLeft: 'auto',
},
shareLinkButton: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
height: 40,
paddingHorizontal: 20,
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
},
shareLinkText: {
color: theme.buttonBg,
...typography('Body', 100, 'SemiBold'),
paddingLeft: 7,
},
shareLinkIcon: {
color: theme.buttonBg,
},
};
});
type SelectionTeamBarProps = {
teamId: string;
teamDisplayName: string;
teamLastIconUpdate: number;
teamInviteId: string;
serverUrl: string;
onLayoutContainer: (e: LayoutChangeEvent) => void;
onClose: () => Promise<void>;
}
export default function SelectionTeamBar({
teamId,
teamDisplayName,
teamLastIconUpdate,
teamInviteId,
serverUrl,
onLayoutContainer,
onClose,
}: SelectionTeamBarProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverDisplayName = useServerDisplayName();
const handleOnLayoutContainer = useCallback((e: LayoutChangeEvent) => {
onLayoutContainer(e);
}, [onLayoutContainer]);
const handleOnShareLink = async () => {
const url = `${serverUrl}/signup_user_complete/?id=${teamInviteId}`;
const title = formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: teamDisplayName});
const message = formatMessage({id: 'invite_people_to_team.message', defaultMessage: 'Heres a link to collaborate and communicate with us on Mattermost.'});
const icon = 'data:<data_type>/<file_extension>;base64,<base64_data>';
const options: ShareOptions = Platform.select({
ios: {
activityItemSources: [
{
placeholderItem: {
type: 'url',
content: url,
},
item: {
default: {
type: 'text',
content: `${message} ${url}`,
},
copyToPasteBoard: {
type: 'url',
content: url,
},
},
subject: {
default: title,
},
linkMetadata: {
originalUrl: url,
url,
title,
icon,
},
},
],
},
default: {
title,
subject: title,
url,
showAppsToView: true,
},
});
await onClose();
Share.open(
options,
).catch(() => {
// do nothing
});
};
const handleShareLink = useCallback(preventDoubleTap(() => handleOnShareLink()), []);
return (
<View
style={styles.container}
onLayout={handleOnLayoutContainer}
>
<View style={styles.iconContainer}>
<TeamIcon
id={teamId}
displayName={teamDisplayName}
lastIconUpdate={teamLastIconUpdate}
selected={false}
textColor={theme.centerChannelColor}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.16)}
testID='invite.team_icon'
/>
</View>
<View style={styles.textContainer}>
<Text
style={styles.teamText}
numberOfLines={1}
testID='invite.team_display_name'
>
{teamDisplayName}
</Text>
<Text
style={styles.serverText}
numberOfLines={1}
testID='invite.server_display_name'
>
{serverDisplayName}
</Text>
</View>
<TouchableOpacity
onPress={handleShareLink}
style={styles.shareLink}
>
<View
style={styles.shareLinkButton}
testID='invite.share_link.button'
>
<CompassIcon
name='export-variant'
size={18}
style={styles.shareLinkIcon}
/>
<FormattedText
id='invite.shareLink'
defaultMessage='Share link'
style={styles.shareLinkText}
/>
</View>
</TouchableOpacity>
</View>
);
}

View File

@@ -18,6 +18,8 @@ import {typography} from '@utils/typography';
import {SearchResult, Result} from './invite';
import SummaryReport, {SummaryReportType} from './summary_report';
const MAX_WIDTH_CONTENT = 480;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
@@ -26,36 +28,40 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
summary: {
display: 'flex',
flexGrowth: 1,
flex: 1,
},
summaryContainer: {
flexGrow: 1,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
margin: 20,
alignSelf: 'center',
marginTop: 20,
marginHorizontal: 20,
paddingBottom: 20,
maxWidth: MAX_WIDTH_CONTENT,
},
summarySvg: {
marginBottom: 20,
alignSelf: 'center',
},
summaryMessageText: {
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
textAlign: 'center',
marginHorizontal: 32,
marginBottom: 16,
marginBottom: 24,
},
summaryButton: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
margin: 20,
padding: 15,
},
summaryButtonContainer: {
footer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
padding: 20,
},
summaryButtonContainer: {
flexGrow: 1,
maxWidth: MAX_WIDTH_CONTENT,
},
};
});
@@ -63,6 +69,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
type SummaryProps = {
result: Result;
selectedIds: {[id: string]: SearchResult};
error?: string;
testID: string;
onClose: () => void;
}
@@ -70,6 +77,7 @@ type SummaryProps = {
export default function Summary({
result,
selectedIds,
error,
testID,
onClose,
}: SummaryProps) {
@@ -84,10 +92,21 @@ export default function Summary({
const styleButtonText = buttonTextStyle(theme, 'lg', 'primary');
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary');
let message = '';
let svg = <></>;
let message = '';
if (sentCount && !notSentCount) {
if (error) {
svg = <ErrorSvg/>;
} else if (!sentCount && notSentCount) {
svg = <ErrorSvg/>;
message = error || formatMessage(
{
id: 'invite.summary.not_sent',
defaultMessage: '{notSentCount, plural, one {Invitation wasnt} other {Invitations werent}} sent',
},
{notSentCount},
);
} else if (sentCount && !notSentCount) {
svg = <SuccessSvg/>;
message = formatMessage(
{
@@ -105,15 +124,6 @@ export default function Summary({
},
{notSentCount},
);
} else if (!sentCount && notSentCount) {
svg = <ErrorSvg/>;
message = formatMessage(
{
id: 'invite.summary.not_sent',
defaultMessage: '{notSentCount, plural, one {Invitation wasnt} other {Invitations werent}} sent',
},
{notSentCount},
);
}
const handleOnPressButton = () => {
@@ -136,31 +146,37 @@ export default function Summary({
<Text style={styles.summaryMessageText}>
{message}
</Text>
<SummaryReport
type={SummaryReportType.NOT_SENT}
invites={notSent}
selectedIds={selectedIds}
testID='invite.summary_report'
/>
<SummaryReport
type={SummaryReportType.SENT}
invites={sent}
selectedIds={selectedIds}
testID='invite.summary_report'
/>
{!error && (
<>
<SummaryReport
type={SummaryReportType.NOT_SENT}
invites={notSent}
selectedIds={selectedIds}
testID='invite.summary_report'
/>
<SummaryReport
type={SummaryReportType.SENT}
invites={sent}
selectedIds={selectedIds}
testID='invite.summary_report'
/>
</>
)}
</ScrollView>
<View style={styles.summaryButtonContainer}>
<Button
containerStyle={[styles.summaryButton, styleButtonBackground]}
onPress={handleOnPressButton}
testID='invite.summary_button'
>
<FormattedText
id='invite.summary.done'
defaultMessage='Done'
style={styleButtonText}
/>
</Button>
<View style={styles.footer}>
<View style={styles.summaryButtonContainer}>
<Button
containerStyle={styleButtonBackground}
onPress={handleOnPressButton}
testID='invite.summary_button'
>
<FormattedText
id='invite.summary.done'
defaultMessage='Done'
style={styleButtonText}
/>
</Button>
</View>
</View>
</View>
);

View File

@@ -22,14 +22,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
width: '100%',
marginBottom: 16,
paddingVertical: 8,
},
summaryInvitationsTitle: {
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
@@ -42,7 +40,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
summaryInvitationsItem: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingVertical: 12,
},
summaryInvitationsUser: {

View File

@@ -331,6 +331,7 @@
"invite.search.email_invite": "invite",
"invite.search.no_results": "No one found matching",
"invite.searchPlaceholder": "Type a name or email address…",
"invite.send_error": "Received an unexpected error. Please try again or contact your System Admin for assistance.",
"invite.send_invite": "Send",
"invite.sendInvitationsTo": "Send invitations to…",
"invite.shareLink": "Share link",

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {ChannelListScreen} from '@support/ui/screen';
import {timeouts} from '@support/utils';
import {timeouts, wait} from '@support/utils';
import {expect} from 'detox';
class InviteScreen {
@@ -111,6 +111,7 @@ class InviteScreen {
open = async () => {
await ChannelListScreen.headerPlusButton.tap();
await wait(timeouts.ONE_SEC);
await ChannelListScreen.invitePeopleToTeamItem.tap();
return this.toBeVisible();

View File

@@ -45,6 +45,7 @@ class ServerScreen {
connectToServer = async (serverUrl: string, serverDisplayName: string) => {
await this.toBeVisible();
await this.serverUrlInput.replaceText(serverUrl);
await this.serverUrlInput.tapReturnKey();
await this.serverDisplayNameInput.replaceText(serverDisplayName);
await this.connectButton.tap();
};