/* * Wire * Copyright (C) 2022 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. * */ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import {RECEIPT_MODE} from '@wireapp/api-client/lib/conversation/data/ConversationReceiptModeUpdateData'; import {ConversationProtocol} from '@wireapp/api-client/lib/conversation/NewConversation'; import {isNonFederatingBackendsError} from '@wireapp/core/lib/errors'; import {amplify} from 'amplify'; import cx from 'classnames'; import {container} from 'tsyringe'; import {Button, ButtonVariant, Option, Select} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; import {FadingScrollbar} from 'Components/FadingScrollbar'; import * as Icon from 'Components/Icon'; import {ModalComponent} from 'Components/Modals/ModalComponent'; import {SearchInput} from 'Components/SearchInput'; import {TextInput} from 'Components/TextInput'; import {BaseToggle} from 'Components/toggle/BaseToggle'; import {InfoToggle} from 'Components/toggle/InfoToggle'; import {UserSearchableList} from 'Components/UserSearchableList'; import {SidebarTabs, useSidebarStore} from 'src/script/page/LeftSidebar/panels/Conversations/useSidebarStore'; import {generateConversationUrl} from 'src/script/router/routeGenerator'; import {createNavigate, createNavigateKeyboard} from 'src/script/router/routerBindings'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {handleEnterDown, handleEscDown, isKeyboardEvent} from 'Util/KeyboardUtil'; import {replaceLink, t} from 'Util/LocalizerUtil'; import {sortUsersByPriority} from 'Util/StringUtil'; import {Config} from '../../../Config'; import {ACCESS_STATE} from '../../../conversation/AccessState'; import { ACCESS_TYPES, teamPermissionsForAccessState, toggleFeature, } from '../../../conversation/ConversationAccessPermission'; import {ConversationRepository} from '../../../conversation/ConversationRepository'; import {User} from '../../../entity/User'; import {isProtocolOption, ProtocolOption} from '../../../guards/Protocol'; import {RootContext} from '../../../page/RootProvider'; import {TeamState} from '../../../team/TeamState'; import {UserState} from '../../../user/UserState'; import {PrimaryModal} from '../PrimaryModal'; interface GroupCreationModalProps { userState?: UserState; teamState?: TeamState; } enum GroupCreationModalState { DEFAULT = 'GroupCreationModal.STATE.DEFAULT', PARTICIPANTS = 'GroupCreationModal.STATE.PARTICIPANTS', PREFERENCES = 'GroupCreationModal.STATE.PREFERENCES', } const GroupCreationModal: React.FC = ({ userState = container.resolve(UserState), teamState = container.resolve(TeamState), }) => { const { isTeam, isMLSEnabled: isMLSEnabledForTeam, isProtocolToggleEnabledForUser, } = useKoSubscribableChildren(teamState, ['isTeam', 'isMLSEnabled', 'isProtocolToggleEnabledForUser']); const {self: selfUser} = useKoSubscribableChildren(userState, ['self']); const isMLSFeatureEnabled = Config.getConfig().FEATURE.ENABLE_MLS; const enableMLSToggle = isMLSFeatureEnabled && isMLSEnabledForTeam && isProtocolToggleEnabledForUser; //if feature flag is set to false or mls is disabled for current team use proteus as default const defaultProtocol = isMLSFeatureEnabled && isMLSEnabledForTeam ? teamState.teamFeatures()?.mls?.config.defaultProtocol : ConversationProtocol.PROTEUS; const protocolOptions: ProtocolOption[] = ([ConversationProtocol.PROTEUS, ConversationProtocol.MLS] as const).map( protocol => ({ label: `${t(`modalCreateGroupProtocolSelect.${protocol}`)}${ protocol === defaultProtocol ? t(`modalCreateGroupProtocolSelect.default`) : '' }`, value: protocol, }), ); const initialProtocol = protocolOptions.find(protocol => protocol.value === defaultProtocol)!; const [isShown, setIsShown] = useState(false); const [selectedContacts, setSelectedContacts] = useState([]); const [enableReadReceipts, setEnableReadReceipts] = useState(false); const [selectedProtocol, setSelectedProtocol] = useState(initialProtocol); const [showContacts, setShowContacts] = useState(false); const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [accessState, setAccessState] = useState(ACCESS_STATE.TEAM.GUESTS_SERVICES); const [nameError, setNameError] = useState(''); const [groupName, setGroupName] = useState(''); const [participantsInput, setParticipantsInput] = useState(''); const [groupCreationState, setGroupCreationState] = useState( GroupCreationModalState.DEFAULT, ); const mainViewModel = useContext(RootContext); useEffect(() => { const showCreateGroup = (_: string, userEntity: User) => { setEnableReadReceipts(isTeam); setIsShown(true); setGroupCreationState(GroupCreationModalState.PREFERENCES); if (userEntity) { setSelectedContacts([...selectedContacts, userEntity]); } }; amplify.subscribe(WebAppEvents.CONVERSATION.CREATE_GROUP, showCreateGroup); }, []); useEffect(() => { setSelectedProtocol(protocolOptions.find(protocol => protocol.value === selectedProtocol.value)!); }, [defaultProtocol]); const stateIsPreferences = groupCreationState === GroupCreationModalState.PREFERENCES; const stateIsParticipants = groupCreationState === GroupCreationModalState.PARTICIPANTS; const isServicesRoom = accessState === ACCESS_STATE.TEAM.SERVICES; const isGuestAndServicesRoom = accessState === ACCESS_STATE.TEAM.GUESTS_SERVICES; const isGuestRoom = accessState === ACCESS_STATE.TEAM.GUEST_ROOM; const isGuestEnabled = isGuestRoom || isGuestAndServicesRoom; const isServicesEnabled = isServicesRoom || isGuestAndServicesRoom; const {setCurrentTab: setCurrentSidebarTab} = useSidebarStore(); const contacts = useMemo(() => { if (showContacts) { if (!isTeam) { return userState.connectedUsers(); } if (isGuestEnabled) { return teamState.teamUsers(); } return teamState.teamMembers().sort(sortUsersByPriority); } return []; }, [isGuestEnabled, isTeam, showContacts, teamState, userState]); const filteredContacts = contacts.filter(user => user.isAvailable()); const handleEscape = useCallback( (event: React.KeyboardEvent | KeyboardEvent): void => { handleEscDown(event, () => { if (stateIsPreferences) { setIsShown(false); } }); }, [setIsShown, stateIsPreferences], ); useEffect(() => { let timerId: number; if (stateIsParticipants) { timerId = window.setTimeout(() => setShowContacts(true)); } else { setShowContacts(false); } return () => { window.clearTimeout(timerId); }; }, [stateIsParticipants]); if (!mainViewModel) { return null; } const {content: contentViewModel} = mainViewModel; const { conversation: conversationRepository, search: searchRepository, team: teamRepository, } = contentViewModel.repositories; const maxNameLength = ConversationRepository.CONFIG.GROUP.MAX_NAME_LENGTH; const maxSize = ConversationRepository.CONFIG.GROUP.MAX_SIZE; const onClose = () => { setIsCreatingConversation(false); setNameError(''); setGroupName(''); setParticipantsInput(''); setSelectedContacts([]); setGroupCreationState(GroupCreationModalState.DEFAULT); setAccessState(ACCESS_STATE.TEAM.GUESTS_SERVICES); }; const clickOnCreate = async ( event: React.MouseEvent | React.KeyboardEvent, ): Promise => { if (!isCreatingConversation) { setIsCreatingConversation(true); try { const conversation = await conversationRepository.createGroupConversation( selectedContacts, groupName, isTeam ? accessState : undefined, { protocol: enableMLSToggle ? selectedProtocol.value : defaultProtocol, receipt_mode: enableReadReceipts ? RECEIPT_MODE.ON : RECEIPT_MODE.OFF, }, ); setCurrentSidebarTab(SidebarTabs.RECENT); if (isKeyboardEvent(event)) { createNavigateKeyboard(generateConversationUrl(conversation.qualifiedId), true)(event); } else { createNavigate(generateConversationUrl(conversation.qualifiedId))(event); } } catch (error) { if (isNonFederatingBackendsError(error)) { const tempName = groupName; setIsShown(false); const backendString = error.backends.join(', and '); const replaceBackends = replaceLink( Config.getConfig().URL.SUPPORT.NON_FEDERATING_INFO, 'modal__text__read-more', 'read-more-backends', ); return PrimaryModal.show(PrimaryModal.type.MULTI_ACTIONS, { preventClose: true, primaryAction: { text: t('groupCreationPreferencesNonFederatingEditList'), action: () => { setGroupName(tempName); setIsShown(true); setIsCreatingConversation(false); setGroupCreationState(GroupCreationModalState.PARTICIPANTS); }, }, secondaryAction: { text: t('groupCreationPreferencesNonFederatingLeave'), action: () => { setIsCreatingConversation(false); }, }, text: { htmlMessage: t( 'groupCreationPreferencesNonFederatingMessage', {backends: backendString}, replaceBackends, ), title: t('groupCreationPreferencesNonFederatingHeadline'), }, }); } amplify.publish(WebAppEvents.CONVERSATION.SHOW, undefined, {}); setIsCreatingConversation(false); } setIsShown(false); } }; const onGroupNameChange = (event: React.ChangeEvent) => { const {value} = event.target; const trimmedNameInput = value.trim(); const nameTooLong = trimmedNameInput.length > maxNameLength; const nameTooShort = !trimmedNameInput.length; setGroupName(value); if (nameTooLong) { return setNameError(t('groupCreationPreferencesErrorNameLong')); } else if (nameTooShort) { return setNameError(t('groupCreationPreferencesErrorNameShort')); } setNameError(''); }; const onProtocolChange = (option: Option | null) => { if (!isProtocolOption(option)) { return; } setSelectedProtocol(option); if ( (option.value === ConversationProtocol.MLS && isServicesEnabled) || (option.value === ConversationProtocol.PROTEUS && !isServicesEnabled) ) { clickOnToggleServicesMode(); } }; const groupNameLength = groupName.length; const hasNameError = nameError.length > 0; const clickOnNext = (): void => { const nameTooLong = groupNameLength > maxNameLength; if (groupNameLength && !nameTooLong) { setGroupCreationState(GroupCreationModalState.PARTICIPANTS); } }; const clickOnToggle = (feature: number): void => { const newAccessState = toggleFeature(feature, accessState); setAccessState(newAccessState); }; const clickOnToggleServicesMode = () => clickOnToggle(ACCESS_TYPES.SERVICE); const clickOnToggleGuestMode = () => clickOnToggle(teamPermissionsForAccessState(ACCESS_STATE.TEAM.GUEST_FEATURES)); const clickOnBack = (): void => { setGroupCreationState(GroupCreationModalState.PREFERENCES); }; const participantsActionText = selectedContacts.length ? t('groupCreationParticipantsActionCreate') : t('groupCreationParticipantsActionSkip'); const isInputValid = groupNameLength && !nameError.length; return (
{stateIsParticipants && ( <>

{selectedContacts.length ? t('groupCreationParticipantsHeaderWithCounter', selectedContacts.length) : t('groupCreationParticipantsHeader')}

)} {stateIsPreferences && ( <>

{t('groupCreationPreferencesHeader')}

)}
{stateIsParticipants && ( )} {stateIsParticipants && selfUser && ( )} {/* eslint jsx-a11y/no-autofocus : "off" */} {stateIsPreferences && ( <>
setGroupName('')} onChange={onGroupNameChange} onBlur={event => { const {value} = event.target as HTMLInputElement; const trimmedName = value.trim(); setGroupName(trimmedName); }} onKeyDown={(event: React.KeyboardEvent) => { handleEnterDown(event, clickOnNext); }} value={groupName} isError={hasNameError} errorMessage={nameError} />
{isTeam && ( <>

{t('groupSizeInfo', maxSize)}


{selectedProtocol.value !== ConversationProtocol.MLS && ( )} {enableMLSToggle && ( <>