diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index af34b8b7e9ea..a49df16b570a 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -204,7 +204,7 @@ const ROUTES = {
},
REPORT_ATTACHMENTS: {
route: 'r/:reportID/attachment',
- getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const,
+ getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURIComponent(source)}` as const,
},
REPORT_PARTICIPANTS: {
route: 'r/:reportID/participants',
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index b934bdfdd738..9524c5203110 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -15,10 +15,19 @@ import CONST from '@src/CONST';
function extractAttachmentsFromReport(parentReportAction, reportActions) {
const actions = [parentReportAction, ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
const attachments = [];
+ // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate
+ // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position.
+ const uniqueSources = new Set();
const htmlParser = new HtmlParser({
onopentag: (name, attribs) => {
if (name === 'video') {
+ const source = tryResolveUrlFromApiRoot(attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
const splittedUrl = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE].split('/');
attachments.unshift({
reportActionID: null,
@@ -35,7 +44,20 @@ function extractAttachmentsFromReport(parentReportAction, reportActions) {
if (name === 'img' && attribs.src) {
const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src);
- const fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+ if (uniqueSources.has(source)) {
+ return;
+ }
+
+ uniqueSources.add(source);
+ let fileName = attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || FileUtils.getFileName(`${source}`);
+
+ // Public image URLs might lack a file extension in the source URL, without an extension our
+ // AttachmentView fails to recognize them as images and renders fallback content instead.
+ // We apply this small hack to add an image extension and ensure AttachmentView renders the image.
+ const fileInfo = FileUtils.splitExtensionFromFileName(fileName);
+ if (!fileInfo.fileExtension) {
+ fileName = `${fileInfo.fileName || 'image'}.jpg`;
+ }
// By iterating actions in chronological order and prepending each attachment
// we ensure correct order of attachments even across actions with multiple attachments.
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 461548f0d2b1..9fe37734e8ee 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -79,6 +79,7 @@ const defaultProps = {
reportActionID: '',
isHovered: false,
optionalVideoDuration: 0,
+ fallbackSource: Expensicons.Gallery,
};
function AttachmentView({
@@ -201,6 +202,21 @@ function AttachmentView({
// We also check for numeric source since this is how static images (used for preview) are represented in RN.
const isImage = typeof source === 'number' || Str.isImage(source);
if (isImage || (file && Str.isImage(file.name))) {
+ if (imageError) {
+ // AttachmentViewImage can't handle icon fallbacks, so we need to handle it here
+ if (typeof fallbackSource === 'number' || _.isFunction(fallbackSource)) {
+ return (
+
+ );
+ }
+ }
+
return (
{