MM-42835_Invite People - add email+user invites

This commit is contained in:
Julian Mondragon
2022-12-12 16:36:49 -05:00
parent 602bb0984b
commit a476b53d5f
27 changed files with 1953 additions and 122 deletions

View File

@@ -98,6 +98,46 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
}
}
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[]) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
EphemeralStore.startAddingToTeam(teamId);
const members = await client.addUsersToTeamGracefully(teamId, userIds);
EphemeralStore.finishAddingToTeam(teamId);
return {members};
} catch (error) {
EphemeralStore.finishAddingToTeam(teamId);
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
}
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails);
return {members};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
}
export async function fetchMyTeams(serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> {
let client;
try {
@@ -355,3 +395,21 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
logDebug('Failed to kick user from team', error);
}
}
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[]) {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const members = await client.getTeamMembersByIds(teamId, userIds);
return {members};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
}

View File

@@ -18,7 +18,10 @@ export interface ClientTeamsMix {
getMyTeamMembers: () => Promise<TeamMembership[]>;
getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise<TeamMembership[]>;
getTeamMember: (teamId: string, userId: string) => Promise<TeamMembership>;
getTeamMembersByIds: (teamId: string, userIds: string[]) => Promise<TeamMembership[]>;
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise<TeamMemberWithError[]>;
sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise<TeamInviteWithError[]>;
joinTeam: (inviteId: string) => Promise<TeamMembership>;
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
getTeamStats: (teamId: string) => Promise<any>;
@@ -120,6 +123,13 @@ const ClientTeams = (superclass: any) => class extends superclass {
);
};
getTeamMembersByIds = (teamId: string, userIds: string[]) => {
return this.doFetch(
`${this.getTeamMembersRoute(teamId)}/ids`,
{method: 'post', body: userIds},
);
};
addToTeam = async (teamId: string, userId: string) => {
this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId});
@@ -130,6 +140,27 @@ const ClientTeams = (superclass: any) => class extends superclass {
);
};
addUsersToTeamGracefully = (teamId: string, userIds: string[]) => {
this.analytics.trackAPI('api_teams_batch_add_members', {team_id: teamId, count: userIds.length});
const members: Array<{team_id: string; user_id: string}> = [];
userIds.forEach((id) => members.push({team_id: teamId, user_id: id}));
return this.doFetch(
`${this.getTeamMembersRoute(teamId)}/batch?graceful=true`,
{method: 'post', body: members},
);
};
sendEmailInvitesToTeamGracefully = (teamId: string, emails: string[]) => {
this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId});
return this.doFetch(
`${this.getTeamRoute(teamId)}/invite/email?graceful=true`,
{method: 'post', body: emails},
);
};
joinTeam = async (inviteId: string) => {
const query = buildQueryString({invite_id: inviteId});
return this.doFetch(

View File

@@ -100,7 +100,7 @@ type FloatingTextInputProps = TextInputProps & {
error?: string;
errorIcon?: string;
isKeyboardInput?: boolean;
label: string;
label?: string;
labelTextStyle?: TextStyle;
multiline?: boolean;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
@@ -245,14 +245,16 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
onLayout={onLayout}
>
<View style={combinedContainerStyle}>
<Animated.Text
onPress={onAnimatedTextPress}
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
{label && (
<Animated.Text
onPress={onAnimatedTextPress}
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
)}
<TextInput
{...props}
editable={isKeyboardInput && editable}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as React from 'react';
import Svg, {Path} from 'react-native-svg';
function AlertSvgComponent() {
return (
<Svg
width={55}
height={55}
viewBox='0 0 55 55'
fill='none'
>
<Path
d='M4.43715 54.2949C1.46653 54.2949 0.12055 52.1536 1.44612 49.5365L25.4558 2.37353C26.8154 -0.236815 28.9566 -0.236815 30.289 2.37353L54.292 49.5365C55.6515 52.1468 54.292 54.2949 51.3009 54.2949H4.43715Z'
fill='#FFBC1F'
/>
<Path
d='M24.1032 19.8165L26.5708 36.3963C26.5946 36.7253 26.7422 37.0331 26.9837 37.2578C27.2252 37.4824 27.5428 37.6073 27.8726 37.6073C28.2025 37.6073 28.5201 37.4824 28.7616 37.2578C29.0031 37.0331 29.1506 36.7253 29.1744 36.3963L31.642 19.8165C32.0907 13.3518 23.6478 13.3518 24.1032 19.8165Z'
fill='#2D3039'
/>
<Path
d='M27.8688 39.3942C28.6161 39.3955 29.3461 39.6183 29.9668 40.0344C30.5874 40.4506 31.0708 41.0413 31.3559 41.7321C31.6409 42.4228 31.7147 43.1825 31.5681 43.9153C31.4215 44.648 31.061 45.3208 30.5322 45.8487C30.0033 46.3766 29.3299 46.7359 28.5969 46.8812C27.8639 47.0265 27.1043 46.9512 26.4141 46.6649C25.7239 46.3787 25.134 45.8943 24.719 45.2729C24.304 44.6515 24.0825 43.921 24.0825 43.1737C24.0825 42.6768 24.1804 42.1848 24.3708 41.7258C24.5612 41.2668 24.8402 40.8498 25.1919 40.4988C25.5436 40.1477 25.9611 39.8695 26.4204 39.6799C26.8798 39.4904 27.3719 39.3933 27.8688 39.3942Z'
fill='#2D3039'
/>
</Svg>
);
}
export default AlertSvgComponent;

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as React from 'react';
import Svg, {G, Path, Defs, ClipPath, Rect} from 'react-native-svg';
function ErrorSvgComponent() {
return (
<Svg
width={46}
height={45}
viewBox='0 0 46 45'
fill='none'
>
<G clipPath='url(#clip0_1304_35713)'>
<Path
d='M45.2126 6.63077L38.8691 0.287231L23.0033 16.153L7.13065 0.287231L0.787109 6.63077L16.6529 22.5035L0.787109 38.3692L7.13065 44.7128L23.0033 28.847L38.8691 44.7128L45.2126 38.3692L29.3469 22.5035L45.2126 6.63077Z'
fill='#D24B4E'
/>
</G>
<Defs>
<ClipPath id='clip0_1304_35713'>
<Rect
width='44.4255'
height='44.4255'
fill='white'
transform='translate(0.787109 0.287231)'
/>
</ClipPath>
</Defs>
</Svg>
);
}
export default ErrorSvgComponent;

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as React from 'react';
import Svg, {Path} from 'react-native-svg';
function SuccessSvgComponent() {
return (
<Svg
width={46}
height={47}
viewBox='0 0 46 47'
fill='none'
>
<Path
d='M41.1767 0.776611L13.0005 31.7625L4.82284 25.5642H0.276367L13.0005 46.2234L45.7232 0.776611H41.1767Z'
fill='#3DB887'
/>
</Svg>
);
}
export default SuccessSvgComponent;

View File

@@ -27,7 +27,7 @@ type Props = {
/*
* The user that this component represents.
*/
user: UserProfile;
user: UserProfile|string;
/*
* 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,36 +84,42 @@ export default function SelectedUser({
const style = getStyleFromTheme(theme);
const intl = useIntl();
const onPress = useCallback(() => {
onRemove(user.id);
}, [onRemove, user.id]);
const isProfile = typeof user !== 'string';
const id = isProfile ? user.id : user;
const onPress = useCallback(() => {
onRemove(id);
}, [onRemove, id]);
const userItemTestID = `${testID}.${id}`;
const userItemTestID = `${testID}.${user.id}`;
return (
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
testID={`${testID}.${user.id}`}
testID={`${userItemTestID}`}
>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
{isProfile && (
<View style={style.picture}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
)}
<Text
style={style.text}
testID={`${testID}.${user.id}.display_name`}
testID={`${userItemTestID}.display_name`}
>
{displayUsername(user, intl.locale, teammateNameDisplay)}
{isProfile ? displayUsername(user, intl.locale, teammateNameDisplay) : id}
</Text>
<TouchableOpacity
style={style.remove}
onPress={onPress}
testID={`${testID}.${user.id}.remove.button`}
testID={`${userItemTestID}.remove.button`}
>
<CompassIcon
name='close-circle'

View File

@@ -80,7 +80,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
color: changeOpacity(theme.centerChannelColor, 0.64),
fontSize: 15,
fontFamily: 'OpenSans',
flexShrink: 5,
},
icon: {
marginLeft: 4,
@@ -111,6 +110,15 @@ const UserItem = ({
const userItemTestId = `${testID}.${user?.id}`;
let rowUsernameFlexShrink = 1;
if (user) {
for (const rowInfoElem of [bot, guest, Boolean(name.length), isCurrentUser]) {
if (rowInfoElem) {
rowUsernameFlexShrink++;
}
}
}
return (
<View
style={[style.row, containerStyle]}
@@ -125,7 +133,7 @@ const UserItem = ({
/>
</View>
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
style={[style.rowInfo, {maxWidth: shared ? '75%' : '85%'}]}
>
{bot && <BotTag testID={`${userItemTestId}.bot.tag`}/>}
{guest && <GuestTag testID={`${userItemTestId}.guest.tag`}/>}
@@ -146,15 +154,15 @@ const UserItem = ({
testID={`${userItemTestId}.current_user_indicator`}
/>
}
{Boolean(user) &&
<Text
style={style.rowUsername}
numberOfLines={1}
testID={`${userItemTestId}.username`}
>
{` @${user!.username}`}
</Text>
}
{Boolean(user) && (
<Text
style={{...style.rowUsername, flexShrink: rowUsernameFlexShrink}}
numberOfLines={1}
testID={`${userItemTestId}.username`}
>
{` @${user!.username}`}
</Text>
)}
</View>
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
<CustomStatusEmoji

View File

@@ -28,6 +28,7 @@ export const GLOBAL_THREADS = 'GlobalThreads';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const INTERACTIVE_DIALOG = 'InteractiveDialog';
export const INVITE = 'Invite';
export const IN_APP_NOTIFICATION = 'InAppNotification';
export const LATEX = 'Latex';
export const LOGIN = 'Login';
@@ -93,6 +94,7 @@ export default {
HOME,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
INVITE,
IN_APP_NOTIFICATION,
LATEX,
LOGIN,
@@ -143,6 +145,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
EMOJI_PICKER,
FIND_CHANNELS,
GALLERY,
INVITE,
PERMALINK,
REACTIONS,
]);

View File

@@ -5,4 +5,5 @@ export default {
DELETED_ROOT_POST_ERROR: 'api.post.create_post.root_id.app_error',
TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only',
PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post',
SEND_EMAIL_WITH_DEFAULTS_ERROR: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error',
};

View File

@@ -97,7 +97,6 @@ const ChannelListHeader = ({
canJoinChannels,
canInvitePeople,
displayName,
inviteId,
iconPad,
onHeaderPress,
pushProxyStatus,
@@ -124,8 +123,6 @@ const ChannelListHeader = ({
canCreateChannels={canCreateChannels}
canJoinChannels={canJoinChannels}
canInvitePeople={canInvitePeople}
displayName={displayName}
inviteId={inviteId}
/>
);
};

View File

@@ -52,9 +52,6 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
displayName: team.pipe(
switchMap((t) => of$(t?.displayName)),
),
inviteId: team.pipe(
switchMap((t) => of$(t?.inviteId)),
),
pushProxyStatus: observePushVerificationStatus(database),
};
});

View File

@@ -3,13 +3,9 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform} 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 {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, showModal} from '@screens/navigation';
@@ -20,14 +16,11 @@ type Props = {
canCreateChannels: boolean;
canJoinChannels: boolean;
canInvitePeople: boolean;
displayName?: string;
inviteId?: string;
}
const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, displayName, inviteId}: Props) => {
const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const browseChannels = useCallback(async () => {
await dismissBottomSheet();
@@ -57,58 +50,18 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, disp
});
}, [intl, theme]);
const invitePeopleToTeam = async () => {
const invitePeopleToTeam = useCallback(async () => {
await dismissBottomSheet();
const url = `${serverUrl}/signup_user_complete/?id=${inviteId}`;
const title = intl.formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: displayName});
const message = intl.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 title = intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'});
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
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,
},
});
Share.open(
options,
).catch(() => {
// do nothing
});
};
showModal(
Screens.INVITE,
title,
{closeButton},
);
}, [intl, theme]);
return (
<>

View File

@@ -127,6 +127,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.INTEGRATION_SELECTOR:
screen = withServerDatabase(require('@screens/integration_selector').default);
break;
case Screens.INVITE:
screen = withServerDatabase(require('@screens/invite').default);
break;
case Screens.IN_APP_NOTIFICATION: {
const notificationScreen = require('@screens/in_app_notification').default;
Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () =>

View File

@@ -0,0 +1,41 @@
// 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, distinctUntilChanged, map} from 'rxjs/operators';
import {observeCurrentTeam} from '@queries/servers/team';
import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user';
import {isSystemAdmin} from '@utils/user';
import Invite from './invite';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const team = observeCurrentTeam(database);
return {
teamId: team.pipe(
switchMap((t) => of$(t?.id)),
),
teamDisplayName: team.pipe(
switchMap((t) => of$(t?.displayName)),
),
teamLastIconUpdate: team.pipe(
switchMap((t) => of$(t?.lastTeamIconUpdatedAt)),
),
teamInviteId: team.pipe(
switchMap((t) => of$(t?.inviteId)),
),
teammateNameDisplay: observeTeammateNameDisplay(database),
isAdmin: observeCurrentUser(database).pipe(
map((user) => isSystemAdmin(user?.roles || '')),
distinctUntilChanged(),
),
};
});
export default withDatabase(enhanced(Invite));

View File

@@ -0,0 +1,362 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState, useRef} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {Keyboard, View, LayoutChangeEvent} from 'react-native';
import {ImageResource, OptionsTopBarButton} from 'react-native-navigation';
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 {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useModalPosition} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, setButtons, setTitle} from '@screens/navigation';
import {isEmail} from '@utils/helpers';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {isGuest} from '@utils/user';
import Selection from './selection';
import Summary from './summary';
import type {NavButtons} from '@typings/screens/navigation';
const CLOSE_BUTTON_ID = 'close-invite';
const SEND_BUTTON_ID = 'send-invite';
const makeLeftButton = (icon: ImageResource): OptionsTopBarButton => {
return {
id: CLOSE_BUTTON_ID,
icon,
testID: 'invite.close.button',
};
};
const makeRightButton = (theme: Theme, formatMessage: IntlShape['formatMessage'], enabled: boolean): OptionsTopBarButton => ({
id: SEND_BUTTON_ID,
text: formatMessage({id: 'invite.send_invite', defaultMessage: 'Send'}),
showAsAction: 'always',
testID: 'invite.send.button',
color: theme.sidebarHeaderTextColor,
disabledColor: changeOpacity(theme.sidebarHeaderTextColor, 0.4),
enabled,
});
const closeModal = async () => {
Keyboard.dismiss();
await dismissModal();
};
const getStyleSheet = makeStyleSheetFromTheme(() => {
return {
container: {
flex: 1,
flexDirection: 'column',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
};
});
export type EmailInvite = string;
export type SearchResult = UserProfile|EmailInvite;
export type InviteResult = {
userId: string;
reason: string;
};
export type Result = {
sent: InviteResult[];
notSent: InviteResult[];
}
enum Stage {
SELECTION = 'selection',
RESULT = 'result',
LOADING = 'loading',
}
type InviteProps = {
componentId: string;
closeButton: ImageResource;
teamId: string;
teamDisplayName: string;
teamLastIconUpdate: number;
teamInviteId: string;
teammateNameDisplay: string;
isAdmin: boolean;
}
export default function Invite({
componentId,
closeButton,
teamId,
teamDisplayName,
teamLastIconUpdate,
teamInviteId,
teammateNameDisplay,
isAdmin,
}: InviteProps) {
const intl = useIntl();
const {formatMessage, locale} = intl;
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const [term, setTerm] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [selectedIds, setSelectedIds] = useState<{[id: string]: SearchResult}>({});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<Result>({sent: [], notSent: []});
const [wrapperHeight, setWrapperHeight] = useState(0);
const [stage, setStage] = useState(Stage.SELECTION);
const selectedCount = Object.keys(selectedIds).length;
const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => {
setWrapperHeight(e.nativeEvent.layout.height);
}, []);
const setHeaderButtons = useCallback((right: boolean, rightEnabled: boolean) => {
const buttons: NavButtons = {
leftButtons: [makeLeftButton(closeButton)],
rightButtons: right ? [makeRightButton(theme, formatMessage, rightEnabled)] : [],
};
setButtons(componentId, buttons);
}, [closeButton, locale, theme, componentId]);
const setHeaderTitle = useCallback((title: string) => {
setTitle(componentId, title);
}, [locale, theme, componentId]);
const searchUsers = useCallback(async (searchTerm: string) => {
if (searchTerm === '') {
handleClearSearch();
return;
}
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {allow_inactive: true});
const results: SearchResult[] = data ?? [];
if (isEmail(searchTerm.trim())) {
results.unshift(searchTerm.trim() as EmailInvite);
}
setSearchResults(results);
}, [serverUrl, teamId]);
const handleClearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const handleSearchChange = useCallback((text: string) => {
setLoading(true);
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(async () => {
await searchUsers(text);
setLoading(false);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
}, [searchUsers]);
const handleSelectItem = useCallback((item: SearchResult) => {
const email = typeof item === 'string';
const id = email ? item : (item as UserProfile).id;
const newSelectedIds = Object.assign({}, selectedIds);
if (!selectedIds[id]) {
newSelectedIds[id] = item;
}
setSelectedIds(newSelectedIds);
handleClearSearch();
}, [selectedIds, handleClearSearch]);
const handleSend = async () => {
if (!selectedCount) {
return;
}
setStage(Stage.LOADING);
const userIds = [];
const emails = [];
for (const [id, item] of Object.entries(selectedIds)) {
if (typeof item === 'string') {
emails.push(item);
} else {
userIds.push(id);
}
}
const {members: currentTeamMembers = []} = await getTeamMembersByIds(serverUrl, teamId, userIds);
const currentMemberIds: Record<string, boolean> = {};
for (const {user_id: currentMemberId} of currentTeamMembers) {
currentMemberIds[currentMemberId] = true;
}
const sent: InviteResult[] = [];
const notSent: InviteResult[] = [];
const usersToAdd = [];
for (const userId of userIds) {
if (isGuest((selectedIds[userId] as UserProfile).roles)) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.user-is-guest', defaultMessage: 'Contact your admin to make this guest a full member'})});
} else if (currentMemberIds[userId]) {
notSent.push({userId, reason: formatMessage({id: 'invite.members.already-member', defaultMessage: 'This person is already a team member'})});
} else {
usersToAdd.push(userId);
}
}
if (usersToAdd.length) {
const {members} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
if (members) {
const membersWithError: Record<string, string> = {};
for (const {user_id, error} of members) {
if (error) {
membersWithError[user_id] = error.message;
}
}
for (const userId of usersToAdd) {
if (membersWithError[userId]) {
notSent.push({userId, reason: membersWithError[userId]});
} else {
sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})});
}
}
}
}
if (emails.length) {
const {members} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
if (members) {
const membersWithError: Record<string, string> = {};
for (const {email, error} of members) {
if (error) {
membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? (
formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'})
) : (
error.message
);
}
}
for (const email of emails) {
if (membersWithError[email]) {
notSent.push({userId: email, reason: membersWithError[email]});
} else {
sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
}
}
}
}
setResult({sent, notSent});
setStage(Stage.RESULT);
};
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]);
useNavButtonPressed(SEND_BUTTON_ID, componentId, handleSend, [handleSend]);
useEffect(() => {
// Update header buttons in case anything related to the header changes
setHeaderButtons(stage === Stage.SELECTION, selectedCount > 0);
}, [theme, locale, selectedCount, stage]);
useEffect(() => {
if (stage === Stage.RESULT) {
// Update header title in case anything related to the header changes
setHeaderTitle(formatMessage({id: 'invite.title.summary', defaultMessage: 'Invite summary'}));
}
}, [locale, stage]);
const handleRemoveItem = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const renderContent = () => {
switch (stage) {
case Stage.LOADING:
return (
<Loading
containerStyle={styles.loadingContainer}
size='large'
color={theme.centerChannelColor}
/>
);
case Stage.RESULT:
return (
<Summary
result={result}
selectedIds={selectedIds}
onClose={closeModal}
testID='invite.screen.summary'
/>
);
default:
return (
<Selection
teamId={teamId}
teamDisplayName={teamDisplayName}
teamLastIconUpdate={teamLastIconUpdate}
teamInviteId={teamInviteId}
teammateNameDisplay={teammateNameDisplay}
serverUrl={serverUrl}
term={term}
searchResults={searchResults}
selectedIds={selectedIds}
modalPosition={modalPosition}
wrapperHeight={wrapperHeight}
loading={loading}
onSearchChange={handleSearchChange}
onSelectItem={handleSelectItem}
onRemoveItem={handleRemoveItem}
onClose={closeModal}
testID='invite.screen.selection'
/>
);
}
};
return (
<SafeAreaView
style={styles.container}
onLayout={onLayoutWrapper}
ref={mainView}
testID='invite.screen'
>
{renderContent()}
</SafeAreaView>
);
}

View File

@@ -0,0 +1,512 @@
// 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 {Keyboard, Platform, View, Text, TouchableOpacity, 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 {typography} from '@utils/typography';
import {SearchResult} from './invite';
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 INITIAL_BATCH_TO_RENDER = 15;
const SCROLL_EVENT_THROTTLE = 60;
const keyboardDismissProp = Platform.select({
android: {
onScrollBeginDrag: Keyboard.dismiss,
},
ios: {
keyboardDismissMode: 'on-drag' as const,
},
});
const keyExtractor = (item: SearchResult) => (
typeof item === 'string' ? item : (item as UserProfile).id
);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
flex: 1,
},
teamContainer: {
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,
},
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,
position: 'absolute',
bottom: Platform.select({ios: 'auto', default: undefined}),
},
searchListBorder: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
overflow: 'hidden',
borderRadius: 4,
elevation: 3,
},
searchListShadow: {
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 6,
shadowOffset: {
width: 0,
height: 6,
},
},
searchListFlatList: {
backgroundColor: theme.centerChannelBg,
borderRadius: 4,
},
selectedItems: {
display: 'flex',
flexGrowth: 1,
},
selectedItemsContainer: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: 20,
marginVertical: 16,
},
};
});
type SelectionProps = {
teamId: string;
teamDisplayName: string;
teamLastIconUpdate: number;
teamInviteId: string;
teammateNameDisplay: string;
serverUrl: string;
term: string;
searchResults: SearchResult[];
selectedIds: {[id: string]: SearchResult};
modalPosition: number;
wrapperHeight: number;
loading: boolean;
testID: string;
onSearchChange: (text: string) => void;
onSelectItem: (item: SearchResult) => void;
onRemoveItem: (id: string) => void;
onClose: () => Promise<void>;
}
export default function Selection({
teamId,
teamDisplayName,
teamLastIconUpdate,
teamInviteId,
teammateNameDisplay,
serverUrl,
term,
searchResults,
selectedIds,
modalPosition,
wrapperHeight,
loading,
testID,
onSearchChange,
onSelectItem,
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 [searchBarHeight, setSearchBarHeight] = useState(0);
const [searchBarTitleHeight, setSearchBarTitleHeight] = useState(0);
const onLayoutHeader = useCallback((e: LayoutChangeEvent) => {
setHeaderFieldHeight(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 keyboardOverlap = Platform.select({
ios: isTablet ? (
Math.max(0, keyboardHeight - bottomSpace)
) : (
insetsAdjust
),
default: 0,
});
const keyboardAdjust = Platform.select({
ios: isTablet ? keyboardOverlap : insetsAdjust,
default: 0,
});
const workingSpace = wrapperHeight - keyboardOverlap;
const spaceOnTop = otherElementsSize - AUTOCOMPLETE_ADJUST;
const spaceOnBottom = workingSpace - (otherElementsSize + BOTTOM_AUTOCOMPLETE_SEPARATION);
const autocompletePosition = spaceOnBottom > spaceOnTop ? (
otherElementsSize
) : (
(workingSpace + AUTOCOMPLETE_ADJUST + keyboardAdjust) - otherElementsSize
);
const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop;
const isLandscape = dimensions.width > dimensions.height;
const maxHeightAdjust = (isTablet && isLandscape) ? MAX_LIST_TABLET_DIFF : 0;
const defaultMaxHeight = MAX_LIST_HEIGHT - maxHeightAdjust;
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
const maxHeight = useDerivedValue(() => {
return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight);
}, [defaultMaxHeight]);
const searchListContainerStyle = useMemo(() => {
const style = [];
style.push(
styles.searchList,
{
top: animatedAutocompletePosition.value,
maxHeight: maxHeight.value,
},
);
if (searchResults.length) {
style.push(styles.searchListBorder);
}
if (Platform.OS === 'ios') {
style.push(styles.searchListShadow);
}
return style;
}, [searchResults, styles]);
const renderNoResults = useCallback(() => {
if (!term || loading) {
return null;
}
return (
<TextItem
text={term}
type={TextItemType.SEARCH_NO_RESULTS}
testID='invite.search_list_no_results'
/>
);
}, [term, loading]);
const renderItem = useCallback(({item}: ListRenderItemInfo<SearchResult>) => {
const key = keyExtractor(item);
return (
<TouchableWithFeedback
key={key}
index={key}
onPress={() => onSelectItem(item)}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type='native'
testID={`invite.search_list_item.${key}`}
>
{typeof item === 'string' ? (
<TextItem
text={item}
type={TextItemType.SEARCH_INVITE}
testID='invite.search_list_text_item'
/>
) : (
<UserItem
user={item}
testID='invite.search_list_user_item'
/>
)}
</TouchableWithFeedback>
);
}, [searchResults, onSelectItem]);
const renderSelectedItems = useMemo(() => {
const selectedItems = [];
for (const id of Object.keys(selectedIds)) {
selectedItems.push(
<SelectedUser
key={id}
user={selectedIds[id]}
teammateNameDisplay={teammateNameDisplay}
onRemove={handleOnRemoveItem}
testID='invite.selected_item'
/>,
);
}
return selectedItems;
}, [selectedIds]);
return (
<View
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'
/>
<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}
contentContainerStyle={styles.selectedItemsContainer}
testID='invite.selected_items'
>
{renderSelectedItems}
</ScrollView>
)}
<Animated.View style={searchListContainerStyle}>
<FlatList
data={searchResults}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ListEmptyComponent={renderNoResults}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
renderItem={renderItem}
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
style={styles.searchListFlatList}
testID='invite.search_list'
/>
</Animated.View>
</View>
);
}

View File

@@ -0,0 +1,167 @@
// 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, Text, ScrollView} from 'react-native';
import Button from 'react-native-button';
import FormattedText from '@components/formatted_text';
import AlertSvg from '@components/illustrations/alert';
import ErrorSvg from '@components/illustrations/error';
import SuccessSvg from '@components/illustrations/success';
import {useTheme} from '@context/theme';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {SearchResult, Result} from './invite';
import SummaryReport, {SummaryReportType} from './summary_report';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
flex: 1,
},
summary: {
display: 'flex',
flexGrowth: 1,
},
summaryContainer: {
flexGrow: 1,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
margin: 20,
paddingBottom: 20,
},
summarySvg: {
marginBottom: 20,
},
summaryMessageText: {
color: theme.centerChannelColor,
...typography('Heading', 700, 'SemiBold'),
textAlign: 'center',
marginHorizontal: 32,
marginBottom: 16,
},
summaryButton: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
margin: 20,
padding: 15,
},
summaryButtonContainer: {
display: 'flex',
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
},
};
});
type SummaryProps = {
result: Result;
selectedIds: {[id: string]: SearchResult};
testID: string;
onClose: () => void;
}
export default function Summary({
result,
selectedIds,
testID,
onClose,
}: SummaryProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const {sent, notSent} = result;
const sentCount = sent.length;
const notSentCount = notSent.length;
const styleButtonText = buttonTextStyle(theme, 'lg', 'primary');
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary');
let message = '';
let svg = <></>;
if (sentCount && !notSentCount) {
svg = <SuccessSvg/>;
message = formatMessage(
{
id: 'invite.summary.sent',
defaultMessage: 'Your {sentCount, plural, one {invitation has} other {invitations have}} been sent',
},
{sentCount},
);
} else if (sentCount && notSentCount) {
svg = <AlertSvg/>;
message = formatMessage(
{
id: 'invite.summary.some_not_sent',
defaultMessage: '{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent',
},
{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 = () => {
onClose();
};
return (
<View
style={styles.container}
testID={testID}
>
<ScrollView
style={styles.summary}
contentContainerStyle={styles.summaryContainer}
testID='invite.summary'
>
<View style={styles.summarySvg}>
{svg}
</View>
<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'
/>
</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>
</View>
);
}

View File

@@ -0,0 +1,153 @@
// 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, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import UserItem from '@components/user_item';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {SearchResult, InviteResult} from './invite';
import TextItem, {TextItemType} from './text_item';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
summaryInvitationsContainer: {
display: 'flex',
flexDirection: 'column',
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,
},
summaryInvitationsTitleText: {
marginLeft: 12,
...typography('Heading', 300, 'SemiBold'),
color: theme.centerChannelColor,
},
summaryInvitationsItem: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingVertical: 12,
},
summaryInvitationsUser: {
paddingTop: 0,
paddingBottom: 0,
height: 'auto',
},
summaryInvitationsReason: {
paddingLeft: 56,
paddingRight: 20,
...typography('Body', 75, 'Regular'),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
export enum SummaryReportType {
SENT = 'sent',
NOT_SENT = 'not_sent',
}
type SummaryReportProps = {
type: SummaryReportType;
invites: InviteResult[];
selectedIds: {[id: string]: SearchResult};
testID: string;
}
export default function SummaryReport({
type,
invites,
selectedIds,
testID,
}: SummaryReportProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const count = invites.length;
if (!count) {
return null;
}
const sent = type === SummaryReportType.SENT;
const message = sent ? (
formatMessage(
{
id: 'invite.summary.report.sent',
defaultMessage: '{count} successful {count, plural, one {invitation} other {invitations}}',
},
{count},
)
) : (
formatMessage(
{
id: 'invite.summary.report.notSent',
defaultMessage: '{count} {count, plural, one {invitation} other {invitations}} not sent',
},
{count},
)
);
return (
<View
style={styles.summaryInvitationsContainer}
testID={`${testID}.${type}`}
>
<View style={styles.summaryInvitationsTitle}>
<CompassIcon
name={sent ? 'check-circle' : 'close-circle'}
size={24}
style={{color: sent ? '#3db887' : '#d24b4e'}}
/>
<Text style={styles.summaryInvitationsTitleText}>
{message}
</Text>
</View>
{invites.map(({userId, reason}) => {
const item = selectedIds[userId];
return (
<View
key={userId}
style={styles.summaryInvitationsItem}
>
{typeof item === 'string' ? (
<TextItem
text={item}
type={TextItemType.SUMMARY}
testID={`${testID}.text_item`}
/>
) : (
<UserItem
user={item}
containerStyle={styles.summaryInvitationsUser}
testID={`${testID}.user_item`}
/>
)}
<Text style={styles.summaryInvitationsReason}>
{reason}
</Text>
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,111 @@
// 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, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
item: {
paddingHorizontal: 20,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
},
search: {
height: 40,
paddingVertical: 8,
paddingHorizontal: 16,
},
itemText: {
display: 'flex',
...typography('Body', 200, 'Regular'),
color: theme.centerChannelColor,
},
itemTerm: {
display: 'flex',
...typography('Body', 200, 'SemiBold'),
color: theme.centerChannelColor,
marginLeft: 4,
},
itemImage: {
alignItems: 'center',
justifyContent: 'center',
height: 24,
width: 24,
borderRadius: 12,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginRight: 12,
},
itemIcon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
};
});
export enum TextItemType {
SEARCH_INVITE = 'search_invite',
SEARCH_NO_RESULTS = 'search_no_results',
SUMMARY = 'summary',
}
type TextItemProps = {
text?: string;
type: TextItemType;
testID: string;
}
export default function TextItem({
text = '',
type,
testID,
}: TextItemProps) {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const search = type === TextItemType.SEARCH_INVITE || type === TextItemType.SEARCH_NO_RESULTS;
const email = type === TextItemType.SEARCH_INVITE || type === TextItemType.SUMMARY;
return (
<View
style={[styles.item, search ? styles.search : {}]}
testID={`${testID}.${text}`}
>
{email && (
<View style={styles.itemImage}>
<CompassIcon
name={search ? 'email-plus-outline' : 'email-outline'}
size={14}
style={styles.itemIcon}
/>
</View>)
}
{search && (
<Text
style={styles.itemText}
numberOfLines={1}
>
{email ? (
formatMessage({id: 'invite.search.email_invite', defaultMessage: 'invite'})
) : (
formatMessage({id: 'invite.search.no_results', defaultMessage: 'No one found matching'})
)}
</Text>
)}
<Text
style={[search ? styles.itemTerm : styles.itemText, {flex: 1}]}
numberOfLines={1}
testID={`${testID}.text.${text}`}
>
{text}
</Text>
</View>
);
}

View File

@@ -695,7 +695,21 @@ export function setButtons(componentId: string, buttons: NavButtons = {leftButto
mergeNavigationOptions(componentId, options);
}
export function showOverlay(name: string, passProps = {}, options: Options = {}) {
export function setTitle(componentId: string, title: string) {
const theme = getThemeFromState();
const options = {
topBar: {
title: {
color: theme.sidebarHeaderTextColor,
text: title,
},
},
};
mergeNavigationOptions(componentId, options);
}
export function showOverlay(name: string, passProps = {}, options = {}) {
if (!isScreenRegistered(name)) {
return;
}

View File

@@ -327,6 +327,25 @@
"intro.welcome.public": "Add some more team members to the channel or start a conversation below.",
"invite_people_to_team.message": "Heres a link to collaborate and communicate with us on Mattermost.",
"invite_people_to_team.title": "Join the {team} team",
"invite.members.already-member": "This person is already a team member",
"invite.members.user-is-guest": "Contact your admin to make this guest a full member",
"invite.search.email_invite": "invite",
"invite.search.no_results": "No one found matching",
"invite.searchPlaceholder": "Type a name or email address…",
"invite.send_invite": "Send",
"invite.sendInvitationsTo": "Send invitations to…",
"invite.shareLink": "Share link",
"invite.summary.done": "Done",
"invite.summary.email_invite": "An invitation email has been sent",
"invite.summary.member_invite": "Invited as a member of {teamDisplayName}",
"invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasnt} other {Invitations werent}} sent",
"invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent",
"invite.summary.report.sent": "{count} successful {count, plural, one {invitation} other {invitations}}",
"invite.summary.sent": "Your {sentCount, plural, one {invitation has} other {invitations have}} been sent",
"invite.summary.smtp_failure": "SMTP is not configured in System Console",
"invite.summary.some_not_sent": "{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent",
"invite.title": "Invite",
"invite.title.summary": "Invite summary",
"last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.",
"last_users_message.added_to_team.type": "were **added to the team** by {actor}.",
"last_users_message.first": "{firstUser} and ",

View File

@@ -23,6 +23,7 @@ import EmojiPickerScreen from './emoji_picker';
import FindChannelsScreen from './find_channels';
import GlobalThreadsScreen from './global_threads';
import HomeScreen from './home';
import Invite from './invite';
import LoginScreen from './login';
import MentionNotificationSettingsScreen from './mention_notification_settings';
import NotificationSettingsScreen from './notification_settings';
@@ -69,6 +70,7 @@ export {
FindChannelsScreen,
GlobalThreadsScreen,
HomeScreen,
Invite,
LoginScreen,
MentionNotificationSettingsScreen,
NotificationSettingsScreen,

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ChannelListScreen} from '@support/ui/screen';
import {timeouts} from '@support/utils';
import {expect} from 'detox';
class InviteScreen {
testID = {
inviteScreen: 'invite.screen',
screenSummary: 'invite.screen.summary',
screenSelection: 'invite.screen.selection',
closeButton: 'invite.close.button',
sendButton: 'invite.send.button',
teamIcon: 'invite.team_icon',
teamDisplayName: 'invite.team_display_name',
serverDisplayName: 'invite.server_display_name',
shareLinkButton: 'invite.share_link.button',
searchBarTitle: 'invite.search_bar_title',
searchBarInput: 'invite.search_bar_input',
selectedItems: 'invite.selected_items',
selectedItemPrefix: 'invite.selected_item',
searchList: 'invite.search_list',
searchListItemPrefix: 'invite.search_list_item.',
searchListTextItemPrefix: 'invite.search_list_text_item',
searchListUserItemPrefix: 'invite.search_list_user_item',
searchListNoResultsPrefix: 'invite.search_list_no_results',
summaryReportPrefix: 'invite.summary_report',
summaryReportTextItemPrefix: 'invite.summary_report.text_item',
summaryReportUserItemPrefix: 'invite.summary_report.user_item',
};
inviteScreen = element(by.id(this.testID.inviteScreen));
screenSummary = element(by.id(this.testID.screenSummary));
screenSelection = element(by.id(this.testID.screenSelection));
closeButton = element(by.id(this.testID.closeButton));
sendButton = element(by.id(this.testID.sendButton));
teamIcon = element(by.id(this.testID.teamIcon));
teamDisplayName = element(by.id(this.testID.teamDisplayName));
serverDisplayName = element(by.id(this.testID.serverDisplayName));
shareLinkButton = element(by.id(this.testID.shareLinkButton));
searchBarTitle = element(by.id(this.testID.searchBarTitle));
searchBarInput = element(by.id(this.testID.searchBarInput));
selectedItems = element(by.id(this.testID.selectedItems));
selectedItemPrefix = element(by.id(this.testID.selectedItemPrefix));
searchList = element(by.id(this.testID.searchList));
searchListItemPrefix = element(by.id(this.testID.searchListItemPrefix));
searchListTextItemPrefix = element(by.id(this.testID.searchListTextItemPrefix));
searchListUserItemPrefix = element(by.id(this.testID.searchListUserItemPrefix));
searchListNoResultsPrefix = element(by.id(this.testID.searchListNoResultsPrefix));
summaryReportTextItemPrefix = element(by.id(this.testID.summaryReportTextItemPrefix));
summaryReportUserItemPrefix = element(by.id(this.testID.summaryReportUserItemPrefix));
getSearchListTextItem = (id: string) => {
return element(by.id(`${this.testID.searchListTextItemPrefix}.${id}`));
};
getSearchListTextItemText = (id: string) => {
return element(by.id(`${this.testID.searchListTextItemPrefix}.text.${id}`));
};
getSearchListUserItem = (id: string) => {
return element(by.id(`${this.testID.searchListUserItemPrefix}.${id}`));
};
getSearchListUserItemText = (id: string) => {
return element(by.id(`${this.testID.searchListUserItemPrefix}.${id}.username`));
};
getSearchListNoResults = (id: string) => {
return element(by.id(`${this.testID.searchListNoResultsPrefix}.${id}`));
};
getSearchListNoResultsText = (id: string) => {
return element(by.id(`${this.testID.searchListNoResultsPrefix}.text.${id}`));
};
getSelectedItem = (id: string) => {
return element(by.id(`${this.testID.selectedItemPrefix}.${id}`));
};
getSummaryReportSent = () => {
return element(by.id(`${this.testID.summaryReportPrefix}.sent`));
};
getSummaryReportNotSent = () => {
return element(by.id(`${this.testID.summaryReportPrefix}.not_sent`));
};
getSummaryReportTextItem = (id: string) => {
return element(by.id(`${this.testID.summaryReportTextItemPrefix}.${id}`));
};
getSummaryReportTextItemText = (id: string) => {
return element(by.id(`${this.testID.summaryReportTextItemPrefix}.text.${id}`));
};
getSummaryReportUserItem = (id: string) => {
return element(by.id(`${this.testID.summaryReportUserItemPrefix}.${id}`));
};
getSummaryReportUserItemText = (id: string) => {
return element(by.id(`${this.testID.summaryReportUserItemPrefix}.${id}.username`));
};
toBeVisible = async () => {
await waitFor(this.inviteScreen).toExist().withTimeout(timeouts.TEN_SEC);
return this.inviteScreen;
};
open = async () => {
await ChannelListScreen.headerPlusButton.tap();
await ChannelListScreen.invitePeopleToTeamItem.tap();
return this.toBeVisible();
};
close = async () => {
await this.closeButton.tap();
await expect(this.inviteScreen).not.toBeVisible();
};
}
const inviteScreen = new InviteScreen();
export default inviteScreen;

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();
};

View File

@@ -7,32 +7,34 @@
// - Use element testID when selecting an element. Create one if none.
// *******************************************************************
import {Setup} from '@support/server_api';
import {Setup, User} from '@support/server_api';
import {
serverOneUrl,
siteOneUrl,
} from '@support/test_config';
import {
ChannelListScreen,
Invite,
HomeScreen,
LoginScreen,
ServerScreen,
} from '@support/ui/screen';
import {isIos} from '@support/utils';
import {isIos, timeouts} from '@support/utils';
import {expect} from 'detox';
function systemDialog(label: string) {
if (device.getPlatform() === 'ios') {
if (isIos()) {
return element(by.label(label)).atIndex(0);
}
return element(by.text(label));
}
describe('Teams - Invite people', () => {
describe('Teams - Invite', () => {
const serverOneDisplayName = 'Server 1';
let testTeam: any;
let testUser: any;
let testUser1: any;
beforeAll(async () => {
const {team, user} = await Setup.apiInit(siteOneUrl);
@@ -40,37 +42,208 @@ describe('Teams - Invite people', () => {
testTeam = team;
testUser = user;
const {user: user1} = await User.apiCreateUser(siteOneUrl, {prefix: 'i'});
testUser1 = user1;
// # Log in to server
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
await LoginScreen.login(testUser);
});
beforeEach(async () => {
await device.reloadReactNative();
// * Verify on channel list screen
await ChannelListScreen.toBeVisible();
// # Open invite screen
await Invite.open();
});
afterAll(async () => {
// # Close share dialog
await ChannelListScreen.headerTeamDisplayName.tap();
// # Close invite screen
await Invite.close();
// # Log out
await HomeScreen.logout();
});
it('MM-T - should open the invite screen', async () => {
// * Verify invite screen Header buttons
await expect(Invite.closeButton).toBeVisible();
await expect(Invite.sendButton).toBeVisible();
// * Verify Team data
await expect(Invite.teamDisplayName).toHaveText(testTeam.display_name);
await expect(Invite.teamIcon).toBeVisible();
// * Verify default Selection
await expect(Invite.screenSelection).toBeVisible();
// * Verify Server data
await expect(Invite.serverDisplayName).toHaveText(serverOneDisplayName);
// * Verify Share Link
await expect(Invite.shareLinkButton).toBeVisible();
// * Verify Search bar
await expect(Invite.searchBarTitle).toBeVisible();
await expect(Invite.searchBarInput).toBeVisible();
});
it('MM-T5221 - should be able to share a URL invite to the team', async () => {
// # Open plus menu
await ChannelListScreen.headerPlusButton.tap();
// * Verify invite people to team item is available
await expect(ChannelListScreen.invitePeopleToTeamItem).toExist();
// # Tap on invite people to team item
await ChannelListScreen.invitePeopleToTeamItem.tap();
// # Tap on Share link
await Invite.shareLinkButton.tap();
if (isIos()) {
const dialog = systemDialog(`Join the ${testTeam.display_name} team`);
// * Verify share dialog is open
await expect(systemDialog(`Join the ${testTeam.display_name} team`)).toExist();
await expect(dialog).toExist();
// # Close share dialog
await dialog.swipe('down');
}
});
it('MM-T - should show no results item in search list', async () => {
const noUser = 'qwertyuiop';
// # Search for a non existent user
await Invite.searchBarInput.replaceText(noUser);
// * Validate no results item in search list
await expect(Invite.getSearchListNoResults(noUser)).toBeVisible();
await expect(Invite.getSearchListNoResultsText(noUser)).toHaveText(noUser);
});
it('MM-T - should be able to send email invite', async () => {
const noUserEmailFormat = 'qwerty@ui.op';
// # Search for a non existent user with email format
await Invite.searchBarInput.replaceText(noUserEmailFormat);
// * Validate email invite item in search list
await expect(Invite.getSearchListTextItem(noUserEmailFormat)).toBeVisible();
await expect(Invite.getSearchListTextItemText(noUserEmailFormat)).toHaveText(noUserEmailFormat);
// # Select email invite item
await Invite.getSearchListTextItem(noUserEmailFormat).tap();
await expect(Invite.getSearchListTextItem(noUserEmailFormat)).not.toBeVisible();
// * Validate email invite is added to selected items
await expect(Invite.getSelectedItem(noUserEmailFormat)).toBeVisible();
// # Send invitation
await Invite.sendButton.tap();
// * Validate summary report sent
await expect(Invite.screenSummary).toBeVisible();
await expect(Invite.getSummaryReportSent()).toBeVisible();
await expect(Invite.getSummaryReportNotSent()).not.toExist();
await expect(Invite.getSummaryReportTextItem(noUserEmailFormat)).toBeVisible();
await expect(Invite.getSummaryReportTextItemText(noUserEmailFormat)).toHaveText(noUserEmailFormat);
});
it('MM-T - should be able to send user invite', async () => {
const username = ` @${testUser1.username}`;
// # Search for a existent user
await Invite.searchBarInput.replaceText(testUser1.username);
// * Validate user item in search list
await expect(Invite.getSearchListUserItem(testUser1.id)).toBeVisible();
await expect(Invite.getSearchListUserItemText(testUser1.id)).toHaveText(username);
// # Select user item
await Invite.getSearchListUserItem(testUser1.id).tap();
await expect(Invite.getSearchListUserItem(testUser1.id)).not.toBeVisible();
// * Validate user is added to selected items
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
// # Send invitation
await Invite.sendButton.tap();
// * Validate summary report sent
await expect(Invite.screenSummary).toBeVisible();
await expect(Invite.getSummaryReportSent()).toBeVisible();
await expect(Invite.getSummaryReportNotSent()).not.toExist();
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username);
});
it('MM-T - should not be able to send user invite to user already in team', async () => {
const username = ` @${testUser1.username}`;
// # Search for a existent user already in team
await Invite.searchBarInput.replaceText(testUser1.username);
// * Validate user item in search list
await expect(Invite.getSearchListUserItem(testUser1.id)).toBeVisible();
// # Select user item
await Invite.getSearchListUserItem(testUser1.id).tap();
// * Validate user is added to selected items
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
// # Send invitation
await Invite.sendButton.tap();
// * Validate summary report not sent
await expect(Invite.screenSummary).toBeVisible();
await expect(Invite.getSummaryReportSent()).not.toExist();
await expect(Invite.getSummaryReportNotSent()).toBeVisible();
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username);
});
it('MM-T - should handle both sent and not sent invites', async () => {
const {user: testUser2} = await User.apiCreateUser(siteOneUrl, {prefix: 'i'});
const username1 = ` @${testUser1.username}`;
const username2 = ` @${testUser2.username}`;
// # Search for a existent user
await Invite.searchBarInput.replaceText(testUser2.username);
// * Validate user item in search list
await expect(Invite.getSearchListUserItem(testUser2.id)).toBeVisible();
// # Select user item
await Invite.getSearchListUserItem(testUser2.id).tap();
// * Validate user is added to selected items
await expect(Invite.getSelectedItem(testUser2.id)).toBeVisible();
// # Search for a existent user already in team
await Invite.searchBarInput.replaceText(testUser1.username);
// # Wait for user item in search list
await waitFor(Invite.getSearchListUserItem(testUser1.id)).toExist().withTimeout(timeouts.TWO_SEC);
// # Select user item
await Invite.getSearchListUserItem(testUser1.id).tap();
// * Validate user is added to selected items
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
// # Send invitation
await Invite.sendButton.tap();
// * Validate summary
await expect(Invite.screenSummary).toBeVisible();
// * Validate summary report not sent
await expect(Invite.getSummaryReportNotSent()).toBeVisible();
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username1);
// * Validate summary report sent
await expect(Invite.getSummaryReportSent()).toBeVisible();
await expect(Invite.getSummaryReportUserItem(testUser2.id)).toBeVisible();
await expect(Invite.getSummaryReportUserItemText(testUser2.id)).toHaveText(username2);
});
});

View File

@@ -19,6 +19,11 @@ type TeamMemberWithError = {
error: ApiError;
}
type TeamInviteWithError = {
email: string;
error: ApiError;
}
type TeamType = 'O' | 'I';
type Team = {