Skip to content

Commit

Permalink
Merge pull request Expensify#35847 from pasyukevich/feature/ts-migrat…
Browse files Browse the repository at this point in the history
…e-AvatarWithImagePicker

[TS migration] Migrate 'AvatarWithImagePicker.js' component to TypeSc…
  • Loading branch information
roryabraham authored Feb 25, 2024
2 parents 5eed632 + cfa5825 commit 8bdb753
Show file tree
Hide file tree
Showing 2 changed files with 79 additions and 115 deletions.
Original file line number Diff line number Diff line change
@@ -1,186 +1,160 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import _ from 'underscore';
import type {StyleProp, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
import stylePropTypes from '@styles/stylePropTypes';
import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type IconAsset from '@src/types/utils/IconAsset';
import AttachmentModal from './AttachmentModal';
import AttachmentPicker from './AttachmentPicker';
import Avatar from './Avatar';
import AvatarCropModal from './AvatarCropModal/AvatarCropModal';
import DotIndicatorMessage from './DotIndicatorMessage';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import sourcePropTypes from './Image/sourcePropTypes';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';

const propTypes = {
type ErrorData = {
validationError?: TranslationPaths | null | '';
phraseParam: Record<string, unknown>;
};

type OpenPickerParams = {
onPicked: (image: File) => void;
};
type OpenPicker = (args: OpenPickerParams) => void;

type MenuItem = {
icon: IconAsset;
text: string;
onSelected: () => void;
};

type AvatarWithImagePickerProps = {
/** Avatar source to display */
source: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
source?: AvatarSource;

/** Additional style props */
style: stylePropTypes,
style?: StyleProp<ViewStyle>;

/** Additional style props for disabled picker */
disabledStyle: stylePropTypes,
disabledStyle?: StyleProp<ViewStyle>;

/** Executed once an image has been selected */
onImageSelected: PropTypes.func,
onImageSelected?: () => void;

/** Execute when the user taps "remove" */
onImageRemoved: PropTypes.func,
onImageRemoved?: () => void;

/** A default avatar component to display when there is no source */
DefaultAvatar: PropTypes.func,
DefaultAvatar?: () => React.ReactNode;

/** Whether we are using the default avatar */
isUsingDefaultAvatar: PropTypes.bool,
isUsingDefaultAvatar?: boolean;

/** Size of Indicator */
size: PropTypes.oneOf([CONST.AVATAR_SIZE.XLARGE, CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
size?: typeof CONST.AVATAR_SIZE.XLARGE | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT;

/** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
fallbackIcon: sourcePropTypes,
fallbackIcon?: AvatarSource;

/** Denotes whether it is an avatar or a workspace avatar */
type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]),
type?: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE;

/** Image crop vector mask */
editorMaskImage: sourcePropTypes,
editorMaskImage?: IconAsset;

/** Additional style object for the error row */
errorRowStyles: stylePropTypes,
errorRowStyles?: StyleProp<ViewStyle>;

/** A function to run when the X button next to the error is clicked */
onErrorClose: PropTypes.func,
onErrorClose?: () => void;

/** The type of action that's pending */
pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
pendingAction?: OnyxCommon.PendingAction;

/** The errors to display */
// eslint-disable-next-line react/forbid-prop-types
errors: PropTypes.object,
errors?: OnyxCommon.Errors;

/** Title for avatar preview modal */
headerTitle: PropTypes.string,
headerTitle?: string;

/** Avatar source for avatar preview modal */
previewSource: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]),
previewSource?: AvatarSource;

/** File name of the avatar */
originalFileName: PropTypes.string,
originalFileName?: string;

/** Whether navigation is focused */
isFocused: PropTypes.bool.isRequired,
isFocused: boolean;

/** Style applied to the avatar */
avatarStyle: stylePropTypes.isRequired,
avatarStyle: StyleProp<ViewStyle>;

/** Indicates if picker feature should be disabled */
disabled: PropTypes.bool,
disabled?: boolean;

/** Executed once click on view photo option */
onViewPhotoPress: PropTypes.func,

/** Where the popover should be positioned relative to the anchor points. */
anchorAlignment: PropTypes.shape({
horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
onViewPhotoPress?: () => void;

/** Allows to open an image without Attachment Picker. */
enablePreview: PropTypes.bool,
};

const defaultProps = {
source: '',
onImageSelected: () => {},
onImageRemoved: () => {},
style: [],
disabledStyle: [],
DefaultAvatar: () => {},
isUsingDefaultAvatar: false,
size: CONST.AVATAR_SIZE.DEFAULT,
fallbackIcon: Expensicons.FallbackAvatar,
type: CONST.ICON_TYPE_AVATAR,
editorMaskImage: undefined,
errorRowStyles: [],
onErrorClose: () => {},
pendingAction: null,
errors: null,
headerTitle: '',
previewSource: '',
originalFileName: '',
disabled: false,
onViewPhotoPress: undefined,
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
},
enablePreview: false,
enablePreview?: boolean;
};

function AvatarWithImagePicker({
isFocused,
DefaultAvatar,
DefaultAvatar = () => null,
style,
disabledStyle,
pendingAction,
errors,
errorRowStyles,
onErrorClose,
source,
fallbackIcon,
size,
type,
headerTitle,
previewSource,
originalFileName,
isUsingDefaultAvatar,
onImageRemoved,
onImageSelected,
onErrorClose = () => {},
source = '',
fallbackIcon = Expensicons.FallbackAvatar,
size = CONST.AVATAR_SIZE.DEFAULT,
type = CONST.ICON_TYPE_AVATAR,
headerTitle = '',
previewSource = '',
originalFileName = '',
isUsingDefaultAvatar = false,
onImageSelected = () => {},
onImageRemoved = () => {},
editorMaskImage,
avatarStyle,
disabled,
disabled = false,
onViewPhotoPress,
enablePreview,
}) {
enablePreview = false,
}: AvatarWithImagePickerProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {windowWidth} = useWindowDimensions();
const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [errorData, setErrorData] = useState({
validationError: null,
phraseParam: {},
});
const [errorData, setErrorData] = useState<ErrorData>({validationError: null, phraseParam: {}});
const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
const [imageData, setImageData] = useState({
uri: '',
name: '',
type: '',
});
const anchorRef = useRef();
const anchorRef = useRef<View>(null);
const {translate} = useLocalize();

/**
* @param {String} error
* @param {Object} phraseParam
*/
const setError = (error, phraseParam) => {
const setError = (error: TranslationPaths | null, phraseParam: Record<string, unknown>) => {
setErrorData({
validationError: error,
phraseParam,
Expand All @@ -198,40 +172,29 @@ function AvatarWithImagePicker({

/**
* Check if the attachment extension is allowed.
*
* @param {Object} image
* @returns {Boolean}
*/
const isValidExtension = (image) => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
const isValidExtension = (image: File): boolean => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(image?.name ?? '');
return CONST.AVATAR_ALLOWED_EXTENSIONS.some((extension) => extension === fileExtension.toLowerCase());
};

/**
* Check if the attachment size is less than allowed size.
*
* @param {Object} image
* @returns {Boolean}
*/
const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
const isValidSize = (image: File): boolean => (image?.size ?? 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;

/**
* Check if the attachment resolution matches constraints.
*
* @param {Object} image
* @returns {Promise}
*/
const isValidResolution = (image) =>
const isValidResolution = (image: File): Promise<boolean> =>
getImageResolution(image).then(
({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);

/**
* Validates if an image has a valid resolution and opens an avatar crop modal
*
* @param {Object} image
*/
const showAvatarCropModal = (image) => {
const showAvatarCropModal = (image: File) => {
if (!isValidExtension(image)) {
setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
Expand Down Expand Up @@ -269,11 +232,8 @@ function AvatarWithImagePicker({

/**
* Create menu items list for avatar menu
*
* @param {Function} openPicker
* @returns {Array}
*/
const createMenuItems = (openPicker) => {
const createMenuItems = (openPicker: OpenPicker): MenuItem[] => {
const menuItems = [
{
icon: Expensicons.Upload,
Expand Down Expand Up @@ -318,6 +278,7 @@ function AvatarWithImagePicker({
vertical: y + height + variables.spacing2,
});
});

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMenuVisible, windowWidth]);

Expand Down Expand Up @@ -383,7 +344,11 @@ function AvatarWithImagePicker({
maybeIcon={isUsingDefaultAvatar}
>
{({show}) => (
<AttachmentPicker type={CONST.ATTACHMENT_PICKER_TYPE.IMAGE}>
<AttachmentPicker
// @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript.
type={CONST.ATTACHMENT_PICKER_TYPE.IMAGE}
>
{/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */}
{({openPicker}) => {
const menuItems = createMenuItems(openPicker);

Expand Down Expand Up @@ -432,7 +397,8 @@ function AvatarWithImagePicker({
{errorData.validationError && (
<DotIndicatorMessage
style={[styles.mt6]}
messages={{0: [errorData.validationError, errorData.phraseParam]}}
// eslint-disable-next-line @typescript-eslint/naming-convention
messages={{0: translate(errorData.validationError, errorData.phraseParam as never)}}
type="error"
/>
)}
Expand All @@ -449,8 +415,6 @@ function AvatarWithImagePicker({
);
}

AvatarWithImagePicker.propTypes = propTypes;
AvatarWithImagePicker.defaultProps = defaultProps;
AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';

export default withNavigationFocus(AvatarWithImagePicker);
2 changes: 1 addition & 1 deletion src/components/PopoverMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ type PopoverMenuProps = Partial<PopoverModalProps> & {
anchorPosition: AnchorPosition;

/** Ref of the anchor */
anchorRef: RefObject<HTMLDivElement>;
anchorRef: RefObject<View | HTMLDivElement>;

/** Where the popover should be positioned relative to the anchor points. */
anchorAlignment?: AnchorAlignment;
Expand Down

0 comments on commit 8bdb753

Please sign in to comment.