// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useCallback, useEffect, useState, useRef} from 'react'; import {type IntlShape, useIntl} from 'react-intl'; import {Keyboard, View, type LayoutChangeEvent} from 'react-native'; 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 {useKeyboardOverlap} 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 UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens, NavButtons} from '@typings/screens/navigation'; import type {OptionsTopBarButton} from 'react-native-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|UserModel|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: AvailableScreens; 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 [wrapperHeight, setWrapperHeight] = useState(0); const keyboardOverlap = useKeyboardOverlap(mainView, wrapperHeight); 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 [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()} ); }