diff --git a/src/CONST.ts b/src/CONST.ts index c1c3366f9fbd..67ad4c941822 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -517,6 +517,8 @@ const CONST = { DELETE_TAG: 'POLICYCHANGELOG_DELETE_TAG', IMPORT_CUSTOM_UNIT_RATES: 'POLICYCHANGELOG_IMPORT_CUSTOM_UNIT_RATES', IMPORT_TAGS: 'POLICYCHANGELOG_IMPORT_TAGS', + INVITE_TO_ROOM: 'POLICYCHANGELOG_INVITETOROOM', + REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM', SET_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT', SET_AUTO_JOIN: 'POLICYCHANGELOG_SET_AUTO_JOIN', SET_CATEGORY_NAME: 'POLICYCHANGELOG_SET_CATEGORY_NAME', @@ -551,6 +553,11 @@ const CONST = { UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED', UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE', }, + ROOMCHANGELOG: { + INVITE_TO_ROOM: 'INVITETOROOM', + REMOVE_FROM_ROOM: 'REMOVEFROMROOM', + JOIN_ROOM: 'JOINROOM', + }, }, }, ARCHIVE_REASON: { @@ -1424,6 +1431,7 @@ const CONST = { REPORT_DETAILS_MENU_ITEM: { SHARE_CODE: 'shareCode', MEMBERS: 'member', + INVITE: 'invite', SETTINGS: 'settings', LEAVE_ROOM: 'leaveRoom', WELCOME_MESSAGE: 'welcomeMessage', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3b7bc2546fe2..b5ceb8fc557d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -213,6 +213,14 @@ export default { route: 'r/:reportID/notes/:accountID/edit', getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`, }, + ROOM_MEMBERS: { + route: 'r/:reportID/members', + getRoute: (reportID: string) => `r/${reportID}/members`, + }, + ROOM_INVITE: { + route: 'r/:reportID/invite', + getRoute: (reportID: string) => `r/${reportID}/invite`, + }, // To see the available iouType, please refer to CONST.IOU.TYPE MONEY_REQUEST: { diff --git a/src/components/FormAlertWrapper.js b/src/components/FormAlertWrapper.js index 704d9b5a241c..67e031ce6ab6 100644 --- a/src/components/FormAlertWrapper.js +++ b/src/components/FormAlertWrapper.js @@ -66,7 +66,7 @@ function FormAlertWrapper(props) { ); } else if (props.isMessageHtml) { - children = ${props.message}`} />; + children = ${props.message}`} />; } return ( diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index c806bedc31c7..04759b89e5d0 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -29,9 +29,13 @@ const customHTMLElementModels = { edited: defaultHTMLElementModels.span.extend({ tagName: 'edited', }), + 'alert-text': defaultHTMLElementModels.div.extend({ + tagName: 'alert-text', + mixedUAStyles: {...styles.formError, ...styles.mb0}, + }), 'muted-text': defaultHTMLElementModels.div.extend({ tagName: 'muted-text', - mixedUAStyles: {...styles.formError, ...styles.mb0}, + mixedUAStyles: {...styles.colorMuted, ...styles.mb0}, }), comment: defaultHTMLElementModels.div.extend({ tagName: 'comment', diff --git a/src/languages/en.ts b/src/languages/en.ts index c7295b523010..0e8512fb254f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -57,7 +57,7 @@ import type { ConfirmThatParams, UntilTimeParams, StepCounterParams, - UserIsAlreadyMemberOfWorkspaceParams, + UserIsAlreadyMemberParams, GoToRoomParams, WelcomeNoteParams, RoomNameReservedErrorParams, @@ -1200,7 +1200,7 @@ export default { messages: { errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, errorMessageInvalidEmail: 'Invalid email', - userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} is already a member of ${workspace}`, + userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} is already a member of ${name}`, }, onfidoStep: { acceptTerms: 'By continuing with the request to activate your Expensify wallet, you confirm that you have read, understand and accept ', @@ -1590,13 +1590,18 @@ export default { selectAWorkspace: 'Select a workspace', growlMessageOnRenameError: 'Unable to rename policy room, please check your connection and try again.', visibilityOptions: { - restricted: 'Restricted', + restricted: 'Workspace', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored. private: 'Private', public: 'Public', // eslint-disable-next-line @typescript-eslint/naming-convention public_announce: 'Public Announce', }, }, + roomMembersPage: { + memberNotFound: 'Member not found. To invite a new member to the room, please use the Invite button above.', + notAuthorized: `You do not have access to this page. Are you trying to join the room? Please reach out to a member of this room so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, + removeMembersPrompt: 'Are you sure you want to remove the selected members from the room?', + }, newTaskPage: { assignTask: 'Assign task', assignMe: 'Assign to me', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4a24b2243e03..a8ee93e35282 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -57,7 +57,7 @@ import type { ConfirmThatParams, UntilTimeParams, StepCounterParams, - UserIsAlreadyMemberOfWorkspaceParams, + UserIsAlreadyMemberParams, GoToRoomParams, WelcomeNoteParams, RoomNameReservedErrorParams, @@ -1218,7 +1218,7 @@ export default { messages: { errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, errorMessageInvalidEmail: 'Email inválido', - userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} ya es miembro de ${workspace}`, + userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} ya es miembro de ${name}`, }, onfidoStep: { acceptTerms: 'Al continuar con la solicitud para activar su billetera Expensify, confirma que ha leído, comprende y acepta ', @@ -1614,13 +1614,18 @@ export default { selectAWorkspace: 'Seleccionar un espacio de trabajo', growlMessageOnRenameError: 'No se ha podido cambiar el nombre del espacio de trabajo, por favor, comprueba tu conexión e inténtalo de nuevo.', visibilityOptions: { - restricted: 'Restringida', + restricted: 'Espacio de trabajo', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored. private: 'Privada', public: 'Público', // eslint-disable-next-line @typescript-eslint/naming-convention public_announce: 'Anuncio Público', }, }, + roomMembersPage: { + memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro a la sala de chat, por favor, utiliza el botón Invitar que está más arriba.', + notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte a la sala de chat? Comunícate con el propietario de esta sala de chat para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, + removeMembersPrompt: '¿Estás seguro de que quieres eliminar a los miembros seleccionados de la sala de chat?', + }, newTaskPage: { assignTask: 'Asignar tarea', assignMe: 'Asignar a mí mismo', diff --git a/src/languages/types.ts b/src/languages/types.ts index 5f8093e96520..5a1847e31e71 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -169,7 +169,7 @@ type UntilTimeParams = {time: string}; type StepCounterParams = {step: number; total?: number; text?: string}; -type UserIsAlreadyMemberOfWorkspaceParams = {login: string; workspace: string}; +type UserIsAlreadyMemberParams = {login: string; name: string}; type GoToRoomParams = {roomName: string}; @@ -303,7 +303,7 @@ export type { ConfirmThatParams, UntilTimeParams, StepCounterParams, - UserIsAlreadyMemberOfWorkspaceParams, + UserIsAlreadyMemberParams, GoToRoomParams, WelcomeNoteParams, RoomNameReservedErrorParams, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 7c8403cc9534..cfc8f815e4fe 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -91,6 +91,14 @@ const ReportParticipantsModalStackNavigator = createModalStackNavigator({ ReportParticipants_Root: () => require('../../../pages/ReportParticipantsPage').default, }); +const RoomMembersModalStackNavigator = createModalStackNavigator({ + RoomMembers_Root: () => require('../../../pages/RoomMembersPage').default, +}); + +const RoomInviteModalStackNavigator = createModalStackNavigator({ + RoomInvite_Root: () => require('../../../pages/RoomInvitePage').default, +}); + const SearchModalStackNavigator = createModalStackNavigator({ Search_Root: () => require('../../../pages/SearchPage').default, }); @@ -231,4 +239,6 @@ export { PrivateNotesModalStackNavigator, NewTeachersUniteNavigator, SignInModalStackNavigator, + RoomMembersModalStackNavigator, + RoomInviteModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index 27a15fa3d763..5f24ec159828 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -64,6 +64,14 @@ function RightModalNavigator(props) { name="Participants" component={ModalStackNavigators.ReportParticipantsModalStackNavigator} /> + + lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN; +/** + * + * @param {String} policyID + * @param {Object} policies + * @returns {Boolean} + */ +const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => policy.id === policyID); + /** * @param {Object} policyMembers * @param {Object} personalDetails @@ -276,6 +284,7 @@ export { isPolicyAdmin, getMemberAccountIDsForWorkspace, getIneligibleInvitees, + isPolicyMember, getTag, getTagListName, getTagList, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1f71b290e386..db8ac3aadaa1 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1,4 +1,5 @@ import {isEqual, max, parseISO} from 'date-fns'; +import _ from 'lodash'; import lodashFindLast from 'lodash/findLast'; import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import {ValueOf} from 'type-fest'; @@ -10,6 +11,7 @@ import {ActionName} from '../types/onyx/OriginalMessage'; import * as CollectionUtils from './CollectionUtils'; import Log from './Log'; import isReportMessageAttachment from './isReportMessageAttachment'; +import * as Environment from './Environment/Environment'; type LastVisibleMessage = { lastMessageTranslationKey?: string; @@ -49,6 +51,9 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = val?.isOffline ?? false), }); +let environmentURL: string; +Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); + function isCreatedAction(reportAction: OnyxEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; } @@ -288,8 +293,8 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } - const {POLICYCHANGELOG: policyChangelogTypes, ...otherActionTypes} = CONST.REPORT.ACTIONS.TYPE; - const supportedActionTypes: ActionName[] = [...Object.values(otherActionTypes), ...Object.values(policyChangelogTypes)]; + const {POLICYCHANGELOG: policyChangelogTypes, ROOMCHANGELOG: roomChangeLogTypes, ...otherActionTypes} = CONST.REPORT.ACTIONS.TYPE; + const supportedActionTypes: ActionName[] = [...Object.values(otherActionTypes), ...Object.values(policyChangelogTypes), ...Object.values(roomChangeLogTypes)]; // Filter out any unsupported reportAction types if (!supportedActionTypes.includes(reportAction.actionName)) { @@ -333,6 +338,34 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry { const updatedActionsToMerge: ReportActions = {}; if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) { @@ -397,7 +430,8 @@ function getSortedReportActionsForDisplay(reportActions: ReportActions | null): const filteredReportActions = Object.entries(reportActions ?? {}) .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key)) .map((entry) => entry[1]); - return getSortedReportActions(filteredReportActions, true); + const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURL(reportAction)); + return getSortedReportActions(baseURLAdjustedReportActions, true); } /** diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 4e351d2dc5e3..05078f7ad94a 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3156,7 +3156,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, (report.participantAccountIDs && report.participantAccountIDs.length === 0 && !isChatThread(report) && - !isPublicRoom(report) && + !isUserCreatedPolicyRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index dd6db33902fb..caa8fb384e56 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -354,6 +354,34 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`; } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`; + } else if ( + lastAction && + _.includes( + [ + CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM, + CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM, + ], + lastAction.actionName, + ) + ) { + const targetAccountIDs = lodashGet(lastAction, 'originalMessage.targetAccountIDs', []); + const verb = + lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? 'invited' + : 'removed'; + const users = targetAccountIDs.length > 1 ? 'users' : 'user'; + result.alternateText = `${verb} ${targetAccountIDs.length} ${users}`; + + const roomName = lodashGet(lastAction, 'originalMessage.roomName', ''); + if (roomName) { + const preposition = + lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? ' to' + : ' from'; + result.alternateText += `${preposition} ${roomName}`; + } } else if (lastAction && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 5ca0a4655730..6f8f6840eaea 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1931,8 +1931,9 @@ function getCurrentUserAccountID() { * Leave a report by setting the state to submitted and closed * * @param {String} reportID + * @param {Boolean} isWorkspaceMemberLeavingWorkspaceRoom */ -function leaveRoom(reportID) { +function leaveRoom(reportID, isWorkspaceMemberLeavingWorkspaceRoom = false) { const report = lodashGet(allReports, [reportID], {}); const reportKeys = _.keys(report); @@ -1941,38 +1942,144 @@ function leaveRoom(reportID) { // between Onyx report being null and Pusher's leavingStatus becoming true. broadcastUserIsLeavingRoom(reportID); + // If a workspace member is leaving a workspace room, they don't actually lose the room from Onyx. + // Instead, their notification preference just gets set to "hidden". + const optimisticData = [ + isWorkspaceMemberLeavingWorkspaceRoom + ? { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + }, + } + : { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.CLOSED, + }, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: isWorkspaceMemberLeavingWorkspaceRoom ? {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN} : _.object(reportKeys, Array(reportKeys.length).fill(null)), + }, + ]; + API.write( 'LeaveRoom', { reportID, }, + { + optimisticData, + successData, + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: report, + }, + ], + }, + ); +} + +/** + * Invites people to a room + * + * @param {String} reportID + * @param {Object} inviteeEmailsToAccountIDs + */ +function inviteToRoom(reportID, inviteeEmailsToAccountIDs) { + const report = lodashGet(allReports, [reportID], {}); + + const inviteeEmails = _.keys(inviteeEmailsToAccountIDs); + const inviteeAccountIDs = _.values(inviteeEmailsToAccountIDs); + + const {participantAccountIDs} = report; + const participantAccountIDsAfterInvitation = _.uniq([...participantAccountIDs, ...inviteeAccountIDs]); + + API.write( + 'InviteToRoom', + { + reportID, + inviteeEmails, + }, { optimisticData: [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS.CLOSED, + participantAccountIDs: participantAccountIDsAfterInvitation, }, }, ], - // Manually clear the report using merge. Should not use set here since it would cause race condition - // if it was called right after a merge. - successData: [ + failureData: [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: _.object(reportKeys, Array(reportKeys.length).fill(null)), + value: { + participantAccountIDs, + }, + }, + ], + }, + ); +} + +/** + * Removes people from a room + * + * @param {String} reportID + * @param {Array} targetAccountIDs + */ +function removeFromRoom(reportID, targetAccountIDs) { + const report = lodashGet(allReports, [reportID], {}); + + const {participantAccountIDs} = report; + const participantAccountIDsAfterRemoval = _.difference(participantAccountIDs, targetAccountIDs); + + API.write( + 'RemoveFromRoom', + { + reportID, + targetAccountIDs, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + participantAccountIDs: participantAccountIDsAfterRemoval, + }, }, ], failureData: [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + participantAccountIDs, + }, + }, + ], + + // We need to add success data here since in high latency situations, + // the OpenRoomMembersPage call has the chance of overwriting the optimistic data we set above. + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS.OPEN, + participantAccountIDs: participantAccountIDsAfterRemoval, }, }, ], @@ -2193,6 +2300,17 @@ function getReportPrivateNote(reportID) { ); } +/** + * Loads necessary data for rendering the RoomMembersPage + * + * @param {String|Number} reportID + */ +function openRoomMembersPage(reportID) { + API.read('OpenRoomMembersPage', { + reportID, + }); +} + /** * Checks if there are any errors in the private notes for a given report * @@ -2331,6 +2449,8 @@ export { hasAccountIDEmojiReacted, shouldShowReportActionNotification, leaveRoom, + inviteToRoom, + removeFromRoom, getCurrentUserAccountID, setLastOpenedPublicRoom, flagComment, @@ -2339,6 +2459,7 @@ export { getReportPrivateNote, clearPrivateNotesError, hasErrorInPrivateNotes, + openRoomMembersPage, savePrivateNotesDraft, getDraftPrivateNote, }; diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 00bb27892792..c6338159f65e 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -2,6 +2,7 @@ import React, {useMemo} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import lodashGet from 'lodash/get'; import {View, ScrollView} from 'react-native'; import RoomHeaderAvatars from '../components/RoomHeaderAvatars'; import compose from '../libs/compose'; @@ -61,6 +62,7 @@ const defaultProps = { function ReportDetailsPage(props) { const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); + const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]); const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(props.report), [props.report]); const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]); const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]); @@ -93,7 +95,7 @@ function ReportDetailsPage(props) { return items; } - if (participants.length) { + if ((!isUserCreatedPolicyRoom && participants.length) || (isUserCreatedPolicyRoom && isPolicyMember)) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.MEMBERS, translationKey: 'common.members', @@ -101,7 +103,21 @@ function ReportDetailsPage(props) { subtitle: participants.length, isAnonymousAction: false, action: () => { - Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(props.report.reportID)); + if (isUserCreatedPolicyRoom && !props.report.parentReportID) { + Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(props.report.reportID)); + } else { + Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(props.report.reportID)); + } + }, + }); + } else if ((!participants.length || !isPolicyMember) && isUserCreatedPolicyRoom && !props.report.parentReportID) { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.INVITE, + translationKey: 'common.invite', + icon: Expensicons.Users, + isAnonymousAction: false, + action: () => { + Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(props.report.reportID)); }, }); } @@ -129,17 +145,18 @@ function ReportDetailsPage(props) { } if (isUserCreatedPolicyRoom || canLeaveRoom) { + const isWorkspaceMemberLeavingWorkspaceRoom = lodashGet(props.report, 'visibility', '') === CONST.REPORT.VISIBILITY.RESTRICTED && isPolicyMember; items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM, translationKey: isThread ? 'common.leaveThread' : 'common.leaveRoom', icon: Expensicons.Exit, isAnonymousAction: false, - action: () => Report.leaveRoom(props.report.reportID), + action: () => Report.leaveRoom(props.report.reportID, isWorkspaceMemberLeavingWorkspaceRoom), }); } return items; - }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isUserCreatedPolicyRoom, canLeaveRoom, isGroupDMChat]); + }, [props.report, isMoneyRequestReport, participants.length, isArchivedRoom, isThread, isUserCreatedPolicyRoom, canLeaveRoom, isGroupDMChat, isPolicyMember]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js new file mode 100644 index 000000000000..c923a8d96d70 --- /dev/null +++ b/src/pages/RoomInvitePage.js @@ -0,0 +1,265 @@ +import React, {useEffect, useMemo, useState, useCallback} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import Navigation from '../libs/Navigation/Navigation'; +import styles from '../styles/styles'; +import compose from '../libs/compose'; +import ONYXKEYS from '../ONYXKEYS'; +import FormAlertWithSubmitButton from '../components/FormAlertWithSubmitButton'; +import * as OptionsListUtils from '../libs/OptionsListUtils'; +import CONST from '../CONST'; +import {policyDefaultProps, policyPropTypes} from './workspace/withPolicy'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import reportPropTypes from './reportPropTypes'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import ROUTES from '../ROUTES'; +import * as PolicyUtils from '../libs/PolicyUtils'; +import useLocalize from '../hooks/useLocalize'; +import SelectionList from '../components/SelectionList'; +import * as Report from '../libs/actions/Report'; +import * as ReportUtils from '../libs/ReportUtils'; +import Permissions from '../libs/Permissions'; +import personalDetailsPropType from './personalDetailsPropType'; +import * as Browser from '../libs/Browser'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All of the personal details for everyone */ + personalDetails: PropTypes.objectOf(personalDetailsPropType), + + /** URL Route params */ + route: PropTypes.shape({ + /** Params from the URL path */ + params: PropTypes.shape({ + /** policyID passed via route: /workspace/:policyID/invite */ + policyID: PropTypes.string, + }), + }).isRequired, + + /** The report currently being looked at */ + report: reportPropTypes.isRequired, + + /** The policies which the user has access to and which the report could be tied to */ + policies: PropTypes.shape({ + /** ID of the policy */ + id: PropTypes.string, + }).isRequired, + + ...policyPropTypes, +}; + +const defaultProps = { + personalDetails: {}, + betas: [], + ...policyDefaultProps, +}; + +function RoomInvitePage(props) { + const {translate} = useLocalize(); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOptions, setSelectedOptions] = useState([]); + const [personalDetails, setPersonalDetails] = useState([]); + const [userToInvite, setUserToInvite] = useState(null); + + // Any existing participants and Expensify emails should not be eligible for invitation + const excludedUsers = useMemo(() => [...lodashGet(props.report, 'participants', []), ...CONST.EXPENSIFY_EMAILS], [props.report]); + + useEffect(() => { + // Kick the user out if they tried to navigate to this via the URL + if (Permissions.canUsePolicyRooms(props.betas)) { + return; + } + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID)); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, searchTerm, excludedUsers); + + // Update selectedOptions with the latest personalDetails information + const detailsMap = {}; + _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false))); + const newSelectedOptions = []; + _.forEach(selectedOptions, (option) => { + newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option); + }); + + setUserToInvite(inviteOptions.userToInvite); + setPersonalDetails(inviteOptions.personalDetails); + setSelectedOptions(newSelectedOptions); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + }, [props.personalDetails, props.betas, searchTerm, excludedUsers]); + + const getSections = () => { + const sections = []; + let indexOffset = 0; + + sections.push({ + title: undefined, + data: selectedOptions, + shouldShow: true, + indexOffset, + }); + indexOffset += selectedOptions.length; + + // Filtering out selected users from the search results + const selectedLogins = _.map(selectedOptions, ({login}) => login); + const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login)); + const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false)); + const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login); + + sections.push({ + title: translate('common.contacts'), + data: personalDetailsFormatted, + shouldShow: !_.isEmpty(personalDetailsFormatted), + indexOffset, + }); + indexOffset += personalDetailsFormatted.length; + + if (hasUnselectedUserToInvite) { + sections.push({ + title: undefined, + data: [OptionsListUtils.formatMemberForList(userToInvite, false)], + shouldShow: true, + indexOffset, + }); + } + + return sections; + }; + + const toggleOption = useCallback( + (option) => { + const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); + + let newSelectedOptions; + if (isOptionInList) { + newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + } + + setSelectedOptions(newSelectedOptions); + }, + [selectedOptions], + ); + + const validate = useCallback(() => { + const errors = {}; + if (selectedOptions.length <= 0) { + errors.noUserSelected = true; + } + + return _.size(errors) <= 0; + }, [selectedOptions]); + + // Non policy members should not be able to view the participants of a room + const reportID = props.report.reportID; + const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]); + const backRoute = useMemo(() => (isPolicyMember ? ROUTES.ROOM_MEMBERS.getRoute(reportID) : ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID)), [isPolicyMember, reportID]); + const reportName = useMemo(() => ReportUtils.getReportName(props.report), [props.report]); + const inviteUsers = useCallback(() => { + if (!validate()) { + return; + } + const invitedEmailsToAccountIDs = {}; + _.each(selectedOptions, (option) => { + const login = option.login || ''; + const accountID = lodashGet(option, 'accountID', ''); + if (!login.toLowerCase().trim() || !accountID) { + return; + } + invitedEmailsToAccountIDs[login] = Number(accountID); + }); + Report.inviteToRoom(props.report.reportID, invitedEmailsToAccountIDs); + Navigation.navigate(backRoute); + }, [selectedOptions, backRoute, props.report.reportID, validate]); + + const headerMessage = useMemo(() => { + const searchValue = searchTerm.trim().toLowerCase(); + if (!userToInvite && CONST.EXPENSIFY_EMAILS.includes(searchValue)) { + return translate('messages.errorMessageInvalidEmail'); + } + if (!userToInvite && excludedUsers.includes(searchValue)) { + return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName}); + } + return OptionsListUtils.getHeaderMessage(personalDetails.length !== 0, Boolean(userToInvite), searchValue); + }, [excludedUsers, translate, searchTerm, userToInvite, personalDetails, reportName]); + return ( + + {({didScreenTransitionEnd}) => { + const sections = didScreenTransitionEnd ? getSections() : []; + + return ( + Navigation.goBack(backRoute)} + > + { + Navigation.goBack(backRoute); + }} + /> + + + + + + ); + }} + + ); +} + +RoomInvitePage.propTypes = propTypes; +RoomInvitePage.defaultProps = defaultProps; +RoomInvitePage.displayName = 'RoomInvitePage'; + +export default compose( + withReportOrNotFound, + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + }), +)(RoomInvitePage); diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js new file mode 100644 index 000000000000..87e1afab8ae9 --- /dev/null +++ b/src/pages/RoomMembersPage.js @@ -0,0 +1,335 @@ +import React, {useMemo, useState, useCallback, useEffect} from 'react'; +import _ from 'underscore'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../styles/styles'; +import compose from '../libs/compose'; +import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; +import ROUTES from '../ROUTES'; +import Navigation from '../libs/Navigation/Navigation'; +import ScreenWrapper from '../components/ScreenWrapper'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import ConfirmModal from '../components/ConfirmModal'; +import Button from '../components/Button'; +import SelectionList from '../components/SelectionList'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../components/withWindowDimensions'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import personalDetailsPropType from './personalDetailsPropType'; +import reportPropTypes from './reportPropTypes'; +import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails'; +import * as PolicyUtils from '../libs/PolicyUtils'; +import * as OptionsListUtils from '../libs/OptionsListUtils'; +import * as UserUtils from '../libs/UserUtils'; +import * as Report from '../libs/actions/Report'; +import * as ReportUtils from '../libs/ReportUtils'; +import Permissions from '../libs/Permissions'; +import Log from '../libs/Log'; +import * as Browser from '../libs/Browser'; + +const propTypes = { + /** All personal details asssociated with user */ + personalDetails: PropTypes.objectOf(personalDetailsPropType), + + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** The report currently being looked at */ + report: reportPropTypes.isRequired, + + /** The policies which the user has access to and which the report could be tied to */ + policies: PropTypes.shape({ + /** ID of the policy */ + id: PropTypes.string, + }), + + /** URL Route params */ + route: PropTypes.shape({ + /** Params from the URL path */ + params: PropTypes.shape({ + /** reportID passed via route: /workspace/:reportID/members */ + reportID: PropTypes.string, + }), + }).isRequired, + + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, + }), + + ...withLocalizePropTypes, + ...windowDimensionsPropTypes, + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + personalDetails: {}, + session: { + accountID: 0, + }, + report: {}, + policies: {}, + betas: [], + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +function RoomMembersPage(props) { + const [selectedMembers, setSelectedMembers] = useState([]); + const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false); + + /** + * Get members for the current room + */ + const getRoomMembers = useCallback(() => { + Report.openRoomMembersPage(props.report.reportID); + setDidLoadRoomMembers(true); + }, [props.report.reportID]); + + useEffect(() => { + // Kick the user out if they tried to navigate to this via the URL + if (!PolicyUtils.isPolicyMember(props.report.policyID, props.policies) || !Permissions.canUsePolicyRooms(props.betas)) { + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID)); + return; + } + getRoomMembers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Open the modal to invite a user + */ + const inviteUser = () => { + setSearchValue(''); + Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(props.report.reportID)); + }; + + /** + * Remove selected users from the room + */ + const removeUsers = () => { + Report.removeFromRoom(props.report.reportID, selectedMembers); + setSelectedMembers([]); + setRemoveMembersConfirmModalVisible(false); + }; + + /** + * Add user from the selectedMembers list + * + * @param {String} login + */ + const addUser = useCallback((accountID) => { + setSelectedMembers((prevSelected) => [...prevSelected, accountID]); + }, []); + + /** + * Remove user from the selectedEmployees list + * + * @param {String} login + */ + const removeUser = useCallback((accountID) => { + setSelectedMembers((prevSelected) => _.without(prevSelected, accountID)); + }, []); + + /** + * Toggle user from the selectedMembers list + * + * @param {String} accountID + * @param {String} pendingAction + * + */ + const toggleUser = useCallback( + (accountID, pendingAction) => { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + + // Add or remove the user if the checkbox is enabled + if (_.contains(selectedMembers, Number(accountID))) { + removeUser(Number(accountID)); + } else { + addUser(Number(accountID)); + } + }, + [selectedMembers, addUser, removeUser], + ); + + /** + * Add or remove all users passed from the selectedMembers list + * @param {Object} memberList + */ + const toggleAllUsers = (memberList) => { + const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled); + const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedMembers, Number(member.keyForList))); + + if (everyoneSelected) { + setSelectedMembers([]); + } else { + const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList)); + setSelectedMembers(everyAccountId); + } + }; + + /** + * Show the modal to confirm removal of the selected members + */ + const askForConfirmationToRemove = () => { + setRemoveMembersConfirmModalVisible(true); + }; + + const getMemberOptions = () => { + let result = []; + + _.each(props.report.participantAccountIDs, (accountID) => { + const details = props.personalDetails[accountID]; + + if (!details) { + Log.hmmm(`[RoomMembersPage] no personal details found for room member with accountID: ${accountID}`); + return; + } + + // If search value is provided, filter out members that don't match the search value + if (searchValue.trim()) { + let memberDetails = ''; + if (details.login) { + memberDetails += ` ${details.login.toLowerCase()}`; + } + if (details.firstName) { + memberDetails += ` ${details.firstName.toLowerCase()}`; + } + if (details.lastName) { + memberDetails += ` ${details.lastName.toLowerCase()}`; + } + if (details.displayName) { + memberDetails += ` ${details.displayName.toLowerCase()}`; + } + if (details.phoneNumber) { + memberDetails += ` ${details.phoneNumber.toLowerCase()}`; + } + + if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) { + return; + } + } + + result.push({ + keyForList: String(accountID), + accountID: Number(accountID), + isSelected: _.contains(selectedMembers, Number(accountID)), + isDisabled: accountID === props.session.accountID, + text: props.formatPhoneNumber(details.displayName), + alternateText: props.formatPhoneNumber(details.login), + icons: [ + { + source: UserUtils.getAvatar(details.avatar, accountID), + name: details.login, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + }); + }); + + result = _.sortBy(result, (value) => value.text.toLowerCase()); + + return result; + }; + + const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]); + const data = getMemberOptions(); + const headerMessage = searchValue.trim() && !data.length ? props.translate('roomMembersPage.memberNotFound') : ''; + return ( + + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID))} + > + { + setSearchValue(''); + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID)); + }} + /> + setRemoveMembersConfirmModalVisible(false)} + prompt={props.translate('roomMembersPage.removeMembersPrompt')} + confirmText={props.translate('common.remove')} + cancelText={props.translate('common.cancel')} + /> + + +