diff --git a/src/languages/en.ts b/src/languages/en.ts index 817f06f6b344..a276de4e0f7c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1547,6 +1547,12 @@ export default { invitePeople: 'Invite new members', genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.', pleaseEnterValidLogin: `Please ensure the email or phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + user: 'user', + users: 'users', + invited: 'invited', + removed: 'removed', + to: 'to', + from: 'from', }, inviteMessage: { inviteMessageTitle: 'Add message', diff --git a/src/languages/es.ts b/src/languages/es.ts index b219021daa0f..290d80a6f65d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1569,6 +1569,12 @@ export default { invitePeople: 'Invitar nuevos miembros', genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..', pleaseEnterValidLogin: `Asegúrese de que el correo electrónico o el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + user: 'usuario', + users: 'usuarios', + invited: 'invitó', + removed: 'eliminó', + to: 'a', + from: 'de', }, inviteMessage: { inviteMessageTitle: 'Añadir un mensaje', diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index 488ff0d9b98a..77c34ebdc576 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -1,6 +1,7 @@ import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; +import {MessageElementBase, MessageTextElement} from '@libs/MessageElement'; import Config from '@src/CONFIG'; import CONST from '@src/CONST'; import translations from '@src/languages/translations'; @@ -121,15 +122,48 @@ function translateIfPhraseKey(message: MaybePhraseKey): string { } } +function getPreferredListFormat(): Intl.ListFormat { + if (!CONJUNCTION_LIST_FORMATS_FOR_LOCALES) { + init(); + } + + return CONJUNCTION_LIST_FORMATS_FOR_LOCALES[BaseLocaleListener.getPreferredLocale()]; +} + /** * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") */ -function arrayToString(anArray: string[]) { - if (!CONJUNCTION_LIST_FORMATS_FOR_LOCALES) { - init(); +function formatList(components: string[]) { + const listFormat = getPreferredListFormat(); + return listFormat.format(components); +} + +function formatMessageElementList(elements: readonly E[]): ReadonlyArray { + const listFormat = getPreferredListFormat(); + const parts = listFormat.formatToParts(elements.map((e) => e.content)); + const resultElements: Array = []; + + let nextElementIndex = 0; + for (const part of parts) { + if (part.type === 'element') { + /** + * The standard guarantees that all input elements will be present in the constructed parts, each exactly + * once, and without any modifications: https://tc39.es/ecma402/#sec-createpartsfromlist + */ + const element = elements[nextElementIndex++]; + + resultElements.push(element); + } else { + const literalElement: MessageTextElement = { + kind: 'text', + content: part.value, + }; + + resultElements.push(literalElement); + } } - const listFormat = CONJUNCTION_LIST_FORMATS_FOR_LOCALES[BaseLocaleListener.getPreferredLocale()]; - return listFormat.format(anArray); + + return resultElements; } /** @@ -139,5 +173,5 @@ function getDevicePreferredLocale(): string { return RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES])?.languageTag ?? CONST.LOCALES.DEFAULT; } -export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale}; +export {translate, translateLocal, translateIfPhraseKey, formatList, formatMessageElementList, getDevicePreferredLocale}; export type {PhraseParameters, Phrase, MaybePhraseKey}; diff --git a/src/libs/MessageElement.ts b/src/libs/MessageElement.ts new file mode 100644 index 000000000000..584d7e1e289a --- /dev/null +++ b/src/libs/MessageElement.ts @@ -0,0 +1,11 @@ +type MessageElementBase = { + readonly kind: string; + readonly content: string; +}; + +type MessageTextElement = { + readonly kind: 'text'; + readonly content: string; +} & MessageElementBase; + +export type {MessageElementBase, MessageTextElement}; diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index 560480dcec9d..8a4151391453 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -197,6 +197,18 @@ function getFormattedAddress(privatePersonalDetails) { return formattedAddress.trim().replace(/,$/, ''); } +/** + * @param {Object} personalDetail - details object + * @returns {String | undefined} - The effective display name + */ +function getEffectiveDisplayName(personalDetail) { + if (personalDetail) { + return LocalePhoneNumber.formatPhoneNumber(personalDetail.login) || personalDetail.displayName; + } + + return undefined; +} + export { getDisplayNameOrDefault, getPersonalDetailsByIDs, @@ -206,4 +218,5 @@ export { getFormattedAddress, getFormattedStreet, getStreetLines, + getEffectiveDisplayName, }; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f58021e17064..ff36a2ac3401 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -5,14 +5,17 @@ import OnyxUtils from 'react-native-onyx/lib/utils'; import {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {ActionName} from '@src/types/onyx/OriginalMessage'; +import {ActionName, ChangeLog} from '@src/types/onyx/OriginalMessage'; import Report from '@src/types/onyx/Report'; -import ReportAction, {ReportActions} from '@src/types/onyx/ReportAction'; +import ReportAction, {Message, ReportActions} from '@src/types/onyx/ReportAction'; import {EmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CollectionUtils from './CollectionUtils'; import * as Environment from './Environment/Environment'; import isReportMessageAttachment from './isReportMessageAttachment'; +import * as Localize from './Localize'; import Log from './Log'; +import {MessageElementBase, MessageTextElement} from './MessageElement'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; type LastVisibleMessage = { lastMessageTranslationKey?: string; @@ -20,6 +23,19 @@ type LastVisibleMessage = { lastMessageHtml?: string; }; +type MemberChangeMessageUserMentionElement = { + readonly kind: 'userMention'; + readonly accountID: number; +} & MessageElementBase; + +type MemberChangeMessageRoomReferenceElement = { + readonly kind: 'roomReference'; + readonly roomName: string; + readonly roomID: number; +} & MessageElementBase; + +type MemberChangeMessageElement = MessageTextElement | MemberChangeMessageUserMentionElement | MemberChangeMessageRoomReferenceElement; + const allReports: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -100,7 +116,7 @@ function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } -function isChannelLogMemberAction(reportAction: OnyxEntry) { +function isMemberChangeAction(reportAction: OnyxEntry) { return ( reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || @@ -109,6 +125,10 @@ function isChannelLogMemberAction(reportAction: OnyxEntry) { ); } +function isInviteMemberAction(reportAction: OnyxEntry) { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM; +} + function isReimbursementDeQueuedAction(reportAction: OnyxEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED; } @@ -639,6 +659,89 @@ function isNotifiableReportAction(reportAction: OnyxEntry): boolea return actions.includes(reportAction.actionName); } +function getMemberChangeMessageElements(reportAction: OnyxEntry): readonly MemberChangeMessageElement[] { + const isInviteAction = isInviteMemberAction(reportAction); + + // Currently, we only render messages when members are invited + const verb = isInviteAction ? Localize.translateLocal('workspace.invite.invited') : Localize.translateLocal('workspace.invite.removed'); + + const originalMessage = reportAction?.originalMessage as ChangeLog; + const targetAccountIDs: number[] = originalMessage?.targetAccountIDs ?? []; + const personalDetails = PersonalDetailsUtils.getPersonalDetailsByIDs(targetAccountIDs, 0); + + const mentionElements = targetAccountIDs.map((accountID): MemberChangeMessageUserMentionElement => { + const personalDetail = personalDetails.find((personal) => personal.accountID === accountID); + const handleText = PersonalDetailsUtils.getEffectiveDisplayName(personalDetail) ?? Localize.translateLocal('common.hidden'); + + return { + kind: 'userMention', + content: `@${handleText}`, + accountID, + }; + }); + + const buildRoomElements = (): readonly MemberChangeMessageElement[] => { + const roomName = originalMessage?.roomName; + + if (roomName) { + const preposition = isInviteAction ? ` ${Localize.translateLocal('workspace.invite.to')} ` : ` ${Localize.translateLocal('workspace.invite.from')} `; + + if (originalMessage.reportID) { + return [ + { + kind: 'text', + content: preposition, + }, + { + kind: 'roomReference', + roomName, + roomID: originalMessage.reportID, + content: roomName, + }, + ]; + } + } + + return []; + }; + + return [ + { + kind: 'text', + content: `${verb} `, + }, + ...Localize.formatMessageElementList(mentionElements), + ...buildRoomElements(), + ]; +} + +function getMemberChangeMessageFragment(reportAction: OnyxEntry): Message { + const messageElements: readonly MemberChangeMessageElement[] = getMemberChangeMessageElements(reportAction); + const html = messageElements + .map((messageElement) => { + switch (messageElement.kind) { + case 'userMention': + return ``; + case 'roomReference': + return `${messageElement.roomName}`; + default: + return messageElement.content; + } + }) + .join(''); + + return { + html: `${html}`, + text: reportAction?.message ? reportAction?.message[0].text : '', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }; +} + +function getMemberChangeMessagePlainText(reportAction: OnyxEntry): string { + const messageElements = getMemberChangeMessageElements(reportAction); + return messageElements.map((element) => element.content).join(''); +} + /** * Helper method to determine if the provided accountID has made a request on the specified report. * @@ -701,7 +804,9 @@ export { shouldReportActionBeVisibleAsLastAction, hasRequestFromCurrentAccount, getFirstVisibleReportActionID, - isChannelLogMemberAction, + isMemberChangeAction, + getMemberChangeMessageFragment, + getMemberChangeMessagePlainText, isReimbursementDeQueuedAction, }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 23382e87936a..b50b4611a249 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -18,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {Beta, Login, PersonalDetails, Policy, PolicyTags, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import {ChangeLog, IOUMessage, OriginalMessageActionName} from '@src/types/onyx/OriginalMessage'; +import {IOUMessage, OriginalMessageActionName} from '@src/types/onyx/OriginalMessage'; import {Message, ReportActions} from '@src/types/onyx/ReportAction'; import {Receipt, WaypointCollection} from '@src/types/onyx/Transaction'; import DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -4174,44 +4174,6 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) }); } -/** - * Return room channel log display message - */ -function getChannelLogMemberMessage(reportAction: OnyxEntry): string { - const verb = - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM - ? 'invited' - : 'removed'; - - const mentions = (reportAction?.originalMessage as ChangeLog)?.targetAccountIDs?.map(() => { - const personalDetail = allPersonalDetails?.accountID; - const displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '') || (personalDetail?.displayName ?? '') || Localize.translateLocal('common.hidden'); - return `@${displayNameOrLogin}`; - }); - - const lastMention = mentions?.pop(); - let message = ''; - - if (mentions?.length === 0) { - message = `${verb} ${lastMention}`; - } else if (mentions?.length === 1) { - message = `${verb} ${mentions?.[0]} and ${lastMention}`; - } else { - message = `${verb} ${mentions?.join(', ')}, and ${lastMention}`; - } - - const roomName = (reportAction?.originalMessage as ChangeLog)?.roomName ?? ''; - if (roomName) { - const preposition = - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM - ? ' to' - : ' from'; - message += `${preposition} ${roomName}`; - } - - return message; -} - /** * Checks if a report is a group chat. * @@ -4446,7 +4408,6 @@ export { getReimbursementQueuedActionMessage, getReimbursementDeQueuedActionMessage, getPersonalDetailsForAccountID, - getChannelLogMemberMessage, getRoom, shouldDisableWelcomeMessage, navigateToPrivateNotes, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index bace29e06d28..6e382e11b49b 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -375,17 +375,17 @@ function getOptionData( const targetAccountIDs = 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'; + ? Localize.translate(preferredLocale, 'workspace.invite.invited') + : Localize.translate(preferredLocale, 'workspace.invite.removed'); + const users = Localize.translate(preferredLocale, targetAccountIDs.length > 1 ? 'workspace.invite.users' : 'workspace.invite.user'); result.alternateText = `${verb} ${targetAccountIDs.length} ${users}`; const roomName = 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'; + ? ` ${Localize.translate(preferredLocale, 'workspace.invite.to')}` + : ` ${Localize.translate(preferredLocale, 'workspace.invite.from')}`; result.alternateText += `${preposition} ${roomName}`; } } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 4f35926c5957..6c645bc87486 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -281,8 +281,8 @@ export default [ } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); Clipboard.setString(displayMessage); - } else if (ReportActionsUtils.isChannelLogMemberAction(reportAction)) { - const logMessage = ReportUtils.getChannelLogMemberMessage(reportAction); + } else if (ReportActionsUtils.isMemberChangeAction(reportAction)) { + const logMessage = ReportActionsUtils.getMemberChangeMessagePlainText(reportAction); Clipboard.setString(logMessage); } else if (content) { const parser = new ExpensiMark(); diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 2265530f29a1..46e0438f250a 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -8,6 +8,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; +import TextCommentFragment from './comment/TextCommentFragment'; import ReportActionItemFragment from './ReportActionItemFragment'; import reportActionPropTypes from './reportActionPropTypes'; @@ -40,6 +41,20 @@ function ReportActionItemMessage(props) { const styles = useThemeStyles(); const fragments = _.compact(props.action.previousMessage || props.action.message); const isIOUReport = ReportActionsUtils.isMoneyRequestAction(props.action); + if (ReportActionsUtils.isMemberChangeAction(props.action)) { + const fragment = ReportActionsUtils.getMemberChangeMessageFragment(props.action); + + return ( + + ); + } + let iouMessage; if (isIOUReport) { const iouReportID = lodashGet(props.action, 'originalMessage.IOUReportID'); diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index f76fbd5ffd7d..72ea275e3ba3 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -140,6 +140,7 @@ type ChronosOOOTimestamp = { type ChangeLog = { targetAccountIDs?: number[]; roomName?: string; + reportID?: number; }; type ChronosOOOEvent = { diff --git a/tests/unit/LocalizeTests.js b/tests/unit/LocalizeTests.js index 4c89d587fc06..7693a0a4a88d 100644 --- a/tests/unit/LocalizeTests.js +++ b/tests/unit/LocalizeTests.js @@ -15,7 +15,7 @@ describe('localize', () => { afterEach(() => Onyx.clear()); - describe('arrayToString', () => { + describe('formatList', () => { test.each([ [ [], @@ -52,9 +52,9 @@ describe('localize', () => { [CONST.LOCALES.ES]: 'rory, vit e ionatan', }, ], - ])('arrayToSpokenList(%s)', (input, {[CONST.LOCALES.DEFAULT]: expectedOutput, [CONST.LOCALES.ES]: expectedOutputES}) => { - expect(Localize.arrayToString(input)).toBe(expectedOutput); - return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(Localize.arrayToString(input)).toBe(expectedOutputES)); + ])('formatList(%s)', (input, {[CONST.LOCALES.DEFAULT]: expectedOutput, [CONST.LOCALES.ES]: expectedOutputES}) => { + expect(Localize.formatList(input)).toBe(expectedOutput); + return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES).then(() => expect(Localize.formatList(input)).toBe(expectedOutputES)); }); }); });