diff --git a/src/CONST.ts b/src/CONST.ts index 740ee2206876..47fd23d992dd 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1693,6 +1693,16 @@ const CONST = { STUDENT_AMBASSADOR: 'studentambassadors@expensify.com', SVFG: 'svfg@expensify.com', EXPENSIFY_EMAIL_DOMAIN: '@expensify.com', + EXPENSIFY_TEAM_EMAIL_DOMAIN: '@team.expensify.com', + }, + + FULL_STORY: { + MASK: 'fs-mask', + UNMASK: 'fs-unmask', + CUSTOMER: 'customer', + CONCIERGE: 'concierge', + OTHER: 'other', + WEB_PROP_ATTR: 'data-testid', }, 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..ad4cfb31f4d3 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,8 +1,9 @@ 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'; /** * Fullstory React-Native lib adapter @@ -63,5 +64,45 @@ 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 ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + // prefixed for Native apps should contain only component name + if (prefix) { + return name; + } + + return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`]; + } + const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`; + return [`${formattedName}`, `${CONST.FULL_STORY.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..39d2d7e310e5 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -1,10 +1,79 @@ 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'; +/** + * Extract values from non-scraped at build time attribute WEB_PROP_ATTR, + * reevaluate "fs-class". + */ +function parseFSAttributes(): void { + window?.document?.querySelectorAll(`[${CONST.FULL_STORY.WEB_PROP_ATTR}]`).forEach((o) => { + const attr = o.getAttribute(CONST.FULL_STORY.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(CONST.FULL_STORY.WEB_PROP_ATTR, cleanedAttrs); + } else { + o.removeAttribute(CONST.FULL_STORY.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 ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + + if (prefix) { + return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`; + } + + return `${name}`; +} + +function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] { + if (!name) { + return ['', '']; + } + if (isConciergeChatReport(report)) { + const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`]; + } + if (shouldUnmaskChat(context, report)) { + const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`]; + } + + const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`; + return [`${formattedName},${CONST.FULL_STORY.MASK}`, `${formattedName}`]; +} + // Placeholder Browser API does not support Manual Page definition class FSPage { private pageName; @@ -16,7 +85,9 @@ class FSPage { this.properties = properties; } - start() {} + start() { + parseFSAttributes(); + } } /** @@ -93,4 +164,4 @@ const FS = { }; export default FS; -export {FSPage}; +export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e9c017db43ce..f6ff6c225c5d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8537,6 +8537,53 @@ function hasInvoiceReports() { return reports.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 = Object.keys(report.participants); + + if (participantAccountIDs.length > 2) { + return false; + } + + if (participantsContext) { + 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}`]; } @@ -8864,6 +8911,7 @@ export { getAllReportErrors, getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, + shouldUnmaskChat, getReportMetadata, isHiddenForCurrentUser, }; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 84f867afa0aa..cdaf146e5139 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 isReportScreenTopmostCentralPane from '@libs/Navigation/isReportScreenTopmostCentralPane'; import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; import Navigation from '@libs/Navigation/Navigation'; @@ -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}`); const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); + const participantsContext = useContext(PersonalDetailsContext); useEffect(() => { const unsubscriber = Visibility.onVisibilityChange(() => { @@ -714,13 +717,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');