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
70 changes: 70 additions & 0 deletions src/components/Avatar/connected/PolicyAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import getAvatarLayout from '@components/Avatar/layouts/getAvatarLayout';
import SingleAvatar from '@components/Avatar/layouts/SingleAvatar';
import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar';

import useStyleUtils from '@hooks/useStyleUtils';

import CONST from '@src/CONST';

import type {ColorValue, StyleProp, ViewStyle} from 'react-native';
import type {ValueOf} from 'type-fest';

import React from 'react';

import usePolicyIcons from './usePolicyIcons';

type PolicyAvatarProps = {
/** Policy whose workspace avatar to render */
policyID: string;

/** Account to render as the subscript on the workspace avatar. Omit to render the workspace avatar on its own. */
accountID?: number;

/** Size of the avatar */
size?: ValueOf<typeof CONST.AVATAR_SIZE>;

/** Workspace name used to seed the default avatar, and as a fallback for the avatar tooltip */
fallbackDisplayName?: string;

/** Container styles for the avatar. Replaces the size-derived default container styles when provided. Only applies to the single-avatar layout. */
containerStyle?: StyleProp<ViewStyle>;

/** Border color for the subscript avatar */
subscriptAvatarBorderColor?: ColorValue;
};

/** Renders a workspace avatar, resolving it from the policy's `avatarURL` and `name` alone. Pass an `accountID` to render that account as the subscript. */
function PolicyAvatar({policyID, accountID, size = CONST.AVATAR_SIZE.DEFAULT, fallbackDisplayName, containerStyle, subscriptAvatarBorderColor}: PolicyAvatarProps) {
const StyleUtils = useStyleUtils();
const icons = usePolicyIcons(policyID, accountID, fallbackDisplayName);

const {layout, primaryIcon, secondaryIcon} = getAvatarLayout({
icons,
avatarType: accountID ? CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT : CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE,
// The account icon is a nameless placeholder until personal details load, and a nameless subscript renders as an empty ring.
shouldRequireSecondaryIconName: true,
});

if (layout === CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT) {
return (
<SubscriptAvatar
primaryAvatar={primaryIcon}
secondaryAvatar={secondaryIcon}
size={size}
subscriptAvatarBorderColor={subscriptAvatarBorderColor}
fallbackDisplayName={fallbackDisplayName}
/>
);
}

return (
<SingleAvatar
avatar={primaryIcon}
size={size}
containerStyles={containerStyle ?? StyleUtils.getContainerStyles(size)}
fallbackDisplayName={fallbackDisplayName}
/>
);
}

export default PolicyAvatar;
45 changes: 45 additions & 0 deletions src/components/Avatar/connected/usePolicyIcons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import useOnyx from '@hooks/useOnyx';

import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy} from '@src/types/onyx';
import type {Icon} from '@src/types/onyx/OnyxCommon';

import type {OnyxEntry} from 'react-native-onyx';

import useAccountIcons from './useAccountIcons';

type PolicyAvatarFields = {
/** Remote URL of the uploaded workspace avatar. An empty string when the workspace has none. */
avatarURL?: string;

/** Workspace name. Seeds the default workspace avatar and the avatar tooltip. */
name?: string;
};

/** Kept at module level so `useOnyx` keeps the same subscription across renders. */
const policyAvatarSelector = (policy: OnyxEntry<Policy>): PolicyAvatarFields => ({avatarURL: policy?.avatarURL, name: policy?.name});

/** Resolves a policy ID and account ID into avatars. */
function usePolicyIcons(policyID?: string, accountID?: number, fallbackDisplayName = ''): [Icon, ...Icon[]] {
const [policyAvatar] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, {selector: policyAvatarSelector});
const accountIcons = useAccountIcons(accountID ? [accountID] : []);

const name = policyAvatar?.name ?? fallbackDisplayName;

const policyIcon: Icon = {
id: policyID,
type: CONST.ICON_TYPE_WORKSPACE,
name,
// A workspace with no uploaded avatar has `avatarURL: ''`, which has to fall through to the default avatar.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
source: policyAvatar?.avatarURL || getDefaultWorkspaceAvatar(name),
};

return [policyIcon, ...accountIcons];
}

export default usePolicyIcons;
2 changes: 2 additions & 0 deletions src/components/Avatar/layouts/getAvatarLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type AvatarLayoutResult = {
};

