mattermost-mobile-upstream/app/components/files/files.tsx
Elias Nahum b6fc85385e
Migrate from RNN to Expo router (#9402)
* Expo-Router partial migration

* Full migration to ExpoRouter

* i18n

* add tests

* Merge branch 'main' into expo-router

* align package-lock.json

* i18n

* feedback review

* feedback review

* android 16kb patch

* Refactored emoji picker keyboard using a state machine

* set android launch_screen in drawables instead of layout

* no conditional hooks

* remove duplicate call in test

* address feedback

* fix tests and address more feedback

* small corrections

* small corrections

* small corrections

* fix draft test

* fix draft test 2

* remove navigateToScreen in draff test

* fix nit in draft test

* remove doc file

* Feedback review

* fix snapshots

* improve test coverage

* fix i18n

* Upgrade t o RN 0.83, other dependencies and new architecture (#9737)

* library hardware-keyboard android new arch support fix

* library rnshare android new arch support fix

* library rnutils android new arch support fix

* library secure-pdf-viewer add new arch support

* Enable new arch on iOS and Android

* update emm and network client with new-arch

* update intune to use new-arch

* performance improvements

* fix tests

* fixes post input on new-arch

* fix android emoji picker

* remove doc

* run tests in parallel

* upgrade to React Native 0.83.9, Expo 55, React 19, and New Architecture

Bumps core dependencies to their latest versions:
- React Native 0.77.3 → 0.83.9 (New Architecture enabled)
- React 18.3.1 → 19.2.5
- Expo SDK 52 → 55
- TypeScript 5.8.3 → 6.0.3
- Node.js 22.16.0 → 24.15.0
- react-native-reanimated 3.17.3 → 4.3.0
- Various other dependency upgrades

Migrates iOS AppDelegate from Objective-C to Swift, updates Android
build tooling and Gradle wrapper, refreshes all patches and font
assets, and adapts app code, tests, and CI workflows throughout for
API changes introduced by these upgrades.

* remove unecessary dismissKeyboard in notifications

* feedback review

* update intune to use new-arch

* update @mattermost deps

* missing i18n string

* additional fixes

* update fastlane
2026-05-13 17:44:55 +08:00

251 lines
8.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
import {DeviceEventEmitter, type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import {Events} from '@constants';
import {GalleryInit} from '@context/gallery';
import {usePostConfig} from '@context/post_config';
import {useIsTablet} from '@hooks/device';
import {useImageAttachments} from '@hooks/files';
import {usePreventDoubleTap} from '@hooks/utils';
import {fileExists, isImage, isVideo} from '@utils/file';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {getViewPortWidth} from '@utils/images';
import File from './file';
type FilesProps = {
failed?: boolean;
filesInfo: FileInfo[];
layoutWidth?: number;
location: string;
isReplyPost: boolean;
postId?: string;
postProps?: Record<string, unknown>;
isPermalinkPreview?: boolean;
}
const MAX_VISIBLE_ROW_IMAGES = 4;
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
marginTop: 5,
flexShrink: 0,
},
rowItemContainer: {
flex: 1,
},
gutter: {
marginLeft: 8,
},
failed: {
opacity: 0.5,
},
marginTop: {
marginTop: 10,
},
rowPermalinkPreview: {
marginTop: 0,
},
});
const Files = ({
failed,
filesInfo,
isReplyPost,
layoutWidth,
location,
postId,
postProps,
isPermalinkPreview = false,
}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const isTablet = useIsTablet();
const {canDownloadFiles, enableSecureFilePreview} = usePostConfig();
// Validate local paths asynchronously after render
const [validatedFilesInfo, setValidatedFilesInfo] = useState(filesInfo);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(validatedFilesInfo);
// Force re-render when a file is rejected (to move it from images to nonImages)
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
const [filesForGallery, setFilesForGallery] = useState(() => [...imageAttachments, ...nonImageAttachments]);
const attachmentIndex = (fileId: string) => {
return filesForGallery.findIndex((file) => file.id === fileId) || 0;
};
const handlePreviewPress = usePreventDoubleTap(useCallback((idx: number) => {
const items = filesForGallery.map((f) => fileToGalleryItem(f, f.user_id, postProps, 0, f.id));
openGalleryAtIndex(galleryIdentifier, idx, items);
}, [filesForGallery, galleryIdentifier, postProps]));
const updateFileForGallery = useCallback((idx: number, file: FileInfo) => {
const newFilesForGallery = [...filesForGallery];
newFilesForGallery[idx] = file;
setFilesForGallery(newFilesForGallery);
}, [filesForGallery]);
const isSingleImage = useMemo(() => filesInfo.filter((f) => isImage(f) || isVideo(f)).length === 1, [filesInfo]);
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false, isImageRow = false) => {
let nonVisibleImagesCount: number;
// Apply flex: 1 for multiple items, except in permalink previews where vertical
// document lists should not use flex to prevent shrinking under maxHeight constraints.
// Horizontal image rows (includeGutter=true) still need flex for space distribution.
const shouldApplyFlex = items.length > 1 && !(isPermalinkPreview && !includeGutter);
let container: StyleProp<ViewStyle> = shouldApplyFlex ? styles.rowItemContainer : undefined;
const containerWithGutter = [container, styles.gutter];
const wrapperWidth = getViewPortWidth(isReplyPost, isTablet) - 6;
return items.map((file, idx) => {
if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) {
nonVisibleImagesCount = moreImagesCount;
}
if (idx !== 0 && includeGutter) {
container = containerWithGutter;
}
const shouldRemoveMarginTop = isPermalinkPreview && (
(isImageRow) || // Remove marginTop for all images in image row
(!isImageRow && idx === 0 && imageAttachments.length === 0) // Remove marginTop for first non-image only if no images present
);
return (
<View
style={[container, styles.marginTop, shouldRemoveMarginTop && {marginTop: 0}]}
testID={`${file.id}-file-container`}
key={file.id}
>
<File
galleryIdentifier={galleryIdentifier}
key={file.id}
canDownloadFiles={canDownloadFiles}
enableSecureFilePreview={enableSecureFilePreview}
file={file}
index={attachmentIndex(file.id!)}
onPress={handlePreviewPress}
isSingleImage={isSingleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
updateFileForGallery={updateFileForGallery}
wrapperWidth={layoutWidth || wrapperWidth}
inViewPort={inViewPort}
/>
</View>
);
});
};
const renderImageRow = () => {
if (imageAttachments.length === 0) {
return null;
}
const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES);
const portraitPostWidth = layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 6);
let nonVisibleImagesCount;
if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) {
nonVisibleImagesCount = imageAttachments.length - MAX_VISIBLE_ROW_IMAGES;
}
return (
<View
style={[
styles.row,
{width: portraitPostWidth},
isPermalinkPreview && {marginTop: 0},
]}
testID='image-row'
>
{ renderItems(visibleImages, nonVisibleImagesCount, true, true) }
</View>
);
};
useEffect(() => {
const onScrollEnd = DeviceEventEmitter.addListener(Events.ITEM_IN_VIEWPORT, (viewableItems) => {
if (`${location}-${postId}` in viewableItems) {
setInViewPort(true);
}
});
return () => onScrollEnd.remove();
}, [location, postId]);
useEffect(() => {
setFilesForGallery([...imageAttachments, ...nonImageAttachments]);
}, [imageAttachments, nonImageAttachments]);
// Validate local paths asynchronously in background
useEffect(() => {
let isCancelled = false;
const validateFiles = () => {
// If no files have localPath, skip validation but still update state
const hasLocalPaths = filesInfo.some((f) => f.localPath);
if (!hasLocalPaths) {
if (!isCancelled) {
setValidatedFilesInfo(filesInfo);
}
return;
}
// Validate each file's localPath
const validated = filesInfo.map((file) => {
if (file.localPath && !fileExists(file.localPath)) {
return {...file, localPath: ''};
}
return file;
});
// Only update state if component is still mounted
if (!isCancelled) {
setValidatedFilesInfo(validated);
}
};
validateFiles();
return () => {
isCancelled = true;
};
}, [filesInfo]);
// Listen for file rejection events and re-render if one of our files is rejected
// This handles the race condition where files render before rejection status is known
useEffect(() => {
const fileIds = new Set(filesInfo.map((f) => f.id));
const onFileRejected = ({fileId}: {fileId: string}) => {
if (fileIds.has(fileId)) {
forceUpdate();
}
};
const listener = DeviceEventEmitter.addListener(Events.FILE_REJECTED, onFileRejected);
return () => listener.remove();
}, [filesInfo]);
return (
<GalleryInit galleryIdentifier={galleryIdentifier}>
<Animated.View
testID='files-container'
style={[
failed ? styles.failed : undefined,
isPermalinkPreview && {marginTop: 0},
]}
>
{renderImageRow()}
{renderItems(nonImageAttachments)}
</Animated.View>
</GalleryInit>
);
};
export default React.memo(Files);