Skip to content
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
2 changes: 1 addition & 1 deletion Mobile-Expensify
2 changes: 0 additions & 2 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,6 @@ const ONYXKEYS = {
PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList',
CARD_FEED_ERRORS: 'cardFeedErrors',
RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions',
LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap',
},

/** Stores HybridApp specific state required to interoperate with OldDot */
Expand Down Expand Up @@ -1697,7 +1696,6 @@ type OnyxDerivedValuesMapping = {
[ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue;
[ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue;
[ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue;
[ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue;
};

type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider';
import UserPills from '@components/UserPills';
import useAttendees from '@hooks/useAttendees';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import {enrichAndSortAttendees} from '@libs/AttendeeUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getAttendeesListDisplayString} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import type {IOUAction, IOUType} from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import {attendeeSliceSelector} from './selectors';
Expand All @@ -33,13 +31,12 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
const personalDetailsList = usePersonalDetails();
const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP);
const shouldDisplayAttendeesError = formError === 'violations.missingAttendees';

const attendeeSlice = useTransactionSelector(transactionID, attendeeSliceSelector);

const rawIouAttendees = useAttendees(attendeeSlice as OnyxEntry<OnyxTypes.Transaction>);
const iouAttendees = enrichAndSortAttendees(rawIouAttendees, loginToAccountIDMap, personalDetailsList, localeCompare);
const iouAttendees = enrichAndSortAttendees(rawIouAttendees, personalDetailsList, localeCompare);

return (
<MenuItemWithTopDescription
Expand All @@ -55,9 +52,9 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
<UserPills
users={iouAttendees.map((a) => ({
avatar: a?.avatarUrl,
displayName: a?.displayName ?? a?.email ?? '',
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
accountID: a?.accountID,
email: a?.email,
email: a?.email ?? a?.login,
}))}
maxVisible={isReadOnly ? iouAttendees.length : undefined}
/>
Expand Down
7 changes: 3 additions & 4 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ function MoneyRequestView({
const {getReportRHPActiveRoute} = useActiveRoute();
const {showConfirmModal} = useConfirmModal();
const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH);
const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP);

const {currentSearchResults} = useSearchResultsContext();
const reportAttributes = useReportAttributes();
Expand Down Expand Up @@ -320,7 +319,7 @@ function MoneyRequestView({
const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest);

const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees;
const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare);
const actualAttendees = enrichAndSortAttendees(rawActualAttendees, personalDetailsList, localeCompare);

// Use the updated transaction amount in merge flow to have correct positive/negative sign
const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount;
Expand Down Expand Up @@ -1423,9 +1422,9 @@ function MoneyRequestView({
<UserPills
users={actualAttendees.map((a) => ({
avatar: a?.avatarUrl,
displayName: a?.displayName ?? a?.email ?? '',
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
accountID: a?.accountID,
email: a?.email,
email: a?.email ?? a?.login,
}))}
maxVisible={canEdit ? undefined : actualAttendees.length}
/>
Expand Down
16 changes: 6 additions & 10 deletions src/components/Search/SearchList/ListItem/AttendeesCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,12 @@ type AttendeesCellProps = {

function AttendeesCell({attendees, isHovered, isPressed}: AttendeesCellProps) {
const defaultAvatars = useDefaultAvatars();
const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP);
const attendeeIcons: IconType[] = attendees.map((attendee) => {
const accountID = loginToAccountIDMap?.[attendee.email ?? ''] ?? CONST.DEFAULT_NUMBER_ID;
return {
id: accountID,
name: attendee.displayName ?? attendee.email,
source: (attendee.avatarUrl || getDefaultAvatar({accountID, accountEmail: attendee.email, defaultAvatars})) ?? '',
type: CONST.ICON_TYPE_AVATAR,
};
});
const attendeeIcons: IconType[] = attendees.map((attendee) => ({
id: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID,
name: attendee.displayName ?? attendee.email,
source: (attendee.avatarUrl || getDefaultAvatar({accountID: attendee.accountID, accountEmail: attendee.email, defaultAvatars})) ?? '',
type: CONST.ICON_TYPE_AVATAR,
}));

const theme = useTheme();
const styles = useThemeStyles();
Expand Down
27 changes: 15 additions & 12 deletions src/libs/AttendeeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
import type {LoginToAccountIDMapDerivedValue, PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx';
import type {PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import {sortAlphabetically} from './OptionsListUtils';
Expand All @@ -16,13 +16,15 @@ function getNormalizedString(value?: string): string | undefined {
}

function normalizeAttendee(attendee: Attendee): Attendee {
const {email, displayName: attendeeDisplayName, ...rest} = attendee;
const {email, displayName: attendeeDisplayName, login: attendeeLogin, ...rest} = attendee;
const normalizedEmail = getNormalizedString(email);
const displayName = getNormalizedString(attendeeDisplayName) ?? normalizedEmail ?? '';
const normalizedLogin = getNormalizedString(attendeeLogin);
const displayName = getNormalizedString(attendeeDisplayName) ?? normalizedEmail ?? normalizedLogin ?? '';

return {
...rest,
displayName,
login: normalizedLogin,
...(normalizedEmail ? {email: normalizedEmail} : {}),
};
}
Expand Down Expand Up @@ -93,7 +95,7 @@ function getIsMissingAttendeesViolation(
const attendees = convertAttendeesToArray(iouAttendees);
// Check both login and email since attendee objects may have identifier in either property
const attendeesMinusCreatorCount = attendees.filter((a) => {
const attendeeIdentifier = a?.email;
const attendeeIdentifier = a?.login ?? a?.email;
return attendeeIdentifier !== creatorLogin && attendeeIdentifier !== creatorEmail;
}).length;

Expand Down Expand Up @@ -147,28 +149,29 @@ function syncMissingAttendeesViolation<T extends {name: string}>(
return violations;
}

type AttendeeWithAccountID = Attendee & {accountID?: number};

/**
* Enrich each attendee with live `personalDetails` and return them sorted alphabetically by displayName.
*/
function enrichAndSortAttendees(attendees: Attendee[], personalDetailsList: OnyxEntry<PersonalDetailsList>, localeCompare: LocaleContextProps['localeCompare']): Attendee[];
function enrichAndSortAttendees(
attendees: Attendee[] | undefined,
loginToAccountIDMap: OnyxEntry<LoginToAccountIDMapDerivedValue>,
attendees: Attendee[] | string | undefined,
personalDetailsList: OnyxEntry<PersonalDetailsList>,
localeCompare: LocaleContextProps['localeCompare'],
): Attendee[] | string | undefined;
function enrichAndSortAttendees(
attendees: Attendee[] | string | undefined,
personalDetailsList: OnyxEntry<PersonalDetailsList>,
localeCompare: LocaleContextProps['localeCompare'],
): AttendeeWithAccountID[] | undefined {
): Attendee[] | string | undefined {
if (!Array.isArray(attendees)) {
return attendees;
}
return sortAlphabetically(
attendees.map((a) => {
const accountID = loginToAccountIDMap?.[a.email ?? ''] ?? CONST.DEFAULT_NUMBER_ID;
const pd = personalDetailsList?.[accountID];
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
return {
...a,
accountID,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
displayName: pd?.displayName || a?.displayName,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
Expand Down
7 changes: 7 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,13 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
email: 'string',
displayName: 'string',
avatarUrl: 'string',
accountID: 'number',
text: 'string',
login: 'string',
searchText: 'string',
selected: 'boolean',
iouType: CONST.IOU.TYPE,
reportID: 'string',
});
case 'modifiedWaypoints':
return validateObject<ObjectElement<Transaction, 'modifiedWaypoints'>>(
Expand Down
7 changes: 6 additions & 1 deletion src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ function shouldUseTransactionDraft(action: IOUAction | undefined, type?: IOUType
return action === CONST.IOU.ACTION.CREATE || type === CONST.IOU.TYPE.SPLIT_EXPENSE || isMovingTransactionFromTrackExpense(action);
}

function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails) {
function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails, reportID?: string) {
if (!currentUser) {
return;
}
Expand All @@ -347,8 +347,13 @@ function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails) {

const initialAttendee: Attendee = {
email: login,
login,
displayName,
avatarUrl: SafeString(currentUser.avatar),
accountID: currentUser.accountID,
text: login,
selected: true,
reportID,
};

return [initialAttendee];
Expand Down
8 changes: 6 additions & 2 deletions src/libs/MergeTransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,12 @@ function getMergeableDataAndConflictFields(
}

if (field === 'attendees') {
const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare);
const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare);
const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? [])
.filter((login): login is string => !!login)
.sort(localeCompare);
const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? [])
.filter((login): login is string => !!login)
.sort(localeCompare);

if (deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) {
mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue;
Expand Down
6 changes: 5 additions & 1 deletion src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2858,12 +2858,16 @@ function getFilteredRecentAttendees(
currentUserEmail: string,
currentUserAccountID: number,
): Option[] {
const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserEmail);
const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserEmail || attendee.login === currentUserEmail);
if (!recentAttendeeHasCurrentUser && currentUserEmail) {
const details = getPersonalDetailByEmail(currentUserEmail);
recentAttendees.push({
email: currentUserEmail,
login: currentUserEmail,
displayName: details?.displayName ?? currentUserEmail,
accountID: currentUserAccountID,
text: details?.displayName ?? currentUserEmail,
searchText: details?.displayName ?? currentUserEmail,
avatarUrl: details?.avatarThumbnail ?? '',
});
}
Expand Down
7 changes: 6 additions & 1 deletion src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,8 +1146,13 @@ function getReportOwnerAsAttendee(creatorDetails: OnyxEntry<PersonalDetails>): A
const creatorDisplayName = creatorDetails?.displayName ?? creatorLogin;
return {
email: creatorLogin,
login: creatorLogin,
displayName: creatorDisplayName,
accountID: creatorDetails?.accountID,
text: creatorDisplayName,
searchText: creatorDisplayName,
avatarUrl: (creatorDetails?.avatarThumbnail ?? creatorDetails?.avatar ?? '') as string,
selected: true,
};
}

Expand Down Expand Up @@ -1197,7 +1202,7 @@ function getAttendees(transaction: OnyxInputOrEntry<Transaction>, reportOwnerAsA
* Strips the SMS domain so phone-login attendees render the same as in the rendered pills.
*/
function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): string {
const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.email ?? '');
const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.login ?? '');
const ordered = localeCompare
? // Lowercase to match sortAlphabetically (the pill sort) so joined string and pill order never disagree on case.
[...attendees].sort((a, b) => localeCompare(getName(a).toLowerCase(), getName(b).toLowerCase()))
Expand Down
32 changes: 20 additions & 12 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {isReceiptError} from '@libs/ErrorUtils';
import {getCurrentUserEmail} from '@libs/Network/NetworkStore';
import Parser from '@libs/Parser';
import Permissions from '@libs/Permissions';
import {getLoginByAccountID} from '@libs/PersonalDetailsUtils';
import {
arePolicyRulesEnabled,
getDistanceRateCustomUnitRate,
Expand Down Expand Up @@ -706,19 +705,28 @@ const ViolationsUtils = {
const attendees = convertAttendeesToArray(rawAttendees);
const isAttendeeTrackingEnabled = isAttendeeTrackingEnabledForPolicy(policy);
// Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present
const ownerAccountID = iouReport?.ownerAccountID;
// Calculate attendees minus owner. When ownerAccountID is known, filter by accountID.
// When ownerAccountID is undefined (offline split where iouReport is unavailable),
// fallback to using login/email to identify the owner (similar to AttendeeUtils approach).
let attendeesMinusOwnerCount: number;
// Prefer the actual report owner's login; fall back to the current user when the report is unavailable (e.g. an offline-created expense)
const ownerLogin = getLoginByAccountID(iouReport?.ownerAccountID) ?? getCurrentUserEmail();
if (ownerLogin) {
// Filter by login or email to identify owner
attendeesMinusOwnerCount = attendees.filter((a) => {
const attendeeIdentifier = a?.email;
return attendeeIdentifier !== ownerLogin;
}).length;
if (ownerAccountID !== undefined) {
// Normal case: filter by accountID
attendeesMinusOwnerCount = attendees.filter((a) => a?.accountID !== ownerAccountID).length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve owner lookup for legacy attendee records

When iouReport.ownerAccountID is available but comment.attendees comes from older/server data that only has email (the shape used before this change), this branch treats every attendee with no accountID as a non-owner because undefined !== ownerAccountID. A control-policy expense with attendee tracking required and only the report owner in the attendee list will therefore clear/avoid the missingAttendees violation, even though there is no non-owner attendee. Please fall back to comparing the owner login/email when an attendee lacks accountID rather than counting it as non-owner.

Useful? React with 👍 / 👎.

} else {
// Can't identify owner at all - if there are attendees, assume owner is one of them
// This means we need at least 2 attendees to have a non-owner attendee
attendeesMinusOwnerCount = Math.max(0, attendees.length - 1);
// Offline scenario: ownerAccountID unavailable, use login/email as fallback
const currentUserEmail = getCurrentUserEmail();
if (currentUserEmail) {
// Filter by login or email to identify owner
attendeesMinusOwnerCount = attendees.filter((a) => {
const attendeeIdentifier = a?.login ?? a?.email;
return attendeeIdentifier !== currentUserEmail;
}).length;
} else {
// Can't identify owner at all - if there are attendees, assume owner is one of them
// This means we need at least 2 attendees to have a non-owner attendee
attendeesMinusOwnerCount = Math.max(0, attendees.length - 1);
}
}

const shouldShowMissingAttendees =
Expand Down
2 changes: 1 addition & 1 deletion src/libs/actions/IOU/MoneyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ function initMoneyRequest({
}

const comment: Comment = {
attendees: formatCurrentUserToAttendee(currentUserPersonalDetails),
attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID),
};
let requestCategory: string | null = null;

Expand Down
2 changes: 0 additions & 2 deletions src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {ValueOf} from 'type-fest';
import ONYXKEYS from '@src/ONYXKEYS';
import cardFeedErrorsConfig from './configs/cardFeedErrors';
import loginToAccountIDMapConfig from './configs/loginToAccountIDMap';
import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList';
import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID';
import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList';
Expand All @@ -24,7 +23,6 @@ const ONYX_DERIVED_VALUES = {
[ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig,
[ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig,
[ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig,
[ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig,
} as const satisfies {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[Key in ValueOf<typeof ONYXKEYS.DERIVED>]: OnyxDerivedValueConfig<Key, any>;
Expand Down
Loading
Loading