/** Resolves the avatar layout primitive and primary/secondary icons from pre-computed icons and a requested layout type. */
function getAvatarLayout(params: GetAvatarLayoutParams & {icons: [Icon, ...Icon[]]}): AvatarLayoutResult & {primaryIcon: Icon};
function getAvatarLayout(params: GetAvatarLayoutParams): AvatarLayoutResult;
function getAvatarLayout({icons, avatarType, shouldStackHorizontally = false, hasCardFeed = false, shouldRequireSecondaryIconName = false}: GetAvatarLayoutParams): AvatarLayoutResult {
const primaryIcon = icons.at(0);
const secondaryIcon = icons.at(1);
Expand Down
65 changes: 40 additions & 25 deletions src/components/SelectionList/ListItem/UserListItemContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AccountAvatar from '@components/Avatar/connected/AccountAvatar';
import PolicyAvatar from '@components/Avatar/connected/PolicyAvatar';
import {AvatarTooltipsProvider} from '@components/Avatar/tooltips/AvatarTooltipContext';
import Icon from '@components/Icon';
import ReportActionAvatars from '@components/ReportActionAvatars';
Expand Down Expand Up @@ -82,6 +83,44 @@ function UserListItemContent<TItem extends ListItem>({
const policyID = isThereOnlyWorkspaceIcon && shouldUseIconPolicyID ? String(item.icons?.at(0)?.id) : item.policyID;

const isHovered = hovered && !shouldDisableHoverStyle;
const fallbackDisplayName = item.text ?? item.alternateText ?? undefined;

// A report resolves its own avatars, so it keeps going through `ReportActionAvatars`, otherwise using Account/Policy.
let avatar: React.ReactNode;
if (reportExists) {
avatar = (
<ReportActionAvatars
subscriptAvatarBorderColor={isHovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor}
secondaryAvatarContainerStyle={[
StyleUtils.getBackgroundAndBorderStyle(theme.sidebar),
isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined,
isHovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined,
]}
reportID={item.reportID}
singleAvatarContainerStyle={[styles.actionAvatar, styles.mr3]}
fallbackDisplayName={fallbackDisplayName}
/>
);
} else if (policyID) {
avatar = (
<PolicyAvatar
policyID={policyID}
accountID={itemAccountID}
containerStyle={[styles.actionAvatar, styles.mr3]}
subscriptAvatarBorderColor={isHovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor}
fallbackDisplayName={fallbackDisplayName}
/>
);
} else if (itemAccountID) {
avatar = (
<AccountAvatar
accountID={itemAccountID}
containerStyle={[styles.actionAvatar, styles.mr3]}
fallbackDisplayName={fallbackDisplayName}
/>
);
}

const baseAccessibilityLabel = getAccessibilityLabel(item);
const accessibilityLabel =
shouldDisableAccessibleGrouping && item.isSelected !== undefined
Expand All @@ -95,31 +134,7 @@ function UserListItemContent<TItem extends ListItem>({
role={shouldDisableAccessibleGrouping ? CONST.ROLE.BUTTON : undefined}
style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}
>
{(!!reportExists || !!itemAccountID || !!policyID) && (
<AvatarTooltipsProvider isEnabled={showTooltip}>
{!reportExists && !policyID && !!itemAccountID ? (
<AccountAvatar
accountID={itemAccountID}
containerStyle={[styles.actionAvatar, styles.mr3]}
fallbackDisplayName={item.text ?? item.alternateText ?? undefined}
/>
) : (
<ReportActionAvatars
subscriptAvatarBorderColor={isHovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor}
secondaryAvatarContainerStyle={[
StyleUtils.getBackgroundAndBorderStyle(theme.sidebar),
isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined,
isHovered && !isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined,
]}
reportID={reportExists ? item.reportID : undefined}
accountIDs={!reportExists && !!itemAccountID ? [itemAccountID] : []}
policyID={!reportExists && !!policyID ? policyID : undefined}
singleAvatarContainerStyle={[styles.actionAvatar, styles.mr3]}
fallbackDisplayName={item.text ?? item.alternateText ?? undefined}
/>
)}
</AvatarTooltipsProvider>
)}
{!!avatar && <AvatarTooltipsProvider isEnabled={showTooltip}>{avatar}</AvatarTooltipsProvider>}
<View style={[styles.flex1, styles.flexColumn, styles.justifyContentCenter, styles.alignItemsStretch, styles.optionRow]}>
<TextWithTooltip
shouldShowTooltip={showTooltip}
Expand Down
22 changes: 5 additions & 17 deletions src/pages/DynamicNewReportWorkspaceSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import useDebouncedState from '@hooks/useDebouncedState';
import useDelegateAccountID from '@hooks/useDelegateAccountID';
import useDynamicBackPath from '@hooks/useDynamicBackPath';
import {useIsAppLoadPending} from '@hooks/useInFlightRequests';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
Expand All @@ -33,7 +32,6 @@ import Navigation from '@libs/Navigation/Navigation';
import type {NewReportWorkspaceSelectionNavigatorParamList} from '@libs/Navigation/types';
import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils';
import {canSubmitPerDiemExpenseFromWorkspace, getGroupPoliciesWhereReportCanBeCreated, isPolicyAdmin} from '@libs/PolicyUtils';
import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
import {buildTransactionsByReportID} from '@libs/TodosUtils';
import {isPerDiemRequest} from '@libs/TransactionUtils';
Expand All @@ -58,7 +56,7 @@ import {View} from 'react-native';

type WorkspaceListItem = {
text: string;
policyID?: string;
policyID: string;
isPolicyAdmin?: boolean;
} & ListItem;

Expand All @@ -68,7 +66,6 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec
const {isMovingExpenses} = route.params ?? {};
const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.path);
const {isOffline} = useNetwork();
const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']);
const {selectedTransactions, selectedTransactionIDs} = useSearchSelectionContext();
const {clearSelectedTransactions} = useSearchSelectionActions();
const styles = useThemeStyles();
Expand Down Expand Up @@ -204,7 +201,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec
return;
}

const policyForRestriction = policy.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`] : undefined;
const policyForRestriction = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`];
if (
policyForRestriction &&
shouldRestrictUserBillableActions(policyForRestriction, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)
Expand Down Expand Up @@ -238,18 +235,9 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec
}

result.push({
text: policy?.name ?? '',
policyID: policy?.id,
icons: [
{
source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
fallbackIcon: icons.FallbackWorkspaceAvatar,
name: policy?.name,
type: CONST.ICON_TYPE_WORKSPACE,
id: policy?.id,
},
],
keyForList: `${policy?.id}-${index}`,
text: policy.name,
policyID: policy.id,
keyForList: `${policy.id}-${index}`,
isPolicyAdmin: isPolicyAdmin(policy),
shouldSyncFocus: true,
});
Expand Down
15 changes: 4 additions & 11 deletions src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import useThemeStyles from '@hooks/useThemeStyles';

import {navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding';
import Navigation from '@libs/Navigation/Navigation';
import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils';

import {askToJoinPolicy, joinAccessiblePolicy} from '@userActions/Policy/Member';
Expand All @@ -40,7 +39,7 @@ import {View} from 'react-native';
import type {BaseOnboardingWorkspacesProps} from './types';

function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboardingWorkspacesProps) {
const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar', 'DownArrow']);
const icons = useMemoizedLazyExpensifyIcons(['DownArrow']);
const {isOffline} = useNetwork();
const theme = useTheme();
const styles = useThemeStyles();
Expand Down Expand Up @@ -123,6 +122,9 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding
.map((policyInfo) => ({
text: policyInfo.policyName,
alternateText: translate('onboarding.workspaceMemberList', policyInfo.employeeCount, policyInfo.policyOwner),
// The user is not a member of these workspaces yet, so they are absent from Onyx and the avatar falls back
// to the default one seeded from `text` - the same icon this list used to build by hand.
policyID: policyInfo.policyID,
keyForList: policyInfo.policyID,
isDisabled: true,
rightElement: (
Expand All @@ -138,15 +140,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding
<Button.Text>{policyInfo.automaticJoiningEnabled ? translate('workspace.workspaceList.joinNow') : translate('workspace.workspaceList.askToJoin')}</Button.Text>
</Button>
),
icons: [
{
id: policyInfo.policyID,
source: getDefaultWorkspaceAvatar(policyInfo.policyName),
fallbackIcon: icons.FallbackWorkspaceAvatar,
name: policyInfo.policyName,
type: CONST.ICON_TYPE_WORKSPACE,
},
],
}));

const hasMoreThanLimit = allPolicyIDItems.length > CONST.ONBOARDING_JOINABLE_WORKSPACES_LIMIT;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';

import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';

import Navigation from '@navigation/Navigation';
import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@navigation/types';
Expand All @@ -20,7 +17,6 @@ import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper';

import {setDomainGroupCreatePreferredPolicyID} from '@userActions/Domain';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
Expand All @@ -43,7 +39,6 @@ function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePrefe

const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']);

const [currentPolicyID] = useOnyx(ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)});
Expand All @@ -60,15 +55,6 @@ function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePrefe
created: policy.created,
keyForList: policy.id,
isSelected: currentPolicyID === policy.id,
icons: [
{
source: policy.avatarURL ?? getDefaultWorkspaceAvatar(policy.name),
fallbackIcon: icons.FallbackWorkspaceAvatar,
name: policy.name,
type: CONST.ICON_TYPE_WORKSPACE,
id: policy.id,
},
],
});
}

Expand Down
Loading
Loading