diff --git a/src/CONST.ts b/src/CONST.ts index 2d38d26d8820..ca95ae4209a4 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1682,6 +1682,7 @@ const CONST = { STUDENT_AMBASSADOR: 'studentambassadors@expensify.com', SVFG: 'svfg@expensify.com', EXPENSIFY_EMAIL_DOMAIN: '@expensify.com', + EXPENSIFY_TEAM_EMAIL_DOMAIN: '@team.expensify.com', }, CONCIERGE_DISPLAY_NAME: 'Concierge', diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index 23ddf2b0c4dd..796920372c9d 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -6,7 +6,7 @@ import createOnyxContext from './createOnyxContext'; // Set up any providers for individual keys. This should only be used in cases where many components will subscribe to // the same key (e.g. FlatList renderItem components) const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK); -const [, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); +const [, PersonalDetailsProvider, PersonalDetailsContext, usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); const [, BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [, BetasProvider, BetasContext, useBetas] = createOnyxContext(ONYXKEYS.BETAS); @@ -55,6 +55,7 @@ export { PreferredThemeContext, useBetas, useFrequentlyUsedEmojis, + PersonalDetailsContext, PreferredEmojiSkinToneContext, useBlockedFromConcierge, useSession, diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index 30a5a77ae9f3..0ba2fc222f67 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,8 +1,15 @@ import FullStory, {FSPage} from '@fullstory/react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; -import type {UserMetadata} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx'; + +const MASK = 'fs-mask'; +const UNMASK = 'fs-unmask'; +const CUSTOMER = 'customer'; +const CONCIERGE = 'concierge'; +const OTHER = 'other'; /** * Fullstory React-Native lib adapter @@ -63,5 +70,44 @@ const FS = { }, }; +/** + * Placeholder function for Mobile-Web compatibility. + */ +function parseFSAttributes(): void { + // pass +} + +/* + prefix? if component name should be used as a prefix, + in case data-test-id attribute usage, + clean component name should be preserved in data-test-id. +*/ +function getFSAttributes(name: string, mask: boolean, prefix: boolean): string { + if (!name && !prefix) { + return `${mask ? MASK : UNMASK}`; + } + // prefixed for Native apps should contain only component name + if (prefix) { + return name; + } + + return `${name},${mask ? MASK : UNMASK}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONCIERGE}-${name}`; + return [`${formattedName}`, `${UNMASK},${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CUSTOMER}-${name}`; + return [`${formattedName}`, `${UNMASK},${formattedName}`]; + } + const formattedName = `${OTHER}-${name}`; + return [`${formattedName}`, `${MASK},${formattedName}`]; +} export default FS; -export {FSPage}; +export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes}; diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index 0aa0b2094591..563ef200ea11 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -1,10 +1,18 @@ import {FullStory, init, isInitialized} from '@fullstory/browser'; import type {OnyxEntry} from 'react-native-onyx'; +import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import * as Environment from '@src/libs/Environment/Environment'; -import type {UserMetadata} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx'; import type NavigationProperties from './types'; +const WEB_PROP_ATTR = 'data-testid'; +const MASK = 'fs-mask'; +const UNMASK = 'fs-unmask'; +const CUSTOMER = 'customer'; +const CONCIERGE = 'concierge'; +const OTHER = 'other'; + // Placeholder Browser API does not support Manual Page definition class FSPage { private pageName; @@ -16,7 +24,9 @@ class FSPage { this.properties = properties; } - start() {} + start() { + parseFSAttributes(); + } } /** @@ -92,5 +102,73 @@ const FS = { init: (_value: OnyxEntry) => {}, }; +/** + * Extract values from non-scraped at build time attribute WEB_PROP_ATTR, + * reevaluate "fs-class". + */ +function parseFSAttributes(): void { + window?.document?.querySelectorAll(`[${WEB_PROP_ATTR}]`).forEach((o) => { + const attr = o.getAttribute(WEB_PROP_ATTR) ?? ''; + if (!/fs-/gim.test(attr)) { + return; + } + + const fsAttrs = attr.match(/fs-[a-zA-Z0-9_-]+/g) ?? []; + o.setAttribute('fs-class', fsAttrs.join(',')); + + let cleanedAttrs = attr; + fsAttrs.forEach((fsAttr) => { + cleanedAttrs = cleanedAttrs.replace(fsAttr, ''); + }); + + cleanedAttrs = cleanedAttrs + .replace(/,+/g, ',') + .replace(/\s*,\s*/g, ',') + .replace(/^,+|,+$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (cleanedAttrs) { + o.setAttribute(WEB_PROP_ATTR, cleanedAttrs); + } else { + o.removeAttribute(WEB_PROP_ATTR); + } + }); +} + +/* + prefix? if component name should be used as a prefix, + in case data-test-id attribute usage, + clean component name should be preserved in data-test-id. +*/ +function getFSAttributes(name: string, mask: boolean, prefix: boolean): string { + if (!name) { + return `${mask ? MASK : UNMASK}`; + } + + if (prefix) { + return `${name},${mask ? MASK : UNMASK}`; + } + + return `${name}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONCIERGE}-${name}`; + return [`${formattedName},${UNMASK}`, `${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CUSTOMER}-${name}`; + return [`${formattedName},${UNMASK}`, `${formattedName}`]; + } + + const formattedName = `${OTHER}-${name}`; + return [`${formattedName},${MASK}`, `${formattedName}`]; +} + export default FS; -export {FSPage}; +export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a304ee800131..25c7a91fc8c0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8478,6 +8478,51 @@ function hasInvoiceReports() { return allReports.some((report) => isInvoiceReport(report)); } +function shouldUnmaskChat(participantsContext: OnyxEntry, report: OnyxInputOrEntry): boolean { + if (!report?.participants) { + return true; + } + + if (isThread(report) && report.chatType && report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT) { + return true; + } + + if (isThread(report) && report.type === CONST.REPORT.TYPE.EXPENSE) { + return true; + } + + const participantAccountIDs = new Set(Object.keys(report.participants)); + if (participantAccountIDs.size > 2) { + return false; + } + + if (participantsContext) { + // by email participants + let teamInChat = false; + let userInChat = false; + for (const participantAccountID of participantAccountIDs) { + const id = Number(participantAccountID); + const contextAccountData = participantsContext[id]; + + if (contextAccountData) { + const login = contextAccountData.login ?? ''; + + if (login.endsWith(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN) || login.endsWith(CONST.EMAIL.EXPENSIFY_TEAM_EMAIL_DOMAIN)) { + teamInChat = true; + } else { + userInChat = true; + } + } + } + // exclude teamOnly chat + if (teamInChat && userInChat) { + return true; + } + } + + return false; +} + function getReportMetadata(reportID?: string) { return allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`]; } @@ -8675,6 +8720,7 @@ export { isClosedExpenseReportWithNoExpenses, isCompletedTaskReport, isConciergeChatReport, + shouldUnmaskChat, isControlPolicyExpenseChat, isControlPolicyExpenseReport, isCurrentUserSubmitter, diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 48c05f7dc8e9..5d1f264fa4e6 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -2,7 +2,7 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/Vir import {useIsFocused, useRoute} from '@react-navigation/native'; // eslint-disable-next-line lodash/import-scope import type {DebouncedFunc} from 'lodash'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; +import {getChatFSAttributes} from '@libs/Fullstory'; import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -29,6 +30,7 @@ import Visibility from '@libs/Visibility'; import type {AuthScreensParamList} from '@navigation/types'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; +import {PersonalDetailsContext} from '@src/components/OnyxProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -171,6 +173,7 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); + const participantsContext = useContext(PersonalDetailsContext); useEffect(() => { const unsubscriber = Visibility.onVisibilityChange(() => { @@ -719,13 +722,19 @@ function ReportActionsList({ // When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server. // This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet. const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete; + const [reportActionsListTestID, reportActionsListFSClass] = getChatFSAttributes(participantsContext, 'ReportActionsList', report); + return ( <> - + ({ + default: { + consentAndIdentify: jest.fn(), + }, + getFSAttributes: jest.fn(), + getChatFSAttributes: jest.fn().mockReturnValue(['mockTestID', 'mockFSClass']), +})); + jest.mock('@components/withCurrentUserPersonalDetails', () => { // Lazy loading of LHNTestUtils const lazyLoadLHNTestUtils = () => require('../utils/LHNTestUtils');