* refactor: implement custom ExpoImage wrapper for cache control Add ExpoImage component with automatic cacheKey/cachePath management and replace all expo-image imports across the app * refactor(ios): convert Gekidou to CocoaPods Migrate from Swift Package Manager to CocoaPods, add Keychain write operations, refactor notification handler to remove react-native-notifications headers, and upgrade Swift to 5.0 * npm audit * update fastlane * feat(ci): integrate Intune MAM for enterprise builds with strict OSS protection Add Intune submodule, CI actions, Fastlane configuration, developer scripts, pre-commit hooks, and validation workflows to enable internal MAM builds while protecting OSS repository * fix tests by mocking @mattermost/intune * feat: implement Intune MAM integration with comprehensive security enforcement Add IntuneManager, refactor SecurityManager/SessionManager for MAM policies, implement native OIDC auth flow, add biometric enforcement, conditional launch blocking, and file protection controls * fix alerts when no server database is present * Unify cache strategy * fix emit config changed after it was stored in the db * Handle Mid-Session Enrollment Detection * fix ADALLogOverrideDisabled missing in Fastfile * fix flow for initial enrollment * fix and add unit tests * enable Intune configuration for PR and beta builds, CLIENT_ID should be changed before actual release * Update intune submodule with addressed feedback * fix validate-intune-clean workflow * feat(intune): add comprehensive error handling and SAML+Entra support Add production-ready error handling for native Entra authentication with user-friendly i18n messages, comprehensive test coverage, and support for Entra login when server requires SAML. * update i18n * update intune submodule * update build-pr token * fix race condition between server auth and intune enrollment * fix CI workflow to build with intune * use deploy key for intune submodule * set the config directly in the submodule .git * debug injection * try setting GIT_SSH_COMMAND * remove action debug * fix server url input * match pod cache with intune hash * Fastfile and envs * have workflows check for intune/.git * have ci cache intune frameworks as well * update Fastlane to set no-cache to artifacts uploaded * fix s3 upload * fix pblist template * Attempt to remove the cache control for PR uploads to s3 * use hash from commit for S3 path * Implement crash-resilient selective wipe with automatic retry and add removeInternetPassword to Gekidou Keychain * Fix surface errors from intune login * fix postinstall scripts * use cacheKey for draft md images * remove unnecessary double await during test * Have isMinimumLicenseTier accept valid license sku tier as target * Add missing Auth error messages * remove the last period for intune errors in i18n * do not call unenroll with wipe on manual logout * Fix tests and Intune error messages * do not filter any SSO type regardless of which is used for Intune * fix 412 to not retry * fix tests, app logs sharing and share_extension avatar cache * apply setScreenCapturePolicy on license change Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * re-apply screen capture on enrollment Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * use userData from intunr login and prevent getMe Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com> * Check for Biometrics and Jailbreak as we used to --------- Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>
197 lines
7 KiB
TypeScript
197 lines
7 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {useCallback, useEffect, useMemo, 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 {useIsTablet} from '@hooks/device';
|
|
import {useImageAttachments} from '@hooks/files';
|
|
import {usePreventDoubleTap} from '@hooks/utils';
|
|
import {isImage, isVideo} from '@utils/file';
|
|
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
|
|
import {getViewPortWidth} from '@utils/images';
|
|
|
|
import File from './file';
|
|
|
|
type FilesProps = {
|
|
canDownloadFiles: boolean;
|
|
enableSecureFilePreview: boolean;
|
|
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 = ({
|
|
canDownloadFiles,
|
|
enableSecureFilePreview,
|
|
failed,
|
|
filesInfo,
|
|
isReplyPost,
|
|
layoutWidth,
|
|
location,
|
|
postId,
|
|
postProps,
|
|
isPermalinkPreview = false,
|
|
}: FilesProps) => {
|
|
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
|
|
const [inViewPort, setInViewPort] = useState(false);
|
|
const isTablet = useIsTablet();
|
|
|
|
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo);
|
|
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]);
|
|
|
|
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);
|