* feature edit profile screen * minor refactoring * Apply suggestions from code review Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * ts fixes * revert floatingTextInput label This reverts commit a778e1f76191aea7c1a18d60a23ffbd6d3dec0eb. * code clean up * Apply suggestions from code review Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * code fix * code fix * Adding preventDoubleTap * rename id to fieldKey * Update edit_profile.tsx * wip * navigating through fields; partly done * navigating through fields - partly done * navigating through fields; partly done * completed field navigation * added theme into dependency array * code clean up * revert conditions for disabling fields * Added colorFilters prop to Loading component * Completed loading feature on Edit Profile screen * code clean up * Add profile_error * renamed valid_mime_types to valid_image_mime_types * added props isDisabled to email field * refactored next field logic * fix * fix * code clean up * code clean up * Updated ESLINT hook rules to warning instead of disabled * code fix * code fix * new line within your_profile component * added memo for Field component * added canSave to dependency array * update loading component - color filter * Update app/screens/edit_profile/edit_profile.tsx Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * dependency fix * fix to fetch my latest status * fix to remove unnecessary user id for local action updateLocalUser * prevents bouncing for iOS * code revert * Adding textInputStyle and animatedTextStyle to FloatingTextInput component * correction after dev session * adding changes as per new ux * Update edit_profile.tsx * corrections after ux review * ux review * ux review * code clean up * Adding userProfileFields into useMemo * Add enableSaveButton to dependency of submitUser * Added react-native-image-picker * fix picker util * Added action for setDefaultProfileImage * account outline on remove picture * Update edit_profile.tsx * fix image picker * style fix * fix image picker * removed unused types * mmjstool issue with integrity checksum * perform camera permission check for Android * fix to pull latest status * updated ChangeProfilePicture to EditProfilePciture * removed integrity key for mmjstool in package-lock.json * corrections from pr review * bumping react-native-image-picker to v4.7.1 * pod install * update to hooks dependency * fix profile picture component * added event emitter from edit_profile_picture * made hitslop a constant * code clean up * uploadProfilePicture as a remote action * else if profileImage removed * removed check on isBot * update renderProfilePicture dependencies * extractFileInfo with try catch * updated snappoints * Revert "updated snappoints" This reverts commit 6d16d480a168755fc80e5bc80569ad3ba561f73b. * profile picture size * refactored renderProfilePicture into its own component * change to if else * platform select for hasPermissions * unneeded comment removed * else if on prefix in edit profile picture * track has update user info now * moved image_picker under edit_screen and increased actionSheets item height * added preventDoubleTap for imagePicker * multiple uploads * switch the conditions * added alert box as requested by Marina * Revert "added alert box as requested by Marina" This reverts commit 20735c17a87b40995e05eb4318c138c1adcc6c8c. * Apply suggestions from code review Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * removed userInfos constant * added useMemo for certain components on profile_picture * converting account-outline into a constant * added panelItem component * adding return instead of making the function return * eslint fix * update i18n desc Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * hasPictureUrl transferred to file utils * removing excess mediaType prop * add USER_PROFILE_PICTURE_SIZE into constant/profile * relocate hasPictureUrl method * relocate hasPictureUrl * rename ImagePicker to ProfileImagePicker * removing isDestructive property from panelTypes. * update sectionLimit for attachFileFromPhotoGallery * Change animation for showModalOverCurrentContext to a quick alpha on iOS * re-create PickerUtil if intl changes * Combine styles in edit_profile_picture component * Split profile image component into smaller components * useCallback for showFileAttachmentOptions * split comment into multiple lines * edit_profile group refs * remove unnecessary casting * add new line to file.d.ts * remove extra space for utils/index.d.ts * allowMultiSelection for attachFilesFromFiles, default is false * Split edit profile screen into smaller components Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {PastedFile} from '@mattermost/react-native-paste-input';
|
|
import Model from '@nozbe/watermelondb/Model';
|
|
import * as FileSystem from 'expo-file-system';
|
|
import mimeDB from 'mime-db';
|
|
import {IntlShape} from 'react-intl';
|
|
import {Platform} from 'react-native';
|
|
import {DocumentPickerResponse} from 'react-native-document-picker';
|
|
import {Asset} from 'react-native-image-picker';
|
|
|
|
import {Files} from '@constants';
|
|
import {generateId} from '@utils/general';
|
|
import {deleteEntititesFile, getIOSAppGroupDetails} from '@utils/mattermost_managed';
|
|
import {hashCode} from '@utils/security';
|
|
import {removeProtocol} from '@utils/url';
|
|
|
|
import type FileModel from '@typings/database/models/servers/file';
|
|
import type {ExtractedFileInfo} from '@typings/utils/file';
|
|
|
|
const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
|
|
const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i;
|
|
const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
|
|
|
|
export const GENERAL_SUPPORTED_DOCS_FORMAT = [
|
|
'application/json',
|
|
'application/msword',
|
|
'application/pdf',
|
|
'application/rtf',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/x-x509-ca-cert',
|
|
'application/xml',
|
|
'text/csv',
|
|
'text/plain',
|
|
];
|
|
|
|
const SUPPORTED_DOCS_FORMAT = Platform.select({
|
|
android: GENERAL_SUPPORTED_DOCS_FORMAT,
|
|
ios: [
|
|
...GENERAL_SUPPORTED_DOCS_FORMAT,
|
|
'application/vnd.apple.pages',
|
|
'application/vnd.apple.numbers',
|
|
'application/vnd.apple.keynote',
|
|
],
|
|
});
|
|
|
|
const SUPPORTED_VIDEO_FORMAT = Platform.select({
|
|
ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'],
|
|
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm'],
|
|
});
|
|
|
|
const types: Record<string, string> = {};
|
|
const extensions: Record<string, readonly string[]> = {};
|
|
|
|
/**
|
|
* Populate the extensions and types maps.
|
|
* @private
|
|
*/
|
|
|
|
function populateMaps() {
|
|
// source preference (least -> most)
|
|
const preference = ['nginx', 'apache', undefined, 'iana'];
|
|
|
|
Object.keys(mimeDB).forEach((type) => {
|
|
const mime = mimeDB[type];
|
|
const exts = mime.extensions;
|
|
|
|
if (!exts || !exts.length) {
|
|
return;
|
|
}
|
|
|
|
extensions[type] = exts;
|
|
|
|
for (let i = 0; i < exts.length; i++) {
|
|
const extension = exts[i];
|
|
|
|
if (types[extension]) {
|
|
const from = preference.indexOf(mimeDB[types[extension]].source);
|
|
const to = preference.indexOf(mime.source);
|
|
|
|
if (types[extension] !== 'application/octet-stream' &&
|
|
(from > to || (from === to && types[extension].substr(0, 12) === 'application/'))) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
types[extension] = type;
|
|
}
|
|
});
|
|
}
|
|
|
|
const vectorIconsDir = 'vectorIcons';
|
|
const dirsToExclude = ['Cache.db', 'WebKit', 'WebView', vectorIconsDir];
|
|
async function getDirectorySize(fileStats: FileSystem.FileInfo) {
|
|
if (fileStats?.exists) {
|
|
let total = 0;
|
|
if (fileStats.isDirectory) {
|
|
const exclude = dirsToExclude.find((f) => fileStats.uri.includes(f));
|
|
if (!exclude) {
|
|
const paths = await FileSystem.readDirectoryAsync(fileStats.uri);
|
|
for await (const path of paths) {
|
|
const info = await FileSystem.getInfoAsync(`${fileStats.uri}/${path}`, {size: true});
|
|
if (info.isDirectory) {
|
|
const dirSize = await getDirectorySize(info);
|
|
total += dirSize;
|
|
} else {
|
|
total += (info.size || 0);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
total = fileStats.size;
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
export async function getFileCacheSize() {
|
|
if (FileSystem.cacheDirectory) {
|
|
const cacheStats = await FileSystem.getInfoAsync(FileSystem.cacheDirectory);
|
|
const size = await getDirectorySize(cacheStats);
|
|
|
|
return size;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
export async function deleteV1Data() {
|
|
const dir = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupSharedDirectory : FileSystem.documentDirectory;
|
|
|
|
try {
|
|
const mmkvDirInfo = await FileSystem.getInfoAsync(`${dir}/mmkv`);
|
|
if (mmkvDirInfo.exists) {
|
|
await FileSystem.deleteAsync(mmkvDirInfo.uri, {idempotent: true});
|
|
}
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
|
|
try {
|
|
const entitiesInfo = await FileSystem.getInfoAsync(`${dir}/entities`);
|
|
if (entitiesInfo.exists) {
|
|
deleteEntititesFile();
|
|
}
|
|
} catch (e) {
|
|
// do nothing
|
|
}
|
|
}
|
|
|
|
export async function deleteFileCache(serverUrl: string) {
|
|
const serverDir = hashCode(serverUrl);
|
|
const cacheDir = `${FileSystem.cacheDirectory}/${serverDir}`;
|
|
if (cacheDir) {
|
|
const cacheDirInfo = await FileSystem.getInfoAsync(cacheDir);
|
|
if (cacheDirInfo.exists) {
|
|
if (Platform.OS === 'ios') {
|
|
await FileSystem.deleteAsync(cacheDir, {idempotent: true});
|
|
await FileSystem.makeDirectoryAsync(cacheDir, {intermediates: true});
|
|
} else {
|
|
const lstat = await FileSystem.readDirectoryAsync(cacheDir);
|
|
lstat.forEach((stat: string) => {
|
|
FileSystem.deleteAsync(stat, {idempotent: true});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function lookupMimeType(filename: string) {
|
|
if (!Object.keys(extensions).length) {
|
|
populateMaps();
|
|
}
|
|
|
|
const ext = filename.split('.').pop()?.toLowerCase();
|
|
return types[ext!] || 'application/octet-stream';
|
|
}
|
|
|
|
export function getExtensionFromMime(type: string) {
|
|
if (!Object.keys(extensions).length) {
|
|
populateMaps();
|
|
}
|
|
|
|
if (!type || typeof type !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const match = EXTRACT_TYPE_REGEXP.exec(type);
|
|
|
|
// get extensions
|
|
const exts = match && extensions[match[1].toLowerCase()];
|
|
|
|
if (!exts || !exts.length) {
|
|
return undefined;
|
|
}
|
|
|
|
return exts[0];
|
|
}
|
|
|
|
export function getExtensionFromContentDisposition(contentDisposition: string) {
|
|
const match = CONTENT_DISPOSITION_REGEXP.exec(contentDisposition);
|
|
let extension = match && match[1];
|
|
if (extension) {
|
|
if (!Object.keys(types).length) {
|
|
populateMaps();
|
|
}
|
|
|
|
extension = extension.toLowerCase();
|
|
if (types[extension]) {
|
|
return extension;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const getAllowedServerMaxFileSize = (config: ClientConfig) => {
|
|
return config && config.MaxFileSize ? parseInt(config.MaxFileSize, 10) : DEFAULT_SERVER_MAX_FILE_SIZE;
|
|
};
|
|
|
|
export const isGif = (file?: FileInfo | FileModel) => {
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
|
|
const fi = file as FileInfo;
|
|
const fm = file as FileModel;
|
|
let mime = fi.mime_type || fm.mimeType || '';
|
|
if (mime && mime.includes(';')) {
|
|
mime = mime.split(';')[0];
|
|
} else if (!mime && file?.name) {
|
|
mime = lookupMimeType(file.name);
|
|
}
|
|
|
|
return mime === 'image/gif';
|
|
};
|
|
|
|
export const isImage = (file?: FileInfo | FileModel) => {
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
const fi = file as FileInfo;
|
|
const fm = file as FileModel;
|
|
|
|
const hasPreview = Boolean(fi.mini_preview || fm.imageThumbnail);
|
|
const mimeType = fi.mime_type || fm.mimeType || '';
|
|
|
|
return (hasPreview || isGif(file) || mimeType.startsWith('image/'));
|
|
};
|
|
|
|
export const isDocument = (file?: FileInfo | FileModel) => {
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
|
|
const fi = file as FileInfo;
|
|
const fm = file as FileModel;
|
|
let mime = fi.mime_type || fm.mimeType || '';
|
|
if (mime && mime.includes(';')) {
|
|
mime = mime.split(';')[0];
|
|
} else if (!mime && file?.name) {
|
|
mime = lookupMimeType(file.name);
|
|
}
|
|
|
|
return SUPPORTED_DOCS_FORMAT!.includes(mime);
|
|
};
|
|
|
|
export const isVideo = (file?: FileInfo | FileModel) => {
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
|
|
const fi = file as FileInfo;
|
|
const fm = file as FileModel;
|
|
let mime = fi.mime_type || fm.mimeType || '';
|
|
if (mime && mime.includes(';')) {
|
|
mime = mime.split(';')[0];
|
|
} else if (!mime && file?.name) {
|
|
mime = lookupMimeType(file.name);
|
|
}
|
|
|
|
return SUPPORTED_VIDEO_FORMAT!.includes(mime);
|
|
};
|
|
|
|
export function getFormattedFileSize(bytes: number): string {
|
|
const fileSizes = [
|
|
['TB', 1024 * 1024 * 1024 * 1024],
|
|
['GB', 1024 * 1024 * 1024],
|
|
['MB', 1024 * 1024],
|
|
['KB', 1024],
|
|
];
|
|
const size = fileSizes.find((unitAndMinBytes) => {
|
|
const minBytes = unitAndMinBytes[1];
|
|
return bytes > minBytes;
|
|
});
|
|
|
|
if (size) {
|
|
return `${Math.floor(bytes / (size[1] as number))} ${size[0]}`;
|
|
}
|
|
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
export function getFileType(file: FileInfo): string {
|
|
if (!file || !file.extension) {
|
|
return 'other';
|
|
}
|
|
|
|
const fileExt = file.extension.toLowerCase();
|
|
const fileTypes = [
|
|
'image',
|
|
'code',
|
|
'pdf',
|
|
'video',
|
|
'audio',
|
|
'spreadsheet',
|
|
'text',
|
|
'word',
|
|
'presentation',
|
|
'patch',
|
|
'zip',
|
|
];
|
|
return fileTypes.find((fileType) => {
|
|
const constForFileTypeExtList = `${fileType}_types`.toUpperCase();
|
|
const fileTypeExts = Files[constForFileTypeExtList];
|
|
return fileTypeExts.indexOf(fileExt) > -1;
|
|
}) || 'other';
|
|
}
|
|
|
|
export function getLocalFilePathFromFile(dir: string, serverUrl: string, file: FileInfo | FileModel) {
|
|
if (dir && serverUrl) {
|
|
const server = removeProtocol(serverUrl);
|
|
if (file?.name) {
|
|
let extension: string | undefined = file.extension;
|
|
let filename = file.name;
|
|
|
|
if (!extension) {
|
|
const mimeType = (file instanceof Model) ? file.mimeType : file.mime_type;
|
|
extension = getExtensionFromMime(mimeType);
|
|
}
|
|
|
|
if (extension && filename.includes(`.${extension}`)) {
|
|
filename = filename.replace(`.${extension}`, '');
|
|
} else {
|
|
const fileParts = file.name.split('.');
|
|
|
|
if (fileParts.length > 1) {
|
|
extension = fileParts.pop();
|
|
filename = fileParts.join('.');
|
|
}
|
|
}
|
|
|
|
return `${dir}/${server}/${filename}-${hashCode(file.id!)}.${extension}`;
|
|
} else if (file?.id && file?.extension) {
|
|
return `${dir}/${server}/${file.id}.${file.extension}`;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export async function extractFileInfo(files: Array<Asset | DocumentPickerResponse | PastedFile>) {
|
|
const out: ExtractedFileInfo[] = [];
|
|
|
|
await Promise.all(files.map(async (file) => {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
const outFile = {
|
|
progress: 0,
|
|
localPath: file.uri,
|
|
clientId: generateId(),
|
|
loading: true,
|
|
} as unknown as ExtractedFileInfo;
|
|
|
|
if ('fileSize' in file) {
|
|
outFile.size = file.fileSize || 0;
|
|
outFile.name = file.fileName || '';
|
|
} else {
|
|
const path = Platform.select({
|
|
ios: (file.uri || '').replace('file://', ''),
|
|
default: file.uri || '',
|
|
});
|
|
let fileInfo;
|
|
try {
|
|
fileInfo = await FileSystem.getInfoAsync(path);
|
|
const uri = fileInfo.uri;
|
|
outFile.size = fileInfo.size || 0;
|
|
outFile.name = uri.substring(uri.lastIndexOf('/') + 1);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (file.type) {
|
|
outFile.mime_type = file.type;
|
|
} else {
|
|
outFile.mime_type = lookupMimeType(outFile.name);
|
|
}
|
|
|
|
out.push(outFile);
|
|
}));
|
|
|
|
return out;
|
|
}
|
|
|
|
export function fileSizeWarning(intl: IntlShape, maxFileSize: number) {
|
|
return intl.formatMessage({
|
|
id: 'file_upload.fileAbove',
|
|
defaultMessage: 'Files must be less than {max}',
|
|
}, {
|
|
max: getFormattedFileSize(maxFileSize),
|
|
});
|
|
}
|
|
|
|
export function fileMaxWarning(intl: IntlShape, maxFileCount: number) {
|
|
return intl.formatMessage({
|
|
id: 'mobile.file_upload.max_warning',
|
|
defaultMessage: 'Uploads limited to {count} files maximum.',
|
|
}, {
|
|
count: maxFileCount,
|
|
});
|
|
}
|
|
|
|
export function uploadDisabledWarning(intl: IntlShape) {
|
|
return intl.formatMessage({
|
|
id: 'mobile.file_upload.disabled2',
|
|
defaultMessage: 'File uploads from mobile are disabled.',
|
|
});
|
|
}
|