diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 9a6e91dd34..8adc65eaff 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -114,23 +114,55 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } } -export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[]) { +export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) { let client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } try { + client = NetworkManager.getClient(serverUrl); EphemeralStore.startAddingToTeam(teamId); const members = await client.addUsersToTeamGracefully(teamId, userIds); + if (!fetchOnly) { + setTeamLoading(serverUrl, true); + + const teamMemberships: TeamMembership[] = []; + const roles: Record = {}; + + for (const {member} of members) { + teamMemberships.push(member); + member.roles.split(' ').forEach((role) => { + if (!roles[role]) { + roles[role] = true; + } + }); + } + + fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles)); + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + + if (operator) { + const team = await client.getTeam(teamId); + + const models: Model[] = (await Promise.all([ + operator.handleTeam({teams: [team], prepareRecordsOnly: true}), + operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}), + ])).flat(); + + await operator.batchRecords(models); + } + + setTeamLoading(serverUrl, false); + } + EphemeralStore.finishAddingToTeam(teamId); return {members}; } catch (error) { - EphemeralStore.finishAddingToTeam(teamId); + if (client) { + EphemeralStore.finishAddingToTeam(teamId); + } + forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; } @@ -138,13 +170,10 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) { let client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } try { + client = NetworkManager.getClient(serverUrl); + const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails); return {members}; @@ -469,17 +498,46 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) { } } -export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[]) { +export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) { let client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } try { + client = NetworkManager.getClient(serverUrl); const members = await client.getTeamMembersByIds(teamId, userIds); + if (!fetchOnly) { + setTeamLoading(serverUrl, true); + + const teamMemberships: TeamMembership[] = []; + const roles: Record = {}; + + for (const member of members) { + teamMemberships.push(member); + member.roles.split(' ').forEach((role) => { + if (!roles[role]) { + roles[role] = true; + } + }); + } + + fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles)); + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + + if (operator) { + const team = await client.getTeam(teamId); + + const models: Model[] = (await Promise.all([ + operator.handleTeam({teams: [team], prepareRecordsOnly: true}), + operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}), + ])).flat(); + + await operator.batchRecords(models); + } + + setTeamLoading(serverUrl, false); + } + return {members}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientError); diff --git a/app/components/selected_users/selected_user.tsx b/app/components/selected_users/selected_user.tsx index e8ed300a1c..03ecd506c6 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|string; + user: UserProfile; /* * 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,42 +84,36 @@ export default function SelectedUser({ const style = getStyleFromTheme(theme); const intl = useIntl(); - const isProfile = typeof user !== 'string'; - const id = isProfile ? user.id : user; - const onPress = useCallback(() => { - onRemove(id); - }, [onRemove, id]); - - const userItemTestID = `${testID}.${id}`; + onRemove(user.id); + }, [onRemove, user.id]); + const userItemTestID = `${testID}.${user.id}`; return ( - {isProfile && ( - - - - )} + + + - {isProfile ? displayUsername(user, intl.locale, teammateNameDisplay) : id} + {displayUsername(user, intl.locale, teammateNameDisplay)} { + return [style.rowUsername, {flexShrink: rowUsernameFlexShrink}]; + }, [user, rowUsernameFlexShrink]); + return ( 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 ab69ba93c9..9e1ec49bf9 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 @@ -26,7 +26,7 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro 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, @@ -44,7 +44,7 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro 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, }); @@ -54,12 +54,13 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro await dismissBottomSheet(); const title = intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}); - const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-invite'; showModal( Screens.INVITE, title, - {closeButton}, + {closeButton, closeButtonId}, ); }, [intl, theme]); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index abc829413d..a004fb4a3f 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -10,7 +10,7 @@ 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 {ServerErrors} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useModalPosition} from '@hooks/device'; @@ -27,6 +27,7 @@ import type {NavButtons} from '@typings/screens/navigation'; const CLOSE_BUTTON_ID = 'close-invite'; const SEND_BUTTON_ID = 'send-invite'; +const SEARCH_TIMEOUT_MILLISECONDS = 200; const makeLeftButton = (icon: ImageResource): OptionsTopBarButton => { return { @@ -124,6 +125,7 @@ export default function Invite({ const [result, setResult] = useState({sent: [], notSent: []}); const [wrapperHeight, setWrapperHeight] = useState(0); const [stage, setStage] = useState(Stage.SELECTION); + const [sendError, setSendError] = useState(''); const selectedCount = Object.keys(selectedIds).length; @@ -153,8 +155,8 @@ export default function Invite({ const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {allow_inactive: true}); const results: SearchResult[] = data ?? []; - if (isEmail(searchTerm.trim())) { - results.unshift(searchTerm.trim() as EmailInvite); + if (!results.length && isEmail(searchTerm.trim())) { + results.push(searchTerm.trim() as EmailInvite); } setSearchResults(results); @@ -176,7 +178,7 @@ export default function Invite({ searchTimeoutId.current = setTimeout(async () => { await searchUsers(text); setLoading(false); - }, General.SEARCH_TIMEOUT_MILLISECONDS); + }, SEARCH_TIMEOUT_MILLISECONDS); }, [searchUsers]); const handleSelectItem = useCallback((item: SearchResult) => { @@ -193,6 +195,12 @@ export default function Invite({ handleClearSearch(); }, [selectedIds, handleClearSearch]); + const handleSendError = () => { + setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Received an unexpected error. Please try again or contact your System Admin for assistance.'})); + setResult({sent: [], notSent: []}); + setStage(Stage.RESULT); + }; + const handleSend = async () => { if (!selectedCount) { return; @@ -211,11 +219,19 @@ export default function Invite({ } } - const {members: currentTeamMembers = []} = await getTeamMembersByIds(serverUrl, teamId, userIds); const currentMemberIds: Record = {}; - for (const {user_id: currentMemberId} of currentTeamMembers) { - currentMemberIds[currentMemberId] = true; + 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[currentMemberId] = true; + } } const sent: InviteResult[] = []; @@ -233,7 +249,12 @@ export default function Invite({ } if (usersToAdd.length) { - const {members} = await addUsersToTeam(serverUrl, teamId, usersToAdd); + const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd); + + if (addUsersToTeamError) { + handleSendError(); + return; + } if (members) { const membersWithError: Record = {}; @@ -254,7 +275,12 @@ export default function Invite({ } if (emails.length) { - const {members} = await sendEmailInvitesToTeam(serverUrl, teamId, emails); + const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails); + + if (sendEmailInvitesToTeamError) { + handleSendError(); + return; + } if (members) { const membersWithError: Record = {}; @@ -320,6 +346,7 @@ export default function Invite({ diff --git a/app/screens/invite/selected_email.tsx b/app/screens/invite/selected_email.tsx new file mode 100644 index 0000000000..491f540c10 --- /dev/null +++ b/app/screens/invite/selected_email.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Text, TouchableOpacity} 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 Props = { + email: string; + 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, + }, + remove: { + justifyContent: 'center', + marginLeft: 7, + }, + text: { + marginLeft: 8, + color: theme.centerChannelColor, + ...typography('Body', 100, 'SemiBold'), + }, + }; +}); + +export default function SelectedEmail({ + email, + onRemove, + testID, +}: Props) { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const onPress = useCallback(() => { + onRemove(email); + }, [onRemove, email]); + + const selectedEmailTestID = `${testID}.${email}`; + + return ( + + + {email} + + + + + + ); +} diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index f8e262bbe7..eb42a63a48 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -2,37 +2,37 @@ // 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 { + Keyboard, + Platform, + View, + 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 {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; import {SearchResult} from './invite'; +import SelectedEmail from './selected_email'; +import SelectionSearchBar from './selection_search_bar'; +import SelectionTeamBar from './selection_team_bar'; 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 KEYBOARD_HEIGHT_ADJUST = 3; const INITIAL_BATCH_TO_RENDER = 15; const SCROLL_EVENT_THROTTLE = 60; @@ -104,16 +104,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { 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, @@ -123,10 +113,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { searchListBorder: { borderWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.2), - overflow: 'hidden', borderRadius: 4, elevation: 3, }, + searchListPadding: { + paddingVertical: 8, + }, searchListShadow: { shadowColor: '#000', shadowOpacity: 0.12, @@ -193,98 +185,31 @@ export default function Selection({ 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 [teamBarHeight, setTeamBarHeight] = useState(0); const [searchBarHeight, setSearchBarHeight] = useState(0); - const [searchBarTitleHeight, setSearchBarTitleHeight] = useState(0); - const onLayoutHeader = useCallback((e: LayoutChangeEvent) => { - setHeaderFieldHeight(e.nativeEvent.layout.height); + const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => { + setTeamBarHeight(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 otherElementsSize = teamBarHeight + searchBarHeight; + const insetsAdjust = (keyboardHeight + KEYBOARD_HEIGHT_ADJUST) || insets.bottom; const keyboardOverlap = Platform.select({ ios: isTablet ? ( @@ -301,11 +226,11 @@ export default function Selection({ const workingSpace = wrapperHeight - keyboardOverlap; const spaceOnTop = otherElementsSize - AUTOCOMPLETE_ADJUST; - const spaceOnBottom = workingSpace - (otherElementsSize + BOTTOM_AUTOCOMPLETE_SEPARATION); + const spaceOnBottom = workingSpace - (otherElementsSize + keyboardAdjust); const autocompletePosition = spaceOnBottom > spaceOnTop ? ( otherElementsSize ) : ( - (workingSpace + AUTOCOMPLETE_ADJUST + keyboardAdjust) - otherElementsSize + workingSpace - otherElementsSize ); const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop; const isLandscape = dimensions.width > dimensions.height; @@ -316,7 +241,7 @@ export default function Selection({ const maxHeight = useDerivedValue(() => { return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight); - }, [defaultMaxHeight]); + }, [animatedAutocompleteAvailableSpace, defaultMaxHeight]); const searchListContainerStyle = useMemo(() => { const style = []; @@ -329,14 +254,22 @@ export default function Selection({ }, ); - if (searchResults.length) { - style.push(styles.searchListBorder); - } - if (Platform.OS === 'ios') { style.push(styles.searchListShadow); } + return style; + }, [searchResults, styles, animatedAutocompletePosition, maxHeight]); + + const searchListFlatListStyle = useMemo(() => { + const style = []; + + style.push(styles.searchListFlatList); + + if (searchResults.length) { + style.push(styles.searchListBorder, styles.searchListPadding); + } + return style; }, [searchResults, styles]); @@ -346,11 +279,13 @@ export default function Selection({ } return ( - + + + ); }, [term, loading]); @@ -386,15 +321,24 @@ export default function Selection({ const selectedItems = []; for (const id of Object.keys(selectedIds)) { - selectedItems.push( + const selectedItem = selectedIds[id]; + + selectedItems.push(typeof selectedItem === 'string' ? ( + + ) : ( , - ); + /> + )); } return selectedItems; @@ -405,84 +349,20 @@ export default function Selection({ style={styles.container} testID={testID} > - - - - - - - {teamDisplayName} - - - {serverDisplayName} - - - - - - - - - - + - - - {Object.keys(selectedIds).length > 0 && ( diff --git a/app/screens/invite/selection_search_bar.tsx b/app/screens/invite/selection_search_bar.tsx new file mode 100644 index 0000000000..ad5f9b5e68 --- /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 0000000000..44bff055ed --- /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 index a82cf33747..19a0659ff2 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -18,6 +18,8 @@ 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: { @@ -26,36 +28,40 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, summary: { display: 'flex', - flexGrowth: 1, + flex: 1, }, summaryContainer: { flexGrow: 1, flexDirection: 'column', justifyContent: 'center', - alignItems: 'center', - margin: 20, + alignSelf: 'center', + marginTop: 20, + marginHorizontal: 20, paddingBottom: 20, + maxWidth: MAX_WIDTH_CONTENT, }, summarySvg: { marginBottom: 20, + alignSelf: 'center', }, summaryMessageText: { color: theme.centerChannelColor, ...typography('Heading', 700, 'SemiBold'), textAlign: 'center', marginHorizontal: 32, - marginBottom: 16, + marginBottom: 24, }, - summaryButton: { - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), - margin: 20, - padding: 15, - }, - summaryButtonContainer: { + footer: { display: 'flex', + flexDirection: 'row', + justifyContent: 'center', borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.16), + padding: 20, + }, + summaryButtonContainer: { + flexGrow: 1, + maxWidth: MAX_WIDTH_CONTENT, }, }; }); @@ -63,6 +69,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { type SummaryProps = { result: Result; selectedIds: {[id: string]: SearchResult}; + error?: string; testID: string; onClose: () => void; } @@ -70,6 +77,7 @@ type SummaryProps = { export default function Summary({ result, selectedIds, + error, testID, onClose, }: SummaryProps) { @@ -84,10 +92,21 @@ export default function Summary({ const styleButtonText = buttonTextStyle(theme, 'lg', 'primary'); const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); - let message = ''; let svg = <>; + let message = ''; - if (sentCount && !notSentCount) { + if (error) { + svg = ; + } else if (!sentCount && notSentCount) { + svg = ; + message = error || 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( { @@ -105,15 +124,6 @@ export default function Summary({ }, {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 = () => { @@ -136,31 +146,37 @@ export default function Summary({ {message} - - + {!error && ( + <> + + + + )} - - + + + + ); diff --git a/app/screens/invite/summary_report.tsx b/app/screens/invite/summary_report.tsx index f02dcf43ef..ec7a781b13 100644 --- a/app/screens/invite/summary_report.tsx +++ b/app/screens/invite/summary_report.tsx @@ -22,14 +22,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { 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, @@ -42,7 +40,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { summaryInvitationsItem: { display: 'flex', flexDirection: 'column', - flexGrow: 1, paddingVertical: 12, }, summaryInvitationsUser: { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 55faa1c753..5cb98c873e 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -331,6 +331,7 @@ "invite.search.email_invite": "invite", "invite.search.no_results": "No one found matching", "invite.searchPlaceholder": "Type a name or email address…", + "invite.send_error": "Received an unexpected error. Please try again or contact your System Admin for assistance.", "invite.send_invite": "Send", "invite.sendInvitationsTo": "Send invitations to…", "invite.shareLink": "Share link", diff --git a/detox/e2e/support/ui/screen/invite.ts b/detox/e2e/support/ui/screen/invite.ts index 4129478ca5..3962618fbf 100644 --- a/detox/e2e/support/ui/screen/invite.ts +++ b/detox/e2e/support/ui/screen/invite.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {ChannelListScreen} from '@support/ui/screen'; -import {timeouts} from '@support/utils'; +import {timeouts, wait} from '@support/utils'; import {expect} from 'detox'; class InviteScreen { @@ -111,6 +111,7 @@ class InviteScreen { open = async () => { await ChannelListScreen.headerPlusButton.tap(); + await wait(timeouts.ONE_SEC); await ChannelListScreen.invitePeopleToTeamItem.tap(); return this.toBeVisible(); 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(); };