Skip to content
Merged
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
55 changes: 55 additions & 0 deletions src/libs/UserUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type Login from '@src/types/onyx/Login';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import hashCode from './hashCode';
import {formatPhoneNumber} from './LocalePhoneNumber';
import {translateLocal} from './Localize';

type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24;

Expand Down Expand Up @@ -252,6 +254,58 @@ function getContactMethod(primaryLogin: string | undefined, email: string | unde
return primaryLogin ?? email ?? '';
}

/**
* Gets details about contact methods to be displayed as MenuItems
*/
function getContactMethodsOptions(loginList?: LoginList, defaultEmail?: string) {
if (!loginList) {
return [];
}

// Sort the login list by placing the one corresponding to the default contact method as the first item.
// The default contact method is determined by checking against the session email (the current login).
const sortedLoginList = Object.entries(loginList).sort(([, loginData]) => (loginData.partnerUserID === defaultEmail ? -1 : 1));

return sortedLoginList.map(([loginName, login]) => {
const isDefaultContactMethod = defaultEmail === login?.partnerUserID;
const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
if (!login?.partnerUserID && !pendingAction) {
return null;
}

let description = '';
if (defaultEmail === login?.partnerUserID) {
description = translateLocal('contacts.getInTouch');
} else if (login?.errorFields?.addedLogin) {
description = translateLocal('contacts.failedNewContact');
} else if (!login?.validatedDate) {
description = translateLocal('contacts.pleaseVerify');
}
let indicator;
if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
} else if (!login?.validatedDate && !isDefaultContactMethod) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
} else if (!login?.validatedDate && isDefaultContactMethod && sortedLoginList.length > 1) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
}

// Default to using login key if we deleted login.partnerUserID optimistically
// but still need to show the pending login being deleted while offline.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const partnerUserID = login?.partnerUserID || loginName;
const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID;

return {
partnerUserID,
menuItemTitle,
description,
indicator,
pendingAction,
};
});
}

export {
generateAccountID,
getAvatar,
Expand All @@ -268,5 +322,6 @@ export {
isDefaultAvatar,
getContactMethod,
isCurrentUserValidated,
getContactMethodsOptions,
};
export type {AvatarSource};
80 changes: 23 additions & 57 deletions src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {isUserValidatedSelector} from '@selectors/Account';
import {Str} from 'expensify-common';
import React, {useCallback, useContext} from 'react';
import React, {useCallback, useContext, useMemo} from 'react';
import {View} from 'react-native';
import Button from '@components/Button';
import CopyTextToClipboard from '@components/CopyTextToClipboard';
Expand All @@ -19,77 +18,26 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import {getContactMethodsOptions} from '@libs/UserUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type ContactMethodsPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.PROFILE.CONTACT_METHODS>;

function ContactMethodsPage({route}: ContactMethodsPageProps) {
const styles = useThemeStyles();
const {formatPhoneNumber, translate} = useLocalize();
const {translate} = useLocalize();
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: false});
const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false});
const loginNames = Object.keys(loginList ?? {});
const navigateBackTo = route?.params?.backTo;

const {isActingAsDelegate, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext);
const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector, canBeMissing: false});
const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext);

// Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
// The default contact method is determined by checking against the session email (the current login).
Comment thread
jnowakow marked this conversation as resolved.
const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1));
const loginMenuItems = sortedLoginNames.map((loginName) => {
const login = loginList?.[loginName];
const isDefaultContactMethod = session?.email === login?.partnerUserID;
const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
if (!login?.partnerUserID && !pendingAction) {
return null;
}

let description = '';
if (session?.email === login?.partnerUserID) {
description = translate('contacts.getInTouch');
} else if (login?.errorFields?.addedLogin) {
description = translate('contacts.failedNewContact');
} else if (!login?.validatedDate) {
description = translate('contacts.pleaseVerify');
}
let indicator;
if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
} else if (!login?.validatedDate && !isDefaultContactMethod) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
} else if (!login?.validatedDate && isDefaultContactMethod && loginNames.length > 1) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
}

// Default to using login key if we deleted login.partnerUserID optimistically
// but still need to show the pending login being deleted while offline.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const partnerUserID = login?.partnerUserID || loginName;
const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID;

return (
<OfflineWithFeedback
pendingAction={pendingAction}
key={partnerUserID}
>
<MenuItem
title={menuItemTitle}
description={description}
onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo))}
brickRoadIndicator={indicator}
shouldShowBasicTitle
shouldShowRightIcon
disabled={!!pendingAction}
/>
</OfflineWithFeedback>
);
});
const options = useMemo(() => getContactMethodsOptions(loginList, session?.email), [loginList, session?.email]);

