MM-40364 [Gekidou] - Image Picker for Edit Profile (#5901)

* 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>
This commit is contained in:
Avinash Lingaloo 2022-01-28 16:06:50 +04:00 committed by GitHub
parent be63e3c780
commit dff4f91441
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1485 additions and 386 deletions

View file

@ -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};
};

View file

@ -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<ViewStyle> | StyleProp<TextStyle>;
theme: Theme;

View file

@ -62,7 +62,7 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => {
ellipsizeMode='tail'
style={style.fileInfo}
>
{`${getFormattedFileSize(file)}`}
{`${getFormattedFileSize(file.size)}`}
</Text>
</View>
</>

View file

@ -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 (
<CompassIcon
name={source}
size={iconSize || size}
style={style.icon}
/>
);
}
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 (
<FastImage
key={pictureUrl}
style={fIStyle}
source={imgSource as Source}
/>
);
}
return (
<CompassIcon
name={ACCOUNT_OUTLINE_IMAGE}
size={iconSize || size}
style={style.icon}
/>
);
};
export default Image;

View file

@ -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<ViewProps>;
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<ViewStyle> = {
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 = (
<View style={[style.statusWrapper, statusStyle, {borderRadius: statusSize / 2}]}>
<UserStatus
size={statusSize}
status={author.status}
/>
</View>
);
}
let image;
if (author && client) {
const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate);
image = (
<FastImage
key={pictureUrl}
style={{width: size, height: size, borderRadius: (size / 2)}}
source={{uri: `${serverUrl}${pictureUrl}`}}
/>
);
} 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 = (
<CompassIcon
name='account-outline'
size={iconSize || size}
style={style.icon}
/>
);
}
}, [author, size]);
return (
<View
style={[style.container, containerStyle]}
style={containerStyle}
testID={`${testID}.${author?.id}`}
>
{image}
{statusIcon}
<Image
author={author}
iconSize={iconSize}
size={size}
source={source}
/>
{showStatus &&
<Status
author={author}
statusSize={statusSize}
statusStyle={statusStyle}
theme={theme}
/>
}
</View>
);
};

View file

@ -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<ViewProps>;
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 (
<View
style={containerStyle}
>
<UserStatus
size={statusSize}
status={author.status}
/>
</View>
);
}
return null;
};
export default Status;

View file

@ -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,

10
app/constants/profile.ts Normal file
View file

@ -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,
};

View file

@ -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 (
<View
style={containerStyle}
>
<Text style={styles.text}>
{formatMessage({
id: 'user.settings.general.field_handled_externally',
defaultMessage: 'Some fields below are handled through your login provider. If you want to change them, youll need to do so through your login provider.',
})}
</Text>
</View>
);
};
export default DisabledFields;

View file

@ -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<string|undefined>(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 (
<View
style={styles.container}
testID={`${EditProfilePicture}.${user.id}`}
>
<ProfileImage
size={SIZE}
source={pictureSource}
author={user}
showStatus={false}
/>
<View
style={styles.camera}
>
<ProfileImagePicker
onRemoveProfileImage={handleProfileImage}
uploadFiles={handleProfileImage}
user={user}
/>
</View>
</View>
);
};
export default EditProfilePicture;

View file

@ -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<FloatingTextInputRef>(null);
const lastNameRef = useRef<FloatingTextInputRef>(null);
const usernameRef = useRef<FloatingTextInputRef>(null);
const emailRef = useRef<FloatingTextInputRef>(null);
const nicknameRef = useRef<FloatingTextInputRef>(null);
const positionRef = useRef<FloatingTextInputRef>(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 && <DisabledFields isTablet={isTablet}/>}
<Field
fieldKey='firstName'
fieldRef={firstNameRef}
isDisabled={userProfileFields.firstName.isDisabled}
label={formatMessage(FIELDS.firstName)}
testID='edit_profile.text_setting.firstName'
value={userInfo.firstName}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='lastName'
fieldRef={lastNameRef}
isDisabled={userProfileFields.lastName.isDisabled}
label={formatMessage(FIELDS.lastName)}
testID='edit_profile.text_setting.lastName'
value={userInfo.lastName}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='username'
fieldRef={usernameRef}
isDisabled={userProfileFields.username.isDisabled}
label={formatMessage(FIELDS.username)}
maxLength={22}
testID='edit_profile.text_setting.username'
value={userInfo.username}
{...fieldConfig}
/>
<View style={styles.separator}/>
{userInfo.email && (
<EmailField
authService={currentUser.authService}
isDisabled={userProfileFields.email.isDisabled}
email={userInfo.email}
label={formatMessage(FIELDS.email)}
fieldRef={emailRef}
onChange={onUpdateField}
onFocusNextField={onFocusNextField}
theme={theme}
isTablet={Boolean(isTablet)}
/>
)}
<View style={styles.separator}/>
<Field
fieldKey='nickname'
fieldRef={nicknameRef}
isDisabled={userProfileFields.nickname.isDisabled}
label={formatMessage(FIELDS.nickname)}
maxLength={22}
testID='edit_profile.text_setting.nickname'
value={userInfo.nickname}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='position'
fieldRef={positionRef}
isDisabled={userProfileFields.position.isDisabled}
isOptional={true}
label={formatMessage(FIELDS.position)}
maxLength={128}
{...fieldConfig}
returnKeyType='done'
testID='edit_profile.text_setting.position'
value={userInfo.position}
/>
<View style={styles.footer}/>
</>
);
};
export default ProfileForm;

