diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 1199288a9..de906e42b 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -114,6 +114,54 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } } +export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + EphemeralStore.startAddingToTeam(teamId); + + const members = await client.addUsersToTeamGracefully(teamId, userIds); + + if (!fetchOnly) { + const teamMemberships: TeamMembership[] = []; + const roles = []; + + for (const {member} of members) { + teamMemberships.push(member); + roles.push(...member.roles.split(' ')); + } + + fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles))); + + if (operator) { + await operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}); + } + } + + EphemeralStore.finishAddingToTeam(teamId); + return {members}; + } catch (error) { + if (EphemeralStore.isAddingToTeam(teamId)) { + EphemeralStore.finishAddingToTeam(teamId); + } + + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +} + +export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) { + try { + const client = NetworkManager.getClient(serverUrl); + 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 { let client; try { @@ -428,3 +476,28 @@ 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[], fetchOnly?: boolean) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const members = await client.getTeamMembersByIds(teamId, userIds); + + if (!fetchOnly) { + const roles = []; + + for (const {roles: memberRoles} of members) { + roles.push(...memberRoles.split(' ')); + } + + fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles))); + + await operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true}); + } + + return {members}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +} diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index 9c53c0350..190256568 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -18,7 +18,10 @@ export interface ClientTeamsMix { getMyTeamMembers: () => Promise; getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise; getTeamMember: (teamId: string, userId: string) => Promise; + getTeamMembersByIds: (teamId: string, userIds: string[]) => Promise; addToTeam: (teamId: string, userId: string) => Promise; + addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise; + sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise; joinTeam: (inviteId: string) => Promise; removeFromTeam: (teamId: string, userId: string) => Promise; getTeamStats: (teamId: string) => Promise; @@ -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( diff --git a/app/components/illustrations/alert.tsx b/app/components/illustrations/alert.tsx new file mode 100644 index 000000000..44aaf0e6e --- /dev/null +++ b/app/components/illustrations/alert.tsx @@ -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 ( + + + + + + ); +} + +export default AlertSvgComponent; diff --git a/app/components/illustrations/error.tsx b/app/components/illustrations/error.tsx new file mode 100644 index 000000000..7dd1b636c --- /dev/null +++ b/app/components/illustrations/error.tsx @@ -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 ( + + + + + + + + + + + ); +} + +export default ErrorSvgComponent; diff --git a/app/components/illustrations/success.tsx b/app/components/illustrations/success.tsx new file mode 100644 index 000000000..ac5b3e1b1 --- /dev/null +++ b/app/components/illustrations/success.tsx @@ -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 ( + + + + ); +} + +export default SuccessSvgComponent; diff --git a/app/components/selected_chip/index.tsx b/app/components/selected_chip/index.tsx new file mode 100644 index 000000000..3ea8e221d --- /dev/null +++ b/app/components/selected_chip/index.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Text, TouchableOpacity, View} 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 SelectedChipProps = { + id: string; + text: string; + extra?: React.ReactNode; + 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, + }, + extraContent: { + flexDirection: 'row', + alignItems: 'center', + color: theme.centerChannelColor, + }, + text: { + marginLeft: 8, + color: theme.centerChannelColor, + ...typography('Body', 100, 'SemiBold'), + }, + remove: { + justifyContent: 'center', + marginLeft: 7, + }, + }; +}); + +export default function SelectedChip({ + id, + text, + extra, + onRemove, + testID, +}: SelectedChipProps) { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const onPress = useCallback(() => { + onRemove(id); + }, [onRemove, id]); + + return ( + + {extra && ( + + {extra} + + )} + + {text} + + + + + + ); +} diff --git a/app/components/selected_users/index.tsx b/app/components/selected_users/index.tsx index e2ffad6f7..301e31b90 100644 --- a/app/components/selected_users/index.tsx +++ b/app/components/selected_users/index.tsx @@ -6,6 +6,7 @@ import {LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View} from import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from '@components/selected_chip'; import Toast from '@components/toast'; import {General} from '@constants'; import {useTheme} from '@context/theme'; @@ -13,7 +14,7 @@ import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device'; import Button from '@screens/bottom_sheet/button'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import SelectedUser, {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from './selected_user'; +import SelectedUser from './selected_user'; type Props = { diff --git a/app/components/selected_users/selected_user.tsx b/app/components/selected_users/selected_user.tsx index 03ecd506c..c5d7cebe2 100644 --- a/app/components/selected_users/selected_user.tsx +++ b/app/components/selected_users/selected_user.tsx @@ -3,18 +3,9 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import { - Text, - TouchableOpacity, - View, -} from 'react-native'; -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; -import CompassIcon from '@components/compass_icon'; import ProfilePicture from '@components/profile_picture'; -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; +import SelectedChip from '@components/selected_chip'; import {displayUsername} from '@utils/user'; type Props = { @@ -40,87 +31,34 @@ type Props = { 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, - }, - profileContainer: { - flexDirection: 'row', - alignItems: 'center', - marginRight: 8, - color: theme.centerChannelColor, - }, - text: { - color: theme.centerChannelColor, - ...typography('Body', 100, 'SemiBold'), - }, - }; -}); - export default function SelectedUser({ teammateNameDisplay, user, onRemove, testID, }: Props) { - const theme = useTheme(); - const style = getStyleFromTheme(theme); const intl = useIntl(); - const onPress = useCallback(() => { - onRemove(user.id); - }, [onRemove, user.id]); + const onPress = useCallback((id: string) => { + onRemove(id); + }, [onRemove]); const userItemTestID = `${testID}.${user.id}`; + return ( - - + - - - {displayUsername(user, intl.locale, teammateNameDisplay)} - - - - - + )} + onRemove={onPress} + testID={userItemTestID} + /> ); } diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx index a16e3a443..24c0812d7 100644 --- a/app/components/user_item/user_item.tsx +++ b/app/components/user_item/user_item.tsx @@ -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'; @@ -81,7 +81,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { color: changeOpacity(theme.centerChannelColor, 0.64), fontSize: 15, fontFamily: 'OpenSans', - flexShrink: 5, }, icon: { marginLeft: 4, @@ -113,6 +112,19 @@ 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++; + } + } + } + + const usernameTextStyle = useMemo(() => { + return [style.rowUsername, {flexShrink: rowUsernameFlexShrink}]; + }, [user, rowUsernameFlexShrink]); + return ( {bot && } {guest && } @@ -148,15 +160,15 @@ const UserItem = ({ testID={`${userItemTestId}.current_user_indicator`} /> } - {Boolean(user) && - - {` @${user!.username}`} - - } + {Boolean(user) && ( + + {` @${user!.username}`} + + )} {Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && ( ([ EDIT_SERVER, FIND_CHANNELS, GALLERY, + INVITE, PERMALINK, ]); diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts index 9799970b7..39810710d 100644 --- a/app/constants/server_errors.ts +++ b/app/constants/server_errors.ts @@ -5,5 +5,6 @@ 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', TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied', }; diff --git a/app/screens/home/channel_list/categories_list/header/header.tsx b/app/screens/home/channel_list/categories_list/header/header.tsx index 0d66b56f4..c776b9799 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -33,7 +33,6 @@ type Props = { canJoinChannels: boolean; canInvitePeople: boolean; displayName?: string; - inviteId?: string; iconPad?: boolean; onHeaderPress?: () => void; pushProxyStatus: string; @@ -107,7 +106,6 @@ const ChannelListHeader = ({ canJoinChannels, canInvitePeople, displayName, - inviteId, iconPad, onHeaderPress, pushProxyStatus, @@ -134,8 +132,6 @@ const ChannelListHeader = ({ canCreateChannels={canCreateChannels} canJoinChannels={canJoinChannels} canInvitePeople={canInvitePeople} - displayName={displayName} - inviteId={inviteId} /> ); }; diff --git a/app/screens/home/channel_list/categories_list/header/index.ts b/app/screens/home/channel_list/categories_list/header/index.ts index 4d638cf3d..1aa6d23eb 100644 --- a/app/screens/home/channel_list/categories_list/header/index.ts +++ b/app/screens/home/channel_list/categories_list/header/index.ts @@ -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), }; }); diff --git a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx index 4fabba5a9..61b8a78aa 100644 --- a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx +++ b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx @@ -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,20 +16,17 @@ 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(); 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, @@ -51,64 +44,20 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, disp 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, }); }, [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: 'Here’s a link to collaborate and communicate with us on Mattermost.'}); - const icon = 'data:/;base64,'; - - 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, + intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}), + ); + }, [intl, theme]); return ( <> diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 607ca808f..42986a498 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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, () => diff --git a/app/screens/invite/index.ts b/app/screens/invite/index.ts new file mode 100644 index 000000000..8457f91f5 --- /dev/null +++ b/app/screens/invite/index.ts @@ -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)); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx new file mode 100644 index 000000000..6246f316a --- /dev/null +++ b/app/screens/invite/invite.tsx @@ -0,0 +1,419 @@ +// 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 {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 CompassIcon from '@components/compass_icon'; +import Loading from '@components/loading'; +import {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} from '@screens/navigation'; +import {isEmail} from '@utils/helpers'; +import {mergeNavigationOptions} from '@utils/navigation'; +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 TIMEOUT_MILLISECONDS = 200; +const DEFAULT_RESULT = {sent: [], notSent: []}; + +const makeLeftButton = (theme: Theme): OptionsTopBarButton => ({ + id: CLOSE_BUTTON_ID, + icon: CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor), + 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; + teamId: string; + teamDisplayName: string; + teamLastIconUpdate: number; + teamInviteId: string; + teammateNameDisplay: string; + isAdmin: boolean; +} + +export default function Invite({ + componentId, + 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(null); + const modalPosition = useModalPosition(mainView); + + const searchTimeoutId = useRef(null); + const retryTimeoutId = useRef(null); + + const [term, setTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [selectedIds, setSelectedIds] = useState<{[id: string]: SearchResult}>({}); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(DEFAULT_RESULT); + const [wrapperHeight, setWrapperHeight] = useState(0); + const [stage, setStage] = useState(Stage.SELECTION); + const [sendError, setSendError] = useState(''); + + const selectedCount = Object.keys(selectedIds).length; + + const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => { + setWrapperHeight(e.nativeEvent.layout.height); + }, []); + + const searchUsers = useCallback(async (searchTerm: string) => { + if (searchTerm === '') { + handleClearSearch(); + return; + } + + const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase()); + const results: SearchResult[] = data ?? []; + + if (!results.length && isEmail(searchTerm.trim())) { + results.push(searchTerm.trim() as EmailInvite); + } + + setSearchResults(results); + }, [serverUrl, teamId]); + + const handleReset = () => { + setStage(Stage.LOADING); + setSendError(''); + setTerm(''); + setSearchResults([]); + setResult(DEFAULT_RESULT); + setStage(Stage.SELECTION); + }; + + 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); + }, 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 handleRetry = () => { + setSendError(''); + setStage(Stage.LOADING); + + retryTimeoutId.current = setTimeout(() => { + handleSend(); + }, TIMEOUT_MILLISECONDS); + }; + + const handleSendError = () => { + setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'})); + setResult(DEFAULT_RESULT); + setStage(Stage.RESULT); + }; + + 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 currentMemberIds = new Set(); + + 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.add(currentMemberId); + } + } + + 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.has(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, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd); + + if (addUsersToTeamError) { + handleSendError(); + return; + } + + if (members) { + const membersWithError: Record = {}; + 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, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails); + + if (sendEmailInvitesToTeamError) { + handleSendError(); + return; + } + + if (members) { + const membersWithError: Record = {}; + 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(() => { + const buttons: NavButtons = { + leftButtons: [makeLeftButton(theme)], + rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [], + }; + + setButtons(componentId, buttons); + }, [theme, locale, componentId, selectedCount > 0, stage === Stage.SELECTION, sendError]); + + useEffect(() => { + mergeNavigationOptions(componentId, { + topBar: { + title: { + color: theme.sidebarHeaderTextColor, + text: stage === Stage.RESULT ? ( + formatMessage({id: 'invite.title.summary', defaultMessage: 'Invite summary'}) + ) : ( + formatMessage({id: 'invite.title', defaultMessage: 'Invite'}) + ), + }, + }, + }); + }, [componentId, locale, theme, stage === Stage.RESULT]); + + useEffect(() => { + return () => { + if (searchTimeoutId.current) { + clearTimeout(searchTimeoutId.current); + } + + if (retryTimeoutId.current) { + clearTimeout(retryTimeoutId.current); + } + }; + }, []); + + 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 ( + + ); + case Stage.RESULT: + return ( + + ); + default: + return ( + + ); + } + }; + + return ( + + {renderContent()} + + ); +} diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx new file mode 100644 index 000000000..2581db2f1 --- /dev/null +++ b/app/screens/invite/selection.tsx @@ -0,0 +1,348 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState, useMemo} from 'react'; +import { + Keyboard, + Platform, + View, + LayoutChangeEvent, + useWindowDimensions, + FlatList, + ListRenderItemInfo, + ScrollView, +} from 'react-native'; +import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import SelectedChip from '@components/selected_chip'; +import SelectedUser from '@components/selected_users/selected_user'; +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 {useTheme} from '@context/theme'; +import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; +import {useIsTablet, useKeyboardHeight} from '@hooks/device'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +import {SearchResult} from './invite'; +import SelectionSearchBar from './selection_search_bar'; +import SelectionTeamBar from './selection_team_bar'; +import TextItem, {TextItemType} from './text_item'; + +const AUTOCOMPLETE_ADJUST = 5; +const KEYBOARD_HEIGHT_ADJUST = 3; + +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, + }, + searchList: { + left: 20, + right: 20, + position: 'absolute', + bottom: Platform.select({ios: 'auto', default: undefined}), + }, + searchListBorder: { + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 4, + elevation: 3, + }, + searchListPadding: { + paddingVertical: 8, + }, + searchListShadow: { + shadowColor: '#000', + shadowOpacity: 0.12, + shadowRadius: 6, + shadowOffset: { + width: 0, + height: 6, + }, + borderRadius: 4, + backgroundColor: theme.centerChannelBg, + }, + 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; +} + +export default function Selection({ + teamId, + teamDisplayName, + teamLastIconUpdate, + teamInviteId, + teammateNameDisplay, + serverUrl, + term, + searchResults, + selectedIds, + modalPosition, + wrapperHeight, + loading, + testID, + onSearchChange, + onSelectItem, + onRemoveItem, + onClose, +}: SelectionProps) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const dimensions = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const isTablet = useIsTablet(); + const keyboardHeight = useKeyboardHeight(); + + const [teamBarHeight, setTeamBarHeight] = useState(0); + const [searchBarHeight, setSearchBarHeight] = useState(0); + + const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => { + setTeamBarHeight(e.nativeEvent.layout.height); + }, []); + + const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => { + setSearchBarHeight(e.nativeEvent.layout.height); + }, []); + + const handleOnRemoveItem = (id: string) => { + onRemoveItem(id); + }; + + const bottomSpace = dimensions.height - wrapperHeight - modalPosition; + const otherElementsSize = teamBarHeight + searchBarHeight; + const insetsAdjust = (keyboardHeight + KEYBOARD_HEIGHT_ADJUST) || 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 + keyboardAdjust); + const autocompletePosition = spaceOnBottom > spaceOnTop ? ( + otherElementsSize + ) : ( + workingSpace - 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); + }, [animatedAutocompleteAvailableSpace, defaultMaxHeight]); + + const searchListContainerAnimatedStyle = useAnimatedStyle(() => ({ + top: animatedAutocompletePosition.value, + maxHeight: maxHeight.value, + }), [animatedAutocompletePosition, maxHeight]); + + const searchListContainerStyle = useMemo(() => { + const style = []; + + style.push( + styles.searchList, + searchListContainerAnimatedStyle, + ); + + if (Platform.OS === 'ios') { + style.push(styles.searchListShadow); + } + + return style; + }, [searchResults, styles, searchListContainerAnimatedStyle]); + + const searchListFlatListStyle = useMemo(() => { + const style = []; + + style.push(styles.searchListFlatList); + + if (searchResults.length) { + style.push(styles.searchListBorder, styles.searchListPadding); + } + + return style; + }, [searchResults, styles]); + + const renderNoResults = useCallback(() => { + if (!term || loading) { + return null; + } + + return ( + + + + ); + }, [term, loading]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + const key = keyExtractor(item); + + return ( + onSelectItem(item)} + underlayColor={changeOpacity(theme.buttonBg, 0.08)} + type='native' + testID={`invite.search_list_item.${key}`} + > + {typeof item === 'string' ? ( + + ) : ( + + )} + + ); + }, [searchResults, onSelectItem]); + + const renderSelectedItems = useMemo(() => { + const selectedItems = []; + + for (const id of Object.keys(selectedIds)) { + const selectedItem = selectedIds[id]; + + selectedItems.push(typeof selectedItem === 'string' ? ( + + ) : ( + + )); + } + + return selectedItems; + }, [selectedIds]); + + return ( + + + + {Object.keys(selectedIds).length > 0 && ( + + {renderSelectedItems} + + )} + + + + + ); +} diff --git a/app/screens/invite/selection_search_bar.tsx b/app/screens/invite/selection_search_bar.tsx new file mode 100644 index 000000000..ad5f9b5e6 --- /dev/null +++ b/app/screens/invite/selection_search_bar.tsx @@ -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 ( + + + + + + + ); +} diff --git a/app/screens/invite/selection_team_bar.tsx b/app/screens/invite/selection_team_bar.tsx new file mode 100644 index 000000000..44bff055e --- /dev/null +++ b/app/screens/invite/selection_team_bar.tsx @@ -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; +} + +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: 'Here’s a link to collaborate and communicate with us on Mattermost.'}); + const icon = 'data:/;base64,'; + + 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 ( + + + + + + + {teamDisplayName} + + + {serverDisplayName} + + + + + + + + + + ); +} diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx new file mode 100644 index 000000000..a1590e71e --- /dev/null +++ b/app/screens/invite/summary.tsx @@ -0,0 +1,299 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, ScrollView} from 'react-native'; +import Button from 'react-native-button'; + +import CompassIcon from '@components/compass_icon'; +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 {t} from '@i18n'; +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 MAX_WIDTH_CONTENT = 480; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + display: 'flex', + flex: 1, + }, + summary: { + display: 'flex', + flex: 1, + }, + summaryContainer: { + display: 'flex', + flexGrow: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'stretch', + marginTop: 20, + marginHorizontal: 20, + paddingBottom: 20, + }, + summaryContent: { + flexGrow: 1, + flexDirection: 'column', + justifyContent: 'center', + maxWidth: MAX_WIDTH_CONTENT, + }, + summarySvg: { + marginBottom: 20, + alignSelf: 'center', + }, + summaryMessageText: { + color: theme.centerChannelColor, + ...typography('Heading', 700, 'SemiBold'), + textAlign: 'center', + marginHorizontal: 32, + marginBottom: 24, + }, + summaryErrorText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 200, 'Regular'), + textAlign: 'center', + }, + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.16), + padding: 20, + }, + summaryButtonContainer: { + flexGrow: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + summaryButtonTextContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: 24, + }, + summaryButtonIcon: { + marginRight: 7, + color: theme.buttonColor, + + }, + }; +}); + +enum SummaryButtonType { + DONE = 'done', + RETRY = 'retry', + BACK = 'back', +} + +type SummaryProps = { + result: Result; + selectedIds: {[id: string]: SearchResult}; + error?: string; + testID: string; + onClose: () => void; + onRetry: () => void; + onBack: () => void; +} + +export default function Summary({ + result, + selectedIds, + error, + testID, + onClose, + onRetry, + onBack, +}: SummaryProps) { + const {formatMessage, locale} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const {sent, notSent} = result; + const sentCount = sent.length; + const notSentCount = notSent.length; + + const styleSummaryMessageText = useMemo(() => { + const style = []; + + style.push(styles.summaryMessageText); + + if (error) { + style.push({marginBottom: 8}); + } + + return style; + }, [error, styles]); + + let svg = <>; + let message = ''; + + if (error) { + svg = ; + message = formatMessage( + { + id: 'invite.summary.error', + defaultMessage: '{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully', + }, + {invitationsCount: sentCount + notSentCount}, + ); + } else if (!sentCount && notSentCount) { + svg = ; + message = formatMessage( + { + id: 'invite.summary.not_sent', + defaultMessage: '{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent', + }, + {notSentCount}, + ); + } else if (sentCount && !notSentCount) { + svg = ; + message = formatMessage( + { + id: 'invite.summary.sent', + defaultMessage: 'Your {sentCount, plural, one {invitation has} other {invitations have}} been sent', + }, + {sentCount}, + ); + } else if (sentCount && notSentCount) { + svg = ; + message = formatMessage( + { + id: 'invite.summary.some_not_sent', + defaultMessage: '{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent', + }, + {notSentCount}, + ); + } + + const renderButton = useCallback((type: SummaryButtonType) => { + let onPress; + let iconName = ''; + let text; + let styleButtonText = buttonTextStyle(theme, 'lg', 'primary'); + let styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); + const styleButtonIcon = []; + styleButtonIcon.push(styles.summaryButtonIcon); + + switch (type) { + case SummaryButtonType.BACK: + onPress = onBack; + iconName = 'chevron-left'; + text = { + id: t('invite.summary.back'), + defaultMessage: 'Go back', + }; + styleButtonText = buttonTextStyle(theme, 'lg', 'tertiary'); + styleButtonBackground = [buttonBackgroundStyle(theme, 'lg', 'tertiary'), {marginRight: 8}]; + styleButtonIcon.push({color: theme.buttonBg}); + break; + case SummaryButtonType.RETRY: + onPress = onRetry; + iconName = 'refresh'; + text = { + id: t('invite.summary.try_again'), + defaultMessage: 'Try again', + }; + break; + case SummaryButtonType.DONE: + default: + onPress = onClose; + text = { + id: t('invite.summary.done'), + defaultMessage: 'Done', + }; + break; + } + + return ( + + ); + }, [theme, locale, onClose, onRetry, onBack]); + + return ( + + + + + {svg} + + + {message} + + {error ? ( + + {error} + + ) : ( + <> + {notSent.length > 0 && ( + + )} + {sent.length > 0 && ( + + )} + + )} + + + + + {error ? ( + <> + {renderButton(SummaryButtonType.BACK)} + {renderButton(SummaryButtonType.RETRY)} + + ) : ( + renderButton(SummaryButtonType.DONE) + )} + + + + ); +} diff --git a/app/screens/invite/summary_report.tsx b/app/screens/invite/summary_report.tsx new file mode 100644 index 000000000..588d50b07 --- /dev/null +++ b/app/screens/invite/summary_report.tsx @@ -0,0 +1,149 @@ +// 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 COLOR_SUCCESS = '#3db887'; +const COLOR_ERROR = '#d24b4e'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + display: 'flex', + flexDirection: 'column', + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRadius: 4, + marginBottom: 16, + paddingVertical: 8, + }, + title: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 12, + }, + titleText: { + marginLeft: 12, + ...typography('Heading', 300, 'SemiBold'), + color: theme.centerChannelColor, + }, + item: { + display: 'flex', + flexDirection: 'column', + paddingVertical: 12, + }, + user: { + paddingTop: 0, + paddingBottom: 0, + height: 'auto', + }, + reason: { + 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; + + 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 ( + + + + + {message} + + + {invites.map(({userId, reason}) => { + const item = selectedIds[userId]; + + return ( + + {typeof item === 'string' ? ( + + ) : ( + + )} + + {reason} + + + ); + })} + + ); +} diff --git a/app/screens/invite/text_item.tsx b/app/screens/invite/text_item.tsx new file mode 100644 index 000000000..9a14e3ae1 --- /dev/null +++ b/app/screens/invite/text_item.tsx @@ -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 ( + + {email && ( + + + ) + } + {search && ( + + {email ? ( + formatMessage({id: 'invite.search.email_invite', defaultMessage: 'invite'}) + ) : ( + formatMessage({id: 'invite.search.no_results', defaultMessage: 'No one found matching'}) + )} + + )} + + {text} + + + ); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index dd9aa2adc..1412f15cc 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -327,6 +327,28 @@ "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", "invite_people_to_team.message": "Here’s 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_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.", + "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.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully", + "invite.summary.member_invite": "Invited as a member of {teamDisplayName}", + "invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} 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.summary.try_again": "Try again", + "invite.title": "Invite", + "invite.title.summary": "Invite summary", "join_team.error.group_error": "You need to be a member of a linked group to join this team.", "join_team.error.message": "There has been an error joining the team", "join_team.error.title": "Error joining a team", diff --git a/detox/e2e/support/ui/screen/index.ts b/detox/e2e/support/ui/screen/index.ts index 891e6d90b..99497dda6 100644 --- a/detox/e2e/support/ui/screen/index.ts +++ b/detox/e2e/support/ui/screen/index.ts @@ -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, diff --git a/detox/e2e/support/ui/screen/invite.ts b/detox/e2e/support/ui/screen/invite.ts new file mode 100644 index 000000000..3962618fb --- /dev/null +++ b/detox/e2e/support/ui/screen/invite.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ChannelListScreen} from '@support/ui/screen'; +import {timeouts, wait} 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 wait(timeouts.ONE_SEC); + 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; diff --git a/detox/e2e/support/ui/screen/server.ts b/detox/e2e/support/ui/screen/server.ts index 61f5966fb..6bac98f92 100644 --- a/detox/e2e/support/ui/screen/server.ts +++ b/detox/e2e/support/ui/screen/server.ts @@ -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(); }; diff --git a/detox/e2e/test/teams/invite_people.e2e.ts b/detox/e2e/test/teams/invite_people.e2e.ts index f325701d7..fc0faeaea 100644 --- a/detox/e2e/test/teams/invite_people.e2e.ts +++ b/detox/e2e/test/teams/invite_people.e2e.ts @@ -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, timeouts, wait} 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,38 +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 invite screen + await Invite.close(); + // # Log out await HomeScreen.logout(); }); + it('MM-T5360 - 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 wait(timeouts.ONE_SEC); - 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 device.reloadReactNative(); + await dialog.swipe('down'); } }); + + it('MM-T5361 - 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-T5362 - 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-T5363 - 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-T5364 - 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-T5365 - 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); + }); }); diff --git a/types/api/teams.d.ts b/types/api/teams.d.ts index e3c0784b3..5fe283f0a 100644 --- a/types/api/teams.d.ts +++ b/types/api/teams.d.ts @@ -19,6 +19,11 @@ type TeamMemberWithError = { error: ApiError; } +type TeamInviteWithError = { + email: string; + error: ApiError; +} + type TeamType = 'O' | 'I'; type Team = {