Skip to content

Commit

Permalink
add fullstory chat masking
Browse files Browse the repository at this point in the history
  • Loading branch information
allgandalf committed Dec 24, 2024
1 parent 52b4eca commit ca59e93
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 8 deletions.
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: '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',
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) {
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

0 comments on commit ca59e93

Please sign in to comment.