const onNewContactMethodButtonPress = useCallback(() => {
if (isActingAsDelegate) {
Expand Down Expand Up @@ -129,7 +77,25 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) {
<Text>{translate('contacts.helpTextAfterEmail')}</Text>
</Text>
</View>
{loginMenuItems}
{options.map(
(option) =>
!!option && (
<OfflineWithFeedback
pendingAction={option.pendingAction}
key={option.partnerUserID}
>
<MenuItem
title={option.menuItemTitle}
description={option.description}
onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(option.partnerUserID, navigateBackTo))}
brickRoadIndicator={option.indicator}
shouldShowBasicTitle
shouldShowRightIcon
disabled={!!option.pendingAction}
/>
</OfflineWithFeedback>
),
)}
<FixedFooter style={[styles.mtAuto, styles.pt5]}>
<Button
large
Expand Down
134 changes: 134 additions & 0 deletions tests/unit/UserUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as defaultAvatars from '@components/Icon/DefaultAvatars';
import CONST from '@src/CONST';
import * as UserUtils from '@src/libs/UserUtils';
import type {LoginList} from '@src/types/onyx';

describe('UserUtils', () => {
it('should return default avatar if the url is for default avatar', () => {
Expand All @@ -25,4 +27,136 @@ describe('UserUtils', () => {

expect(avatarUrl).toEqual('https://test.com/images/some_avatar.png');
});

describe('getContactMethodsOptions', () => {
type TestCase = {
name: string;
loginList: LoginList;
defaultEmail?: string;
expectedIndicators: Array<undefined | string>;
};

const TEST_CASES: TestCase[] = [
{
name: 'shows error indicator when any errorFields are present',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'user@example.com': {
partnerUserID: 'user@example.com',
errorFields: {addedLogin: {message: 'err'}},
},
},
defaultEmail: 'user@example.com',
expectedIndicators: [CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR],
},
{
name: 'shows info indicator for unvalidated non-default contact method',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'primary@example.com': {
partnerUserID: 'primary@example.com',
validatedDate: '2024-01-01',
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'secondary@example.com': {
partnerUserID: 'secondary@example.com',
// no validatedDate => unvalidated
},
},
defaultEmail: 'primary@example.com',
// Sorted order puts default first, then secondary
expectedIndicators: [undefined, CONST.BRICK_ROAD_INDICATOR_STATUS.INFO],
},
{
name: 'shows no indicator when validated and no errors',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'ok@example.com': {
partnerUserID: 'ok@example.com',
validatedDate: '2024-01-01',
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'another@example.com': {
partnerUserID: 'another@example.com',
validatedDate: '2024-03-03',
},
},
defaultEmail: 'ok@example.com',
expectedIndicators: [undefined],
},
];

describe.each(TEST_CASES)('$name', ({loginList, defaultEmail, expectedIndicators}) => {
test('verifies indicator states', () => {
const options = UserUtils.getContactMethodsOptions(loginList, defaultEmail);
const indicators = options.map((o) => o?.indicator);
expect(indicators).toEqual(expectedIndicators);
});
});
});

describe('getLoginListBrickRoadIndicator', () => {
type TestCase = {
name: string;
loginList: LoginList;
email?: string;
expected: undefined | string;
};

const TEST_CASES: TestCase[] = [
{
name: 'returns ERROR when any login has errorFields',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'a@example.com': {
partnerUserID: 'a@example.com',
errorFields: {validateCodeSent: {code: 'oops'}},
},
},
email: 'a@example.com',
expected: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
},
{
name: 'returns INFO when there is unvalidated non-default login and no errors',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'primary@example.com': {
partnerUserID: 'primary@example.com',
validatedDate: '2024-01-01',
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'pending@example.com': {
partnerUserID: 'pending@example.com',
// missing validatedDate => unvalidated
},
},
email: 'primary@example.com',
expected: CONST.BRICK_ROAD_INDICATOR_STATUS.INFO,
},
{
name: 'returns undefined when all validated and no errors',
loginList: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'a@example.com': {
partnerUserID: 'a@example.com',
validatedDate: '2024-01-01',
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'b@example.com': {
partnerUserID: 'b@example.com',
validatedDate: '2024-03-03',
},
},
email: 'a@example.com',
expected: undefined,
},
];

describe.each(TEST_CASES)('$name', ({loginList, email, expected}) => {
test('verifies brick road indicator', () => {
const result = UserUtils.getLoginListBrickRoadIndicator(loginList, email);
expect(result).toBe(expected);
});
});
});
});
Loading