diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index c98cbe1bf..ccb4d4e55 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -475,3 +475,54 @@ export const unsetCustomStatus = async (serverUrl: string) => { return {data: true}; }; + +export const setDefaultProfileImage = async (serverUrl: string, userId: string) => { + let client: Client; + + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + await client.setDefaultProfileImage(userId); + updateLocalUser(serverUrl, {last_picture_update: Date.now()}); + } catch (error) { + return {error}; + } + + return {data: true}; +}; + +export const uploadUserProfileImage = async (serverUrl: string, localPath: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const currentUser = await queryCurrentUser(database); + if (currentUser) { + const endpoint = `${client.getUserRoute(currentUser.id)}/image`; + + await client.apiClient.upload(endpoint, localPath, { + skipBytes: 0, + method: 'POST', + multipart: { + fileKey: 'image', + }, + }); + } + } catch (e) { + return {error: e}; + } + return {error: undefined}; +}; diff --git a/app/components/error_text/index.tsx b/app/components/error_text/index.tsx index 647f0a727..d3e8aaa76 100644 --- a/app/components/error_text/index.tsx +++ b/app/components/error_text/index.tsx @@ -7,8 +7,10 @@ import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; import FormattedText from '@components/formatted_text'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import type {ErrorText as ErrorType} from '@typings/utils/file'; + type ErrorProps = { - error: ErrorText; + error: ErrorType; testID?: string; textStyle?: StyleProp | StyleProp; theme: Theme; diff --git a/app/components/post_list/post/body/files/file_info.tsx b/app/components/post_list/post/body/files/file_info.tsx index d2e0f6694..6c1a5c291 100644 --- a/app/components/post_list/post/body/files/file_info.tsx +++ b/app/components/post_list/post/body/files/file_info.tsx @@ -62,7 +62,7 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => { ellipsizeMode='tail' style={style.fileInfo} > - {`${getFormattedFileSize(file)}`} + {`${getFormattedFileSize(file.size)}`} diff --git a/app/components/profile_picture/image.tsx b/app/components/profile_picture/image.tsx new file mode 100644 index 000000000..50a77fcba --- /dev/null +++ b/app/components/profile_picture/image.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import FastImage, {Source} from 'react-native-fast-image'; + +import CompassIcon from '@components/compass_icon'; +import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import NetworkManager from '@init/network_manager'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Client} from '@client/rest'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + iconSize?: number; + size: number; + source?: Source | string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + icon: { + color: changeOpacity(theme.centerChannelColor, 0.48), + }, + }; +}); + +const Image = ({author, iconSize, size, source}: Props) => { + const theme = useTheme(); + const serverUrl = useServerUrl(); + const style = getStyleSheet(theme); + const fIStyle = useMemo(() => ({ + borderRadius: size / 2, + height: size, + width: size, + }), [size]); + + if (typeof source === 'string') { + return ( + + ); + } + + let client: Client | undefined; + + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // handle below that the client is not set + } + + if (author && client) { + const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate); + const imgSource = source ?? {uri: `${serverUrl}${pictureUrl}`}; + return ( + + ); + } + return ( + + ); +}; + +export default Image; diff --git a/app/components/profile_picture/index.tsx b/app/components/profile_picture/index.tsx index c92215d71..d49fb7d15 100644 --- a/app/components/profile_picture/index.tsx +++ b/app/components/profile_picture/index.tsx @@ -1,20 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect} from 'react'; -import {Platform, StyleProp, View, ViewProps, ViewStyle} from 'react-native'; -import FastImage from 'react-native-fast-image'; +import React, {useEffect, useMemo} from 'react'; +import {Platform, StyleProp, View, ViewProps} from 'react-native'; import {fetchStatusInBatch} from '@actions/remote/user'; -import CompassIcon from '@components/compass_icon'; -import UserStatus from '@components/user_status'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import NetworkManager from '@init/network_manager'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import type {Client} from '@client/rest'; +import Image from './image'; +import Status from './status'; + import type UserModel from '@typings/database/models/servers/user'; +import type {Source} from 'react-native-fast-image'; const STATUS_BUFFER = Platform.select({ ios: 3, @@ -29,6 +28,7 @@ type ProfilePictureProps = { statusSize?: number; statusStyle?: StyleProp; testID?: string; + source?: Source | string; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -66,18 +66,13 @@ const ProfilePicture = ({ statusSize = 14, statusStyle, testID, + source, }: ProfilePictureProps) => { const theme = useTheme(); const serverUrl = useServerUrl(); - const style = getStyleSheet(theme); - const buffer = showStatus ? (STATUS_BUFFER || 0) : 0; - let client: Client | undefined; - try { - client = NetworkManager.getClient(serverUrl); - } catch { - // handle below that the client is not set - } + const style = getStyleSheet(theme); + const buffer = showStatus ? STATUS_BUFFER || 0 : 0; useEffect(() => { if (author && !author.status && showStatus) { @@ -85,55 +80,43 @@ const ProfilePicture = ({ } }, []); - let statusIcon; - let containerStyle: StyleProp = { - width: size + buffer, - height: size + buffer, - }; + const containerStyle = useMemo(() => { + if (author) { + return { + width: size + (buffer - 1), + height: size + (buffer - 1), + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: (size + buffer) / 2, + }; + } - if (author?.status && !author.isBot && showStatus) { - statusIcon = ( - - - - ); - } - - let image; - if (author && client) { - const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate); - image = ( - - ); - } else { - containerStyle = { - width: size + (buffer - 1), - height: size + (buffer - 1), - backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + return { + ...style.container, + width: size + buffer, + height: size + buffer, + borderRadius: (size + buffer) / 2, }; - image = ( - - ); - } + }, [author, size]); return ( - {image} - {statusIcon} + + {showStatus && + + } ); }; diff --git a/app/components/profile_picture/status.tsx b/app/components/profile_picture/status.tsx new file mode 100644 index 000000000..6be404b7a --- /dev/null +++ b/app/components/profile_picture/status.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleProp, View, ViewProps} from 'react-native'; + +import UserStatus from '@components/user_status'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + statusSize: number; + statusStyle?: StyleProp; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + statusWrapper: { + position: 'absolute', + bottom: 0, + right: 0, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.centerChannelBg, + borderWidth: 1, + borderColor: theme.centerChannelBg, + }, + }; +}); + +const Status = ({author, statusSize, statusStyle, theme}: Props) => { + const styles = getStyleSheet(theme); + const containerStyle = useMemo(() => ([ + styles.statusWrapper, + statusStyle, + {borderRadius: statusSize / 2}, + ]), []); + if (author?.status && !author.isBot) { + return ( + + + + ); + } + return null; +}; + +export default Status; diff --git a/app/constants/index.ts b/app/constants/index.ts index cd3888aea..c6c24fd9a 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -16,6 +16,7 @@ import Network from './network'; import Permissions from './permissions'; import Post from './post'; import Preferences from './preferences'; +import Profile from './profile'; import Screens from './screens'; import Sso from './sso'; import SupportedServer from './supported_server'; @@ -38,6 +39,7 @@ export { Permissions, Post, Preferences, + Profile, Screens, SupportedServer, Sso, diff --git a/app/constants/profile.ts b/app/constants/profile.ts new file mode 100644 index 000000000..140e7ab21 --- /dev/null +++ b/app/constants/profile.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const ACCOUNT_OUTLINE_IMAGE = 'account-outline'; +export const USER_PROFILE_PICTURE_SIZE = 153; + +export default { + ACCOUNT_OUTLINE_IMAGE, + USER_PROFILE_PICTURE_SIZE, +}; diff --git a/app/screens/edit_profile/components/disabled_fields.tsx b/app/screens/edit_profile/components/disabled_fields.tsx new file mode 100644 index 000000000..8b1dc4829 --- /dev/null +++ b/app/screens/edit_profile/components/disabled_fields.tsx @@ -0,0 +1,49 @@ +// 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 {Text, View} from 'react-native'; + +import {useTheme} from '@app/context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + isTablet?: boolean; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + text: { + ...typography('Body', 75), + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); + +const DisabledFields = ({isTablet}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const containerStyle = useMemo(() => ({ + paddingHorizontal: isTablet ? 42 : 20, + marginBottom: 16, + }), [isTablet]); + + return ( + + + {formatMessage({ + id: 'user.settings.general.field_handled_externally', + defaultMessage: 'Some fields below are handled through your login provider. If you want to change them, you’ll need to do so through your login provider.', + })} + + + ); +}; + +export default DisabledFields; diff --git a/app/screens/edit_profile/components/edit_profile_picture.tsx b/app/screens/edit_profile/components/edit_profile_picture.tsx new file mode 100644 index 000000000..fc15c62f0 --- /dev/null +++ b/app/screens/edit_profile/components/edit_profile_picture.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {DeviceEventEmitter, Platform, View} from 'react-native'; + +import {Client} from '@client/rest'; +import ProfileImage from '@components/profile_picture'; +import {Navigation} from '@constants'; +import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useDidUpdate from '@hooks/did_update'; +import NetworkManager from '@init/network_manager'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import ProfileImagePicker from './profile_image_picker'; + +import type UserModel from '@typings/database/models/servers/user'; + +type ChangeProfilePictureProps = { + user: UserModel; + onUpdateProfilePicture: (info: { localPath?: string; isRemoved?: boolean }) => void; +}; + +const SIZE = 128; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: SIZE / 2, + height: SIZE, + justifyContent: 'center', + width: SIZE, + }, + camera: { + position: 'absolute', + overflow: 'hidden', + height: '100%', + width: '100%', + }, + }; +}); + +const EditProfilePicture = ({user, onUpdateProfilePicture}: ChangeProfilePictureProps) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + + let client: Client | undefined; + + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // does nothing + } + + const [pictureUrl, setPictureUrl] = useState(client?.getProfilePictureUrl(user.id, user.lastPictureUpdate)); + + const styles = getStyleSheet(theme); + + useDidUpdate(() => { + const url = user.id && client ? client.getProfilePictureUrl(user.id, user.lastPictureUpdate) : undefined; + if (url !== pictureUrl) { + setPictureUrl(url); + } + }, [user.id, user.lastPictureUpdate]); + + const handleProfileImage = useCallback((images?: FileInfo[]) => { + let isRemoved = true; + let localPath; + let pUrl = ACCOUNT_OUTLINE_IMAGE; + + const newImage = images?.[0]?.localPath; + if (newImage) { + isRemoved = false; + localPath = newImage; + pUrl = newImage; + } + + setPictureUrl(pUrl); + onUpdateProfilePicture({isRemoved, localPath}); + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + }, [onUpdateProfilePicture]); + + const pictureSource = useMemo(() => { + if (pictureUrl === ACCOUNT_OUTLINE_IMAGE) { + return pictureUrl; + } else if (pictureUrl) { + let prefix = ''; + if (pictureUrl.includes('/api/')) { + prefix = serverUrl; + } else if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') && !pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://')) { + prefix = 'file://'; + } + + return { + uri: `${prefix}${pictureUrl}`, + }; + } + return undefined; + }, [pictureUrl]); + + return ( + + + + + + + ); +}; + +export default EditProfilePicture; diff --git a/app/screens/edit_profile/components/form.tsx b/app/screens/edit_profile/components/form.tsx new file mode 100644 index 000000000..24965153f --- /dev/null +++ b/app/screens/edit_profile/components/form.tsx @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useRef} from 'react'; +import {MessageDescriptor, useIntl} from 'react-intl'; +import {Keyboard, StyleSheet, View} from 'react-native'; + +import {FloatingTextInputRef} from '@app/components/floating_text_input_label'; +import {t} from '@app/i18n'; +import {useTheme} from '@context/theme'; + +import DisabledFields from './disabled_fields'; +import EmailField from './email_field'; +import Field from './field'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {FieldConfig, FieldSequence, UserInfo} from '@typings/screens/edit_profile'; + +type Props = { + canSave: boolean; + currentUser: UserModel; + isTablet?: boolean; + lockedFirstName: boolean; + lockedLastName: boolean; + lockedNickname: boolean; + lockedPosition: boolean; + onUpdateField: (fieldKey: string, name: string) => void; + userInfo: UserInfo; + submitUser: () => void; +} + +const includesSsoService = (sso: string) => ['gitlab', 'google', 'office365'].includes(sso); +const isSAMLOrLDAP = (protocol: string) => ['ldap', 'saml'].includes(protocol); + +const FIELDS: { [id: string]: MessageDescriptor } = { + firstName: { + id: t('user.settings.general.firstName'), + defaultMessage: 'First Name', + }, + lastName: { + id: t('user.settings.general.lastName'), + defaultMessage: 'Last Name', + }, + username: { + id: t('user.settings.general.username'), + defaultMessage: 'Username', + }, + nickname: { + id: t('user.settings.general.nickname'), + defaultMessage: 'Nickname', + }, + position: { + id: t('user.settings.general.position'), + defaultMessage: 'Position', + }, + email: { + id: t('user.settings.general.email'), + defaultMessage: 'Email', + }, +}; + +const styles = StyleSheet.create({ + footer: { + height: 40, + width: '100%', + }, + separator: { + height: 15, + }, +}); + +const ProfileForm = ({ + canSave, currentUser, isTablet, + lockedFirstName, lockedLastName, lockedNickname, lockedPosition, + onUpdateField, userInfo, submitUser, +}: Props) => { + const theme = useTheme(); + const {formatMessage} = useIntl(); + const firstNameRef = useRef(null); + const lastNameRef = useRef(null); + const usernameRef = useRef(null); + const emailRef = useRef(null); + const nicknameRef = useRef(null); + const positionRef = useRef(null); + + const userProfileFields: FieldSequence = useMemo(() => { + const service = currentUser.authService; + return { + firstName: { + ref: firstNameRef, + isDisabled: (isSAMLOrLDAP(service) && lockedFirstName) || includesSsoService(service), + }, + lastName: { + ref: lastNameRef, + isDisabled: (isSAMLOrLDAP(service) && lockedLastName) || includesSsoService(service), + }, + username: { + ref: usernameRef, + isDisabled: service !== '', + }, + email: { + ref: emailRef, + isDisabled: true, + }, + nickname: { + ref: nicknameRef, + isDisabled: isSAMLOrLDAP(service) && lockedNickname, + }, + position: { + ref: positionRef, + isDisabled: isSAMLOrLDAP(service) && lockedPosition, + }, + }; + }, [lockedFirstName, lockedLastName, lockedNickname, lockedPosition, currentUser.authService]); + + const onFocusNextField = useCallback(((fieldKey: string) => { + const findNextField = () => { + const fields = Object.keys(userProfileFields); + const curIndex = fields.indexOf(fieldKey); + const searchIndex = curIndex + 1; + + if (curIndex === -1 || searchIndex > fields.length) { + return undefined; + } + + const remainingFields = fields.slice(searchIndex); + + const nextFieldIndex = remainingFields.findIndex((f: string) => { + const field = userProfileFields[f]; + return !field.isDisabled; + }); + + if (nextFieldIndex === -1) { + return {isLastEnabledField: true, nextField: undefined}; + } + + const fieldName = remainingFields[nextFieldIndex]; + + return {isLastEnabledField: false, nextField: userProfileFields[fieldName]}; + }; + + const next = findNextField(); + if (next?.isLastEnabledField && canSave) { + // performs form submission + Keyboard.dismiss(); + submitUser(); + } else if (next?.nextField) { + next?.nextField?.ref?.current?.focus(); + } else { + Keyboard.dismiss(); + } + }), [canSave, userProfileFields]); + + const hasDisabledFields = Object.values(userProfileFields).filter((field) => field.isDisabled).length > 0; + + const fieldConfig: FieldConfig = { + blurOnSubmit: false, + enablesReturnKeyAutomatically: true, + onFocusNextField, + onTextChange: onUpdateField, + returnKeyType: 'next', + }; + + return ( + <> + {hasDisabledFields && } + + + + + + + {userInfo.email && ( + + )} + + + + + + + ); +}; + +export default ProfileForm; diff --git a/app/screens/edit_profile/components/panel_item.tsx b/app/screens/edit_profile/components/panel_item.tsx new file mode 100644 index 000000000..eb18d7aba --- /dev/null +++ b/app/screens/edit_profile/components/panel_item.tsx @@ -0,0 +1,74 @@ +// 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 DocumentPicker from 'react-native-document-picker'; + +import SlideUpPanelItem from '@components/slide_up_panel_item'; + +import type {MessageDescriptor} from '@formatjs/intl/src/types'; +import type {UploadExtractedFile} from '@typings/utils/file'; +import type PickerUtil from '@utils/file/file_picker'; +import type {Source} from 'react-native-fast-image'; + +type PanelType = { + icon: string | Source; + onPress: () => Promise | void; + testID: string; + text: MessageDescriptor; + isDestructive?: boolean; +} + +type PanelItemProps = { + onRemoveProfileImage?: UploadExtractedFile; + pickerAction: 'takePhoto' | 'browsePhotoLibrary' | 'browseFiles' | 'removeProfilePicture'; + pictureUtils?: PickerUtil; +}; + +const PanelItem = ({pickerAction, pictureUtils, onRemoveProfileImage}: PanelItemProps) => { + const intl = useIntl(); + + const panelTypes = useMemo(() => ({ + takePhoto: { + icon: 'camera-outline', + onPress: () => pictureUtils?.attachFileFromCamera(), + testID: 'attachment.takePhoto', + text: {id: 'mobile.file_upload.camera_photo', defaultMessage: 'Take Photo'}, + }, + browsePhotoLibrary: { + icon: 'file-generic-outline', + onPress: () => pictureUtils?.attachFileFromPhotoGallery(), + testID: 'attachment.browsePhotoLibrary', + text: {id: 'mobile.file_upload.library', defaultMessage: 'Photo Library'}, + }, + browseFiles: { + icon: 'file-multiple-outline', + onPress: () => pictureUtils?.attachFileFromFiles(DocumentPicker.types.images), + testID: 'attachment.browseFiles', + text: {id: 'mobile.file_upload.browse', defaultMessage: 'Browse Files'}, + }, + removeProfilePicture: { + icon: 'trash-can-outline', + onPress: () => { + return onRemoveProfileImage && onRemoveProfileImage(); + }, + testID: 'attachment.removeImage', + text: {id: 'mobile.edit_profile.remove_profile_photo', defaultMessage: 'Remove Photo'}, + }, + }), [pictureUtils, onRemoveProfileImage]); + + const item: PanelType = panelTypes[pickerAction]; + + return ( + + ); +}; + +export default PanelItem; diff --git a/app/screens/edit_profile/components/profile_image_picker.tsx b/app/screens/edit_profile/components/profile_image_picker.tsx new file mode 100644 index 000000000..954d11c6c --- /dev/null +++ b/app/screens/edit_profile/components/profile_image_picker.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {TouchableOpacity} from 'react-native'; + +import {Client} from '@client/rest'; +import CompassIcon from '@components/compass_icon'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import NetworkManager from '@init/network_manager'; +import PanelItem from '@screens/edit_profile/components/panel_item'; +import {bottomSheet} from '@screens/navigation'; +import PickerUtil from '@utils/file/file_picker'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {UploadExtractedFile} from '@typings/utils/file'; + +const hitSlop = {top: 100, bottom: 20, right: 20, left: 100}; +const ACTION_HEIGHT = 55; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + touchable: { + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: theme.centerChannelBg, + borderRadius: 18, + height: 36, + width: 36, + position: 'absolute', + bottom: 0, + right: 0, + backgroundColor: theme.centerChannelBg, + }, + }; +}); + +type ImagePickerProps = { + onRemoveProfileImage: UploadExtractedFile; + uploadFiles: UploadExtractedFile; + user: UserModel; +}; + +const hasPictureUrl = (user: UserModel, serverUrl: string) => { + const {id, lastPictureUpdate} = user; + + let client: Client | undefined; + let profileImageUrl: string | undefined; + + try { + client = NetworkManager.getClient(serverUrl); + profileImageUrl = client.getProfilePictureUrl(id, lastPictureUpdate); + } catch { + return false; + } + + // Check if image url includes query string for timestamp. If so, + // it means the image has been updated from the default, i.e. '.../image?_=1544159746868' + return Boolean(profileImageUrl?.includes('image?_')); +}; + +const ProfileImagePicker = ({ + onRemoveProfileImage, + uploadFiles, + user, +}: ImagePickerProps) => { + const theme = useTheme(); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const pictureUtils = useMemo(() => new PickerUtil(intl, uploadFiles), [uploadFiles, intl]); + const canRemovePicture = hasPictureUrl(user, serverUrl); + const styles = getStyleSheet(theme); + + const showFileAttachmentOptions = useCallback(preventDoubleTap(() => { + const renderContent = () => { + return ( + <> + + + + {canRemovePicture && ( + + )} + + ); + }; + + const snapPoints = canRemovePicture ? 5 : 4; + + return bottomSheet({ + closeButtonId: 'close-edit-profile', + renderContent, + snapPoints: [(snapPoints * ACTION_HEIGHT), 10], + title: '', + theme, + }); + }), [canRemovePicture, onRemoveProfileImage, pictureUtils, theme]); + + return ( + + + + + ); +}; + +export default ProfileImagePicker; diff --git a/app/screens/edit_profile/components/updating.tsx b/app/screens/edit_profile/components/updating.tsx new file mode 100644 index 000000000..880a3ec23 --- /dev/null +++ b/app/screens/edit_profile/components/updating.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import Loading from '@components/loading'; +import {useTheme} from '@context/theme'; + +const styles = StyleSheet.create({ + spinner: { + position: 'absolute', + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + }, +}); + +const Updating = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +export default Updating; diff --git a/app/screens/edit_profile/components/user_profile_picture.tsx b/app/screens/edit_profile/components/user_profile_picture.tsx new file mode 100644 index 000000000..8eb556c21 --- /dev/null +++ b/app/screens/edit_profile/components/user_profile_picture.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import ProfilePicture from '@components/profile_picture'; +import {USER_PROFILE_PICTURE_SIZE} from '@constants/profile'; + +import EditProfilePicture from './edit_profile_picture'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {NewProfileImage} from '@typings/screens/edit_profile'; + +type Props = { + currentUser: UserModel; + lockedPicture: boolean; + onUpdateProfilePicture: (newProfileImage: NewProfileImage) => void; +} + +const UserProfilePicture = ({currentUser, lockedPicture, onUpdateProfilePicture}: Props) => { + if (lockedPicture) { + return ( + + ); + } + + return ( + + ); +}; + +export default UserProfilePicture; diff --git a/app/screens/edit_profile/edit_profile.tsx b/app/screens/edit_profile/edit_profile.tsx index b3d24eaa1..e6f93553a 100644 --- a/app/screens/edit_profile/edit_profile.tsx +++ b/app/screens/edit_profile/edit_profile.tsx @@ -3,126 +3,56 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {BackHandler, DeviceEventEmitter, Keyboard, Platform, Text, View} from 'react-native'; +import {BackHandler, DeviceEventEmitter, Keyboard, Platform, StyleSheet, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {Navigation} from 'react-native-navigation'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; -import {updateMe} from '@actions/remote/user'; +import {updateLocalUser} from '@actions/local/user'; +import {setDefaultProfileImage, updateMe, uploadUserProfileImage} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; -import {FloatingTextInputRef} from '@components/floating_text_input_label'; -import Loading from '@components/loading'; -import ProfilePicture from '@components/profile_picture'; import TabletTitle from '@components/tablet_title'; import {Events} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {t} from '@i18n'; import {dismissModal, popTopScreen, setButtons} from '@screens/navigation'; -import {EditProfileProps, FieldConfig, FieldSequence, UserInfo} from '@typings/screens/edit_profile'; import {preventDoubleTap} from '@utils/tap'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; -import EmailField from './components/email_field'; -import Field from './components/field'; +import ProfileForm from './components/form'; import ProfileError from './components/profile_error'; +import Updating from './components/updating'; +import UserProfilePicture from './components/user_profile_picture'; -import type {MessageDescriptor} from '@formatjs/intl/src/types'; +import type {EditProfileProps, NewProfileImage, UserInfo} from '@typings/screens/edit_profile'; +import type {ErrorText} from '@typings/utils/file'; const edges: Edge[] = ['bottom', 'left', 'right']; -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - flex: { - flex: 1, - }, - top: { - marginVertical: 32, - alignItems: 'center', - justifyContent: 'center', - }, - separator: { - height: 15, - }, - footer: { - height: 40, - width: '100%', - }, - spinner: { - position: 'absolute', - width: '100%', - height: '100%', - alignItems: 'center', - justifyContent: 'center', - zIndex: 1000, - }, - description: { - alignSelf: 'center', - marginBottom: 24, - }, - text: { - ...typography('Body', 75), - color: changeOpacity(theme.centerChannelColor, 0.5), - }, - }; +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + top: { + marginVertical: 32, + alignItems: 'center', + justifyContent: 'center', + }, }); -const FIELDS: { [id: string]: MessageDescriptor } = { - firstName: { - id: t('user.settings.general.firstName'), - defaultMessage: 'First Name', - }, - lastName: { - id: t('user.settings.general.lastName'), - defaultMessage: 'Last Name', - }, - username: { - id: t('user.settings.general.username'), - defaultMessage: 'Username', - }, - nickname: { - id: t('user.settings.general.nickname'), - defaultMessage: 'Nickname', - }, - position: { - id: t('user.settings.general.position'), - defaultMessage: 'Position', - }, - email: { - id: t('user.settings.general.email'), - defaultMessage: 'Email', - }, -}; - const CLOSE_BUTTON_ID = 'close-edit-profile'; const UPDATE_BUTTON_ID = 'update-profile'; -const includesSsoService = (sso: string) => ['gitlab', 'google', 'office365'].includes(sso); -const isSAMLOrLDAP = (protocol: string) => ['ldap', 'saml'].includes(protocol); - const EditProfile = ({ - componentId, - currentUser, - isModal, - isTablet, - lockedFirstName, - lockedLastName, - lockedNickname, - lockedPosition, + componentId, currentUser, isModal, isTablet, + lockedFirstName, lockedLastName, lockedNickname, lockedPosition, lockedPicture, }: EditProfileProps) => { const intl = useIntl(); const serverUrl = useServerUrl(); const theme = useTheme(); const keyboardAwareRef = useRef(); - const firstNameRef = useRef(null); - const lastNameRef = useRef(null); - const usernameRef = useRef(null); - const emailRef = useRef(null); - const nicknameRef = useRef(null); - const positionRef = useRef(null); - - const styles = getStyleSheet(theme); + const changedProfilePicture = useRef(undefined); + const scrollViewRef = useRef(); + const hasUpdateUserInfo = useRef(false); const [userInfo, setUserInfo] = useState({ email: currentUser.email, firstName: currentUser.firstName, @@ -135,8 +65,6 @@ const EditProfile = ({ const [error, setError] = useState(); const [updating, setUpdating] = useState(false); - const scrollViewRef = useRef(); - const buttonText = intl.formatMessage({id: 'mobile.account.settings.save', defaultMessage: 'Save'}); const rightButton = useMemo(() => { return isTablet ? null : { @@ -149,15 +77,13 @@ const EditProfile = ({ }; }, [isTablet, theme.sidebarHeaderTextColor]); - const service = currentUser.authService; - const leftButton = useMemo(() => { return isTablet ? null : { id: CLOSE_BUTTON_ID, icon: CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor), testID: CLOSE_BUTTON_ID, }; - }, [isTablet, theme.sidebarHeaderTextColor]); + }, [isTablet, theme.centerChannelColor]); useEffect(() => { const unsubscribe = Navigation.events().registerComponentListener({ @@ -219,12 +145,29 @@ const EditProfile = ({ setCanSave(value); }, [componentId, rightButton]); + const onUpdateProfilePicture = useCallback((newProfileImage: NewProfileImage) => { + changedProfilePicture.current = newProfileImage; + enableSaveButton(true); + }, [enableSaveButton]); + + const onUpdateField = useCallback((fieldKey: string, name: string) => { + const update = {...userInfo}; + update[fieldKey] = name; + setUserInfo(update); + + // @ts-expect-error access object property by string key + const currentValue = currentUser[fieldKey]; + const didChange = currentValue !== name; + hasUpdateUserInfo.current = currentValue !== name; + enableSaveButton(didChange); + }, [userInfo, currentUser, enableSaveButton]); + const submitUser = useCallback(preventDoubleTap(async () => { enableSaveButton(false); setError(undefined); setUpdating(true); try { - const partialUser: Partial = { + const newUserInfo: Partial = { email: userInfo.email, first_name: userInfo.firstName, last_name: userInfo.lastName, @@ -232,15 +175,30 @@ const EditProfile = ({ position: userInfo.position, username: userInfo.username, }; + const localPath = changedProfilePicture.current?.localPath; + const profileImageRemoved = changedProfilePicture.current?.isRemoved; + if (localPath) { + const now = Date.now(); + const {error: uploadError} = await uploadUserProfileImage(serverUrl, localPath); + if (uploadError) { + resetScreen(uploadError as Error); + return; + } + updateLocalUser(serverUrl, {last_picture_update: now}); + } else if (profileImageRemoved) { + await setDefaultProfileImage(serverUrl, currentUser.id); + } - const {error: reqError} = await updateMe(serverUrl, partialUser); - - if (reqError) { - resetScreen(reqError as Error); - return; + if (hasUpdateUserInfo.current) { + const {error: reqError} = await updateMe(serverUrl, newUserInfo); + if (reqError) { + resetScreen(reqError as Error); + return; + } } close(); + return; } catch (e) { resetScreen(e as Error); } @@ -254,94 +212,6 @@ const EditProfile = ({ scrollViewRef.current?.scrollToPosition(0, 0, true); }, [enableSaveButton]); - const updateField = useCallback((fieldKey: string, name: string) => { - const update = {...userInfo}; - update[fieldKey] = name; - setUserInfo(update); - - // @ts-expect-error access object property by string key - const currentValue = currentUser[fieldKey]; - const didChange = currentValue !== name; - enableSaveButton(didChange); - }, [userInfo, currentUser]); - - const userProfileFields: FieldSequence = useMemo(() => { - return { - firstName: { - ref: firstNameRef, - isDisabled: (isSAMLOrLDAP(service) && lockedFirstName) || includesSsoService(service), - }, - lastName: { - ref: lastNameRef, - isDisabled: (isSAMLOrLDAP(service) && lockedLastName) || includesSsoService(service), - }, - username: { - ref: usernameRef, - isDisabled: service !== '', - }, - email: { - ref: emailRef, - isDisabled: true, - }, - nickname: { - ref: nicknameRef, - isDisabled: isSAMLOrLDAP(service) && lockedNickname, - }, - position: { - ref: positionRef, - isDisabled: isSAMLOrLDAP(service) && lockedPosition, - }, - }; - }, [lockedFirstName, lockedLastName, lockedNickname, lockedPosition, currentUser.authService]); - - const hasDisabledFields = Object.values(userProfileFields).filter((field) => field.isDisabled).length > 0; - - const onFocusNextField = useCallback(((fieldKey: string) => { - const findNextField = () => { - const fields = Object.keys(userProfileFields); - const curIndex = fields.indexOf(fieldKey); - const searchIndex = curIndex + 1; - - if (curIndex === -1 || searchIndex > fields.length) { - return undefined; - } - - const remainingFields = fields.slice(searchIndex); - - const nextFieldIndex = remainingFields.findIndex((f: string) => { - const field = userProfileFields[f]; - return !field.isDisabled; - }); - - if (nextFieldIndex === -1) { - return {isLastEnabledField: true, nextField: undefined}; - } - - const fieldName = remainingFields[nextFieldIndex]; - - return {isLastEnabledField: false, nextField: userProfileFields[fieldName]}; - }; - - const next = findNextField(); - if (next?.isLastEnabledField && canSave) { - // performs form submission - Keyboard.dismiss(); - submitUser(); - } else if (next?.nextField) { - next?.nextField?.ref?.current?.focus(); - } else { - Keyboard.dismiss(); - } - }), [canSave, userProfileFields]); - - const fieldConfig: FieldConfig = { - blurOnSubmit: false, - enablesReturnKeyAutomatically: true, - onFocusNextField, - onTextChange: updateField, - returnKeyType: 'next', - }; - return ( <> {isTablet && @@ -374,108 +244,27 @@ const EditProfile = ({ testID='edit_profile.scroll_view' style={styles.flex} > - {updating && ( - - - - - )} + {updating && } {Boolean(error) && } - - {hasDisabledFields && ( - - - {intl.formatMessage({ - id: 'user.settings.general.field_handled_externally', - defaultMessage: 'Some fields below are handled through your login provider. If you want to change them, you’ll need to do so through your login provider.', - })} - - - )} - - - - - - - {userInfo.email && ( - - )} - - - - - diff --git a/app/screens/home/account/components/options/your_profile/index.tsx b/app/screens/home/account/components/options/your_profile/index.tsx index f6491d9af..ce93e4bbd 100644 --- a/app/screens/home/account/components/options/your_profile/index.tsx +++ b/app/screens/home/account/components/options/your_profile/index.tsx @@ -8,6 +8,7 @@ import {DeviceEventEmitter, TextStyle} from 'react-native'; import DrawerItem from '@components/drawer_item'; import FormattedText from '@components/formatted_text'; import {Events, Screens} from '@constants'; +import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile'; import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; @@ -40,7 +41,7 @@ const YourProfile = ({isTablet, style, theme}: Props) => { style={style} /> } - iconName='account-outline' + iconName={ACCOUNT_OUTLINE_IMAGE} onPress={openProfile} separator={false} theme={theme} diff --git a/app/screens/home/account/index.tsx b/app/screens/home/account/index.tsx index cb198d700..8c1701d08 100644 --- a/app/screens/home/account/index.tsx +++ b/app/screens/home/account/index.tsx @@ -4,15 +4,17 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {ScrollView, View} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {fetchStatusInBatch} from '@actions/remote/user'; import {View as ViewConstants} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {isCustomStatusExpirySupported, isMinimumServerVersion} from '@utils/helpers'; @@ -71,6 +73,14 @@ const AccountScreen = ({currentUser, enableCustomUserStatuses, customStatusExpir const route = useRoute(); const insets = useSafeAreaInsets(); const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + + useEffect(() => { + if (currentUser) { + fetchStatusInBatch(serverUrl, currentUser.id); + } + }, []); + let tabletSidebarStyle; if (isTablet) { const {TABLET_SIDEBAR_WIDTH} = ViewConstants; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 71d100587..fbf27d235 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -417,11 +417,10 @@ export function showModalOverCurrentContext(name: string, passProps = {}, option default: animations = { showModal: { - enter: { - enabled: false, - }, - exit: { - enabled: false, + alpha: { + from: 0, + to: 1, + duration: 250, }, }, dismissModal: { diff --git a/app/utils/file/file_picker/index.ts b/app/utils/file/file_picker/index.ts new file mode 100644 index 000000000..28f78e95c --- /dev/null +++ b/app/utils/file/file_picker/index.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {IntlShape} from 'react-intl'; +import {Alert, DeviceEventEmitter, NativeModules, Platform, StatusBar} from 'react-native'; +import AndroidOpenSettings from 'react-native-android-open-settings'; +import DeviceInfo from 'react-native-device-info'; +import DocumentPicker, {DocumentPickerResponse} from 'react-native-document-picker'; +import {Asset, CameraOptions, ImageLibraryOptions, ImagePickerResponse, launchCamera, launchImageLibrary} from 'react-native-image-picker'; +import Permissions, {AndroidPermission, IOSPermission} from 'react-native-permissions'; + +import {Navigation} from '@constants'; +import {extractFileInfo, lookupMimeType} from '@utils/file'; + +import type {ExtractedFileInfo} from '@typings/utils/file'; + +const ShareExtension = NativeModules.MattermostShare; + +type PermissionSource = 'camera' | 'storage' | 'denied_android' | 'denied_ios' | 'photo'; + +export default class FilePickerUtil { + private readonly uploadFiles: (files: ExtractedFileInfo[]) => void; + private readonly intl: IntlShape; + + constructor( + intl: IntlShape, + uploadFiles: (files: ExtractedFileInfo[]) => void) { + this.intl = intl; + this.uploadFiles = uploadFiles; + } + + private getPermissionMessages = (source: PermissionSource) => { + const {formatMessage} = this.intl; + const applicationName = DeviceInfo.getApplicationName(); + + const permissions: Record = { + camera: { + title: formatMessage( + { + id: 'mobile.camera_photo_permission_denied_title', + defaultMessage: + '{applicationName} would like to access your camera', + }, + {applicationName}, + ), + text: formatMessage({ + id: 'mobile.camera_photo_permission_denied_description', + defaultMessage: + 'Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your camera.', + }), + }, + storage: { + title: formatMessage( + { + id: 'mobile.storage_permission_denied_title', + defaultMessage: + '{applicationName} would like to access your files', + }, + {applicationName}, + ), + text: formatMessage({ + id: 'mobile.storage_permission_denied_description', + defaultMessage: + 'Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.', + }), + }, + denied_ios: { + title: formatMessage( + { + id: 'mobile.ios.photos_permission_denied_title', + defaultMessage: + '{applicationName} would like to access your photos', + }, + {applicationName}, + ), + text: formatMessage({ + id: 'mobile.ios.photos_permission_denied_description', + defaultMessage: + 'Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.', + }), + }, + denied_android: { + title: formatMessage( + { + id: 'mobile.android.photos_permission_denied_title', + defaultMessage: + '{applicationName} would like to access your photos', + }, + {applicationName}, + ), + text: formatMessage({ + id: 'mobile.android.photos_permission_denied_description', + defaultMessage: + 'Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.', + }), + }, + }; + + return permissions[source]; + }; + + private prepareFileUpload = async (files: Array) => { + const out = await extractFileInfo(files); + + if (out.length > 0) { + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + this.uploadFiles(out); + } + }; + + private getPermissionDeniedMessage = (source?: PermissionSource) => { + const sources = ['camera', 'storage', 'photo']; + const deniedSource: PermissionSource = Platform.select({android: 'denied_android', ios: 'denied_ios'})!; + const msgForSource = source && sources.includes(source) ? source : deniedSource; + + return this.getPermissionMessages(msgForSource); + }; + + private getFilesFromResponse = async (response: ImagePickerResponse): Promise => { + if (!response?.assets?.length) { + return []; + } + + const files: Asset[] = []; + + await Promise.all((response.assets.map(async (file) => { + if (Platform.OS === 'ios') { + files.push(file); + } else { + // For android we need to retrieve the realPath in case the file being imported is from the cloud + const uri = (await ShareExtension.getFilePath(file.uri)).filePath; + const type = file.type || lookupMimeType(uri); + let fileName = file.fileName; + if (type.includes('video/')) { + fileName = uri.split('\\').pop().split('/').pop(); + } + + if (uri) { + files.push({...file, fileName, uri, type}); + } + } + }))); + + return files; + }; + + private hasPhotoPermission = async (source: PermissionSource) => { + let permissionRequest; + + const targetSource = Platform.select({ + ios: source === 'camera' ? Permissions.PERMISSIONS.IOS.CAMERA : Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY, + android: Permissions.PERMISSIONS.ANDROID.CAMERA, + }) as AndroidPermission | IOSPermission; + + const hasPhotoLibraryPermission = await Permissions.check(targetSource); + + switch (hasPhotoLibraryPermission) { + case Permissions.RESULTS.DENIED: + permissionRequest = await Permissions.request(targetSource); + if (permissionRequest !== Permissions.RESULTS.GRANTED) { + return false; + } + break; + case Permissions.RESULTS.BLOCKED: { + const grantOption = { + text: this.intl.formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => Permissions.openSettings(), + }; + + const {title, text} = this.getPermissionDeniedMessage(source); + + Alert.alert(title, text, [ + grantOption, + { + text: this.intl.formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: "Don't Allow", + }), + }, + ]); + return false; + } + default: return true; + } + return false; + }; + + private hasStoragePermission = async () => { + if (Platform.OS === 'ios') { + return true; + } + + const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE; + let permissionRequest; + const hasPermissionToStorage = await Permissions.check(storagePermission); + switch (hasPermissionToStorage) { + case Permissions.RESULTS.DENIED: + permissionRequest = await Permissions.request(storagePermission); + if (permissionRequest !== Permissions.RESULTS.GRANTED) { + return false; + } + break; + case Permissions.RESULTS.BLOCKED: { + const {title, text} = this.getPermissionDeniedMessage(); + + Alert.alert(title, text, [ + { + text: this.intl.formatMessage({ + id: 'mobile.permission_denied_dismiss', + defaultMessage: "Don't Allow", + }), + }, + { + text: this.intl.formatMessage({ + id: 'mobile.permission_denied_retry', + defaultMessage: 'Settings', + }), + onPress: () => AndroidOpenSettings.appDetailsSettings(), + }, + ]); + return false; + } + default: return true; + } + + return false; + }; + + private buildUri = async (doc: DocumentPickerResponse) => { + let uri: string = doc.uri; + + if (Platform.OS === 'android') { + // For android we need to retrieve the realPath in case the file being imported is from the cloud + const newUri = await ShareExtension.getFilePath(doc.uri); + uri = newUri?.filePath; + if (uri === undefined) { + return {doc: undefined}; + } + } + + // Decode file uri to get the actual path + doc.uri = decodeURIComponent(uri); + return {doc}; + }; + + attachFileFromCamera = async (customOptions?: CameraOptions) => { + let options = customOptions; + if (!options) { + options = { + quality: 0.8, + videoQuality: 'high', + mediaType: 'photo', + saveToPhotos: true, + }; + } + + const hasCameraPermission = await this.hasPhotoPermission('camera'); + if (hasCameraPermission) { + launchCamera(options, async (response: ImagePickerResponse) => { + StatusBar.setHidden(false); + + if (response.errorCode || response.didCancel) { + return; + } + + const files = await this.getFilesFromResponse(response); + await this.prepareFileUpload(files); + }); + } + }; + + attachFileFromFiles = async (browseFileType?: string, allowMultiSelection = false) => { + const hasPermission = await this.hasStoragePermission(); + const fileType = browseFileType ?? Platform.select({ios: 'public.item', default: '*/*'}); + + if (hasPermission) { + try { + const docResponse = (await DocumentPicker.pick({allowMultiSelection, type: [fileType]})); + const proDocs = docResponse.map(async (d: DocumentPickerResponse) => { + const {doc} = await this.buildUri(d); + return doc; + }); + + const docs = await Promise.all(proDocs) as unknown as DocumentPickerResponse[]; + + await this.prepareFileUpload(docs); + } catch (error) { + // Do nothing + } + } + }; + + attachFileFromPhotoGallery = async (selectionLimit = 1) => { + const options: ImageLibraryOptions = { + quality: 1, + mediaType: 'mixed', + includeBase64: false, + selectionLimit, + }; + + const hasPermission = await this.hasPhotoPermission('photo'); + if (hasPermission) { + launchImageLibrary(options, async (response: ImagePickerResponse) => { + StatusBar.setHidden(false); + if (response.errorMessage || response.didCancel) { + return; + } + + const files = await this.getFilesFromResponse(response); + await this.prepareFileUpload(files); + }); + } + }; +} diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 25aeedb62..3087ffbc9 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -1,17 +1,23 @@ // 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; @@ -288,8 +294,7 @@ export const isVideo = (file?: FileInfo | FileModel) => { return SUPPORTED_VIDEO_FORMAT!.includes(mime); }; -export function getFormattedFileSize(file: FileInfo): string { - const bytes = file.size; +export function getFormattedFileSize(bytes: number): string { const fileSizes = [ ['TB', 1024 * 1024 * 1024 * 1024], ['GB', 1024 * 1024 * 1024], @@ -365,3 +370,74 @@ export function getLocalFilePathFromFile(dir: string, serverUrl: string, file: F return undefined; } + +export async function extractFileInfo(files: Array) { + 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.', + }); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fe3baecfc..8bf536003 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -270,6 +270,8 @@ PODS: - React-Core - react-native-hw-keyboard-event (0.0.4): - React + - react-native-image-picker (4.7.1): + - React-Core - react-native-netinfo (7.1.7): - React-Core - react-native-network-client (0.1.0): @@ -501,6 +503,7 @@ DEPENDENCIES: - react-native-document-picker (from `../node_modules/react-native-document-picker`) - "react-native-emm (from `../node_modules/@mattermost/react-native-emm`)" - react-native-hw-keyboard-event (from `../node_modules/react-native-hw-keyboard-event`) + - react-native-image-picker (from `../node_modules/react-native-image-picker`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)" - react-native-notifications (from `../node_modules/react-native-notifications`) @@ -634,6 +637,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@mattermost/react-native-emm" react-native-hw-keyboard-event: :path: "../node_modules/react-native-hw-keyboard-event" + react-native-image-picker: + :path: "../node_modules/react-native-image-picker" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-network-client: @@ -773,6 +778,7 @@ SPEC CHECKSUMS: react-native-document-picker: 429972f7ece4463aa5bcdd789622b3a674a3c5d1 react-native-emm: a326f295d2bd3444178cf36a9e2d9307e0dc0dcc react-native-hw-keyboard-event: b517cefb8d5c659a38049c582de85ff43337dc53 + react-native-image-picker: 5fe0a96bef4935bbdfb02f59b910bf40d5526109 react-native-netinfo: 27f287f2d191693f3b9d01a4273137fcf91c3b5d react-native-network-client: 30ab97e7e6c8d6f2d2b10cc1ebad0cbf9c894c6e react-native-notifications: 805108822ceff3440644d5701944f0cda35f5b4b diff --git a/package-lock.json b/package-lock.json index 5935f9f2f..5529e9959 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-native-gesture-handler": "2.2.0", "react-native-haptic-feedback": "1.13.0", "react-native-hw-keyboard-event": "0.0.4", + "react-native-image-picker": "4.7.1", "react-native-keyboard-aware-scroll-view": "0.9.5", "react-native-keyboard-tracking-view": "5.7.0", "react-native-keychain": "8.0.0", @@ -17742,13 +17743,11 @@ "node_modules/mmjstool/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, "node_modules/mmjstool/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "engines": { "node": ">=8" @@ -17757,7 +17756,6 @@ "node_modules/mmjstool/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "dependencies": { "emoji-regex": "^8.0.0", @@ -17771,7 +17769,6 @@ "node_modules/mmjstool/node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "engines": { "node": ">=10" @@ -17780,7 +17777,6 @@ "node_modules/mmjstool/node_modules/yargs": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", - "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", "dev": true, "dependencies": { "cliui": "^7.0.2", @@ -17798,7 +17794,6 @@ "node_modules/mmjstool/node_modules/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==", "dev": true, "engines": { "node": ">=12" @@ -19848,6 +19843,15 @@ "react-native": ">=0.50" } }, + "node_modules/react-native-image-picker": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-4.7.1.tgz", + "integrity": "sha512-8jOHvSX2UMX+76Ixjt2fCOuTLfBx55WGlmn4HK7ZTWfZWXGilpEDDXGnchwBAJkw4Xv953/8tsvSNFEoyvNjFg==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-iphone-x-helper": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz", @@ -37896,19 +37900,16 @@ "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "requires": { "emoji-regex": "^8.0.0", @@ -37919,13 +37920,11 @@ "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, "yargs": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", - "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", "dev": true, "requires": { "cliui": "^7.0.2", @@ -37940,7 +37939,6 @@ "yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==", "dev": true } } @@ -39631,6 +39629,12 @@ "integrity": "sha512-G8qp0nm17PHigLb/axgdF9xg51BKCG2p1AGeq//J/luLp5zNczIcQJh+nm02R1MeEUE3e53wqO4LMe0MV3raZg==", "requires": {} }, + "react-native-image-picker": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-4.7.1.tgz", + "integrity": "sha512-8jOHvSX2UMX+76Ixjt2fCOuTLfBx55WGlmn4HK7ZTWfZWXGilpEDDXGnchwBAJkw4Xv953/8tsvSNFEoyvNjFg==", + "requires": {} + }, "react-native-iphone-x-helper": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz", diff --git a/package.json b/package.json index 68ff74552..d90c2fc59 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-native-gesture-handler": "2.2.0", "react-native-haptic-feedback": "1.13.0", "react-native-hw-keyboard-event": "0.0.4", + "react-native-image-picker": "4.7.1", "react-native-keyboard-aware-scroll-view": "0.9.5", "react-native-keyboard-tracking-view": "5.7.0", "react-native-keychain": "8.0.0", diff --git a/types/api/files.d.ts b/types/api/files.d.ts index 7668a8574..2aca2de37 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -3,23 +3,23 @@ type FileInfo = { id?: string; - user_id: string; - post_id: string; - create_at: number; - update_at: number; - delete_at: number; - name: string; - extension: string; - mini_preview?: string; - size: number; - mime_type: string; - width: number; - height: number; - has_preview_image: boolean; clientId?: string; - localPath?: string; - uri?: string; + create_at: number; + delete_at: number; + extension: string; + has_preview_image: boolean; + height: number; loading?: boolean; + localPath?: string; + mime_type: string; + mini_preview?: string; + name: string; + post_id: string; + size: number; + update_at: number; + uri?: string; + user_id: string; + width: number; }; type FilesState = { diff --git a/types/screens/edit_profile.d.ts b/types/screens/edit_profile.d.ts index ad0a91338..b794643be 100644 --- a/types/screens/edit_profile.d.ts +++ b/types/screens/edit_profile.d.ts @@ -5,7 +5,8 @@ import {RefObject} from 'react'; import {FloatingTextInputRef} from '@components/floating_text_input_label'; import {FieldProps} from '@screens/edit_profile/components/field'; -import UserModel from '@typings/database/models/servers/user'; + +import type UserModel from '@typings/database/models/servers/user'; interface UserInfo extends Record { email: string; @@ -28,9 +29,12 @@ type EditProfileProps = { lockedPicture: boolean; }; +type NewProfileImage = { localPath?: string; isRemoved?: boolean }; + type FieldSequence = Record; isDisabled: boolean; }> type FieldConfig = Pick + diff --git a/types/utils/file.d.ts b/types/utils/file.d.ts new file mode 100644 index 000000000..3edf71fa4 --- /dev/null +++ b/types/utils/file.d.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as FileSystem from 'expo-file-system'; + +export type ErrorText = Partial | string; + +export type ExtractedFileInfo = Partial & { name: string; mime_type: string} + +export type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void; diff --git a/types/utils/index.d.ts b/types/utils/index.d.ts index 3e7c47811..75e178b63 100644 --- a/types/utils/index.d.ts +++ b/types/utils/index.d.ts @@ -4,5 +4,3 @@ type Dictionary = { [key: string]: T; }; - -type ErrorText = Partial | string;