diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
index 8507713c887e..46576bc62e7a 100644
--- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
+++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import {propTypes as anchorForAttachmentsOnlyPropTypes, defaultProps as anchorForAttachmentsOnlyDefaultProps} from './anchorForAttachmentsOnlyPropTypes';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
-import AttachmentView from '../AttachmentView';
+import AttachmentView from '../Attachments/AttachmentView';
import * as Download from '../../libs/actions/Download';
import fileDownload from '../../libs/fileDownload';
import addEncryptedAuthTokenToURL from '../../libs/addEncryptedAuthTokenToURL';
diff --git a/src/components/AttachmentCarousel/createInitialState.js b/src/components/AttachmentCarousel/createInitialState.js
deleted file mode 100644
index 9c9eb7b777f9..000000000000
--- a/src/components/AttachmentCarousel/createInitialState.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import _ from 'underscore';
-import {Parser as HtmlParser} from 'htmlparser2';
-import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
-import tryResolveUrlFromApiRoot from '../../libs/tryResolveUrlFromApiRoot';
-import Navigation from '../../libs/Navigation/Navigation';
-import CONST from '../../CONST';
-
-/**
- * Constructs the initial component state from report actions
- * @param {propTypes} props
- * @returns {{page: Number, attachments: Array}}
- */
-function createInitialState(props) {
- const actions = [ReportActionsUtils.getParentReportAction(props.report), ...ReportActionsUtils.getSortedReportActions(_.values(props.reportActions))];
- const attachments = [];
-
- const htmlParser = new HtmlParser({
- onopentag: (name, attribs) => {
- if (name !== 'img' || !attribs.src) {
- return;
- }
-
- const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
-
- // By iterating actions in chronological order and prepending each attachment
- // we ensure correct order of attachments even across actions with multiple attachments.
- attachments.unshift({
- source: tryResolveUrlFromApiRoot(expensifySource || attribs.src),
- isAuthTokenRequired: Boolean(expensifySource),
- file: {name: attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE]},
- });
- },
- });
-
- _.forEach(actions, (action, key) => {
- if (!ReportActionsUtils.shouldReportActionBeVisible(action, key)) {
- return;
- }
- htmlParser.write(_.get(action, ['message', 0, 'html']));
- });
- htmlParser.end();
-
- // Inverting the list for touchscreen devices that can swipe or have an animation when scrolling
- // promotes the natural feeling of swiping left/right to go to the next/previous image
- // We don't want to invert the list for desktop/web because this interferes with mouse
- // wheel or trackpad scrolling (in cases like document preview where you can scroll vertically)
- if (DeviceCapabilities.canUseTouchScreen()) {
- attachments.reverse();
- }
-
- const page = _.findIndex(attachments, (a) => a.source === props.source);
- if (page === -1) {
- Navigation.dismissModal();
- return;
- }
-
- // Update the parent modal's state with the source and name from the mapped attachments
- props.onNavigate(attachments[page]);
-
- return {
- page,
- attachments,
- };
-}
-
-export default createInitialState;
diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js
deleted file mode 100644
index 3f2524a2992e..000000000000
--- a/src/components/AttachmentCarousel/index.js
+++ /dev/null
@@ -1,332 +0,0 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {View, FlatList, PixelRatio, Keyboard} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import * as Expensicons from '../Icon/Expensicons';
-import styles from '../../styles/styles';
-import themeColors from '../../styles/themes/default';
-import CarouselActions from './CarouselActions';
-import Button from '../Button';
-import AttachmentView from '../AttachmentView';
-import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-import useWindowDimensions from '../../hooks/useWindowDimensions';
-import CONST from '../../CONST';
-import ONYXKEYS from '../../ONYXKEYS';
-import Tooltip from '../Tooltip';
-import useLocalize from '../../hooks/useLocalize';
-import createInitialState from './createInitialState';
-import {propTypes, defaultProps} from './attachmentCarouselPropTypes';
-
-const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
-const viewabilityConfig = {
- // To facilitate paging through the attachments, we want to consider an item "viewable" when it is
- // more than 90% visible. When that happens we update the page index in the state.
- itemVisiblePercentThreshold: 95,
-};
-
-function AttachmentCarousel(props) {
- const {onNavigate} = props;
-
- const {translate} = useLocalize();
- const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
-
- const scrollRef = useRef(null);
- const autoHideArrowTimeout = useRef(null);
-
- const [page, setPage] = useState(0);
- const [attachments, setAttachments] = useState([]);
- const [shouldShowArrow, setShouldShowArrow] = useState(canUseTouchScreen);
- const [containerWidth, setContainerWidth] = useState(0);
- const [isZoomed, setIsZoomed] = useState(false);
- const [activeSource, setActiveSource] = useState(null);
-
- let isForwardDisabled = page === 0;
- let isBackDisabled = page === _.size(attachments) - 1;
-
- if (canUseTouchScreen) {
- isForwardDisabled = isBackDisabled;
- isBackDisabled = page === 0;
- }
-
- useEffect(() => {
- const initialState = createInitialState(props);
- if (initialState) {
- setPage(initialState.page);
- setAttachments(initialState.attachments);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.reportActions]);
-
- useEffect(() => {
- if (!scrollRef || !scrollRef.current) {
- return;
- }
- scrollRef.current.scrollToIndex({index: page, animated: false});
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [attachments]);
-
- /**
- * Calculate items layout information to optimize scrolling performance
- * @param {*} data
- * @param {Number} index
- * @returns {{offset: Number, length: Number, index: Number}}
- */
- const getItemLayout = useCallback(
- (data, index) => ({
- length: containerWidth,
- offset: containerWidth * index,
- index,
- }),
- [containerWidth],
- );
-
- /**
- * Cancels the automatic hiding of the arrows.
- */
- const cancelAutoHideArrow = useCallback(() => {
- clearTimeout(autoHideArrowTimeout.current);
- }, []);
-
- /**
- * Toggles the visibility of the arrows
- * @param {Boolean} newShouldShowArrow
- */
- const toggleArrowsVisibility = useCallback(
- (newShouldShowArrow) => {
- // Don't toggle arrows in a zoomed state
- if (isZoomed) {
- return;
- }
-
- setShouldShowArrow((prevState) => (_.isBoolean(newShouldShowArrow) ? newShouldShowArrow : !prevState));
- },
- [isZoomed],
- );
-
- /**
- * On a touch screen device, automatically hide the arrows
- * if there is no interaction for 3 seconds.
- */
- const autoHideArrow = useCallback(() => {
- if (!canUseTouchScreen) {
- return;
- }
- cancelAutoHideArrow();
- autoHideArrowTimeout.current = setTimeout(() => {
- toggleArrowsVisibility(false);
- }, CONST.ARROW_HIDE_DELAY);
- }, [cancelAutoHideArrow, toggleArrowsVisibility]);
-
- useEffect(() => {
- if (shouldShowArrow) {
- autoHideArrow();
- } else {
- cancelAutoHideArrow();
- }
- }, [shouldShowArrow, autoHideArrow, cancelAutoHideArrow]);
-
- /**
- * Updates zoomed state to enable/disable panning the PDF
- * @param {Number} scale current PDF scale
- */
- const updateZoomState = useCallback(
- (scale) => {
- const newIsZoomed = scale > 1;
- if (newIsZoomed === isZoomed) {
- return;
- }
- if (newIsZoomed) {
- toggleArrowsVisibility(false);
- }
- setIsZoomed(newIsZoomed);
- },
- [isZoomed, toggleArrowsVisibility],
- );
-
- /**
- * Increments or decrements the index to get another selected item
- * @param {Number} deltaSlide
- */
- const cycleThroughAttachments = useCallback(
- (deltaSlide) => {
- let delta = deltaSlide;
- if (canUseTouchScreen) {
- delta = deltaSlide * -1;
- }
-
- const nextIndex = page - delta;
- const nextItem = attachments[nextIndex];
-
- if (!nextItem || !scrollRef.current) {
- return;
- }
-
- // The sliding transition is a bit too much on web, because of the wider and bigger images,
- // so we only enable it for mobile
- scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen});
- },
- [attachments, page],
- );
-
- /**
- * Updates the page state when the user navigates between attachments
- * @param {Array<{item: {source, file}, index: Number}>} viewableItems
- */
- const updatePage = useRef(({viewableItems}) => {
- Keyboard.dismiss();
- // Since we can have only one item in view at a time, we can use the first item in the array
- // to get the index of the current page
- const entry = _.first(viewableItems);
- if (!entry) {
- setActiveSource(null);
- return;
- }
-
- const pageToSet = entry.index;
- onNavigate(entry.item);
- setPage(pageToSet);
- setIsZoomed(false);
- setActiveSource(entry.item.source);
- }).current;
-
- /**
- * Defines how a container for a single attachment should be rendered
- * @param {Object} cellRendererProps
- * @returns {JSX.Element}
- */
- const renderCell = useCallback(
- (cellRendererProps) => {
- // Use window width instead of layout width to address the issue in https://github.com/Expensify/App/issues/17760
- // considering horizontal margin and border width in centered modal
- const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true);
- const style = [cellRendererProps.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}];
-
- return (
-
- );
- },
- [isSmallScreenWidth, windowWidth],
- );
-
- /**
- * Defines how a single attachment should be rendered
- * @param {Object} item
- * @param {Boolean} item.isAuthTokenRequired
- * @param {String} item.source
- * @param {Object} item.file
- * @param {String} item.file.name
- * @returns {JSX.Element}
- */
- const renderItem = useCallback(
- ({item}) => (
-
- ),
- [activeSource, toggleArrowsVisibility, updateZoomState],
- );
-
- return (
- setContainerWidth(PixelRatio.roundToNearestPixel(nativeEvent.layout.width))}
- onMouseEnter={() => !canUseTouchScreen && toggleArrowsVisibility(true)}
- onMouseLeave={() => !canUseTouchScreen && toggleArrowsVisibility(false)}
- >
- {shouldShowArrow && (
- <>
- {!isBackDisabled && (
-
-
-
-
- )}
- {!isForwardDisabled && (
-
-
-
-
- )}
- >
- )}
-
- {containerWidth > 0 && (
- item.source}
- viewabilityConfig={viewabilityConfig}
- onViewableItemsChanged={updatePage}
- />
- )}
-
-
- );
-}
-
-AttachmentCarousel.propTypes = propTypes;
-AttachmentCarousel.defaultProps = defaultProps;
-
-export default withOnyx({
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
-})(AttachmentCarousel);
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 7bfd6e30f398..7a371e73439f 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -7,9 +7,9 @@ import lodashExtend from 'lodash/extend';
import _ from 'underscore';
import CONST from '../CONST';
import Modal from './Modal';
+import AttachmentView from './Attachments/AttachmentView';
+import AttachmentCarousel from './Attachments/AttachmentCarousel';
import useLocalize from '../hooks/useLocalize';
-import AttachmentView from './AttachmentView';
-import AttachmentCarousel from './AttachmentCarousel';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import * as FileUtils from '../libs/fileDownload/FileUtils';
@@ -343,6 +343,7 @@ function AttachmentModal(props) {
report={props.report}
onNavigate={onNavigate}
source={props.source}
+ onClose={closeModal}
onToggleKeyboard={updateConfirmButtonVisibility}
/>
) : (
diff --git a/src/components/AttachmentView.js b/src/components/AttachmentView.js
deleted file mode 100755
index d880ac9b9076..000000000000
--- a/src/components/AttachmentView.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import React, {memo, useState} from 'react';
-import {View, ActivityIndicator} from 'react-native';
-import _ from 'underscore';
-import PropTypes from 'prop-types';
-import Str from 'expensify-common/lib/str';
-import styles from '../styles/styles';
-import PDFView from './PDFView';
-import ImageView from './ImageView';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import compose from '../libs/compose';
-import Text from './Text';
-import Tooltip from './Tooltip';
-import themeColors from '../styles/themes/default';
-import variables from '../styles/variables';
-import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL';
-import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
-import CONST from '../CONST';
-
-const propTypes = {
- /** Whether source url requires authentication */
- isAuthTokenRequired: PropTypes.bool,
-
- /** URL to full-sized attachment or SVG function */
- source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
-
- /** File object maybe be instance of File or Object */
- file: PropTypes.shape({
- name: PropTypes.string,
- }),
-
- /** Flag to show/hide download icon */
- shouldShowDownloadIcon: PropTypes.bool,
-
- /** Flag to show the loading indicator */
- shouldShowLoadingSpinnerIcon: PropTypes.bool,
-
- /** Whether this view is the active screen */
- isFocused: PropTypes.bool,
-
- /** Function for handle on press */
- onPress: PropTypes.func,
-
- /** Handles scale changed event */
- onScaleChanged: PropTypes.func,
-
- /** Notify parent that the UI should be modified to accommodate keyboard */
- onToggleKeyboard: PropTypes.func,
-
- /** Extra styles to pass to View wrapper */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- isAuthTokenRequired: false,
- file: {
- name: '',
- },
- shouldShowDownloadIcon: false,
- shouldShowLoadingSpinnerIcon: false,
- onPress: undefined,
- onScaleChanged: () => {},
- onToggleKeyboard: () => {},
- containerStyles: [],
- isFocused: false,
-};
-
-function AttachmentView(props) {
- const [loadComplete, setLoadComplete] = useState(false);
- const containerStyles = [styles.flex1, styles.flexRow, styles.alignSelfStretch];
-
- // Handles case where source is a component (ex: SVG)
- if (_.isFunction(props.source)) {
- return (
-
- );
- }
-
- // Check both source and file.name since PDFs dragged into the the text field
- // will appear with a source that is a blob
- if (Str.isPDF(props.source) || (props.file && Str.isPDF(props.file.name || props.translate('attachmentView.unknownFilename')))) {
- const sourceURL = props.isAuthTokenRequired ? addEncryptedAuthTokenToURL(props.source) : props.source;
- return (
- !loadComplete && setLoadComplete(true)}
- />
- );
- }
-
- // For this check we use both source and file.name since temporary file source is a blob
- // both PDFs and images will appear as images when pasted into the the text field
- const isImage = Str.isImage(props.source);
- if (isImage || (props.file && Str.isImage(props.file.name))) {
- const children = (
-
- );
- return props.onPress ? (
-
- {children}
-
- ) : (
- children
- );
- }
-
- return (
-
-
-
-
-
- {props.file && props.file.name}
- {!props.shouldShowLoadingSpinnerIcon && props.shouldShowDownloadIcon && (
-
-
-
-
-
- )}
- {props.shouldShowLoadingSpinnerIcon && (
-
-
-
-
-
- )}
-
- );
-}
-
-AttachmentView.propTypes = propTypes;
-AttachmentView.defaultProps = defaultProps;
-AttachmentView.displayName = 'AttachmentView';
-
-export default compose(memo, withLocalize)(AttachmentView);
diff --git a/src/components/AttachmentCarousel/CarouselActions.js b/src/components/Attachments/AttachmentCarousel/CarouselActions.js
similarity index 78%
rename from src/components/AttachmentCarousel/CarouselActions.js
rename to src/components/Attachments/AttachmentCarousel/CarouselActions.js
index 8cc77af2c093..8861039b8501 100644
--- a/src/components/AttachmentCarousel/CarouselActions.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselActions.js
@@ -1,15 +1,15 @@
import {useEffect} from 'react';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import KeyboardShortcut from '../../libs/KeyboardShortcut';
-import CONST from '../../CONST';
+import KeyboardShortcut from '../../../libs/KeyboardShortcut';
+import CONST from '../../../CONST';
const propTypes = {
/** Callback to cycle through attachments */
onCycleThroughAttachments: PropTypes.func.isRequired,
};
-function Carousel(props) {
+function CarouselActions({onCycleThroughAttachments}) {
useEffect(() => {
const shortcutLeftConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT;
const unsubscribeLeftKey = KeyboardShortcut.subscribe(
@@ -20,7 +20,7 @@ function Carousel(props) {
e.target.blur();
}
- props.onCycleThroughAttachments(-1);
+ onCycleThroughAttachments(-1);
},
shortcutLeftConfig.descriptionKey,
shortcutLeftConfig.modifiers,
@@ -35,7 +35,7 @@ function Carousel(props) {
e.target.blur();
}
- props.onCycleThroughAttachments(1);
+ onCycleThroughAttachments(1);
},
shortcutRightConfig.descriptionKey,
shortcutRightConfig.modifiers,
@@ -45,12 +45,11 @@ function Carousel(props) {
unsubscribeLeftKey();
unsubscribeRightKey();
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.onCycleThroughAttachments]);
+ }, [onCycleThroughAttachments]);
return null;
}
-Carousel.propTypes = propTypes;
+CarouselActions.propTypes = propTypes;
-export default Carousel;
+export default CarouselActions;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
new file mode 100644
index 000000000000..e19f7617bb28
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import _ from 'underscore';
+import {View} from 'react-native';
+import * as Expensicons from '../../Icon/Expensicons';
+import Tooltip from '../../Tooltip';
+import Button from '../../Button';
+import styles from '../../../styles/styles';
+import themeColors from '../../../styles/themes/default';
+import * as AttachmentCarouselViewPropTypes from '../propTypes';
+import useLocalize from '../../../hooks/useLocalize';
+import useWindowDimensions from '../../../hooks/useWindowDimensions';
+
+const propTypes = {
+ /** Where the arrows should be visible */
+ shouldShowArrows: PropTypes.bool.isRequired,
+
+ /** The current page index */
+ page: PropTypes.number.isRequired,
+
+ /** The attachments from the carousel */
+ attachments: AttachmentCarouselViewPropTypes.attachmentsPropType.isRequired,
+
+ /** Callback to go one page back */
+ onBack: PropTypes.func.isRequired,
+ /** Callback to go one page forward */
+ onForward: PropTypes.func.isRequired,
+
+ autoHideArrow: PropTypes.func,
+ cancelAutoHideArrow: PropTypes.func,
+};
+
+const defaultProps = {
+ autoHideArrow: () => {},
+ cancelAutoHideArrow: () => {},
+};
+
+function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward, cancelAutoHideArrow, autoHideArrow}) {
+ const isBackDisabled = page === 0;
+ const isForwardDisabled = page === _.size(attachments) - 1;
+
+ const {translate} = useLocalize();
+ const {isSmallScreenWidth} = useWindowDimensions;
+
+ return shouldShowArrows ? (
+ <>
+ {!isBackDisabled && (
+
+
+
+
+
+ )}
+ {!isForwardDisabled && (
+
+
+
+
+
+ )}
+ >
+ ) : null;
+}
+
+CarouselButtons.propTypes = propTypes;
+CarouselButtons.defaultProps = defaultProps;
+
+export default CarouselButtons;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
new file mode 100644
index 000000000000..b1a844e4172d
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
@@ -0,0 +1,156 @@
+/* eslint-disable es/no-optional-chaining */
+import React, {useContext, useEffect, useState} from 'react';
+import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native';
+import PropTypes from 'prop-types';
+import Image from '../../../Image';
+import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
+import ImageTransformer from './ImageTransformer';
+import ImageWrapper from './ImageWrapper';
+import * as AttachmentsPropTypes from '../../propTypes';
+
+function getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight}) {
+ const imageScaleX = canvasWidth / imageWidth;
+ const imageScaleY = canvasHeight / imageHeight;
+
+ return {imageScaleX, imageScaleY};
+}
+
+const cachedDimensions = new Map();
+
+const pagePropTypes = {
+ /** Whether source url requires authentication */
+ isAuthTokenRequired: PropTypes.bool,
+
+ /** URL to full-sized attachment or SVG function */
+ source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
+
+ isActive: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ isAuthTokenRequired: false,
+};
+
+function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialIsActive}) {
+ const {canvasWidth, canvasHeight} = useContext(AttachmentCarouselPagerContext);
+
+ const dimensions = cachedDimensions.get(source);
+
+ const [isActive, setIsActive] = useState(initialIsActive);
+ // We delay setting a page to active state by a (few) millisecond(s),
+ // to prevent the image transformer from flashing while still rendering
+ // Instead, we show the fallback image while the image transformer is loading the image
+ useEffect(() => {
+ if (initialIsActive) setTimeout(() => setIsActive(true), 1);
+ else setIsActive(false);
+ }, [initialIsActive]);
+
+ const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive);
+ const [isImageLoading, setIsImageLoading] = useState(true);
+ const [showFallback, setShowFallback] = useState(isImageLoading);
+
+ // We delay hiding the fallback image while image transformer is still rendering
+ useEffect(() => {
+ if (isImageLoading) setShowFallback(true);
+ else setTimeout(() => setShowFallback(false), 100);
+ }, [isImageLoading]);
+
+ return (
+ <>
+ {isActive && (
+
+
+ setIsImageLoading(true)}
+ onLoadEnd={() => setIsImageLoading(false)}
+ onLoad={(evt) => {
+ const imageWidth = (evt.nativeEvent?.width || 0) / PixelRatio.get();
+ const imageHeight = (evt.nativeEvent?.height || 0) / PixelRatio.get();
+
+ const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
+
+ // Don't update the dimensions if they are already set
+ if (
+ dimensions?.imageWidth !== imageWidth ||
+ dimensions?.imageHeight !== imageHeight ||
+ dimensions?.imageScaleX !== imageScaleX ||
+ dimensions?.imageScaleY !== imageScaleY
+ ) {
+ cachedDimensions.set(source, {
+ ...dimensions,
+ imageWidth,
+ imageHeight,
+ imageScaleX,
+ imageScaleY,
+ });
+ }
+
+ // On the initial render of the active page, the onLoadEnd event is never fired.
+ // That's why we instead set isImageLoading to false in the onLoad event.
+ if (initialActivePageLoad) {
+ setIsImageLoading(false);
+ setInitialActivePageLoad(false);
+ }
+ }}
+ />
+
+
+ )}
+
+ {/* Keep rendering the image without gestures as fallback while ImageTransformer is loading the image */}
+ {(!isActive || showFallback) && (
+
+ setIsImageLoading(true)}
+ onLoad={(evt) => {
+ const imageWidth = evt.nativeEvent.width;
+ const imageHeight = evt.nativeEvent.height;
+
+ const {imageScaleX, imageScaleY} = getCanvasFitScale({canvasWidth, canvasHeight, imageWidth, imageHeight});
+ const minImageScale = Math.min(imageScaleX, imageScaleY);
+
+ const scaledImageWidth = imageWidth * minImageScale;
+ const scaledImageHeight = imageHeight * minImageScale;
+
+ // Don't update the dimensions if they are already set
+ if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) return;
+
+ cachedDimensions.set(source, {
+ ...dimensions,
+ scaledImageWidth,
+ scaledImageHeight,
+ });
+ }}
+ style={dimensions == null ? undefined : {width: dimensions.scaledImageWidth, height: dimensions.scaledImageHeight}}
+ />
+
+ )}
+
+ {/* Show activity indicator while ImageTransfomer is still loading the image. */}
+ {isActive && isImageLoading && (
+
+ )}
+ >
+ );
+}
+AttachmentCarouselPage.propTypes = pagePropTypes;
+AttachmentCarouselPage.defaultProps = defaultProps;
+
+export default AttachmentCarouselPage;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
new file mode 100644
index 000000000000..39535288e22d
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext.js
@@ -0,0 +1,5 @@
+import {createContext} from 'react';
+
+const AttachmentCarouselContextPager = createContext(null);
+
+export default AttachmentCarouselContextPager;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
new file mode 100644
index 000000000000..4475df168df2
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
@@ -0,0 +1,574 @@
+/* eslint-disable es/no-optional-chaining */
+import React, {useContext, useEffect, useRef, useState, useMemo} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import Animated, {
+ cancelAnimation,
+ runOnJS,
+ runOnUI,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ useWorkletCallback,
+ withDecay,
+ withSpring,
+} from 'react-native-reanimated';
+import styles from '../../../../styles/styles';
+import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
+import ImageWrapper from './ImageWrapper';
+
+const MIN_ZOOM_SCALE_WITHOUT_BOUNCE = 1;
+const MAX_ZOOM_SCALE_WITHOUT_BOUNCE = 20;
+
+const MIN_ZOOM_SCALE_WITH_BOUNCE = MIN_ZOOM_SCALE_WITHOUT_BOUNCE * 0.7;
+const MAX_ZOOM_SCALE_WITH_BOUNCE = MAX_ZOOM_SCALE_WITHOUT_BOUNCE * 1.5;
+
+const DOUBLE_TAP_SCALE = 3;
+
+const SPRING_CONFIG = {
+ mass: 1,
+ stiffness: 1000,
+ damping: 500,
+};
+
+function clamp(value, lowerBound, upperBound) {
+ 'worklet';
+
+ return Math.min(Math.max(lowerBound, value), upperBound);
+}
+
+const imageTransformerPropTypes = {
+ imageWidth: PropTypes.number,
+ imageHeight: PropTypes.number,
+ imageScaleX: PropTypes.number,
+ imageScaleY: PropTypes.number,
+ scaledImageWidth: PropTypes.number,
+ scaledImageHeight: PropTypes.number,
+ isActive: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+const imageTransformerDefaultProps = {
+ imageWidth: 0,
+ imageHeight: 0,
+ imageScaleX: 1,
+ imageScaleY: 1,
+ scaledImageWidth: 0,
+ scaledImageHeight: 0,
+};
+
+function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, scaledImageWidth, scaledImageHeight, isActive, children}) {
+ const {canvasWidth, canvasHeight, onTap, onSwipe, onSwipeSuccess, pagerRef, shouldPagerScroll, isScrolling, onPinchGestureChange} = useContext(AttachmentCarouselPagerContext);
+
+ const minImageScale = useMemo(() => Math.min(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
+ const maxImageScale = useMemo(() => Math.max(imageScaleX, imageScaleY), [imageScaleX, imageScaleY]);
+
+ // On double tap zoom to fill, but at least 3x zoom
+ const doubleTapScale = useMemo(() => Math.max(maxImageScale / minImageScale, DOUBLE_TAP_SCALE), [maxImageScale, minImageScale]);
+
+ const zoomScale = useSharedValue(1);
+ // Adding together the pinch zoom scale and the initial scale to fit the image into the canvas
+ // Using the smaller imageScale, so that the immage is not bigger than the canvas
+ // and not smaller than needed to fit
+ const totalScale = useDerivedValue(() => zoomScale.value * minImageScale, [minImageScale]);
+
+ const zoomScaledImageWidth = useDerivedValue(() => imageWidth * totalScale.value, [imageWidth]);
+ const zoomScaledImageHeight = useDerivedValue(() => imageHeight * totalScale.value, [imageHeight]);
+
+ // used for pan gesture
+ const translateY = useSharedValue(0);
+ const translateX = useSharedValue(0);
+ const offsetX = useSharedValue(0);
+ const offsetY = useSharedValue(0);
+ const isSwiping = useSharedValue(false);
+
+ // used for moving fingers when pinching
+ const pinchTranslateX = useSharedValue(0);
+ const pinchTranslateY = useSharedValue(0);
+ const pinchBounceTranslateX = useSharedValue(0);
+ const pinchBounceTranslateY = useSharedValue(0);
+
+ // storage for the the origin of the gesture
+ const origin = {
+ x: useSharedValue(0),
+ y: useSharedValue(0),
+ };
+
+ // storage for the pan velocity to calculate the decay
+ const panVelocityX = useSharedValue(0);
+ const panVelocityY = useSharedValue(0);
+
+ // store scale in between gestures
+ const pinchScaleOffset = useSharedValue(1);
+
+ // disable pan vertically when image is smaller than screen
+ const canPanVertically = useDerivedValue(() => canvasHeight < zoomScaledImageHeight.value, [canvasHeight]);
+
+ // calculates bounds of the scaled image
+ // can we pan left/right/up/down
+ // can be used to limit gesture or implementing tension effect
+ const getBounds = useWorkletCallback(() => {
+ let rightBoundary = 0;
+ let topBoundary = 0;
+
+ if (canvasWidth < zoomScaledImageWidth.value) {
+ rightBoundary = Math.abs(canvasWidth - zoomScaledImageWidth.value) / 2;
+ }
+
+ if (canvasHeight < zoomScaledImageHeight.value) {
+ topBoundary = Math.abs(zoomScaledImageHeight.value - canvasHeight) / 2;
+ }
+
+ const maxVector = {x: rightBoundary, y: topBoundary};
+ const minVector = {x: -rightBoundary, y: -topBoundary};
+
+ const target = {
+ x: clamp(offsetX.value, minVector.x, maxVector.x),
+ y: clamp(offsetY.value, minVector.y, maxVector.y),
+ };
+
+ const isInBoundaryX = target.x === offsetX.value;
+ const isInBoundaryY = target.y === offsetY.value;
+
+ return {
+ target,
+ isInBoundaryX,
+ isInBoundaryY,
+ minVector,
+ maxVector,
+ canPanLeft: target.x < maxVector.x,
+ canPanRight: target.x > minVector.x,
+ };
+ }, [canvasWidth, canvasHeight]);
+
+ const afterPanGesture = useWorkletCallback(() => {
+ const {target, isInBoundaryX, isInBoundaryY, minVector, maxVector} = getBounds();
+
+ if (!canPanVertically.value) {
+ offsetY.value = withSpring(target.y, SPRING_CONFIG);
+ }
+
+ if (zoomScale.value === 1 && offsetX.value === 0 && offsetY.value === 0 && translateX.value === 0 && translateY.value === 0) {
+ // we don't need to run any animations
+ return;
+ }
+
+ if (zoomScale.value <= 1) {
+ // just center it
+ offsetX.value = withSpring(0, SPRING_CONFIG);
+ offsetY.value = withSpring(0, SPRING_CONFIG);
+ return;
+ }
+
+ const deceleration = 0.9915;
+
+ if (isInBoundaryX) {
+ if (Math.abs(panVelocityX.value) > 0 && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ offsetX.value = withDecay({
+ velocity: panVelocityX.value,
+ clamp: [minVector.x, maxVector.x],
+ deceleration,
+ rubberBandEffect: false,
+ });
+ }
+ } else {
+ offsetX.value = withSpring(target.x, SPRING_CONFIG);
+ }
+
+ if (isInBoundaryY) {
+ if (
+ Math.abs(panVelocityY.value) > 0 &&
+ zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE &&
+ // limit vertical pan only when image is smaller than screen
+ offsetY.value !== minVector.y &&
+ offsetY.value !== maxVector.y
+ ) {
+ offsetY.value = withDecay({
+ velocity: panVelocityY.value,
+ clamp: [minVector.y, maxVector.y],
+ deceleration,
+ });
+ }
+ } else {
+ offsetY.value = withSpring(target.y, SPRING_CONFIG, () => {
+ isSwiping.value = false;
+ });
+ }
+ });
+
+ const stopAnimation = useWorkletCallback(() => {
+ cancelAnimation(offsetX);
+ cancelAnimation(offsetY);
+ });
+
+ const zoomToCoordinates = useWorkletCallback(
+ (canvasFocalX, canvasFocalY) => {
+ 'worklet';
+
+ stopAnimation();
+
+ const canvasOffsetX = Math.max(0, (canvasWidth - scaledImageWidth) / 2);
+ const canvasOffsetY = Math.max(0, (canvasHeight - scaledImageHeight) / 2);
+
+ const imageFocal = {
+ x: clamp(canvasFocalX - canvasOffsetX, 0, scaledImageWidth),
+ y: clamp(canvasFocalY - canvasOffsetY, 0, scaledImageHeight),
+ };
+
+ const canvasCenter = {
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ };
+
+ const originImageCenter = {
+ x: scaledImageWidth / 2,
+ y: scaledImageHeight / 2,
+ };
+
+ const targetImageSize = {
+ width: scaledImageWidth * doubleTapScale,
+ height: scaledImageHeight * doubleTapScale,
+ };
+
+ const targetImageCenter = {
+ x: targetImageSize.width / 2,
+ y: targetImageSize.height / 2,
+ };
+
+ const currentOrigin = {
+ x: (targetImageCenter.x - canvasCenter.x) * -1,
+ y: (targetImageCenter.y - canvasCenter.y) * -1,
+ };
+
+ const koef = {
+ x: (1 / originImageCenter.x) * imageFocal.x - 1,
+ y: (1 / originImageCenter.y) * imageFocal.y - 1,
+ };
+
+ const target = {
+ x: currentOrigin.x * koef.x,
+ y: currentOrigin.y * koef.y,
+ };
+
+ if (targetImageSize.height < canvasHeight) {
+ target.y = 0;
+ }
+
+ offsetX.value = withSpring(target.x, SPRING_CONFIG);
+ offsetY.value = withSpring(target.y, SPRING_CONFIG);
+ zoomScale.value = withSpring(doubleTapScale, SPRING_CONFIG);
+ pinchScaleOffset.value = doubleTapScale;
+ },
+ [scaledImageWidth, scaledImageHeight, canvasWidth, canvasHeight],
+ );
+
+ const reset = useWorkletCallback((animated) => {
+ pinchScaleOffset.value = 1;
+
+ stopAnimation();
+
+ if (animated) {
+ offsetX.value = withSpring(0, SPRING_CONFIG);
+ offsetY.value = withSpring(0, SPRING_CONFIG);
+ zoomScale.value = withSpring(1, SPRING_CONFIG);
+ } else {
+ zoomScale.value = 1;
+ translateX.value = 0;
+ translateY.value = 0;
+ offsetX.value = 0;
+ offsetY.value = 0;
+ pinchTranslateX.value = 0;
+ pinchTranslateY.value = 0;
+ }
+ });
+
+ const doubleTap = Gesture.Tap()
+ .numberOfTaps(2)
+ .maxDelay(150)
+ .maxDistance(20)
+ .onEnd((evt) => {
+ if (zoomScale.value > 1) {
+ reset(true);
+ } else {
+ zoomToCoordinates(evt.x, evt.y);
+ }
+ });
+
+ const panGestureRef = useRef(Gesture.Pan());
+
+ const singleTap = Gesture.Tap()
+ .numberOfTaps(1)
+ .maxDuration(50)
+ .requireExternalGestureToFail(doubleTap, panGestureRef)
+ .onBegin(() => {
+ stopAnimation();
+ })
+ .onFinalize((evt, success) => {
+ if (!success || !onTap) return;
+
+ runOnJS(onTap)();
+ });
+
+ const previousTouch = useSharedValue(null);
+
+ const panGesture = Gesture.Pan()
+ .manualActivation(true)
+ .averageTouches(true)
+ .onTouchesMove((evt, state) => {
+ if (zoomScale.value > 1) {
+ state.activate();
+ }
+
+ // TODO: Swipe down to close carousel gesture
+ // this needs fine tuning to work properly
+ // if (!isScrolling.value && scale.value === 1 && previousTouch.value != null) {
+ // const velocityX = Math.abs(evt.allTouches[0].x - previousTouch.value.x);
+ // const velocityY = evt.allTouches[0].y - previousTouch.value.y;
+
+ // // TODO: this needs tuning
+ // if (Math.abs(velocityY) > velocityX && velocityY > 20) {
+ // state.activate();
+
+ // isSwiping.value = true;
+ // previousTouch.value = null;
+
+ // runOnJS(onSwipeDown)();
+ // return;
+ // }
+ // }
+
+ if (previousTouch.value == null) {
+ previousTouch.value = {
+ x: evt.allTouches[0].x,
+ y: evt.allTouches[0].y,
+ };
+ }
+ })
+ .simultaneousWithExternalGesture(pagerRef, doubleTap, singleTap)
+ .onBegin(() => {
+ stopAnimation();
+ })
+ .onChange((evt) => {
+ // since we running both pinch and pan gesture handlers simultaneously
+ // we need to make sure that we don't pan when we pinch and move fingers
+ // since we track it as pinch focal gesture
+ if (evt.numberOfPointers > 1 || isScrolling.value) {
+ return;
+ }
+
+ panVelocityX.value = evt.velocityX;
+
+ panVelocityY.value = evt.velocityY;
+
+ if (!isSwiping.value) {
+ translateX.value += evt.changeX;
+ }
+
+ if (canPanVertically.value || isSwiping.value) {
+ translateY.value += evt.changeY;
+ }
+ })
+ .onEnd((evt) => {
+ previousTouch.value = null;
+
+ if (isScrolling.value) {
+ return;
+ }
+
+ offsetX.value += translateX.value;
+ offsetY.value += translateY.value;
+ translateX.value = 0;
+ translateY.value = 0;
+
+ if (isSwiping.value) {
+ const enoughVelocity = Math.abs(evt.velocityY) > 300 && Math.abs(evt.velocityX) < Math.abs(evt.velocityY);
+ const rightDirection = (evt.translationY > 0 && evt.velocityY > 0) || (evt.translationY < 0 && evt.velocityY < 0);
+
+ if (enoughVelocity && rightDirection) {
+ const maybeInvert = (v) => {
+ const invert = evt.velocityY < 0;
+ return invert ? -v : v;
+ };
+
+ offsetY.value = withSpring(
+ maybeInvert(imageHeight * 2),
+ {
+ stiffness: 50,
+ damping: 30,
+ mass: 1,
+ overshootClamping: true,
+ restDisplacementThreshold: 300,
+ restSpeedThreshold: 300,
+ velocity: Math.abs(evt.velocityY) < 1200 ? maybeInvert(1200) : evt.velocityY,
+ },
+ () => {
+ runOnJS(onSwipeSuccess)();
+ },
+ );
+ return;
+ }
+ }
+
+ afterPanGesture();
+
+ panVelocityX.value = 0;
+ panVelocityY.value = 0;
+ })
+ .withRef(panGestureRef);
+
+ const getAdjustedFocal = useWorkletCallback(
+ (focalX, focalY) => ({
+ x: focalX - (canvasWidth / 2 + offsetX.value),
+ y: focalY - (canvasHeight / 2 + offsetY.value),
+ }),
+ [canvasWidth, canvasHeight],
+ );
+
+ // used to store event scale value when we limit scale
+ const pinchGestureScale = useSharedValue(1);
+ const pinchGestureRunning = useSharedValue(false);
+ const pinchGesture = Gesture.Pinch()
+ .onTouchesDown((evt, state) => {
+ // we don't want to activate pinch gesture when we are scrolling pager
+ if (!isScrolling.value) return;
+
+ state.fail();
+ })
+ .simultaneousWithExternalGesture(panGesture, doubleTap)
+ .onStart((evt) => {
+ pinchGestureRunning.value = true;
+
+ stopAnimation();
+
+ const adjustFocal = getAdjustedFocal(evt.focalX, evt.focalY);
+
+ origin.x.value = adjustFocal.x;
+ origin.y.value = adjustFocal.y;
+ })
+ .onChange((evt) => {
+ const newZoomScale = pinchScaleOffset.value * evt.scale;
+
+ if (zoomScale.value >= MIN_ZOOM_SCALE_WITH_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITH_BOUNCE) {
+ zoomScale.value = newZoomScale;
+ pinchGestureScale.value = evt.scale;
+ }
+
+ const adjustedFocal = getAdjustedFocal(evt.focalX, evt.focalY);
+ const newPinchTranslateX = adjustedFocal.x + pinchGestureScale.value * origin.x.value * -1;
+ const newPinchTranslateY = adjustedFocal.y + pinchGestureScale.value * origin.y.value * -1;
+
+ if (zoomScale.value >= MIN_ZOOM_SCALE_WITHOUT_BOUNCE && zoomScale.value <= MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ pinchTranslateX.value = newPinchTranslateX;
+ pinchTranslateY.value = newPinchTranslateY;
+ } else {
+ pinchBounceTranslateX.value = newPinchTranslateX - pinchTranslateX.value;
+ pinchBounceTranslateY.value = newPinchTranslateY - pinchTranslateY.value;
+ }
+ })
+ .onEnd(() => {
+ offsetX.value += pinchTranslateX.value;
+ offsetY.value += pinchTranslateY.value;
+ pinchTranslateX.value = 0;
+ pinchTranslateY.value = 0;
+ pinchScaleOffset.value = zoomScale.value;
+ pinchGestureScale.value = 1;
+
+ if (pinchScaleOffset.value < MIN_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ pinchScaleOffset.value = MIN_ZOOM_SCALE_WITHOUT_BOUNCE;
+ zoomScale.value = withSpring(MIN_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
+ } else if (pinchScaleOffset.value > MAX_ZOOM_SCALE_WITHOUT_BOUNCE) {
+ pinchScaleOffset.value = MAX_ZOOM_SCALE_WITHOUT_BOUNCE;
+ zoomScale.value = withSpring(MAX_ZOOM_SCALE_WITHOUT_BOUNCE, SPRING_CONFIG);
+ }
+
+ if (pinchBounceTranslateX.value !== 0 || pinchBounceTranslateY.value !== 0) {
+ pinchBounceTranslateX.value = withSpring(0, SPRING_CONFIG);
+ pinchBounceTranslateY.value = withSpring(0, SPRING_CONFIG);
+ }
+
+ pinchGestureRunning.value = false;
+ });
+
+ const [isPinchGestureInUse, setIsPinchGestureInUse] = useState(false);
+ useAnimatedReaction(
+ () => [zoomScale.value, pinchGestureRunning.value],
+ ([zoom, running]) => {
+ const newIsPinchGestureInUse = zoom !== 1 || running;
+ if (isPinchGestureInUse !== newIsPinchGestureInUse) {
+ runOnJS(setIsPinchGestureInUse)(newIsPinchGestureInUse);
+ }
+ },
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => onPinchGestureChange(isPinchGestureInUse), [isPinchGestureInUse]);
+
+ const animatedStyles = useAnimatedStyle(() => {
+ const x = pinchTranslateX.value + pinchBounceTranslateX.value + translateX.value + offsetX.value;
+ const y = pinchTranslateY.value + pinchBounceTranslateY.value + translateY.value + offsetY.value;
+
+ if (isSwiping.value) {
+ onSwipe(y);
+ }
+
+ return {
+ transform: [
+ {
+ translateX: x,
+ },
+ {
+ translateY: y,
+ },
+ {scale: totalScale.value},
+ ],
+ };
+ });
+
+ // reacts to scale change and enables/disables pager scroll
+ useAnimatedReaction(
+ () => zoomScale.value,
+ () => {
+ shouldPagerScroll.value = zoomScale.value === 1;
+ },
+ );
+
+ const mounted = useRef(false);
+ useEffect(() => {
+ if (!mounted.current) {
+ mounted.current = true;
+ return;
+ }
+
+ if (!isActive) {
+ runOnUI(reset)(false);
+ }
+ }, [isActive, mounted, reset]);
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+ImageTransformer.propTypes = imageTransformerPropTypes;
+ImageTransformer.defaultProps = imageTransformerDefaultProps;
+
+export default ImageTransformer;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
new file mode 100644
index 000000000000..a6a935bbba01
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
@@ -0,0 +1,24 @@
+/* eslint-disable es/no-optional-chaining */
+import React from 'react';
+import {StyleSheet} from 'react-native';
+import PropTypes from 'prop-types';
+import Animated from 'react-native-reanimated';
+import styles from '../../../../styles/styles';
+
+const imageWrapperPropTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+function ImageWrapper({children}) {
+ return (
+
+ {children}
+
+ );
+}
+ImageWrapper.propTypes = imageWrapperPropTypes;
+
+export default ImageWrapper;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js
new file mode 100644
index 000000000000..636a041cbb83
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js
@@ -0,0 +1,176 @@
+/* eslint-disable es/no-optional-chaining */
+import React, {useRef, useState, useImperativeHandle} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import {GestureHandlerRootView, createNativeWrapper} from 'react-native-gesture-handler';
+import Animated, {runOnJS, useAnimatedProps, useAnimatedReaction, useEvent, useHandler, useSharedValue} from 'react-native-reanimated';
+import PagerView from 'react-native-pager-view';
+import _ from 'underscore';
+import styles from '../../../../styles/styles';
+import AttachmentCarouselPagerContext from './AttachmentCarouselPagerContext';
+
+const AnimatedPagerView = Animated.createAnimatedComponent(createNativeWrapper(PagerView));
+
+function usePageScrollHandler(handlers, dependencies) {
+ const {context, doDependenciesDiffer} = useHandler(handlers, dependencies);
+ const subscribeForEvents = ['onPageScroll'];
+
+ return useEvent(
+ (event) => {
+ 'worklet';
+
+ const {onPageScroll} = handlers;
+ if (onPageScroll && event.eventName.endsWith('onPageScroll')) {
+ onPageScroll(event, context);
+ }
+ },
+ subscribeForEvents,
+ doDependenciesDiffer,
+ );
+}
+
+const noopWorklet = () => {
+ 'worklet';
+
+ // noop
+};
+
+const pagerPropTypes = {
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ).isRequired,
+ renderItem: PropTypes.func.isRequired,
+ initialIndex: PropTypes.number,
+ onPageSelected: PropTypes.func,
+ onTap: PropTypes.func,
+ onSwipe: PropTypes.func,
+ onSwipeSuccess: PropTypes.func,
+ onSwipeDown: PropTypes.func,
+ onPinchGestureChange: PropTypes.func,
+ forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ containerWidth: PropTypes.number.isRequired,
+ containerHeight: PropTypes.number.isRequired,
+};
+
+const pagerDefaultProps = {
+ initialIndex: 0,
+ onPageSelected: () => {},
+ onTap: () => {},
+ onSwipe: noopWorklet,
+ onSwipeSuccess: () => {},
+ onSwipeDown: () => {},
+ onPinchGestureChange: () => {},
+ forwardedRef: null,
+};
+
+function AttachmentCarouselPager({
+ items,
+ renderItem,
+ initialIndex,
+ onPageSelected,
+ onTap,
+ onSwipe = noopWorklet,
+ onSwipeSuccess,
+ onSwipeDown,
+ onPinchGestureChange,
+ forwardedRef,
+ containerWidth,
+ containerHeight,
+}) {
+ const shouldPagerScroll = useSharedValue(true);
+ const pagerRef = useRef(null);
+
+ const isScrolling = useSharedValue(false);
+ const activeIndex = useSharedValue(initialIndex);
+
+ const pageScrollHandler = usePageScrollHandler(
+ {
+ onPageScroll: (e) => {
+ 'worklet';
+
+ activeIndex.value = e.position;
+ isScrolling.value = e.offset !== 0;
+ },
+ },
+ [],
+ );
+
+ const [activePage, setActivePage] = useState(initialIndex);
+
+ // we use reanimated for this since onPageSelected is called
+ // in the middle of the pager animation
+ useAnimatedReaction(
+ () => isScrolling.value,
+ (stillScrolling) => {
+ if (stillScrolling) {
+ return;
+ }
+
+ runOnJS(setActivePage)(activeIndex.value);
+ },
+ );
+
+ useImperativeHandle(
+ forwardedRef,
+ () => ({
+ setPage: (...props) => pagerRef.current.setPage(...props),
+ }),
+ [],
+ );
+
+ const animatedProps = useAnimatedProps(() => ({
+ scrollEnabled: shouldPagerScroll.value,
+ }));
+
+ return (
+
+
+
+ {_.map(items, (item, index) => (
+
+ {renderItem({item, index, isActive: index === activePage})}
+
+ ))}
+
+
+
+ );
+}
+AttachmentCarouselPager.propTypes = pagerPropTypes;
+AttachmentCarouselPager.defaultProps = pagerDefaultProps;
+
+export default React.forwardRef((props, ref) => (
+
+));
diff --git a/src/components/AttachmentCarousel/attachmentCarouselPropTypes.js b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
similarity index 70%
rename from src/components/AttachmentCarousel/attachmentCarouselPropTypes.js
rename to src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
index 62ad758555ec..a63b0f23d1ab 100644
--- a/src/components/AttachmentCarousel/attachmentCarouselPropTypes.js
+++ b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
-import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes';
-import reportPropTypes from '../../pages/reportPropTypes';
+import reportPropTypes from '../../../pages/reportPropTypes';
+import reportActionPropTypes from '../../../pages/home/report/reportActionPropTypes';
const propTypes = {
/** source is used to determine the starting index in the array of attachments */
@@ -9,6 +9,9 @@ const propTypes = {
/** Callback to update the parent modal's state with a source and name from the attachments array */
onNavigate: PropTypes.func,
+ /** Callback to close carousel when user swipes down (on native) */
+ onClose: PropTypes.func,
+
/** Object of report actions for this report */
reportActions: PropTypes.shape(reportActionPropTypes),
@@ -20,6 +23,7 @@ const defaultProps = {
source: '',
reportActions: {},
onNavigate: () => {},
+ onClose: () => {},
};
export {propTypes, defaultProps};
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
new file mode 100644
index 000000000000..047a016674b7
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -0,0 +1,68 @@
+import {Parser as HtmlParser} from 'htmlparser2';
+import _ from 'underscore';
+import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
+import CONST from '../../../CONST';
+import tryResolveUrlFromApiRoot from '../../../libs/tryResolveUrlFromApiRoot';
+import Navigation from '../../../libs/Navigation/Navigation';
+
+/**
+ * Constructs the initial component state from report actions
+ * @param {Object} report
+ * @param {Array} reportActions
+ * @param {String} source
+ * @returns {{attachments: Array, initialPage: Number, initialItem: Object, initialActiveSource: String}}
+ */
+function extractAttachmentsFromReport(report, reportActions, source) {
+ const actions = [ReportActionsUtils.getParentReportAction(report), ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
+ let attachments = [];
+
+ const htmlParser = new HtmlParser({
+ onopentag: (name, attribs) => {
+ if (name !== 'img' || !attribs.src) {
+ return;
+ }
+
+ const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
+
+ // By iterating actions in chronological order and prepending each attachment
+ // we ensure correct order of attachments even across actions with multiple attachments.
+ attachments.unshift({
+ source: tryResolveUrlFromApiRoot(expensifySource || attribs.src),
+ isAuthTokenRequired: Boolean(expensifySource),
+ file: {name: attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE]},
+ });
+ },
+ });
+
+ _.forEach(actions, (action, key) => {
+ if (!ReportActionsUtils.shouldReportActionBeVisible(action, key)) {
+ return;
+ }
+ htmlParser.write(_.get(action, ['message', 0, 'html']));
+ });
+ htmlParser.end();
+
+ attachments = attachments.reverse();
+
+ const initialPage = _.findIndex(attachments, (a) => a.source === source);
+ if (initialPage === -1) {
+ Navigation.dismissModal();
+ return {
+ attachments: [],
+ initialPage: 0,
+ initialItem: undefined,
+ initialActiveSource: null,
+ };
+ }
+
+ const initialItem = attachments[initialPage];
+
+ return {
+ attachments,
+ initialPage,
+ initialItem,
+ initialActiveSource: initialItem.source,
+ };
+}
+
+export default extractAttachmentsFromReport;
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
new file mode 100644
index 000000000000..564e60b65dd1
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -0,0 +1,214 @@
+import React, {useRef, useCallback, useState, useEffect, useMemo} from 'react';
+import {View, FlatList, PixelRatio, Keyboard} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
+import styles from '../../../styles/styles';
+import CarouselActions from './CarouselActions';
+import AttachmentView from '../AttachmentView';
+import withWindowDimensions from '../../withWindowDimensions';
+import CarouselButtons from './CarouselButtons';
+import extractAttachmentsFromReport from './extractAttachmentsFromReport';
+import {propTypes, defaultProps} from './attachmentCarouselPropTypes';
+import ONYXKEYS from '../../../ONYXKEYS';
+import withLocalize from '../../withLocalize';
+import compose from '../../../libs/compose';
+import useCarouselArrows from './useCarouselArrows';
+import useWindowDimensions from '../../../hooks/useWindowDimensions';
+
+const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
+const viewabilityConfig = {
+ // To facilitate paging through the attachments, we want to consider an item "viewable" when it is
+ // more than 95% visible. When that happens we update the page index in the state.
+ itemVisiblePercentThreshold: 95,
+};
+
+function AttachmentCarousel({report, reportActions, source, onNavigate}) {
+ const scrollRef = useRef(null);
+
+ const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
+
+ const {attachments, initialPage, initialActiveSource, initialItem} = useMemo(() => extractAttachmentsFromReport(report, reportActions, source), [report, reportActions, source]);
+
+ useEffect(() => {
+ // Update the parent modal's state with the source and name from the mapped attachments
+ if (!initialItem) return;
+ onNavigate(initialItem);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialItem]);
+
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [page, setPage] = useState(initialPage);
+ const [activeSource, setActiveSource] = useState(initialActiveSource);
+ const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+
+ /**
+ * Updates the page state when the user navigates between attachments
+ * @param {Object} item
+ * @param {number} index
+ */
+ const updatePage = useRef(
+ ({viewableItems}) => {
+ Keyboard.dismiss();
+
+ // Since we can have only one item in view at a time, we can use the first item in the array
+ // to get the index of the current page
+ const entry = _.first(viewableItems);
+ if (!entry) {
+ setActiveSource(null);
+ return;
+ }
+
+ setPage(entry.index);
+ setActiveSource(entry.item.source);
+
+ onNavigate(entry.item);
+ },
+ [onNavigate],
+ );
+
+ /**
+ * Increments or decrements the index to get another selected item
+ * @param {Number} deltaSlide
+ */
+ const cycleThroughAttachments = useCallback(
+ (deltaSlide) => {
+ const nextIndex = page + deltaSlide;
+ const nextItem = attachments[nextIndex];
+
+ if (!nextItem || !scrollRef.current) {
+ return;
+ }
+
+ scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen});
+ },
+ [attachments, page],
+ );
+
+ /**
+ * Calculate items layout information to optimize scrolling performance
+ * @param {*} data
+ * @param {Number} index
+ * @returns {{offset: Number, length: Number, index: Number}}
+ */
+ const getItemLayout = useCallback(
+ (_data, index) => ({
+ length: containerWidth,
+ offset: containerWidth * index,
+ index,
+ }),
+ [containerWidth],
+ );
+
+ /**
+ * Defines how a container for a single attachment should be rendered
+ * @param {Object} cellRendererProps
+ * @returns {JSX.Element}
+ */
+ const renderCell = useCallback(
+ (cellProps) => {
+ // Use window width instead of layout width to address the issue in https://github.com/Expensify/App/issues/17760
+ // considering horizontal margin and border width in centered modal
+ const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true);
+ const style = [cellProps.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}];
+
+ return (
+
+ );
+ },
+ [isSmallScreenWidth, windowWidth],
+ );
+
+ /**
+ * Defines how a single attachment should be rendered
+ * @param {Object} item
+ * @param {Boolean} item.isAuthTokenRequired
+ * @param {String} item.source
+ * @param {Object} item.file
+ * @param {String} item.file.name
+ * @returns {JSX.Element}
+ */
+ const renderItem = useCallback(
+ ({item}) => (
+ canUseTouchScreen && setShouldShowArrows(!shouldShowArrows)}
+ isUsedInCarousel
+ />
+ ),
+ [activeSource, setShouldShowArrows, shouldShowArrows],
+ );
+
+ return (
+ setContainerWidth(PixelRatio.roundToNearestPixel(nativeEvent.layout.width))}
+ onMouseEnter={() => !canUseTouchScreen && setShouldShowArrows(true)}
+ onMouseLeave={() => !canUseTouchScreen && setShouldShowArrows(false)}
+ >
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ {containerWidth > 0 && (
+ item.source}
+ viewabilityConfig={viewabilityConfig}
+ onViewableItemsChanged={updatePage.current}
+ />
+ )}
+
+
+
+ );
+}
+AttachmentCarousel.propTypes = propTypes;
+AttachmentCarousel.defaultProps = defaultProps;
+
+export default compose(
+ withOnyx({
+ reportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ },
+ }),
+ withLocalize,
+ withWindowDimensions,
+)(AttachmentCarousel);
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
new file mode 100644
index 000000000000..58e248d514e1
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -0,0 +1,130 @@
+import React, {useCallback, useEffect, useRef, useState, useMemo} from 'react';
+import {View, Keyboard, PixelRatio} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import AttachmentCarouselPager from './Pager';
+import styles from '../../../styles/styles';
+import CarouselButtons from './CarouselButtons';
+import AttachmentView from '../AttachmentView';
+import ONYXKEYS from '../../../ONYXKEYS';
+import {propTypes, defaultProps} from './attachmentCarouselPropTypes';
+import extractAttachmentsFromReport from './extractAttachmentsFromReport';
+import useCarouselArrows from './useCarouselArrows';
+
+function AttachmentCarousel({report, reportActions, source, onNavigate, onClose}) {
+ const {attachments, initialPage, initialActiveSource, initialItem} = useMemo(() => extractAttachmentsFromReport(report, reportActions, source), [report, reportActions, source]);
+
+ useEffect(() => {
+ // Update the parent modal's state with the source and name from the mapped attachments
+ onNavigate(initialItem);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialItem]);
+
+ const pagerRef = useRef(null);
+
+ const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0});
+ const [page, setPage] = useState(initialPage);
+ const [activeSource, setActiveSource] = useState(initialActiveSource);
+ const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true);
+ const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+
+ /**
+ * Updates the page state when the user navigates between attachments
+ * @param {Object} item
+ * @param {number} index
+ */
+ const updatePage = useCallback(
+ (newPageIndex) => {
+ Keyboard.dismiss();
+ setShouldShowArrows(true);
+
+ const item = attachments[newPageIndex];
+
+ setPage(newPageIndex);
+ setActiveSource(item.source);
+
+ onNavigate(item);
+ },
+ [setShouldShowArrows, attachments, onNavigate],
+ );
+
+ /**
+ * Increments or decrements the index to get another selected item
+ * @param {Number} deltaSlide
+ */
+ const cycleThroughAttachments = useCallback(
+ (deltaSlide) => {
+ const nextPageIndex = page + deltaSlide;
+ updatePage(nextPageIndex);
+ pagerRef.current.setPage(nextPageIndex);
+
+ autoHideArrows();
+ },
+ [autoHideArrows, page, updatePage],
+ );
+
+ /**
+ * Defines how a single attachment should be rendered
+ * @param {{ isAuthTokenRequired: Boolean, source: String, file: { name: String } }} item
+ * @returns {JSX.Element}
+ */
+ const renderItem = useCallback(
+ ({item}) => (
+ setShouldShowArrows(!shouldShowArrows)}
+ />
+ ),
+ [activeSource, setShouldShowArrows, shouldShowArrows],
+ );
+
+ return (
+
+ setContainerDimensions({width: PixelRatio.roundToNearestPixel(nativeEvent.layout.width), height: PixelRatio.roundToNearestPixel(nativeEvent.layout.height)})
+ }
+ onMouseEnter={() => setShouldShowArrows(true)}
+ onMouseLeave={() => setShouldShowArrows(false)}
+ >
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ {containerDimensions.width > 0 && containerDimensions.height > 0 && (
+ updatePage(newPage)}
+ onPinchGestureChange={(newIsPinchGestureRunning) => {
+ setIsPinchGestureRunning(newIsPinchGestureRunning);
+ if (!newIsPinchGestureRunning && !shouldShowArrows) setShouldShowArrows(true);
+ }}
+ onSwipeDown={onClose}
+ containerWidth={containerDimensions.width}
+ containerHeight={containerDimensions.height}
+ ref={pagerRef}
+ />
+ )}
+
+ );
+}
+AttachmentCarousel.propTypes = propTypes;
+AttachmentCarousel.defaultProps = defaultProps;
+
+export default withOnyx({
+ reportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ },
+})(AttachmentCarousel);
diff --git a/src/components/Attachments/AttachmentCarousel/useCarouselArrows.js b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.js
new file mode 100644
index 000000000000..f43a26ab94ee
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/useCarouselArrows.js
@@ -0,0 +1,46 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+import CONST from '../../../CONST';
+import * as DeviceCapabilities from '../../../libs/DeviceCapabilities';
+
+const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
+
+function useCarouselArrows() {
+ const [shouldShowArrows, setShouldShowArrowsInternal] = useState(canUseTouchScreen);
+ const autoHideArrowTimeout = useRef(null);
+
+ /**
+ * Cancels the automatic hiding of the arrows.
+ */
+ const cancelAutoHideArrows = useCallback(() => clearTimeout(autoHideArrowTimeout.current), []);
+
+ /**
+ * Automatically hide the arrows if there is no interaction for 3 seconds.
+ */
+ const autoHideArrows = useCallback(() => {
+ if (!canUseTouchScreen) {
+ return;
+ }
+
+ cancelAutoHideArrows();
+ autoHideArrowTimeout.current = setTimeout(() => {
+ setShouldShowArrowsInternal(false);
+ }, CONST.ARROW_HIDE_DELAY);
+ }, [cancelAutoHideArrows]);
+
+ const setShouldShowArrows = useCallback(
+ (show = true) => {
+ setShouldShowArrowsInternal(show);
+ autoHideArrows();
+ },
+ [autoHideArrows],
+ );
+
+ useEffect(() => {
+ autoHideArrows();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows];
+}
+
+export default useCarouselArrows;
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
new file mode 100755
index 000000000000..7de4417a4efc
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
@@ -0,0 +1,42 @@
+import React, {memo} from 'react';
+import styles from '../../../../styles/styles';
+import ImageView from '../../../ImageView';
+import withLocalize, {withLocalizePropTypes} from '../../../withLocalize';
+import compose from '../../../../libs/compose';
+import PressableWithoutFeedback from '../../../Pressable/PressableWithoutFeedback';
+import CONST from '../../../../CONST';
+import {attachmentViewImagePropTypes, attachmentViewImageDefaultProps} from './propTypes';
+
+const propTypes = {
+ ...attachmentViewImagePropTypes,
+ ...withLocalizePropTypes,
+};
+
+function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, onPress, isImage, onScaleChanged, translate}) {
+ const children = (
+
+ );
+ return onPress ? (
+
+ {children}
+
+ ) : (
+ children
+ );
+}
+
+AttachmentViewImage.propTypes = propTypes;
+AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
+
+export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js
new file mode 100755
index 000000000000..7334e5391bc1
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js
@@ -0,0 +1,51 @@
+import React, {memo} from 'react';
+import styles from '../../../../styles/styles';
+import withLocalize, {withLocalizePropTypes} from '../../../withLocalize';
+import ImageView from '../../../ImageView';
+import compose from '../../../../libs/compose';
+import PressableWithoutFeedback from '../../../Pressable/PressableWithoutFeedback';
+import CONST from '../../../../CONST';
+import AttachmentCarouselPage from '../../AttachmentCarousel/Pager/AttachmentCarouselPage';
+import {attachmentViewImagePropTypes, attachmentViewImageDefaultProps} from './propTypes';
+
+const propTypes = {
+ ...attachmentViewImagePropTypes,
+ ...withLocalizePropTypes,
+};
+
+function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUsedInCarousel, loadComplete, onPress, isImage, onScaleChanged, translate}) {
+ const children = isUsedInCarousel ? (
+
+ ) : (
+
+ );
+
+ return onPress ? (
+
+ {children}
+
+ ) : (
+ children
+ );
+}
+
+AttachmentViewImage.propTypes = propTypes;
+AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
+
+export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js
new file mode 100644
index 000000000000..661b940da207
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/propTypes.js
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import {attachmentViewPropTypes, attachmentViewDefaultProps} from '../propTypes';
+
+const attachmentViewImagePropTypes = {
+ ...attachmentViewPropTypes,
+
+ loadComplete: PropTypes.bool.isRequired,
+
+ isImage: PropTypes.bool.isRequired,
+};
+
+const attachmentViewImageDefaultProps = {
+ ...attachmentViewDefaultProps,
+
+ loadComplete: false,
+ isImage: false,
+};
+
+export {attachmentViewImagePropTypes, attachmentViewImageDefaultProps};
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js
new file mode 100644
index 000000000000..fc17f79a0aaa
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js
@@ -0,0 +1,24 @@
+import React, {memo} from 'react';
+import styles from '../../../../styles/styles';
+import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes';
+import PDFView from '../../../PDFView';
+
+function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onScaleChanged, onToggleKeyboard, onLoadComplete}) {
+ return (
+
+ );
+}
+
+AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
+AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+
+export default memo(AttachmentViewPdf);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
new file mode 100644
index 000000000000..5d7fd73e47fb
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
@@ -0,0 +1,43 @@
+import React, {memo, useCallback, useContext} from 'react';
+import styles from '../../../../styles/styles';
+import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes';
+import PDFView from '../../../PDFView';
+import AttachmentCarouselPagerContext from '../../AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
+
+function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete}) {
+ const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+
+ const onScaleChanged = useCallback(
+ (scale) => {
+ onScaleChangedProp();
+
+ // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in
+ if (isUsedInCarousel) {
+ const shouldPagerScroll = scale === 1;
+
+ if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) return;
+
+ attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll;
+ }
+ },
+ [attachmentCarouselPagerContext.shouldPagerScroll, isUsedInCarousel, onScaleChangedProp],
+ );
+
+ return (
+
+ );
+}
+
+AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
+AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+
+export default memo(AttachmentViewPdf);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js
new file mode 100644
index 000000000000..ea17cd9490b3
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import * as AttachmentsPropTypes from '../../propTypes';
+
+const attachmentViewPdfPropTypes = {
+ /** File object maybe be instance of File or Object */
+ file: AttachmentsPropTypes.attachmentFilePropType.isRequired,
+
+ encryptedSourceUrl: PropTypes.string.isRequired,
+ onToggleKeyboard: PropTypes.func.isRequired,
+ onLoadComplete: PropTypes.func.isRequired,
+};
+
+const attachmentViewPdfDefaultProps = {
+ file: {
+ name: '',
+ },
+};
+
+export {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps};
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
new file mode 100755
index 000000000000..3ad643d34bcd
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -0,0 +1,146 @@
+import React, {memo, useState} from 'react';
+import {View, ActivityIndicator} from 'react-native';
+import _ from 'underscore';
+import PropTypes from 'prop-types';
+import Str from 'expensify-common/lib/str';
+import styles from '../../../styles/styles';
+import Icon from '../../Icon';
+import * as Expensicons from '../../Icon/Expensicons';
+import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
+import compose from '../../../libs/compose';
+import Text from '../../Text';
+import Tooltip from '../../Tooltip';
+import themeColors from '../../../styles/themes/default';
+import variables from '../../../styles/variables';
+import AttachmentViewImage from './AttachmentViewImage';
+import AttachmentViewPdf from './AttachmentViewPdf';
+import addEncryptedAuthTokenToURL from '../../../libs/addEncryptedAuthTokenToURL';
+
+import {attachmentViewPropTypes, attachmentViewDefaultProps} from './propTypes';
+
+const propTypes = {
+ ...attachmentViewPropTypes,
+ ...withLocalizePropTypes,
+
+ /** Flag to show/hide download icon */
+ shouldShowDownloadIcon: PropTypes.bool,
+
+ /** Flag to show the loading indicator */
+ shouldShowLoadingSpinnerIcon: PropTypes.bool,
+
+ /** Notify parent that the UI should be modified to accommodate keyboard */
+ onToggleKeyboard: PropTypes.func,
+
+ /** Extra styles to pass to View wrapper */
+ // eslint-disable-next-line react/forbid-prop-types
+ containerStyles: PropTypes.arrayOf(PropTypes.object),
+};
+
+const defaultProps = {
+ ...attachmentViewDefaultProps,
+ shouldShowDownloadIcon: false,
+ shouldShowLoadingSpinnerIcon: false,
+ onToggleKeyboard: () => {},
+ containerStyles: [],
+};
+
+function AttachmentView({
+ source,
+ file,
+ isAuthTokenRequired,
+ isUsedInCarousel,
+ onPress,
+ shouldShowLoadingSpinnerIcon,
+ shouldShowDownloadIcon,
+ containerStyles,
+ onScaleChanged,
+ onToggleKeyboard,
+ translate,
+ isFocused,
+}) {
+ const [loadComplete, setLoadComplete] = useState(false);
+
+ // Handles case where source is a component (ex: SVG)
+ if (_.isFunction(source)) {
+ return (
+
+ );
+ }
+
+ // Check both source and file.name since PDFs dragged into the the text field
+ // will appear with a source that is a blob
+ if (Str.isPDF(source) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) {
+ const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source) : source;
+
+ return (
+ !loadComplete && setLoadComplete(true)}
+ />
+ );
+ }
+
+ // For this check we use both source and file.name since temporary file source is a blob
+ // both PDFs and images will appear as images when pasted into the the text field
+ const isImage = Str.isImage(source);
+ if (isImage || (file && Str.isImage(file.name))) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {file && file.name}
+ {!shouldShowLoadingSpinnerIcon && shouldShowDownloadIcon && (
+
+
+
+
+
+ )}
+ {shouldShowLoadingSpinnerIcon && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+AttachmentView.propTypes = propTypes;
+AttachmentView.defaultProps = defaultProps;
+AttachmentView.displayName = 'AttachmentView';
+
+export default compose(memo, withLocalize)(AttachmentView);
diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js
new file mode 100644
index 000000000000..c9f42861fb54
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/propTypes.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import * as AttachmentsPropTypes from '../propTypes';
+
+const attachmentViewPropTypes = {
+ /** Whether source url requires authentication */
+ isAuthTokenRequired: PropTypes.bool,
+
+ /** URL to full-sized attachment or SVG function */
+ source: AttachmentsPropTypes.attachmentSourcePropType.isRequired,
+
+ /** File object maybe be instance of File or Object */
+ file: AttachmentsPropTypes.attachmentFilePropType,
+
+ /** Whether this view is the active screen */
+ isFocused: PropTypes.bool,
+
+ /** Whether this AttachmentView is shown as part of a AttachmentCarousel */
+ isUsedInCarousel: PropTypes.bool,
+
+ /** Function for handle on press */
+ onPress: PropTypes.func,
+
+ /** Handles scale changed event */
+ onScaleChanged: PropTypes.func,
+};
+
+const attachmentViewDefaultProps = {
+ isAuthTokenRequired: false,
+ file: {
+ name: '',
+ },
+ isFocused: false,
+ isUsedInCarousel: false,
+ onPress: undefined,
+ onScaleChanged: () => {},
+};
+
+export {attachmentViewPropTypes, attachmentViewDefaultProps};
diff --git a/src/components/Attachments/propTypes.js b/src/components/Attachments/propTypes.js
new file mode 100644
index 000000000000..b3f0af6ab217
--- /dev/null
+++ b/src/components/Attachments/propTypes.js
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+
+const attachmentSourcePropType = PropTypes.oneOfType([PropTypes.string, PropTypes.func]);
+const attachmentFilePropType = PropTypes.shape({
+ name: PropTypes.string,
+});
+
+const attachmentPropType = PropTypes.shape({
+ /** Whether source url requires authentication */
+ isAuthTokenRequired: PropTypes.bool,
+
+ /** URL to full-sized attachment or SVG function */
+ source: attachmentSourcePropType.isRequired,
+
+ /** File object maybe be instance of File or Object */
+ file: attachmentFilePropType,
+});
+
+const attachmentsPropType = PropTypes.arrayOf(attachmentPropType);
+
+export {attachmentSourcePropType, attachmentFilePropType, attachmentPropType, attachmentsPropType};
diff --git a/src/components/Image/index.native.js b/src/components/Image/index.native.js
index e9dfe4d1a2e1..0713fa6c7fe2 100644
--- a/src/components/Image/index.native.js
+++ b/src/components/Image/index.native.js
@@ -7,6 +7,12 @@ import ONYXKEYS from '../../ONYXKEYS';
import {defaultProps, imagePropTypes} from './imagePropTypes';
import RESIZE_MODES from './resizeModes';
+const dimensionsCache = new Map();
+
+function resolveDimensions(key) {
+ return dimensionsCache.get(key);
+}
+
function Image(props) {
// eslint-disable-next-line react/destructuring-assignment
const {source, isAuthTokenRequired, session, ...rest} = props;
@@ -29,6 +35,13 @@ function Image(props) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
source={imageSource}
+ onLoad={(evt) => {
+ const {width, height} = evt.nativeEvent;
+ dimensionsCache.set(source.uri, {width, height});
+ if (props.onLoad) {
+ props.onLoad(evt);
+ }
+ }}
/>
);
}
@@ -42,4 +55,5 @@ const ImageWithOnyx = withOnyx({
},
})(Image);
ImageWithOnyx.resizeMode = RESIZE_MODES;
+ImageWithOnyx.resolveDimensions = resolveDimensions;
export default ImageWithOnyx;
diff --git a/src/styles/styles.js b/src/styles/styles.js
index ae4f55c14da2..f7517cd3acb7 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -2496,11 +2496,11 @@ const styles = {
alignSelf: 'flex-start',
},
- attachmentModalArrowsContainer: {
- display: 'flex',
- justifyContent: 'center',
+ attachmentCarouselContainer: {
height: '100%',
width: '100%',
+ display: 'flex',
+ justifyContent: 'center',
...cursor.cursorUnset,
},