View file

@ -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> | 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 (
<SlideUpPanelItem
icon={item.icon}
onPress={item.onPress}
testID={item.testID}
text={intl.formatMessage(item.text)}
destructive={pickerAction === 'removeProfilePicture'}
/>
);
};
export default PanelItem;

View file

@ -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 (
<>
<PanelItem
pickerAction='takePhoto'
pictureUtils={pictureUtils}
/>
<PanelItem
pickerAction='browsePhotoLibrary'
pictureUtils={pictureUtils}
/>
<PanelItem
pickerAction='browseFiles'
pictureUtils={pictureUtils}
/>
{canRemovePicture && (
<PanelItem
pickerAction='removeProfilePicture'
onRemoveProfileImage={onRemoveProfileImage}
/>
)}
</>
);
};
const snapPoints = canRemovePicture ? 5 : 4;
return bottomSheet({
closeButtonId: 'close-edit-profile',
renderContent,
snapPoints: [(snapPoints * ACTION_HEIGHT), 10],
title: '',
theme,
});
}), [canRemovePicture, onRemoveProfileImage, pictureUtils, theme]);
return (
<TouchableOpacity
onPress={showFileAttachmentOptions}
hitSlop={hitSlop}
style={styles.touchable}
>
<CompassIcon
name='camera-outline'
size={24}
color={changeOpacity(theme.centerChannelColor, 0.6)}
/>
</TouchableOpacity>
);
};
export default ProfileImagePicker;

View file

@ -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 (
<View
style={styles.spinner}
>
<Loading
color={theme.buttonBg}
/>
</View>
);
};
export default Updating;

View file

@ -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 (
<ProfilePicture
author={currentUser}
size={USER_PROFILE_PICTURE_SIZE}
showStatus={false}
/>
);
}
return (
<EditProfilePicture
onUpdateProfilePicture={onUpdateProfilePicture}
user={currentUser}
/>
);
};
export default UserProfilePicture;

View file

