diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index a00a83b09d..1ecc9778b6 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -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 { 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}; + } +} diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index 9c53c0350b..1902565680 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/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 89ccc9ef78..2657136867 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -100,7 +100,7 @@ type FloatingTextInputProps = TextInputProps & { error?: string; errorIcon?: string; isKeyboardInput?: boolean; - label: string; + label?: string; labelTextStyle?: TextStyle; multiline?: boolean; onBlur?: (event: NativeSyntheticEvent) => void; @@ -245,14 +245,16 @@ const FloatingTextInput = forwardRef - - {label} - + {label && ( + + {label} + + )} + + + + + ); +} + +export default AlertSvgComponent; diff --git a/app/components/illustrations/error.tsx b/app/components/illustrations/error.tsx new file mode 100644 index 0000000000..7dd1b636c7 --- /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 0000000000..ac5b3e1b18 --- /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_users/selected_user.tsx b/app/components/selected_users/selected_user.tsx index 03ecd506c6..e8ed300a1c 100644 --- a/app/components/selected_users/selected_user.tsx +++ b/app/components/selected_users/selected_user.tsx @@ -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 ( - - - + {isProfile && ( + + + + )} - {displayUsername(user, intl.locale, teammateNameDisplay)} + {isProfile ? displayUsername(user, intl.locale, teammateNameDisplay) : id} { 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 ( {bot && } {guest && } @@ -146,15 +154,15 @@ const UserItem = ({ testID={`${userItemTestId}.current_user_indicator`} /> } - {Boolean(user) && - - {` @${user!.username}`} - - } + {Boolean(user) && ( + + {` @${user!.username}`} + + )} {Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && ( ([ EMOJI_PICKER, FIND_CHANNELS, GALLERY, + INVITE, PERMALINK, REACTIONS, ]); diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts index cca5063561..6fbe7a88ee 100644 --- a/app/constants/server_errors.ts +++ b/app/constants/server_errors.ts @@ -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', }; 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 8cf942240b..c789c1beb9 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -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} /> ); }; 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 4d638cf3dc..1aa6d23ebf 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 4fabba5a9d..ab69ba93c9 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,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: 'Here’s a link to collaborate and communicate with us on Mattermost.'}); - const icon = 'data:/;base64,'; + 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 ( <> diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 308242f719..30148df167 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 0000000000..8457f91f5f --- /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 0000000000..abc829413d --- /dev/null +++ b/app/screens/invite/invite.tsx @@ -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(null); + const modalPosition = useModalPosition(mainView); + + const searchTimeoutId = 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({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 = {}; + + 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 = {}; + 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 = {}; + 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 ( + + ); + 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 0000000000..f8e262bbe7 --- /dev/null +++ b/app/screens/invite/selection.tsx @@ -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; +} + +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: '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()), []); + + 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 ( + + ); + }, [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)) { + selectedItems.push( + , + ); + } + + return selectedItems; + }, [selectedIds]); + + return ( + + + + + + + + {teamDisplayName} + + + {serverDisplayName} + + + + + + + + + + + + + + {Object.keys(selectedIds).length > 0 && ( + + {renderSelectedItems} + + )} + + + + + ); +} diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx new file mode 100644 index 0000000000..a82cf33747 --- /dev/null +++ b/app/screens/invite/summary.tsx @@ -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 = ; + 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}, + ); + } 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}, + ); + } + + const handleOnPressButton = () => { + onClose(); + }; + + return ( + + + + {svg} + + + {message} + + + + + + + + + ); +} diff --git a/app/screens/invite/summary_report.tsx b/app/screens/invite/summary_report.tsx new file mode 100644 index 0000000000..f02dcf43ef --- /dev/null +++ b/app/screens/invite/summary_report.tsx @@ -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 ( + + + + + {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 0000000000..9a14e3ae1c --- /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/app/screens/navigation.ts b/app/screens/navigation.ts index 2e9bf30df3..52bf41cb78 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -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; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index b413c8c1a1..da55cf59d4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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": "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_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 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.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 ", diff --git a/detox/e2e/support/ui/screen/index.ts b/detox/e2e/support/ui/screen/index.ts index 891e6d90b6..99497dda6c 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 0000000000..4129478ca5 --- /dev/null +++ b/detox/e2e/support/ui/screen/invite.ts @@ -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; diff --git a/detox/e2e/support/ui/screen/server.ts b/detox/e2e/support/ui/screen/server.ts index 61f5966fbb..6bac98f927 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 3423006fdc..7269587fcc 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} 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); + }); }); diff --git a/types/api/teams.d.ts b/types/api/teams.d.ts index e3c0784b3e..5fe283f0a6 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 = {