mattermost-mobile/app/components/upload_item_shared/index.tsx
Elias Nahum 44eb76bed7
feat(iOS): Add Microsoft Intune MAM integration with multi-server support (#9312)
* 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>
2025-12-10 13:07:28 +02:00

312 lines
9.8 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {TouchableWithoutFeedback, View, Text, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import ExpoImage from '@components/expo_image';
import FileIcon from '@components/files/file_icon';
import ImageFile from '@components/files/image_file';
import UploadRetry from '@components/post_draft/uploads/upload_item/upload_retry';
import ProgressBar from '@components/progress_bar';
import {useTheme} from '@context/theme';
import {isImage, getFormattedFileSize} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {SHARED_UPLOAD_STYLES} from './constants';
export interface UploadItemFile {
id?: string;
clientId?: string;
name?: string;
extension?: string;
size?: number;
uri?: string;
failed?: boolean;
width?: number;
height?: number;
mime_type?: string;
}
export interface UploadItemProps {
file: UploadItemFile;
onPress?: () => void;
onRetry?: () => void;
loading?: boolean;
progress?: number;
showRetryButton?: boolean;
galleryStyles?: Animated.AnimateStyle<ViewStyle>;
testID?: string;
fullWidth?: boolean;
isShareExtension?: boolean;
forwardRef?: React.RefObject<unknown>;
inViewPort?: boolean;
hasError?: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
previewContainer: {
borderRadius: 4,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
backgroundColor: theme.centerChannelBg,
alignItems: 'center',
position: 'relative',
},
imageOnlyContainer: {
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
padding: 0,
},
fileWithInfoContainer: {
width: SHARED_UPLOAD_STYLES.FILE_CONTAINER_WIDTH,
height: SHARED_UPLOAD_STYLES.FILE_CONTAINER_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
gap: 8,
paddingVertical: 12,
paddingLeft: 8,
paddingRight: 16,
},
fullWidthContainer: {
width: '100%',
height: SHARED_UPLOAD_STYLES.FILE_CONTAINER_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
gap: 8,
paddingVertical: 12,
paddingLeft: 20,
paddingRight: 20,
},
iconContainer: {
width: SHARED_UPLOAD_STYLES.ICON_SIZE,
height: SHARED_UPLOAD_STYLES.ICON_SIZE,
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
},
imageContainer: {
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
borderRadius: 4,
marginRight: 8,
overflow: 'hidden',
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 1,
},
imageOnlyThumbnail: {
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
borderRadius: 4,
overflow: 'hidden',
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 1,
},
imageOnlyImage: {
width: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
height: SHARED_UPLOAD_STYLES.THUMBNAIL_SIZE,
borderRadius: 4,
},
fileInfo: {
flex: 1,
justifyContent: 'center',
alignItems: 'flex-start',
minWidth: 0,
},
fileName: {
color: theme.centerChannelColor,
...typography('Body', 200, 'SemiBold'),
marginBottom: 2,
width: '100%',
},
fileSize: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'Regular'),
width: '100%',
},
progress: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: 4,
justifyContent: 'flex-end',
},
progressContainer: {
paddingVertical: undefined,
position: undefined,
justifyContent: undefined,
},
errorBorder: {
borderColor: theme.errorTextColor,
borderWidth: 2,
},
};
});
export default function UploadItemShared({
file,
onPress,
onRetry,
loading = false,
progress = 0,
showRetryButton = false,
galleryStyles,
testID,
fullWidth = false,
isShareExtension = false,
forwardRef,
inViewPort = false,
hasError = false,
}: UploadItemProps) {
const theme = useTheme();
const intl = useIntl();
const style = getStyleSheet(theme);
const fileForCheck = useMemo(() => ({
name: file.name,
extension: file.extension,
mime_type: file.mime_type,
} as FileInfo), [file.name, file.extension, file.mime_type]);
const isImageFile = useMemo(() => isImage(fileForCheck), [fileForCheck]);
const imageFileData = useMemo(() => ({
...fileForCheck,
id: file.id,
clientId: file.clientId,
size: file.size,
uri: file.uri,
localPath: file.uri,
width: file.width,
height: file.height,
failed: file.failed,
} as FileInfo), [fileForCheck, file.id, file.clientId, file.size, file.uri, file.width, file.height, file.failed]);
const fileDisplay = useMemo(() => {
if (isImageFile) {
if (isShareExtension) {
return (
<View style={style.imageOnlyThumbnail}>
<ExpoImage
source={{uri: file.uri}}
style={style.imageOnlyImage}
contentFit='cover'
cachePolicy='memory'
/>
</View>
);
}
return (
<View style={style.imageOnlyThumbnail}>
<ImageFile
file={imageFileData}
forwardRef={forwardRef}
inViewPort={inViewPort}
contentFit='cover'
/>
</View>
);
}
return (
<View style={style.iconContainer}>
<FileIcon
iconSize={48}
file={fileForCheck}
/>
</View>
);
}, [isImageFile, style.iconContainer, style.imageOnlyThumbnail, style.imageOnlyImage, fileForCheck, isShareExtension, imageFileData, forwardRef, inViewPort, file.uri]);
const fileExtension = file.extension?.toUpperCase() || file.name?.split('.').pop()?.toUpperCase() || '';
const formattedSize = getFormattedFileSize(file.size || 0);
const unknownFileLabel = intl.formatMessage({id: 'upload_item.unknown_file', defaultMessage: 'Unknown file'});
const containerStyle = useMemo(() => {
let containerStyleType;
if (fullWidth && !isImageFile) {
containerStyleType = style.fullWidthContainer;
} else if (isImageFile) {
containerStyleType = style.imageOnlyContainer;
} else {
containerStyleType = style.fileWithInfoContainer;
}
const baseStyles = [style.previewContainer, containerStyleType];
if (hasError) {
return [
...baseStyles,
style.errorBorder,
];
}
return baseStyles;
}, [fullWidth, isImageFile, hasError, style.fileWithInfoContainer, style.imageOnlyContainer, style.fullWidthContainer, style.previewContainer, style.errorBorder]);
return (
<View
style={containerStyle}
testID={testID}
>
<TouchableWithoutFeedback onPress={onPress}>
<Animated.View style={galleryStyles}>
{fileDisplay}
</Animated.View>
</TouchableWithoutFeedback>
{!isImageFile && (
<View style={style.fileInfo}>
<Text
style={style.fileName}
numberOfLines={1}
ellipsizeMode='tail'
>
{file.name || unknownFileLabel}
</Text>
<Text style={style.fileSize}>
{fileExtension && `${fileExtension} `}{formattedSize}
</Text>
</View>
)}
{file.failed && showRetryButton && onRetry && (
<UploadRetry
onPress={onRetry}
/>
)}
{loading && !file.failed && (
<View style={style.progress}>
<ProgressBar
progress={progress || 0}
color={theme.buttonBg}
containerStyle={style.progressContainer}
/>
</View>
)}
</View>
);
}