@ -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<KeyboardAwareScrollView>();
const firstNameRef = useRef<FloatingTextInputRef>(null);
const lastNameRef = useRef<FloatingTextInputRef>(null);
const usernameRef = useRef<FloatingTextInputRef>(null);
const emailRef = useRef<FloatingTextInputRef>(null);
const nicknameRef = useRef<FloatingTextInputRef>(null);
const positionRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const changedProfilePicture = useRef<NewProfileImage | undefined>(undefined);
const scrollViewRef = useRef<KeyboardAwareScrollView>();
const hasUpdateUserInfo = useRef<boolean>(false);
const [userInfo, setUserInfo] = useState<UserInfo>({
email: currentUser.email,
firstName: currentUser.firstName,
@ -135,8 +65,6 @@ const EditProfile = ({
const [error, setError] = useState<ErrorText | undefined>();
const [updating, setUpdating] = useState(false);
const scrollViewRef = useRef<KeyboardAwareScrollView>();
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<UserProfile> = {
const newUserInfo: Partial<UserProfile> = {
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 && (
<View
style={styles.spinner}
>
<Loading
color={theme.buttonBg}
/>
</View>
)}
{updating && <Updating/>}
{Boolean(error) && <ProfileError error={error!}/>}
<View style={styles.top}>
<ProfilePicture
author={currentUser}
size={153}
showStatus={false}
<UserProfilePicture
currentUser={currentUser}
lockedPicture={lockedPicture}
onUpdateProfilePicture={onUpdateProfilePicture}
/>
</View>
{hasDisabledFields && (
<View
style={{
paddingHorizontal: isTablet ? 42 : 20,
marginBottom: 16,
}}
>
<Text style={styles.text}>
{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, youll need to do so through your login provider.',
})}
</Text>
</View>
)}
<Field
fieldKey='firstName'
fieldRef={firstNameRef}
isDisabled={userProfileFields.firstName.isDisabled}
label={intl.formatMessage(FIELDS.firstName)}
testID='edit_profile.text_setting.firstName'
value={userInfo.firstName}
{...fieldConfig}
<ProfileForm
canSave={canSave}
currentUser={currentUser}
isTablet={isTablet}
lockedFirstName={lockedFirstName}
lockedLastName={lockedLastName}
lockedNickname={lockedNickname}
lockedPosition={lockedPosition}
onUpdateField={onUpdateField}
userInfo={userInfo}
submitUser={submitUser}
/>
<View style={styles.separator}/>
<Field
fieldKey='lastName'
fieldRef={lastNameRef}
isDisabled={userProfileFields.lastName.isDisabled}
label={intl.formatMessage(FIELDS.lastName)}
testID='edit_profile.text_setting.lastName'
value={userInfo.lastName}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='username'
fieldRef={usernameRef}
isDisabled={userProfileFields.username.isDisabled}
label={intl.formatMessage(FIELDS.username)}
maxLength={22}
testID='edit_profile.text_setting.username'
value={userInfo.username}
{...fieldConfig}
/>
<View style={styles.separator}/>
{userInfo.email && (
<EmailField
authService={currentUser.authService}
isDisabled={userProfileFields.email.isDisabled}
email={userInfo.email}
label={intl.formatMessage(FIELDS.email)}
fieldRef={emailRef}
onChange={updateField}
onFocusNextField={onFocusNextField}
theme={theme}
isTablet={Boolean(isTablet)}
/>
)}
<View style={styles.separator}/>
<Field
fieldKey='nickname'
fieldRef={nicknameRef}
isDisabled={userProfileFields.nickname.isDisabled}
label={intl.formatMessage(FIELDS.nickname)}
maxLength={22}
testID='edit_profile.text_setting.nickname'
value={userInfo.nickname}
{...fieldConfig}
/>
<View style={styles.separator}/>
<Field
fieldKey='position'
fieldRef={positionRef}
isDisabled={userProfileFields.position.isDisabled}
isOptional={true}
label={intl.formatMessage(FIELDS.position)}
maxLength={128}
{...fieldConfig}
returnKeyType='done'
testID='edit_profile.text_setting.position'
value={userInfo.position}
/>
<View style={styles.footer}/>
</KeyboardAwareScrollView>
</SafeAreaView>
</>

View file

@ -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}

View file

@ -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;

View file

@ -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: {

View file

@ -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<string, { title: string; text: string }> = {
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<Asset | DocumentPickerResponse>) => {
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<Asset[]> => {
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);
});
}
};
}

View file

@ -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<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.',
});
}

View file

@ -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

28
package-lock.json generated
View file

@ -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",

View file

@ -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",

30
types/api/files.d.ts vendored
View file

@ -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 = {

View file

@ -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<string, string | undefined | null| boolean> {
email: string;
@ -28,9 +29,12 @@ type EditProfileProps = {
lockedPicture: boolean;
};
type NewProfileImage = { localPath?: string; isRemoved?: boolean };
type FieldSequence = Record<string, {
ref: RefObject<FloatingTextInputRef>;
isDisabled: boolean;
}>
type FieldConfig = Pick<FieldProps, 'blurOnSubmit' | 'enablesReturnKeyAutomatically' | 'onFocusNextField' | 'onTextChange' | 'returnKeyType'>

10
types/utils/file.d.ts vendored Normal file
View file

@ -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<ClientErrorProps> | string;
export type ExtractedFileInfo = Partial<FileSystem.FileInfo> & { name: string; mime_type: string}
export type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void;

View file

@ -4,5 +4,3 @@
type Dictionary<T> = {
[key: string]: T;
};
type ErrorText = Partial<ClientErrorProps> | string;