From a476b53d5fa284f351b7d78226fe86996d2963dd Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 12 Dec 2022 16:36:49 -0500 Subject: [PATCH 01/12] MM-42835_Invite People - add email+user invites --- app/actions/remote/team.ts | 58 ++ app/client/rest/teams.ts | 31 ++ .../floating_text_input_label/index.tsx | 20 +- app/components/illustrations/alert.tsx | 30 + app/components/illustrations/error.tsx | 34 ++ app/components/illustrations/success.tsx | 22 + .../selected_users/selected_user.tsx | 52 +- app/components/user_item/user_item.tsx | 30 +- app/constants/screens.ts | 3 + app/constants/server_errors.ts | 1 + .../categories_list/header/header.tsx | 3 - .../categories_list/header/index.ts | 3 - .../header/plus_menu/index.tsx | 67 +-- app/screens/index.tsx | 3 + app/screens/invite/index.ts | 41 ++ app/screens/invite/invite.tsx | 362 +++++++++++++ app/screens/invite/selection.tsx | 512 ++++++++++++++++++ app/screens/invite/summary.tsx | 167 ++++++ app/screens/invite/summary_report.tsx | 153 ++++++ app/screens/invite/text_item.tsx | 111 ++++ app/screens/navigation.ts | 16 +- assets/base/i18n/en.json | 19 + detox/e2e/support/ui/screen/index.ts | 2 + detox/e2e/support/ui/screen/invite.ts | 126 +++++ detox/e2e/support/ui/screen/server.ts | 1 + detox/e2e/test/teams/invite_people.e2e.ts | 203 ++++++- types/api/teams.d.ts | 5 + 27 files changed, 1953 insertions(+), 122 deletions(-) create mode 100644 app/components/illustrations/alert.tsx create mode 100644 app/components/illustrations/error.tsx create mode 100644 app/components/illustrations/success.tsx create mode 100644 app/screens/invite/index.ts create mode 100644 app/screens/invite/invite.tsx create mode 100644 app/screens/invite/selection.tsx create mode 100644 app/screens/invite/summary.tsx create mode 100644 app/screens/invite/summary_report.tsx create mode 100644 app/screens/invite/text_item.tsx create mode 100644 detox/e2e/support/ui/screen/invite.ts diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index a00a83b09..1ecc9778b 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 9c53c0350..190256568 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -18,7 +18,10 @@ export interface ClientTeamsMix { getMyTeamMembers: () => Promise; getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise; getTeamMember: (teamId: string, userId: string) => Promise; + getTeamMembersByIds: (teamId: string, userIds: string[]) => Promise; addToTeam: (teamId: string, userId: string) => Promise; + addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise; + sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise; joinTeam: (inviteId: string) => Promise; removeFromTeam: (teamId: string, userId: string) => Promise; getTeamStats: (teamId: string) => Promise; @@ -120,6 +123,13 @@ const ClientTeams = (superclass: any) => class extends superclass { ); }; + getTeamMembersByIds = (teamId: string, userIds: string[]) => { + return this.doFetch( + `${this.getTeamMembersRoute(teamId)}/ids`, + {method: 'post', body: userIds}, + ); + }; + addToTeam = async (teamId: string, userId: string) => { this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId}); @@ -130,6 +140,27 @@ const ClientTeams = (superclass: any) => class extends superclass { ); }; + addUsersToTeamGracefully = (teamId: string, userIds: string[]) => { + this.analytics.trackAPI('api_teams_batch_add_members', {team_id: teamId, count: userIds.length}); + + const members: Array<{team_id: string; user_id: string}> = []; + userIds.forEach((id) => members.push({team_id: teamId, user_id: id})); + + return this.doFetch( + `${this.getTeamMembersRoute(teamId)}/batch?graceful=true`, + {method: 'post', body: members}, + ); + }; + + sendEmailInvitesToTeamGracefully = (teamId: string, emails: string[]) => { + this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId}); + + return this.doFetch( + `${this.getTeamRoute(teamId)}/invite/email?graceful=true`, + {method: 'post', body: emails}, + ); + }; + joinTeam = async (inviteId: string) => { const query = buildQueryString({invite_id: inviteId}); return this.doFetch( diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 89ccc9ef7..265713686 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 000000000..7dd1b636c --- /dev/null +++ b/app/components/illustrations/error.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, {G, Path, Defs, ClipPath, Rect} from 'react-native-svg'; + +function ErrorSvgComponent() { + return ( + + + + + + + + + + + ); +} + +export default ErrorSvgComponent; diff --git a/app/components/illustrations/success.tsx b/app/components/illustrations/success.tsx new file mode 100644 index 000000000..ac5b3e1b1 --- /dev/null +++ b/app/components/illustrations/success.tsx @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function SuccessSvgComponent() { + return ( + + + + ); +} + +export default SuccessSvgComponent; diff --git a/app/components/selected_users/selected_user.tsx b/app/components/selected_users/selected_user.tsx index 03ecd506c..e8ed300a1 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 cca506356..6fbe7a88e 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 8cf942240..c789c1beb 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 4d638cf3d..1aa6d23eb 100644 --- a/app/screens/home/channel_list/categories_list/header/index.ts +++ b/app/screens/home/channel_list/categories_list/header/index.ts @@ -52,9 +52,6 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { displayName: team.pipe( switchMap((t) => of$(t?.displayName)), ), - inviteId: team.pipe( - switchMap((t) => of$(t?.inviteId)), - ), pushProxyStatus: observePushVerificationStatus(database), }; }); diff --git a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx index 4fabba5a9..ab69ba93c 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 308242f71..30148df16 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -127,6 +127,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.INTEGRATION_SELECTOR: screen = withServerDatabase(require('@screens/integration_selector').default); break; + case Screens.INVITE: + screen = withServerDatabase(require('@screens/invite').default); + break; case Screens.IN_APP_NOTIFICATION: { const notificationScreen = require('@screens/in_app_notification').default; Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () => diff --git a/app/screens/invite/index.ts b/app/screens/invite/index.ts new file mode 100644 index 000000000..8457f91f5 --- /dev/null +++ b/app/screens/invite/index.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged, map} from 'rxjs/operators'; + +import {observeCurrentTeam} from '@queries/servers/team'; +import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user'; +import {isSystemAdmin} from '@utils/user'; + +import Invite from './invite'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const team = observeCurrentTeam(database); + + return { + teamId: team.pipe( + switchMap((t) => of$(t?.id)), + ), + teamDisplayName: team.pipe( + switchMap((t) => of$(t?.displayName)), + ), + teamLastIconUpdate: team.pipe( + switchMap((t) => of$(t?.lastTeamIconUpdatedAt)), + ), + teamInviteId: team.pipe( + switchMap((t) => of$(t?.inviteId)), + ), + teammateNameDisplay: observeTeammateNameDisplay(database), + isAdmin: observeCurrentUser(database).pipe( + map((user) => isSystemAdmin(user?.roles || '')), + distinctUntilChanged(), + ), + }; +}); + +export default withDatabase(enhanced(Invite)); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx new file mode 100644 index 000000000..abc829413 --- /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 000000000..f8e262bbe --- /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 000000000..a82cf3374 --- /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 000000000..f02dcf43e --- /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 000000000..9a14e3ae1 --- /dev/null +++ b/app/screens/invite/text_item.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + item: { + paddingHorizontal: 20, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + search: { + height: 40, + paddingVertical: 8, + paddingHorizontal: 16, + }, + itemText: { + display: 'flex', + ...typography('Body', 200, 'Regular'), + color: theme.centerChannelColor, + }, + itemTerm: { + display: 'flex', + ...typography('Body', 200, 'SemiBold'), + color: theme.centerChannelColor, + marginLeft: 4, + }, + itemImage: { + alignItems: 'center', + justifyContent: 'center', + height: 24, + width: 24, + borderRadius: 12, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + marginRight: 12, + }, + itemIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + }; +}); + +export enum TextItemType { + SEARCH_INVITE = 'search_invite', + SEARCH_NO_RESULTS = 'search_no_results', + SUMMARY = 'summary', +} + +type TextItemProps = { + text?: string; + type: TextItemType; + testID: string; +} + +export default function TextItem({ + text = '', + type, + testID, +}: TextItemProps) { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const search = type === TextItemType.SEARCH_INVITE || type === TextItemType.SEARCH_NO_RESULTS; + const email = type === TextItemType.SEARCH_INVITE || type === TextItemType.SUMMARY; + + return ( + + {email && ( + + + ) + } + {search && ( + + {email ? ( + formatMessage({id: 'invite.search.email_invite', defaultMessage: 'invite'}) + ) : ( + formatMessage({id: 'invite.search.no_results', defaultMessage: 'No one found matching'}) + )} + + )} + + {text} + + + ); +} diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 2e9bf30df..52bf41cb7 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 b413c8c1a..da55cf59d 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 891e6d90b..99497dda6 100644 --- a/detox/e2e/support/ui/screen/index.ts +++ b/detox/e2e/support/ui/screen/index.ts @@ -23,6 +23,7 @@ import EmojiPickerScreen from './emoji_picker'; import FindChannelsScreen from './find_channels'; import GlobalThreadsScreen from './global_threads'; import HomeScreen from './home'; +import Invite from './invite'; import LoginScreen from './login'; import MentionNotificationSettingsScreen from './mention_notification_settings'; import NotificationSettingsScreen from './notification_settings'; @@ -69,6 +70,7 @@ export { FindChannelsScreen, GlobalThreadsScreen, HomeScreen, + Invite, LoginScreen, MentionNotificationSettingsScreen, NotificationSettingsScreen, diff --git a/detox/e2e/support/ui/screen/invite.ts b/detox/e2e/support/ui/screen/invite.ts new file mode 100644 index 000000000..4129478ca --- /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 61f5966fb..6bac98f92 100644 --- a/detox/e2e/support/ui/screen/server.ts +++ b/detox/e2e/support/ui/screen/server.ts @@ -45,6 +45,7 @@ class ServerScreen { connectToServer = async (serverUrl: string, serverDisplayName: string) => { await this.toBeVisible(); await this.serverUrlInput.replaceText(serverUrl); + await this.serverUrlInput.tapReturnKey(); await this.serverDisplayNameInput.replaceText(serverDisplayName); await this.connectButton.tap(); }; diff --git a/detox/e2e/test/teams/invite_people.e2e.ts b/detox/e2e/test/teams/invite_people.e2e.ts index 3423006fd..7269587fc 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 e3c0784b3..5fe283f0a 100644 --- a/types/api/teams.d.ts +++ b/types/api/teams.d.ts @@ -19,6 +19,11 @@ type TeamMemberWithError = { error: ApiError; } +type TeamInviteWithError = { + email: string; + error: ApiError; +} + type TeamType = 'O' | 'I'; type Team = { From 09c24f0059a9ba7c01f0d56e6d709c712e99474f Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Fri, 16 Dec 2022 10:49:09 -0500 Subject: [PATCH 02/12] MM-42835_Invite People - add email+user invites --- detox/e2e/support/ui/screen/server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/detox/e2e/support/ui/screen/server.ts b/detox/e2e/support/ui/screen/server.ts index 6bac98f92..61f5966fb 100644 --- a/detox/e2e/support/ui/screen/server.ts +++ b/detox/e2e/support/ui/screen/server.ts @@ -45,7 +45,6 @@ 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(); }; From 38f1f0b1a57a735d6d0ce11faec2427b1cb2dca9 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Fri, 16 Dec 2022 14:05:35 -0500 Subject: [PATCH 03/12] MM-42835_Invite People - add email+user invites --- detox/e2e/test/teams/invite_people.e2e.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/detox/e2e/test/teams/invite_people.e2e.ts b/detox/e2e/test/teams/invite_people.e2e.ts index 7269587fc..fc0faeaea 100644 --- a/detox/e2e/test/teams/invite_people.e2e.ts +++ b/detox/e2e/test/teams/invite_people.e2e.ts @@ -69,7 +69,7 @@ describe('Teams - Invite', () => { await HomeScreen.logout(); }); - it('MM-T - should open the invite screen', async () => { + it('MM-T5360 - should open the invite screen', async () => { // * Verify invite screen Header buttons await expect(Invite.closeButton).toBeVisible(); await expect(Invite.sendButton).toBeVisible(); @@ -107,7 +107,7 @@ describe('Teams - Invite', () => { } }); - it('MM-T - should show no results item in search list', async () => { + it('MM-T5361 - should show no results item in search list', async () => { const noUser = 'qwertyuiop'; // # Search for a non existent user @@ -118,7 +118,7 @@ describe('Teams - Invite', () => { await expect(Invite.getSearchListNoResultsText(noUser)).toHaveText(noUser); }); - it('MM-T - should be able to send email invite', async () => { + it('MM-T5362 - should be able to send email invite', async () => { const noUserEmailFormat = 'qwerty@ui.op'; // # Search for a non existent user with email format @@ -146,7 +146,7 @@ describe('Teams - Invite', () => { await expect(Invite.getSummaryReportTextItemText(noUserEmailFormat)).toHaveText(noUserEmailFormat); }); - it('MM-T - should be able to send user invite', async () => { + it('MM-T5363 - should be able to send user invite', async () => { const username = ` @${testUser1.username}`; // # Search for a existent user @@ -174,7 +174,7 @@ describe('Teams - Invite', () => { 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 () => { + it('MM-T5364 - should not be able to send user invite to user already in team', async () => { const username = ` @${testUser1.username}`; // # Search for a existent user already in team @@ -200,7 +200,7 @@ describe('Teams - Invite', () => { await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username); }); - it('MM-T - should handle both sent and not sent invites', async () => { + it('MM-T5365 - should handle both sent and not sent invites', async () => { const {user: testUser2} = await User.apiCreateUser(siteOneUrl, {prefix: 'i'}); const username1 = ` @${testUser1.username}`; From a9a9c008601aaddf962b810772a68485d42ed278 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 9 Jan 2023 12:33:51 -0500 Subject: [PATCH 04/12] MM-42835_Invite People - add email+user invites --- app/actions/remote/team.ts | 94 +++++-- .../selected_users/selected_user.tsx | 50 ++-- app/components/user_item/user_item.tsx | 8 +- .../header/plus_menu/index.tsx | 9 +- app/screens/invite/invite.tsx | 45 ++- app/screens/invite/selected_email.tsx | 88 ++++++ app/screens/invite/selection.tsx | 264 +++++------------- app/screens/invite/selection_search_bar.tsx | 131 +++++++++ app/screens/invite/selection_team_bar.tsx | 215 ++++++++++++++ app/screens/invite/summary.tsx | 108 ++++--- app/screens/invite/summary_report.tsx | 3 - assets/base/i18n/en.json | 1 + detox/e2e/support/ui/screen/invite.ts | 3 +- detox/e2e/support/ui/screen/server.ts | 1 + 14 files changed, 717 insertions(+), 303 deletions(-) create mode 100644 app/screens/invite/selected_email.tsx create mode 100644 app/screens/invite/selection_search_bar.tsx create mode 100644 app/screens/invite/selection_team_bar.tsx diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 9a6e91dd3..8adc65eaf 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 e8ed300a1..03ecd506c 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 ab69ba93c..9e1ec49bf 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 abc829413..a004fb4a3 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 000000000..491f540c1 --- /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 f8e262bbe..eb42a63a4 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 000000000..ad5f9b5e6 --- /dev/null +++ b/app/screens/invite/selection_search_bar.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {View, TextInput, LayoutChangeEvent} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme, changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const SEARCH_BAR_TITLE_MARGIN_TOP = 24; +const SEARCH_BAR_MARGIN_TOP = 16; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + display: 'flex', + }, + searchBarTitleText: { + marginHorizontal: 20, + marginTop: SEARCH_BAR_TITLE_MARGIN_TOP, + color: theme.centerChannelColor, + ...typography('Heading', 700, 'SemiBold'), + }, + searchBar: { + marginHorizontal: 20, + marginTop: SEARCH_BAR_MARGIN_TOP, + }, + searchInput: { + height: 48, + backgroundColor: 'transparent', + ...typography('Body', 200, 'Regular'), + lineHeight: 20, + color: theme.centerChannelColor, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRadius: 4, + paddingHorizontal: 16, + }, + searchInputPlaceholder: { + color: changeOpacity(theme.centerChannelColor, 0.64), + }, + }; +}); + +type SelectionSearchBarProps = { + term: string; + onSearchChange: (text: string) => void; + onLayoutContainer: (e: LayoutChangeEvent) => void; +} + +export default function SelectionSearchBar({ + term, + onSearchChange, + onLayoutContainer, +}: SelectionSearchBarProps) { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const [isFocused, setIsFocused] = useState(false); + + const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => { + onLayoutContainer(e); + }, [onLayoutContainer]); + + const onTextInputFocus = () => { + setIsFocused(true); + }; + + const onTextInputBlur = () => { + setIsFocused(false); + }; + + const handleSearchChange = (text: string) => { + onSearchChange(text); + }; + + const searchInputStyle = useMemo(() => { + const style = []; + + style.push(styles.searchInput); + + if (isFocused) { + style.push({ + borderWidth: 2, + borderColor: theme.buttonBg, + }); + } + + return style; + }, [isFocused, styles]); + + return ( + + + + + + + ); +} diff --git a/app/screens/invite/selection_team_bar.tsx b/app/screens/invite/selection_team_bar.tsx new file mode 100644 index 000000000..44bff055e --- /dev/null +++ b/app/screens/invite/selection_team_bar.tsx @@ -0,0 +1,215 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import { + Platform, + View, + Text, + TouchableOpacity, + LayoutChangeEvent, +} from 'react-native'; +import Share from 'react-native-share'; +import {ShareOptions} from 'react-native-share/lib/typescript/types'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon'; +import {useServerDisplayName} from '@context/server'; +import {useTheme} from '@context/theme'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + width: '100%', + paddingVertical: 16, + paddingHorizontal: 20, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + }, + iconContainer: { + width: 40, + height: 40, + }, + textContainer: { + display: 'flex', + flexDirection: 'column', + }, + teamText: { + color: theme.centerChannelColor, + marginLeft: 12, + ...typography('Body', 200, 'SemiBold'), + }, + serverText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + marginLeft: 12, + ...typography('Body', 75, 'Regular'), + }, + shareLink: { + display: 'flex', + marginLeft: 'auto', + }, + shareLinkButton: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: 40, + paddingHorizontal: 20, + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + }, + shareLinkText: { + color: theme.buttonBg, + ...typography('Body', 100, 'SemiBold'), + paddingLeft: 7, + }, + shareLinkIcon: { + color: theme.buttonBg, + }, + }; +}); + +type SelectionTeamBarProps = { + teamId: string; + teamDisplayName: string; + teamLastIconUpdate: number; + teamInviteId: string; + serverUrl: string; + onLayoutContainer: (e: LayoutChangeEvent) => void; + onClose: () => Promise; +} + +export default function SelectionTeamBar({ + teamId, + teamDisplayName, + teamLastIconUpdate, + teamInviteId, + serverUrl, + onLayoutContainer, + onClose, +}: SelectionTeamBarProps) { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const serverDisplayName = useServerDisplayName(); + + const handleOnLayoutContainer = useCallback((e: LayoutChangeEvent) => { + onLayoutContainer(e); + }, [onLayoutContainer]); + + const handleOnShareLink = async () => { + const url = `${serverUrl}/signup_user_complete/?id=${teamInviteId}`; + const title = formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: teamDisplayName}); + const message = formatMessage({id: 'invite_people_to_team.message', defaultMessage: 'Here’s a link to collaborate and communicate with us on Mattermost.'}); + const icon = 'data:/;base64,'; + + const options: ShareOptions = Platform.select({ + ios: { + activityItemSources: [ + { + placeholderItem: { + type: 'url', + content: url, + }, + item: { + default: { + type: 'text', + content: `${message} ${url}`, + }, + copyToPasteBoard: { + type: 'url', + content: url, + }, + }, + subject: { + default: title, + }, + linkMetadata: { + originalUrl: url, + url, + title, + icon, + }, + }, + ], + }, + default: { + title, + subject: title, + url, + showAppsToView: true, + }, + }); + + await onClose(); + + Share.open( + options, + ).catch(() => { + // do nothing + }); + }; + + const handleShareLink = useCallback(preventDoubleTap(() => handleOnShareLink()), []); + + return ( + + + + + + + {teamDisplayName} + + + {serverDisplayName} + + + + + + + + + + ); +} diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index a82cf3374..19a0659ff 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 f02dcf43e..ec7a781b1 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 55faa1c75..5cb98c873 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 4129478ca..3962618fb 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 61f5966fb..6bac98f92 100644 --- a/detox/e2e/support/ui/screen/server.ts +++ b/detox/e2e/support/ui/screen/server.ts @@ -45,6 +45,7 @@ class ServerScreen { connectToServer = async (serverUrl: string, serverDisplayName: string) => { await this.toBeVisible(); await this.serverUrlInput.replaceText(serverUrl); + await this.serverUrlInput.tapReturnKey(); await this.serverDisplayNameInput.replaceText(serverDisplayName); await this.connectButton.tap(); }; From cfbacd4aaf0448076b3cb8f3d02cf3aa8e2526ff Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Sun, 15 Jan 2023 16:37:52 -0500 Subject: [PATCH 05/12] MM-42835_Invite People - add email+user invites --- .../header/plus_menu/index.tsx | 7 +- app/screens/invite/invite.tsx | 106 ++++++++++++------ app/screens/invite/summary.tsx | 83 ++++++++++++-- app/screens/navigation.ts | 14 --- assets/base/i18n/en.json | 4 +- 5 files changed, 145 insertions(+), 69 deletions(-) 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 9e1ec49bf..61b8a78aa 100644 --- a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx +++ b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx @@ -53,14 +53,9 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Pro const invitePeopleToTeam = useCallback(async () => { await dismissBottomSheet(); - const title = intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}); - const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); - const closeButtonId = 'close-invite'; - showModal( Screens.INVITE, - title, - {closeButton, closeButtonId}, + intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}), ); }, [intl, theme]); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index a004fb4a3..ea8e7423a 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -3,20 +3,22 @@ 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 {Keyboard, View, LayoutChangeEvent, Platform} from 'react-native'; +import {OptionsTopBarButton} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; import {getTeamMembersByIds, addUsersToTeam, sendEmailInvitesToTeam} from '@actions/remote/team'; import {searchProfiles} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; import Loading from '@components/loading'; import {ServerErrors} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useModalPosition} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; -import {dismissModal, setButtons, setTitle} from '@screens/navigation'; +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'; @@ -26,16 +28,33 @@ import Summary from './summary'; import type {NavButtons} from '@typings/screens/navigation'; const CLOSE_BUTTON_ID = 'close-invite'; +const BACK_BUTTON_ID = 'back-invite'; const SEND_BUTTON_ID = 'send-invite'; const SEARCH_TIMEOUT_MILLISECONDS = 200; +const DEFAULT_RESULT = {sent: [], notSent: []}; -const makeLeftButton = (icon: ImageResource): OptionsTopBarButton => { - return { - id: CLOSE_BUTTON_ID, - icon, - testID: 'invite.close.button', - }; -}; +const makeLeftButton = (theme: Theme, type: LeftButtonType): OptionsTopBarButton => ( + (type === LeftButtonType.BACK) ? ( + { + id: BACK_BUTTON_ID, + icon: CompassIcon.getImageSourceSync( + Platform.select({ + ios: 'arrow-back-ios', + default: 'arrow-left', + }), + 24, + theme.sidebarHeaderTextColor, + ), + testID: 'invite.back.button', + } + ) : ( + { + 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, @@ -86,10 +105,13 @@ enum Stage { LOADING = 'loading', } +enum LeftButtonType { + CLOSE = 'close', + BACK = 'back', +} + type InviteProps = { componentId: string; - closeButton: ImageResource; - teamId: string; teamDisplayName: string; teamLastIconUpdate: number; @@ -100,7 +122,6 @@ type InviteProps = { export default function Invite({ componentId, - closeButton, teamId, teamDisplayName, teamLastIconUpdate, @@ -122,7 +143,7 @@ export default function Invite({ const [searchResults, setSearchResults] = useState([]); const [selectedIds, setSelectedIds] = useState<{[id: string]: SearchResult}>({}); const [loading, setLoading] = useState(false); - const [result, setResult] = useState({sent: [], notSent: []}); + const [result, setResult] = useState(DEFAULT_RESULT); const [wrapperHeight, setWrapperHeight] = useState(0); const [stage, setStage] = useState(Stage.SELECTION); const [sendError, setSendError] = useState(''); @@ -133,19 +154,6 @@ export default function Invite({ 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(); @@ -162,6 +170,16 @@ export default function Invite({ setSearchResults(results); }, [serverUrl, teamId]); + const handleReset = () => { + setTerm(''); + setSearchResults([]); + setSelectedIds({}); + setLoading(false); + setResult(DEFAULT_RESULT); + setStage(Stage.SELECTION); + setSendError(''); + }; + const handleClearSearch = useCallback(() => { setTerm(''); setSearchResults([]); @@ -196,8 +214,8 @@ export default function Invite({ }, [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: []}); + 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); }; @@ -309,19 +327,32 @@ export default function Invite({ }; useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]); + useNavButtonPressed(BACK_BUTTON_ID, componentId, handleReset, [handleReset]); 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]); + const buttons: NavButtons = { + leftButtons: [makeLeftButton(theme, stage === Stage.RESULT && sendError ? LeftButtonType.BACK : LeftButtonType.CLOSE)], + rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [], + }; + + setButtons(componentId, buttons); + }, [theme, locale, componentId, selectedCount, stage, sendError]); 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]); + 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]); const handleRemoveItem = useCallback((id: string) => { const newSelectedIds = Object.assign({}, selectedIds); @@ -348,6 +379,7 @@ export default function Invite({ selectedIds={selectedIds} error={sendError} onClose={closeModal} + onRetry={handleReset} testID='invite.screen.summary' /> ); diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index 19a0659ff..630704e14 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {View, Text, ScrollView} from 'react-native'; import Button from 'react-native-button'; +import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import AlertSvg from '@components/illustrations/alert'; import ErrorSvg from '@components/illustrations/error'; @@ -51,6 +52,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginHorizontal: 32, marginBottom: 24, }, + summaryErrorText: { + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 200, 'Regular'), + textAlign: 'center', + }, footer: { display: 'flex', flexDirection: 'row', @@ -63,6 +69,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flexGrow: 1, maxWidth: MAX_WIDTH_CONTENT, }, + summaryButtonTextContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + height: 24, + }, + summaryButtonIcon: { + marginRight: 7, + color: theme.buttonColor, + + }, }; }); @@ -72,6 +89,7 @@ type SummaryProps = { error?: string; testID: string; onClose: () => void; + onRetry: () => void; } export default function Summary({ @@ -80,6 +98,7 @@ export default function Summary({ error, testID, onClose, + onRetry, }: SummaryProps) { const {formatMessage} = useIntl(); const theme = useTheme(); @@ -91,15 +110,33 @@ export default function Summary({ const styleButtonText = buttonTextStyle(theme, 'lg', 'primary'); const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); + const styleSummaryMessageText = useMemo(() => { + const style = []; + + style.push(styles.summaryMessageText); + + if (error) { + style.push({marginBottom: 8}); + } + + return style; + }, [error, styles]); let svg = <>; let message = ''; if (error) { svg = ; + message = formatMessage( + { + id: 'invite.summary.error', + defaultMessage: '{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully', + }, + {invitationsCount: sentCount + notSentCount}, + ); } else if (!sentCount && notSentCount) { svg = ; - message = error || formatMessage( + message = formatMessage( { id: 'invite.summary.not_sent', defaultMessage: '{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent', @@ -126,9 +163,14 @@ export default function Summary({ ); } - const handleOnPressButton = () => { + const handleOnPressButton = useCallback(() => { + if (error) { + onRetry(); + return; + } + onClose(); - }; + }, [error, onRetry, onClose]); return ( {svg} - + {message} - {!error && ( + {error ? ( + + {error} + + ) : ( <> - + {error ? ( + + + + + ) : ( + + )} diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index cc8bde955..ef732064d 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -701,20 +701,6 @@ export function setButtons(componentId: string, buttons: NavButtons = {leftButto mergeNavigationOptions(componentId, 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 5cb98c873..52dc25f63 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -331,12 +331,13 @@ "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_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.", "invite.send_invite": "Send", "invite.sendInvitationsTo": "Send invitations to…", "invite.shareLink": "Share link", "invite.summary.done": "Done", "invite.summary.email_invite": "An invitation email has been sent", + "invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully", "invite.summary.member_invite": "Invited as a member of {teamDisplayName}", "invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent", "invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent", @@ -344,6 +345,7 @@ "invite.summary.sent": "Your {sentCount, plural, one {invitation has} other {invitations have}} been sent", "invite.summary.smtp_failure": "SMTP is not configured in System Console", "invite.summary.some_not_sent": "{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent", + "invite.summary.try_again": "Try again", "invite.title": "Invite", "invite.title.summary": "Invite summary", "join_team.error.group_error": "You need to be a member of a linked group to join this team.", From 0b86c5129c72c4e375272d4ac34c453b6253c4a1 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 16 Jan 2023 13:28:03 -0500 Subject: [PATCH 06/12] MM-42835_Invite People - add email+user invites --- app/screens/invite/invite.tsx | 59 +++++++---------- app/screens/invite/summary.tsx | 114 +++++++++++++++++++++++---------- 2 files changed, 101 insertions(+), 72 deletions(-) diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index ea8e7423a..6adbd54ff 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useEffect, useState, useRef} from 'react'; import {IntlShape, useIntl} from 'react-intl'; -import {Keyboard, View, LayoutChangeEvent, Platform} from 'react-native'; +import {Keyboard, View, LayoutChangeEvent} from 'react-native'; import {OptionsTopBarButton} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; @@ -28,33 +28,15 @@ import Summary from './summary'; import type {NavButtons} from '@typings/screens/navigation'; const CLOSE_BUTTON_ID = 'close-invite'; -const BACK_BUTTON_ID = 'back-invite'; const SEND_BUTTON_ID = 'send-invite'; -const SEARCH_TIMEOUT_MILLISECONDS = 200; +const TIMEOUT_MILLISECONDS = 200; const DEFAULT_RESULT = {sent: [], notSent: []}; -const makeLeftButton = (theme: Theme, type: LeftButtonType): OptionsTopBarButton => ( - (type === LeftButtonType.BACK) ? ( - { - id: BACK_BUTTON_ID, - icon: CompassIcon.getImageSourceSync( - Platform.select({ - ios: 'arrow-back-ios', - default: 'arrow-left', - }), - 24, - theme.sidebarHeaderTextColor, - ), - testID: 'invite.back.button', - } - ) : ( - { - id: CLOSE_BUTTON_ID, - icon: CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor), - testID: 'invite.close.button', - } - ) -); +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, @@ -105,11 +87,6 @@ enum Stage { LOADING = 'loading', } -enum LeftButtonType { - CLOSE = 'close', - BACK = 'back', -} - type InviteProps = { componentId: string; teamId: string; @@ -171,13 +148,12 @@ export default function Invite({ }, [serverUrl, teamId]); const handleReset = () => { + setStage(Stage.LOADING); + setSendError(''); setTerm(''); setSearchResults([]); - setSelectedIds({}); - setLoading(false); setResult(DEFAULT_RESULT); setStage(Stage.SELECTION); - setSendError(''); }; const handleClearSearch = useCallback(() => { @@ -196,7 +172,7 @@ export default function Invite({ searchTimeoutId.current = setTimeout(async () => { await searchUsers(text); setLoading(false); - }, SEARCH_TIMEOUT_MILLISECONDS); + }, TIMEOUT_MILLISECONDS); }, [searchUsers]); const handleSelectItem = useCallback((item: SearchResult) => { @@ -213,6 +189,15 @@ export default function Invite({ handleClearSearch(); }, [selectedIds, handleClearSearch]); + const handleRetry = () => { + setSendError(''); + setStage(Stage.LOADING); + + 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); @@ -327,12 +312,11 @@ export default function Invite({ }; useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]); - useNavButtonPressed(BACK_BUTTON_ID, componentId, handleReset, [handleReset]); useNavButtonPressed(SEND_BUTTON_ID, componentId, handleSend, [handleSend]); useEffect(() => { const buttons: NavButtons = { - leftButtons: [makeLeftButton(theme, stage === Stage.RESULT && sendError ? LeftButtonType.BACK : LeftButtonType.CLOSE)], + leftButtons: [makeLeftButton(theme)], rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [], }; @@ -379,7 +363,8 @@ export default function Invite({ selectedIds={selectedIds} error={sendError} onClose={closeModal} - onRetry={handleReset} + onRetry={handleRetry} + onBack={handleReset} testID='invite.screen.summary' /> ); diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index 630704e14..8342c0579 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -12,6 +12,7 @@ import AlertSvg from '@components/illustrations/alert'; import ErrorSvg from '@components/illustrations/error'; import SuccessSvg from '@components/illustrations/success'; import {useTheme} from '@context/theme'; +import {t} from '@i18n'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -67,6 +68,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, summaryButtonContainer: { flexGrow: 1, + flexDirection: 'row', + justifyContent: 'center', maxWidth: MAX_WIDTH_CONTENT, }, summaryButtonTextContainer: { @@ -83,6 +86,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); +enum SummaryButtonType { + DONE = 'done', + RETRY = 'retry', + BACK = 'back', +} + type SummaryProps = { result: Result; selectedIds: {[id: string]: SearchResult}; @@ -90,6 +99,7 @@ type SummaryProps = { testID: string; onClose: () => void; onRetry: () => void; + onBack: () => void; } export default function Summary({ @@ -99,8 +109,9 @@ export default function Summary({ testID, onClose, onRetry, + onBack, }: SummaryProps) { - const {formatMessage} = useIntl(); + const {formatMessage, locale} = useIntl(); const theme = useTheme(); const styles = getStyleSheet(theme); @@ -108,8 +119,6 @@ export default function Summary({ const sentCount = sent.length; const notSentCount = notSent.length; - const styleButtonText = buttonTextStyle(theme, 'lg', 'primary'); - const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); const styleSummaryMessageText = useMemo(() => { const style = []; @@ -163,14 +172,67 @@ export default function Summary({ ); } - const handleOnPressButton = useCallback(() => { - if (error) { - onRetry(); - return; + const renderButton = useCallback((type: SummaryButtonType) => { + let onPress; + let iconName = ''; + let text; + let styleButtonText = buttonTextStyle(theme, 'lg', 'primary'); + let styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary'); + const styleButtonIcon = []; + styleButtonIcon.push(styles.summaryButtonIcon); + + switch (type) { + case SummaryButtonType.BACK: + onPress = onBack; + iconName = 'chevron-left'; + text = { + id: t('invite.summary.back'), + defaultMessage: 'Go back', + }; + styleButtonText = buttonTextStyle(theme, 'lg', 'tertiary'); + styleButtonBackground = [buttonBackgroundStyle(theme, 'lg', 'tertiary'), {marginRight: 8}]; + styleButtonIcon.push({color: theme.buttonBg}); + break; + case SummaryButtonType.RETRY: + onPress = onRetry; + iconName = 'refresh'; + text = { + id: t('invite.summary.try_again'), + defaultMessage: 'Try again', + }; + break; + case SummaryButtonType.DONE: + default: + onPress = onClose; + text = { + id: t('invite.summary.done'), + defaultMessage: 'Done', + }; + break; } - onClose(); - }, [error, onRetry, onClose]); + return ( + + ); + }, [theme, locale, onClose, onRetry, onBack]); return ( - + {error ? ( + <> + {renderButton(SummaryButtonType.BACK)} + {renderButton(SummaryButtonType.RETRY)} + + ) : ( + renderButton(SummaryButtonType.DONE) + )} From a8da27d5e975f3384e21cc7726d1b2efab750831 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 16 Jan 2023 19:00:27 -0500 Subject: [PATCH 07/12] MM-42835_Invite People - add email+user invites --- app/screens/invite/summary.tsx | 63 +++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index 8342c0579..13c449ddb 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -33,13 +33,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flex: 1, }, summaryContainer: { + display: 'flex', flexGrow: 1, - flexDirection: 'column', + flexDirection: 'row', justifyContent: 'center', - alignSelf: 'center', + alignItems: 'stretch', marginTop: 20, marginHorizontal: 20, paddingBottom: 20, + }, + summaryContent: { + flexGrow: 1, + flexDirection: 'column', + justifyContent: 'center', maxWidth: MAX_WIDTH_CONTENT, }, summarySvg: { @@ -70,7 +76,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flexGrow: 1, flexDirection: 'row', justifyContent: 'center', - maxWidth: MAX_WIDTH_CONTENT, }, summaryButtonTextContainer: { display: 'flex', @@ -244,32 +249,34 @@ export default function Summary({ contentContainerStyle={styles.summaryContainer} testID='invite.summary' > - - {svg} - - - {message} - - {error ? ( - - {error} + + + {svg} + + + {message} - ) : ( - <> - - - - )} + {error ? ( + + {error} + + ) : ( + <> + + + + )} + From 29a66bbc19492da82253535fad922f2dbe184c70 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Thu, 19 Jan 2023 17:18:51 -0500 Subject: [PATCH 08/12] MM-42835_Invite People - add email+user invites --- app/actions/remote/team.ts | 16 +++------ app/screens/invite/invite.tsx | 21 +++++++++--- app/screens/invite/selection.tsx | 49 --------------------------- app/screens/invite/summary.tsx | 28 ++++++++------- app/screens/invite/summary_report.tsx | 33 +++++++++--------- app/screens/navigation.ts | 2 +- assets/base/i18n/en.json | 4 +-- 7 files changed, 57 insertions(+), 96 deletions(-) diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 8adc65eaf..90d68c09e 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -508,19 +508,13 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use if (!fetchOnly) { setTeamLoading(serverUrl, true); - const teamMemberships: TeamMembership[] = []; - const roles: Record = {}; + const roles = []; - for (const member of members) { - teamMemberships.push(member); - member.roles.split(' ').forEach((role) => { - if (!roles[role]) { - roles[role] = true; - } - }); + for (const {roles: memberRoles} of members) { + roles.push(...memberRoles.split(' ')); } - fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles)); + fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles))); const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -529,7 +523,7 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use const models: Model[] = (await Promise.all([ operator.handleTeam({teams: [team], prepareRecordsOnly: true}), - operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}), + operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true}), ])).flat(); await operator.batchRecords(models); diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index 6adbd54ff..a1b6f5729 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -115,6 +115,7 @@ export default function Invite({ const modalPosition = useModalPosition(mainView); const searchTimeoutId = useRef(null); + const retryTimeoutId = useRef(null); const [term, setTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -137,7 +138,7 @@ export default function Invite({ return; } - const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {allow_inactive: true}); + const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase()); const results: SearchResult[] = data ?? []; if (!results.length && isEmail(searchTerm.trim())) { @@ -193,7 +194,7 @@ export default function Invite({ setSendError(''); setStage(Stage.LOADING); - setTimeout(() => { + retryTimeoutId.current = setTimeout(() => { handleSend(); }, TIMEOUT_MILLISECONDS); }; @@ -243,9 +244,9 @@ export default function Invite({ 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'})}); + 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'})}); + notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})}); } else { usersToAdd.push(userId); } @@ -338,6 +339,18 @@ export default function Invite({ }); }, [componentId, locale, theme, stage]); + useEffect(() => { + return () => { + if (searchTimeoutId.current) { + clearTimeout(searchTimeoutId.current); + } + + if (retryTimeoutId.current) { + clearTimeout(retryTimeoutId.current); + } + }; + }, []); + const handleRemoveItem = useCallback((id: string) => { const newSelectedIds = Object.assign({}, selectedIds); diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index eb42a63a4..5be704b19 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -23,7 +23,6 @@ import {useTheme} from '@context/theme'; import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; import {useIsTablet, useKeyboardHeight} from '@hooks/device'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; -import {typography} from '@utils/typography'; import {SearchResult} from './invite'; import SelectedEmail from './selected_email'; @@ -56,54 +55,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { 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, - }, searchList: { left: 20, right: 20, diff --git a/app/screens/invite/summary.tsx b/app/screens/invite/summary.tsx index 13c449ddb..a1590e71e 100644 --- a/app/screens/invite/summary.tsx +++ b/app/screens/invite/summary.tsx @@ -262,18 +262,22 @@ export default function Summary({ ) : ( <> - - + {notSent.length > 0 && ( + + )} + {sent.length > 0 && ( + + )} )} diff --git a/app/screens/invite/summary_report.tsx b/app/screens/invite/summary_report.tsx index ec7a781b1..588d50b07 100644 --- a/app/screens/invite/summary_report.tsx +++ b/app/screens/invite/summary_report.tsx @@ -14,9 +14,12 @@ import {typography} from '@utils/typography'; import {SearchResult, InviteResult} from './invite'; import TextItem, {TextItemType} from './text_item'; +const COLOR_SUCCESS = '#3db887'; +const COLOR_ERROR = '#d24b4e'; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { - summaryInvitationsContainer: { + container: { display: 'flex', flexDirection: 'column', borderWidth: 1, @@ -25,29 +28,29 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginBottom: 16, paddingVertical: 8, }, - summaryInvitationsTitle: { + title: { display: 'flex', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 12, }, - summaryInvitationsTitleText: { + titleText: { marginLeft: 12, ...typography('Heading', 300, 'SemiBold'), color: theme.centerChannelColor, }, - summaryInvitationsItem: { + item: { display: 'flex', flexDirection: 'column', paddingVertical: 12, }, - summaryInvitationsUser: { + user: { paddingTop: 0, paddingBottom: 0, height: 'auto', }, - summaryInvitationsReason: { + reason: { paddingLeft: 56, paddingRight: 20, ...typography('Body', 75, 'Regular'), @@ -80,10 +83,6 @@ export default function SummaryReport({ const count = invites.length; - if (!count) { - return null; - } - const sent = type === SummaryReportType.SENT; const message = sent ? ( formatMessage( @@ -105,16 +104,16 @@ export default function SummaryReport({ return ( - + - + {message} @@ -124,7 +123,7 @@ export default function SummaryReport({ return ( {typeof item === 'string' ? ( )} - + {reason} diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 9229ddb59..e3698876d 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -703,7 +703,7 @@ export function setButtons(componentId: string, buttons: NavButtons = {leftButto mergeNavigationOptions(componentId, options); } -export function showOverlay(name: string, passProps = {}, options = {}) { +export function showOverlay(name: string, passProps = {}, options: Options = {}) { if (!isScreenRegistered(name)) { return; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f6f7094b3..1e0b3859c 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -327,8 +327,8 @@ "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.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…", From 3e4851e6fab2eac1320fe182f60e61358092f116 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Fri, 20 Jan 2023 12:44:31 -0500 Subject: [PATCH 09/12] MM-42835_Invite People - add email+user invites --- app/actions/remote/team.ts | 44 ++++--------------- .../categories_list/header/header.tsx | 1 - app/screens/invite/invite.tsx | 8 ++-- app/screens/invite/selection.tsx | 16 ++++--- 4 files changed, 22 insertions(+), 47 deletions(-) diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 90d68c09e..7db5a09f9 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -119,47 +119,31 @@ export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: try { client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); EphemeralStore.startAddingToTeam(teamId); const members = await client.addUsersToTeamGracefully(teamId, userIds); if (!fetchOnly) { - setTeamLoading(serverUrl, true); - const teamMemberships: TeamMembership[] = []; - const roles: Record = {}; + const roles = []; for (const {member} of members) { teamMemberships.push(member); - member.roles.split(' ').forEach((role) => { - if (!roles[role]) { - roles[role] = true; - } - }); + roles.push(...member.roles.split(' ')); } - fetchRolesIfNeeded(serverUrl, Object.getOwnPropertyNames(roles)); - - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles))); 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); + await operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true}); } - - setTeamLoading(serverUrl, false); } EphemeralStore.finishAddingToTeam(teamId); return {members}; } catch (error) { - if (client) { + if (EphemeralStore.isAddingToTeam(teamId)) { EphemeralStore.finishAddingToTeam(teamId); } @@ -503,11 +487,10 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use try { client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const members = await client.getTeamMembersByIds(teamId, userIds); if (!fetchOnly) { - setTeamLoading(serverUrl, true); - const roles = []; for (const {roles: memberRoles} of members) { @@ -516,20 +499,9 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use fetchRolesIfNeeded(serverUrl, Array.from(new Set(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: members, prepareRecordsOnly: true}), - ])).flat(); - - await operator.batchRecords(models); + await operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true}); } - - setTeamLoading(serverUrl, false); } return {members}; 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 5e5d25aa0..262cdc0d3 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -31,7 +31,6 @@ type Props = { canJoinChannels: boolean; canInvitePeople: boolean; displayName?: string; - inviteId?: string; iconPad?: boolean; onHeaderPress?: () => void; pushProxyStatus: string; diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index a1b6f5729..001594aa3 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -223,7 +223,7 @@ export default function Invite({ } } - const currentMemberIds: Record = {}; + const currentMemberIds = new Set(); if (userIds.length) { const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds); @@ -234,7 +234,7 @@ export default function Invite({ } for (const {user_id: currentMemberId} of currentTeamMembers) { - currentMemberIds[currentMemberId] = true; + currentMemberIds.add(currentMemberId); } } @@ -245,7 +245,7 @@ export default function Invite({ 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]) { + } 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); @@ -322,7 +322,7 @@ export default function Invite({ }; setButtons(componentId, buttons); - }, [theme, locale, componentId, selectedCount, stage, sendError]); + }, [theme, locale, componentId, selectedCount > 0, stage === Stage.SELECTION, sendError]); useEffect(() => { mergeNavigationOptions(componentId, { diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index 5be704b19..c7e1e3d08 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -12,7 +12,7 @@ import { ListRenderItemInfo, ScrollView, } from 'react-native'; -import Animated, {useDerivedValue} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import SelectedUser from '@components/selected_users/selected_user'; @@ -60,6 +60,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { right: 20, position: 'absolute', bottom: Platform.select({ios: 'auto', default: undefined}), + borderRadius: 4, + backgroundColor: theme.centerChannelBg, }, searchListBorder: { borderWidth: 1, @@ -194,15 +196,17 @@ export default function Selection({ return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight); }, [animatedAutocompleteAvailableSpace, defaultMaxHeight]); + const searchListContainerAnimatedStyle = useAnimatedStyle(() => ({ + top: animatedAutocompletePosition.value, + maxHeight: maxHeight.value, + }), [animatedAutocompletePosition, maxHeight]); + const searchListContainerStyle = useMemo(() => { const style = []; style.push( styles.searchList, - { - top: animatedAutocompletePosition.value, - maxHeight: maxHeight.value, - }, + searchListContainerAnimatedStyle, ); if (Platform.OS === 'ios') { @@ -210,7 +214,7 @@ export default function Selection({ } return style; - }, [searchResults, styles, animatedAutocompletePosition, maxHeight]); + }, [searchResults, styles, searchListContainerAnimatedStyle]); const searchListFlatListStyle = useMemo(() => { const style = []; From 557804ef45a7ba05ed740b98366f6be03ac206eb Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 23 Jan 2023 12:59:57 -0500 Subject: [PATCH 10/12] MM-42835_Invite People - add email+user invites --- .../selected_chip/index.tsx} | 46 ++++++---- app/components/selected_users/index.tsx | 3 +- .../selected_users/selected_user.tsx | 88 +++---------------- app/screens/invite/invite.tsx | 2 +- app/screens/invite/selection.tsx | 9 +- 5 files changed, 50 insertions(+), 98 deletions(-) rename app/{screens/invite/selected_email.tsx => components/selected_chip/index.tsx} (74%) diff --git a/app/screens/invite/selected_email.tsx b/app/components/selected_chip/index.tsx similarity index 74% rename from app/screens/invite/selected_email.tsx rename to app/components/selected_chip/index.tsx index 491f540c1..3ea8e221d 100644 --- a/app/screens/invite/selected_email.tsx +++ b/app/components/selected_chip/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {Text, TouchableOpacity} from 'react-native'; +import {Text, TouchableOpacity, View} from 'react-native'; import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -10,8 +10,10 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -type Props = { - email: string; +type SelectedChipProps = { + id: string; + text: string; + extra?: React.ReactNode; onRemove: (id: string) => void; testID?: string; } @@ -33,49 +35,59 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { marginRight: 8, paddingHorizontal: 7, }, - remove: { - justifyContent: 'center', - marginLeft: 7, + extraContent: { + flexDirection: 'row', + alignItems: 'center', + color: theme.centerChannelColor, }, text: { marginLeft: 8, color: theme.centerChannelColor, ...typography('Body', 100, 'SemiBold'), }, + remove: { + justifyContent: 'center', + marginLeft: 7, + }, }; }); -export default function SelectedEmail({ - email, +export default function SelectedChip({ + id, + text, + extra, onRemove, testID, -}: Props) { +}: SelectedChipProps) { const theme = useTheme(); const style = getStyleFromTheme(theme); const onPress = useCallback(() => { - onRemove(email); - }, [onRemove, email]); - - const selectedEmailTestID = `${testID}.${email}`; + onRemove(id); + }, [onRemove, id]); return ( + {extra && ( + + {extra} + + )} - {email} + {text} { - return { - container: { - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - borderRadius: 16, - height: USER_CHIP_HEIGHT, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), - marginBottom: USER_CHIP_BOTTOM_MARGIN, - marginRight: 8, - paddingHorizontal: 7, - }, - remove: { - justifyContent: 'center', - marginLeft: 7, - }, - profileContainer: { - flexDirection: 'row', - alignItems: 'center', - marginRight: 8, - color: theme.centerChannelColor, - }, - text: { - color: theme.centerChannelColor, - ...typography('Body', 100, 'SemiBold'), - }, - }; -}); - export default function SelectedUser({ teammateNameDisplay, user, onRemove, testID, }: Props) { - const theme = useTheme(); - const style = getStyleFromTheme(theme); const intl = useIntl(); - const onPress = useCallback(() => { - onRemove(user.id); - }, [onRemove, user.id]); + const onPress = useCallback((id: string) => { + onRemove(id); + }, [onRemove]); const userItemTestID = `${testID}.${user.id}`; + return ( - - + - - - {displayUsername(user, intl.locale, teammateNameDisplay)} - - - - - + )} + onRemove={onPress} + testID={userItemTestID} + /> ); } diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index 001594aa3..6246f316a 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -337,7 +337,7 @@ export default function Invite({ }, }, }); - }, [componentId, locale, theme, stage]); + }, [componentId, locale, theme, stage === Stage.RESULT]); useEffect(() => { return () => { diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index c7e1e3d08..f14dd6605 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -15,6 +15,7 @@ import { import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import SelectedChip from '@components/selected_chip'; import SelectedUser from '@components/selected_users/selected_user'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import UserItem from '@components/user_item'; @@ -25,7 +26,6 @@ import {useIsTablet, useKeyboardHeight} from '@hooks/device'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; 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'; @@ -279,11 +279,12 @@ export default function Selection({ const selectedItem = selectedIds[id]; selectedItems.push(typeof selectedItem === 'string' ? ( - ) : ( Date: Mon, 23 Jan 2023 14:40:33 -0500 Subject: [PATCH 11/12] MM-42835_Invite People - add email+user invites --- app/actions/remote/team.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 7db5a09f9..de906e42b 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -115,10 +115,8 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s } export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) { - let client; - try { - client = NetworkManager.getClient(serverUrl); + const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); EphemeralStore.startAddingToTeam(teamId); @@ -153,11 +151,8 @@ 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); - + const client = NetworkManager.getClient(serverUrl); const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails); return {members}; @@ -483,10 +478,8 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) { } export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) { - let client; - try { - client = NetworkManager.getClient(serverUrl); + const client = NetworkManager.getClient(serverUrl); const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const members = await client.getTeamMembersByIds(teamId, userIds); @@ -499,9 +492,7 @@ export async function getTeamMembersByIds(serverUrl: string, teamId: string, use fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles))); - if (operator) { - await operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true}); - } + await operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true}); } return {members}; From c16145713a52e28a68bfcee9a3c38a011f203a24 Mon Sep 17 00:00:00 2001 From: Julian Mondragon Date: Mon, 23 Jan 2023 14:57:16 -0500 Subject: [PATCH 12/12] MM-42835_Invite People - add email+user invites --- app/screens/invite/selection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/screens/invite/selection.tsx b/app/screens/invite/selection.tsx index f14dd6605..2581db2f1 100644 --- a/app/screens/invite/selection.tsx +++ b/app/screens/invite/selection.tsx @@ -60,8 +60,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { right: 20, position: 'absolute', bottom: Platform.select({ios: 'auto', default: undefined}), - borderRadius: 4, - backgroundColor: theme.centerChannelBg, }, searchListBorder: { borderWidth: 1, @@ -80,6 +78,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { width: 0, height: 6, }, + borderRadius: 4, + backgroundColor: theme.centerChannelBg, }, searchListFlatList: { backgroundColor: theme.centerChannelBg,