From 9dba74f24da04664948197aaca3681c85a236216 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Thu, 4 Apr 2024 20:41:27 +0200 Subject: [PATCH] members, avatar, rename report, settings, default view for other reports --- src/ONYXKEYS.ts | 3 + src/ROUTES.ts | 16 + src/SCREENS.ts | 8 +- src/languages/en.ts | 8 + .../parameters/InviteMembersToGroupChat.ts | 6 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 6 +- src/libs/Navigation/linkingConfig/config.ts | 9 +- src/libs/Navigation/types.ts | 14 +- src/libs/ReportUtils.ts | 4 + src/libs/actions/Report.ts | 84 +++- src/pages/GroupChatNameEditPage.tsx | 81 ++++ src/pages/InviteReportParticipantsPage.tsx | 255 ++++++++++ src/pages/NewChatConfirmPage.tsx | 14 +- src/pages/ReportDetailsPage.tsx | 21 +- src/pages/ReportParticipantDetailsPage.tsx | 153 ++++++ ...ortParticipantDetailsRoleSelectionPage.tsx | 79 +++ src/pages/ReportParticipantsPage.tsx | 449 ++++++++++++++---- .../settings/Report/ReportSettingsPage.tsx | 11 +- src/types/form/NewChatNameForm.ts | 18 + src/types/form/index.ts | 1 + 22 files changed, 1131 insertions(+), 112 deletions(-) create mode 100644 src/libs/API/parameters/InviteMembersToGroupChat.ts create mode 100644 src/pages/GroupChatNameEditPage.tsx create mode 100644 src/pages/InviteReportParticipantsPage.tsx create mode 100644 src/pages/ReportParticipantDetailsPage.tsx create mode 100644 src/pages/ReportParticipantDetailsRoleSelectionPage.tsx create mode 100644 src/types/form/NewChatNameForm.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c134d2a65db2..89a8febad03b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -446,6 +446,8 @@ const ONYXKEYS = { WORKSPACE_TAX_NAME_FORM_DRAFT: 'workspaceTaxNameFormDraft', WORKSPACE_TAX_VALUE_FORM: 'workspaceTaxValueForm', WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft', + NEW_CHAT_NAME_FORM: 'newChatNameForm', + NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft', }, } as const; @@ -500,6 +502,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; + [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7050360c2e8e..246617c48bed 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -175,6 +175,10 @@ const ROUTES = { NEW: 'new', NEW_CHAT: 'new/chat', NEW_CHAT_CONFIRM: 'new/chat/confirm', + NEW_CHAT_EDIT_NAME: { + route: 'new/chat/confirm/:chatName/edit', + getRoute: (chatName: string) => `new/chat/confirm/${encodeURIComponent(chatName)}/edit` as const, + }, NEW_ROOM: 'new/room', REPORT: 'r', @@ -211,6 +215,18 @@ const ROUTES = { route: 'r/:reportID/participants', getRoute: (reportID: string) => `r/${reportID}/participants` as const, }, + REPORT_PARTICIPANTS_INVITE: { + route: 'r/:reportID/participants/invite', + getRoute: (reportID: string) => `r/${reportID}/participants/invite` as const, + }, + REPORT_PARTICIPANTS_DETAILS: { + route: 'r/:reportID/participants/:accountID', + getRoute: (reportID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/participants/${accountID}`, backTo), + }, + REPORT_PARTICIPANTS_ROLE_SELECTION: { + route: 'r/:reportID/participants/:accountID/role', + getRoute: (reportID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/participants/${accountID}/role`, backTo), + }, REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/details`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c732594cdcbe..97b77f1a1561 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -260,6 +260,7 @@ const SCREENS = { ROOT: 'NewChat_Root', NEW_CHAT: 'chat', NEW_CHAT_CONFIRM: 'NewChat_Confirm', + NEW_CHAT_EDIT_NAME: 'NewChat_Edit_Name', NEW_ROOM: 'room', }, @@ -287,7 +288,12 @@ const SCREENS = { PROFILE_ROOT: 'Profile_Root', PROCESS_MONEY_REQUEST_HOLD_ROOT: 'ProcessMoneyRequestHold_Root', REPORT_DESCRIPTION_ROOT: 'Report_Description_Root', - REPORT_PARTICIPANTS_ROOT: 'ReportParticipants_Root', + REPORT_PARTICIPANTS: { + ROOT: 'ReportParticipants_Root', + INVITE: 'ReportParticipants_Invite', + DETAILS: 'ReportParticipants_Details', + ROLE: 'ReportParticipants_Role', + }, ROOM_MEMBERS_ROOT: 'RoomMembers_Root', ROOM_INVITE_ROOT: 'RoomInvite_Root', SEARCH_ROOT: 'Search_Root', diff --git a/src/languages/en.ts b/src/languages/en.ts index fd2daa50942f..7c9b73df677d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1213,6 +1213,14 @@ export default { }, groupConfirmPage: { groupName: 'Group name', + editName: { + nameRequiredError: 'Group chat name is required.', + }, + }, + groupPage: { + people: { + groupMembersListTitle: 'Directory of all group members.', + }, }, languagePage: { language: 'Language', diff --git a/src/libs/API/parameters/InviteMembersToGroupChat.ts b/src/libs/API/parameters/InviteMembersToGroupChat.ts new file mode 100644 index 000000000000..dc856d410728 --- /dev/null +++ b/src/libs/API/parameters/InviteMembersToGroupChat.ts @@ -0,0 +1,6 @@ +type InviteMembersToGroupChat = { + reportID: string; + inviteeEmails: string[]; +}; + +export default InviteMembersToGroupChat; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 87d9e2265568..0a8c501b574b 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -93,6 +93,7 @@ export type {default as AddEmojiReactionParams} from './AddEmojiReactionParams'; export type {default as RemoveEmojiReactionParams} from './RemoveEmojiReactionParams'; export type {default as LeaveRoomParams} from './LeaveRoomParams'; export type {default as InviteToRoomParams} from './InviteToRoomParams'; +export type {default as InviteMembersToGroupChat} from './InviteMembersToGroupChat'; export type {default as RemoveFromRoomParams} from './RemoveFromRoomParams'; export type {default as FlagCommentParams} from './FlagCommentParams'; export type {default as UpdateReportPrivateNoteParams} from './UpdateReportPrivateNoteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index fd84e65c028e..0f9fa94d9db4 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -102,6 +102,7 @@ const WRITE_COMMANDS = { REMOVE_EMOJI_REACTION: 'RemoveEmojiReaction', LEAVE_ROOM: 'LeaveRoom', INVITE_TO_ROOM: 'InviteToRoom', + INVITE_MEMBERS_TO_GROUP_CHAT: 'InviteMembersToGroupChat', REMOVE_FROM_ROOM: 'RemoveFromRoom', FLAG_COMMENT: 'FlagComment', UPDATE_REPORT_PRIVATE_NOTE: 'UpdateReportPrivateNote', @@ -286,6 +287,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REMOVE_EMOJI_REACTION]: Parameters.RemoveEmojiReactionParams; [WRITE_COMMANDS.LEAVE_ROOM]: Parameters.LeaveRoomParams; [WRITE_COMMANDS.INVITE_TO_ROOM]: Parameters.InviteToRoomParams; + [WRITE_COMMANDS.INVITE_MEMBERS_TO_GROUP_CHAT]: Parameters.InviteMembersToGroupChat; [WRITE_COMMANDS.REMOVE_FROM_ROOM]: Parameters.RemoveFromRoomParams; [WRITE_COMMANDS.FLAG_COMMENT]: Parameters.FlagCommentParams; [WRITE_COMMANDS.UPDATE_REPORT_PRIVATE_NOTE]: Parameters.UpdateReportPrivateNoteParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 55c58290b1cd..29add0fa2d7b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -133,7 +133,10 @@ const ReportDescriptionModalStackNavigator = createModalStackNavigator({ - [SCREENS.REPORT_PARTICIPANTS_ROOT]: () => require('../../../../pages/ReportParticipantsPage').default as React.ComponentType, + [SCREENS.REPORT_PARTICIPANTS.ROOT]: () => require('../../../../pages/ReportParticipantsPage').default as React.ComponentType, + [SCREENS.REPORT_PARTICIPANTS.INVITE]: () => require('../../../../pages/InviteReportParticipantsPage').default as React.ComponentType, + [SCREENS.REPORT_PARTICIPANTS.DETAILS]: () => require('../../../../pages/ReportParticipantDetailsPage').default as React.ComponentType, + [SCREENS.REPORT_PARTICIPANTS.ROLE]: () => require('../../../../pages/ReportParticipantDetailsRoleSelectionPage').default as React.ComponentType, }); const RoomMembersModalStackNavigator = createModalStackNavigator({ @@ -151,6 +154,7 @@ const SearchModalStackNavigator = createModalStackNavigator({ [SCREENS.NEW_CHAT.ROOT]: () => require('../../../../pages/NewChatSelectorPage').default as React.ComponentType, [SCREENS.NEW_CHAT.NEW_CHAT_CONFIRM]: () => require('../../../../pages/NewChatConfirmPage').default as React.ComponentType, + [SCREENS.NEW_CHAT.NEW_CHAT_EDIT_NAME]: () => require('../../../../pages/GroupChatNameEditPage').default as React.ComponentType, }); const NewTaskModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f272ae24973a..ce8f7e1af97f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -436,6 +436,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.NEW_CHAT_CONFIRM, exact: true, }, + [SCREENS.NEW_CHAT.NEW_CHAT_EDIT_NAME]: { + path: ROUTES.NEW_CHAT_EDIT_NAME.route, + exact: true, + }, }, }, [SCREENS.RIGHT_MODAL.NEW_TASK]: { @@ -475,7 +479,10 @@ const config: LinkingOptions['config'] = { }, [SCREENS.RIGHT_MODAL.PARTICIPANTS]: { screens: { - [SCREENS.REPORT_PARTICIPANTS_ROOT]: ROUTES.REPORT_PARTICIPANTS.route, + [SCREENS.REPORT_PARTICIPANTS.ROOT]: ROUTES.REPORT_PARTICIPANTS.route, + [SCREENS.REPORT_PARTICIPANTS.INVITE]: ROUTES.REPORT_PARTICIPANTS_INVITE.route, + [SCREENS.REPORT_PARTICIPANTS.DETAILS]: ROUTES.REPORT_PARTICIPANTS_DETAILS.route, + [SCREENS.REPORT_PARTICIPANTS.ROLE]: ROUTES.REPORT_PARTICIPANTS_ROLE_SELECTION.route, }, }, [SCREENS.RIGHT_MODAL.ROOM_INVITE]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index b88c44b9aa70..a85709fbe3d0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -279,6 +279,7 @@ type SettingsNavigatorParamList = { type NewChatNavigatorParamList = { [SCREENS.NEW_CHAT.ROOT]: undefined; + [SCREENS.NEW_CHAT.NEW_CHAT_EDIT_NAME]: {chatName: string}; }; type SearchNavigatorParamList = { @@ -322,7 +323,18 @@ type ReportDescriptionNavigatorParamList = { }; type ParticipantsNavigatorParamList = { - [SCREENS.REPORT_PARTICIPANTS_ROOT]: {reportID: string}; + [SCREENS.REPORT_PARTICIPANTS.ROOT]: {reportID: string}; + [SCREENS.REPORT_PARTICIPANTS.INVITE]: {reportID: string}; + [SCREENS.REPORT_PARTICIPANTS.DETAILS]: { + reportID: string; + accountID: string; + backTo: Routes; + }; + [SCREENS.REPORT_PARTICIPANTS.ROLE]: { + reportID: string; + accountID: string; + backTo: Routes; + }; }; type RoomMembersNavigatorParamList = { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a2bf892b96b4..9481a93848ef 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5006,6 +5006,10 @@ function shouldDisableRename(report: OnyxEntry, policy: OnyxEntry) { + const report = currentReportData?.[reportID]; + + if (!report) { + return; + } + + const inviteeEmails = Object.keys(inviteeEmailsToAccountIDs); + const inviteeAccountIDs = Object.values(inviteeEmailsToAccountIDs); + const participantAccountIDsAfterInvitation = [...new Set([...(report?.participantAccountIDs ?? []), ...inviteeAccountIDs])].filter( + (accountID): accountID is number => typeof accountID === 'number', + ); + const visibleMemberAccountIDsAfterInvitation = [...new Set([...(report?.visibleChatMemberAccountIDs ?? []), ...inviteeAccountIDs])].filter( + (accountID): accountID is number => typeof accountID === 'number', + ); + + const participantsAfterInvitation = [...new Set([...(report?.participantAccountIDs ?? []), ...inviteeAccountIDs])].reduce((reportParticipants: Participants, accountID: number) => { + const participant: ReportParticipant = { + hidden: false, + role: accountID === currentUserAccountID ? CONST.REPORT.ROLE.ADMIN : CONST.REPORT.ROLE.MEMBER, + }; + // eslint-disable-next-line no-param-reassign + reportParticipants[accountID] = participant; + return reportParticipants; + }, {} as Participants); + + const logins = inviteeEmails.map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); + const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, inviteeAccountIDs); + const pendingChatMembers = ReportUtils.getPendingChatMembers(inviteeAccountIDs, report?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + participantAccountIDs: participantAccountIDsAfterInvitation, + visibleChatMemberAccountIDs: visibleMemberAccountIDsAfterInvitation, + participants: participantsAfterInvitation, + pendingChatMembers, + }, + }, + ...newPersonalDetailsOnyxData.optimisticData, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingChatMembers: report?.pendingChatMembers ?? null, + }, + }, + ...newPersonalDetailsOnyxData.finallyData, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + participantAccountIDs: report.participantAccountIDs, + visibleChatMemberAccountIDs: report.visibleChatMemberAccountIDs, + participants: report.participants, + pendingChatMembers: report?.pendingChatMembers ?? null, + }, + }, + ...newPersonalDetailsOnyxData.finallyData, + ]; + + const parameters: InviteMembersToGroupChatParams = { + reportID, + inviteeEmails, + }; + + // Looks like a wrong API command + API.write(WRITE_COMMANDS.INVITE_MEMBERS_TO_GROUP_CHAT, parameters, {optimisticData, successData, failureData}); +} + /** Removes people from a room * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details */ @@ -3022,7 +3101,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt API.write(WRITE_COMMANDS.RESOLVE_ACTIONABLE_MENTION_WHISPER, parameters, {optimisticData, failureData}); } -function setGroupDraft(participants: Array<{login: string; accountID: number}>, reportName = '') { +function setGroupDraft(participants?: Array<{login: string; accountID: number}>, reportName = '') { Onyx.merge(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, {participants, reportName}); } @@ -3075,6 +3154,7 @@ export { shouldShowReportActionNotification, leaveRoom, inviteToRoom, + inviteMembersToGroupChat, removeFromRoom, getCurrentUserAccountID, setLastOpenedPublicRoom, diff --git a/src/pages/GroupChatNameEditPage.tsx b/src/pages/GroupChatNameEditPage.tsx new file mode 100644 index 000000000000..9542de644300 --- /dev/null +++ b/src/pages/GroupChatNameEditPage.tsx @@ -0,0 +1,81 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {Keyboard} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import type {NewChatNavigatorParamList} from '@navigation/types'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/NewChatNameForm'; + +type GroupChatNameEditPageProps = StackScreenProps; + +function GroupChatNameEditPage({route}: GroupChatNameEditPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const currentChatName = decodeURIComponent(route.params.chatName); + const validate = useCallback((values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const chatName = values.newChatName.trim(); + if (!ValidationUtils.isRequiredFulfilled(chatName)) { + errors.newChatName = 'groupConfirmPage.editName.nameRequiredError'; + } + return errors; + }, []); + + const editName = useCallback((values: FormOnyxValues) => { + Report.setGroupDraft(undefined, values[INPUT_IDS.NEW_CHAT_NAME]); + Keyboard.dismiss(); + Navigation.goBack(ROUTES.NEW_CHAT_CONFIRM); + }, []); + + return ( + + + + + + + ); +} + +GroupChatNameEditPage.displayName = 'GroupChatNameEditPage'; + +export default GroupChatNameEditPage; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx new file mode 100644 index 000000000000..0bc0eb49f8d8 --- /dev/null +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -0,0 +1,255 @@ +import Str from 'expensify-common/lib/str'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {SectionListData} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import type {Section} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; +import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as LoginUtils from '@libs/LoginUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as PhoneNumber from '@libs/PhoneNumber'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import SearchInputManager from './workspace/SearchInputManager'; + +type InviteReportParticipantsPageOnyxProps = { + /** All of the personal details for everyone */ + personalDetails: OnyxEntry; +}; + +type InviteReportParticipantsPageProps = InviteReportParticipantsPageOnyxProps & WithReportOrNotFoundProps & WithNavigationTransitionEndProps; + +type Sections = Array>>; + +function InviteReportParticipantsPage({betas, personalDetails, report, policies, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOptions, setSelectedOptions] = useState([]); + const [invitePersonalDetails, setInvitePersonalDetails] = useState([]); + const [userToInvite, setUserToInvite] = useState(null); + + useEffect(() => { + setSearchTerm(SearchInputManager.searchInput); + }, []); + + // Any existing participants and Expensify emails should not be eligible for invitation + const excludedUsers = useMemo( + () => + [...PersonalDetailsUtils.getLoginsByAccountIDs(report?.visibleChatMemberAccountIDs ?? []), ...CONST.EXPENSIFY_EMAILS].map((participant) => + PhoneNumber.addSMSDomainIfPhoneNumber(participant), + ), + [report], + ); + + useEffect(() => { + const inviteOptions = OptionsListUtils.getMemberInviteOptions(personalDetails, betas ?? [], searchTerm, excludedUsers); + + // Update selectedOptions with the latest personalDetails information + const detailsMap: Record = {}; + inviteOptions.personalDetails.forEach((detail) => { + if (!detail.login) { + return; + } + detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail); + }); + const newSelectedOptions: ReportUtils.OptionData[] = []; + selectedOptions.forEach((option) => { + newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); + }); + + setUserToInvite(inviteOptions.userToInvite); + setInvitePersonalDetails(inviteOptions.personalDetails); + setSelectedOptions(newSelectedOptions); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + }, [personalDetails, betas, searchTerm, excludedUsers]); + + const sections = useMemo(() => { + const sectionsArr: Sections = []; + + if (!didScreenTransitionEnd) { + return []; + } + + // Filter all options that is a part of the search term or in the personal details + let filterSelectedOptions = selectedOptions; + if (searchTerm !== '') { + filterSelectedOptions = selectedOptions.filter((option) => { + const accountID = option?.accountID; + const isOptionInPersonalDetails = invitePersonalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID); + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm))); + const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number ? parsedPhoneNumber.number.e164 : searchTerm.toLowerCase(); + const isPartOfSearchTerm = (option.text?.toLowerCase() ?? '').includes(searchValue) || (option.login?.toLowerCase() ?? '').includes(searchValue); + return isPartOfSearchTerm || isOptionInPersonalDetails; + }); + } + const filterSelectedOptionsFormatted = filterSelectedOptions.map((selectedOption) => OptionsListUtils.formatMemberForList(selectedOption)); + + sectionsArr.push({ + title: undefined, + data: filterSelectedOptionsFormatted, + }); + + // Filtering out selected users from the search results + const selectedLogins = selectedOptions.map(({login}) => login); + const personalDetailsWithoutSelected = invitePersonalDetails.filter(({login}) => !selectedLogins.includes(login)); + const personalDetailsFormatted = personalDetailsWithoutSelected.map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const hasUnselectedUserToInvite = userToInvite && !selectedLogins.includes(userToInvite.login); + + sectionsArr.push({ + title: translate('common.contacts'), + data: personalDetailsFormatted, + }); + + if (hasUnselectedUserToInvite) { + sectionsArr.push({ + title: undefined, + data: [OptionsListUtils.formatMemberForList(userToInvite)], + }); + } + + return sectionsArr; + }, [invitePersonalDetails, searchTerm, selectedOptions, translate, userToInvite, didScreenTransitionEnd]); + + const toggleOption = useCallback( + (option: OptionsListUtils.MemberForList) => { + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); + + let newSelectedOptions: ReportUtils.OptionData[]; + if (isOptionInList) { + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + } + + setSelectedOptions(newSelectedOptions); + }, + [selectedOptions], + ); + + const validate = useCallback(() => selectedOptions.length > 0, [selectedOptions]); + + const reportID = report?.reportID; + const backRoute = useMemo(() => reportID && ROUTES.REPORT_PARTICIPANTS.getRoute(reportID), [reportID]); + const reportName = useMemo(() => ReportUtils.getReportName(report), [report]); + const inviteUsers = useCallback(() => { + if (!validate()) { + return; + } + const invitedEmailsToAccountIDs: PolicyUtils.MemberEmailsToAccountIDs = {}; + selectedOptions.forEach((option) => { + const login = option.login ?? ''; + const accountID = option.accountID; + if (!login.toLowerCase().trim() || !accountID) { + return; + } + invitedEmailsToAccountIDs[login] = Number(accountID); + }); + if (reportID) { + Report.inviteMembersToGroupChat(reportID, invitedEmailsToAccountIDs); + } + SearchInputManager.searchInput = ''; + Navigation.navigate(backRoute); + }, [selectedOptions, backRoute, reportID, validate]); + + const headerMessage = useMemo(() => { + const searchValue = searchTerm.trim().toLowerCase(); + const expensifyEmails = CONST.EXPENSIFY_EMAILS as string[]; + if (!userToInvite && expensifyEmails.includes(searchValue)) { + return translate('messages.errorMessageInvalidEmail'); + } + if ( + !userToInvite && + excludedUsers.includes( + PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible + ? PhoneNumber.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) + : searchValue, + ) + ) { + return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName}); + } + return OptionsListUtils.getHeaderMessage(invitePersonalDetails.length !== 0, Boolean(userToInvite), searchValue); + }, [searchTerm, userToInvite, excludedUsers, invitePersonalDetails, translate, reportName]); + + return ( + + Navigation.goBack(backRoute)} + > + { + Navigation.goBack(backRoute); + }} + /> + { + SearchInputManager.searchInput = value; + setSearchTerm(value); + }} + headerMessage={headerMessage} + onSelectRow={toggleOption} + onConfirm={inviteUsers} + showScrollIndicator + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(personalDetails)} + /> + + + + + + ); +} + +InviteReportParticipantsPage.displayName = 'InviteReportParticipantsPage'; + +export default withNavigationTransitionEnd( + withReportOrNotFound()( + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + })(InviteReportParticipantsPage), + ), +); diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index e6214b160a99..059190d9b040 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -50,7 +50,9 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP return options; }, [allPersonalDetails, newGroupDraft?.participants]); - const groupName = ReportUtils.getGroupChatName(participantAccountIDs ?? []); + const isReportNameWasChanged = newGroupDraft?.reportName !== ''; + + const groupName = isReportNameWasChanged ? newGroupDraft?.reportName : ReportUtils.getGroupChatName(participantAccountIDs ?? []); const sections: ListItem[] = useMemo( () => @@ -101,13 +103,18 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP return; } const logins: string[] = newGroupDraft.participants.map((participant) => participant.login); - Report.navigateToAndOpenReport(logins, true, groupName); + const reportName = isReportNameWasChanged ? newGroupDraft.reportName : ''; + Report.navigateToAndOpenReport(logins, true, reportName); }; const navigateBack = () => { Navigation.goBack(ROUTES.NEW_CHAT); }; + const navigateToEditChatName = () => { + Navigation.navigate(ROUTES.NEW_CHAT_EDIT_NAME.getRoute(groupName ?? '')); + }; + return ( diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 9093bf32b9dd..3210ecb84f07 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DisplayNames from '@components/DisplayNames'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -198,6 +199,20 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD /> ) : null; + const renderAvatar = ReportUtils.isGroupChat(report) ? ( + + ) : ( + + ); return ( @@ -215,11 +230,7 @@ function ReportDetailsPage({policies, report, session, personalDetails}: ReportD size={CONST.AVATAR_SIZE.LARGE} /> ) : ( - + renderAvatar )} diff --git a/src/pages/ReportParticipantDetailsPage.tsx b/src/pages/ReportParticipantDetailsPage.tsx new file mode 100644 index 000000000000..2d01d6c6ec9b --- /dev/null +++ b/src/pages/ReportParticipantDetailsPage.tsx @@ -0,0 +1,153 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as UserUtils from '@libs/UserUtils'; +import Navigation from '@navigation/Navigation'; +import type {ParticipantsNavigatorParamList} from '@navigation/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; + +type ReportParticipantDetailsOnyxProps = { + /** Personal details of all users */ + personalDetails: OnyxEntry; +}; + +type ReportParticipantDetailsPageProps = ReportParticipantDetailsOnyxProps & + WithReportOrNotFoundProps & + StackScreenProps; + +function ReportParticipantDetails({personalDetails, report, route}: ReportParticipantDetailsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const [isRemoveMemberConfirmModalVisible, setIsRemoveMemberConfirmModalVisible] = React.useState(false); + + const accountID = Number(route.params.accountID); + const backTo = route.params.backTo ?? ('' as Route); + + const member = report?.participants?.[accountID]; + const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); + const avatar = details.avatar ?? UserUtils.getDefaultAvatar(); + const fallbackIcon = details.fallbackIcon ?? ''; + const displayName = details.displayName ?? ''; + const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; + const isSelectedUserAdmin = report?.participants?.[accountID]?.role === CONST.REPORT.ROLE.ADMIN; + + const askForConfirmationToRemove = () => { + setIsRemoveMemberConfirmModalVisible(true); + }; + + const removeUser = useCallback(() => { + setIsRemoveMemberConfirmModalVisible(false); + Navigation.goBack(backTo); + }, [backTo]); + + const navigateToProfile = useCallback(() => { + Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())); + }, [accountID]); + + const openRoleSelectionModal = useCallback(() => { + Navigation.navigate(ROUTES.REPORT_PARTICIPANTS_ROLE_SELECTION.getRoute(report.reportID, accountID, Navigation.getActiveRoute())); + }, [accountID, report.reportID]); + + return ( + + Navigation.goBack(backTo)} + /> + + + + + + {Boolean(details.displayName ?? '') && ( + + {displayName} + + )} + +