Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: Ex4 chat masking #54535

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,16 @@ const CONST = {
STUDENT_AMBASSADOR: '[email protected]',
SVFG: '[email protected]',
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',
Expand Down
3 changes: 2 additions & 1 deletion src/components/OnyxProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -55,6 +55,7 @@ export {
PreferredThemeContext,
useBetas,
useFrequentlyUsedEmojis,
PersonalDetailsContext,
PreferredEmojiSkinToneContext,
useBlockedFromConcierge,
useSession,
Expand Down
45 changes: 43 additions & 2 deletions src/libs/Fullstory/index.native.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<PersonalDetailsList>, name: string, report: OnyxInputOrEntry<Report>): 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};
77 changes: 74 additions & 3 deletions src/libs/Fullstory/index.ts
Original file line number Diff line number Diff line change
@@ -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) {
danieldoglas marked this conversation as resolved.
Show resolved Hide resolved
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<PersonalDetailsList>, name: string, report: OnyxInputOrEntry<Report>): 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;
Expand All @@ -16,7 +85,9 @@ class FSPage {
this.properties = properties;
}

start() {}
start() {
parseFSAttributes();
}
}

/**
Expand Down Expand Up @@ -93,4 +164,4 @@ const FS = {
};

export default FS;
export {FSPage};
export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes};
48 changes: 48 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8537,6 +8537,53 @@ function hasInvoiceReports() {
return reports.some((report) => isInvoiceReport(report));
}

function shouldUnmaskChat(participantsContext: OnyxEntry<PersonalDetailsList>, report: OnyxInputOrEntry<Report>): 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}`];
}
Expand Down Expand Up @@ -8864,6 +8911,7 @@ export {
getAllReportErrors,
getAllReportActionsErrorsAndReportActionThatRequiresAttention,
hasInvoiceReports,
shouldUnmaskChat,
getReportMetadata,
isHiddenForCurrentUser,
};
Expand Down
13 changes: 11 additions & 2 deletions src/pages/home/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 (
<>
<FloatingMessageCounter
isActive={(isFloatingMessageCounterVisible && !!unreadMarkerReportActionID) || canScrollToNewerComments}
onClick={scrollToBottomAndMarkReportAsRead}
/>
<View style={[styles.flex1, !shouldShowReportRecipientLocalTime && !hideComposer ? styles.pb4 : {}]}>
<View
style={[styles.flex1, !shouldShowReportRecipientLocalTime && !hideComposer ? styles.pb4 : {}]}
testID={reportActionsListTestID}
fsClass={reportActionsListFSClass}
>
<InvertedFlatList
accessibilityLabel={translate('sidebarScreen.listOfChatMessages')}
ref={reportScrollManager.ref}
Expand Down
9 changes: 9 additions & 0 deletions tests/perf-test/ReportActionsList.perf-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ type LazyLoadLHNTestUtils = {

const mockedNavigate = jest.fn();

// Mock Fullstory library dependency
jest.mock('@libs/Fullstory', () => ({
default: {
consentAndIdentify: jest.fn(),
},
getFSAttributes: jest.fn(),
getChatFSAttributes: jest.fn().mockReturnValue(['mockTestID', 'mockFSClass']),
}));

jest.mock('@components/withCurrentUserPersonalDetails', () => {
// Lazy loading of LHNTestUtils
const lazyLoadLHNTestUtils = () => require<LazyLoadLHNTestUtils>('../utils/LHNTestUtils');
Expand Down
Loading