feat: Change Avatar View (#4746)

* [NEW] Change Avatar View

* change avatar view and avatar suggestion

* avatar Url

* change avatar for profile it's done, missing fix revalidate the avatar in profile and drawer

* pick image

* clean profile view

* Finished the profile and fixed the avatar when change the user profile

* fix the profile update through all the app

* refactor avatar suggestion

* fix the delete from rooms and finished RoomInfoView, missing RoomInfoEditView

* room info edit view

* ipad navigation

* refactor the submit function

* refactor the e2e test

* minor tweak with themes and buttons

* minor tweak in edit button

* minor tweaks

* tweak IAvatarContainer

* fixing pt-br

* removed the avatarETagUser and search by username and text

* created the useHooke useAvatarETag

* fix lint

* refactor image.ts and test

* refactor avatar component

* fix the edit button

* refactor useAvatarETag

* fix the pt-br translation

* minor tweak tests

* back handleError to views and refactor the submit

* avatar suggestion item

* types for change avatar view context

* fix avatar on header for tablets

* minor tweak changeavatarview

* refactor avatar url to use hook form

* tweak on yup validation

* minor tweak with handle error

* minor tweak

* minor tweak

* interface avatar container

* minor tweak

* Refactor all the changeAvatarView and fix how to test the image url

* fixed the layout

* minor refactor

* minor tweaks

* fix the visual bug when the user change the avatar to new then clear cache

* fix the flicker when upload an image

* update package.json

* test the reset, discard alert, cancel and discard

* separate the avatar test from profile and create new tests for change avatar

* mock imagepicker

* minor tweak, adding console and add echo to config.yml

* use RUNNING_E2E_TESTS as env to other files

* exprt env at android build

* change the to way to set the running e2e test env

* update test

* delete the .env and update the e2e/readme and the file review

* minor tweak

* minor tweak

* update the test, fixing how to dismiss the keyboard

---------

Co-authored-by: Gleidson Daniel Silva <gleidson10daniel@hotmail.com>
This commit is contained in:
Reinaldo Neto 2023-04-10 11:59:00 -03:00 committed by GitHub
parent 056e314bec
commit fd210c4713
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 926 additions and 346 deletions

View File

@ -484,7 +484,7 @@ jobs:
- run:
name: Build Android
command: |
echo "RUNNING_E2E_TESTS=true" > ./.env
export RUNNING_E2E_TESTS=true
yarn e2e:android-build
- save_cache: *save-gradle-cache
- store_artifacts:
@ -582,7 +582,7 @@ jobs:
/usr/libexec/PlistBuddy -c "Set :bugsnag:apiKey $BUGSNAG_KEY" ./ios/RocketChatRN/Info.plist
/usr/libexec/PlistBuddy -c "Set :bugsnag:apiKey $BUGSNAG_KEY" ./ios/ShareRocketChatRN/Info.plist
yarn detox clean-framework-cache && yarn detox build-framework-cache
echo "RUNNING_E2E_TESTS=true" > ./.env
export RUNNING_E2E_TESTS=true
yarn e2e:ios-build
- persist_to_workspace:
root: /Users/distiller/project

2
.env
View File

@ -1,2 +0,0 @@
# DON'T COMMIT THIS FILE
RUNNING_E2E_TESTS=

View File

@ -1,13 +1,11 @@
import { Q } from '@nozbe/watermelondb';
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { Observable, Subscription } from 'rxjs';
import { IApplicationState, TSubscriptionModel, TUserModel } from '../../definitions';
import database from '../../lib/database';
import { IApplicationState } from '../../definitions';
import { getUserSelector } from '../../selectors/login';
import Avatar from './Avatar';
import { IAvatar } from './interfaces';
import { useAvatarETag } from './useAvatarETag';
const AvatarContainer = ({
style,
@ -23,17 +21,13 @@ const AvatarContainer = ({
isStatic,
rid
}: IAvatar): React.ReactElement => {
const subscription = useRef<Subscription>();
const [avatarETag, setAvatarETag] = useState<string | undefined>('');
const isDirect = () => type === 'd';
const server = useSelector((state: IApplicationState) => state.share.server.server || state.server.server);
const serverVersion = useSelector((state: IApplicationState) => state.share.server.version || state.server.version);
const { id, token } = useSelector(
const { id, token, username } = useSelector(
(state: IApplicationState) => ({
id: getUserSelector(state).id,
token: getUserSelector(state).token
token: getUserSelector(state).token,
username: getUserSelector(state).username
}),
shallowEqual
);
@ -48,41 +42,7 @@ const AvatarContainer = ({
true
);
const init = async () => {
const db = database.active;
const usersCollection = db.get('users');
const subsCollection = db.get('subscriptions');
let record;
try {
if (isDirect()) {
const [user] = await usersCollection.query(Q.where('username', text)).fetch();
record = user;
} else if (rid) {
record = await subsCollection.find(rid);
}
} catch {
// Record not found
}
if (record) {
const observable = record.observe() as Observable<TSubscriptionModel | TUserModel>;
subscription.current = observable.subscribe(r => {
setAvatarETag(r.avatarETag);
});
}
};
useEffect(() => {
if (!avatarETag) {
init();
}
return () => {
if (subscription?.current?.unsubscribe) {
subscription.current.unsubscribe();
}
};
}, [text, type, size, avatarETag, externalProviderUrl]);
const { avatarETag } = useAvatarETag({ username, text, type, rid, id });
return (
<Avatar

View File

@ -0,0 +1,85 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import Button from '../Button';
import AvatarContainer from './AvatarContainer';
import { IAvatar } from './interfaces';
import I18n from '../../i18n';
import { useTheme } from '../../theme';
import { BUTTON_HIT_SLOP } from '../message/utils';
import { useAppSelector } from '../../lib/hooks';
import { compareServerVersion } from '../../lib/methods/helpers';
import sharedStyles from '../../views/Styles';
const styles = StyleSheet.create({
editAvatarButton: {
marginTop: 8,
paddingVertical: 8,
paddingHorizontal: 12,
marginBottom: 0,
height: undefined
},
textButton: {
fontSize: 12,
...sharedStyles.textSemibold
}
});
interface IAvatarContainer extends Omit<IAvatar, 'size'> {
handleEdit?: () => void;
}
const AvatarWithEdit = ({
style,
text = '',
avatar,
emoji,
borderRadius,
type,
children,
onPress,
getCustomEmoji,
isStatic,
rid,
handleEdit
}: IAvatarContainer): React.ReactElement => {
const { colors } = useTheme();
const { serverVersion } = useAppSelector(state => ({
serverVersion: state.server.version
}));
return (
<>
<AvatarContainer
style={style}
text={text}
avatar={avatar}
emoji={emoji}
size={120}
borderRadius={borderRadius}
type={type}
children={children}
onPress={onPress}
getCustomEmoji={getCustomEmoji}
isStatic={isStatic}
rid={rid}
/>
{handleEdit && serverVersion && compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '3.6.0') ? (
<Button
title={I18n.t('Edit')}
type='secondary'
backgroundColor={colors.editAndUploadButtonAvatar}
onPress={handleEdit}
testID='avatar-edit-button'
style={styles.editAvatarButton}
styleText={styles.textButton}
color={colors.titleText}
hitSlop={BUTTON_HIT_SLOP}
/>
) : null}
</>
);
};
export default AvatarWithEdit;

View File

@ -0,0 +1,5 @@
import Avatar from './AvatarContainer';
export { default as AvatarWithEdit } from './AvatarWithEdit';
export default Avatar;

View File

@ -0,0 +1,67 @@
import { Q } from '@nozbe/watermelondb';
import { useEffect, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { TLoggedUserModel, TSubscriptionModel, TUserModel } from '../../definitions';
import database from '../../lib/database';
export const useAvatarETag = ({
username,
text,
type = '',
rid,
id
}: {
type?: string;
username: string;
text: string;
rid?: string;
id: string;
}) => {
const [avatarETag, setAvatarETag] = useState<string | undefined>('');
const isDirect = () => type === 'd';
useEffect(() => {
let subscription: Subscription;
if (!avatarETag) {
const observeAvatarETag = async () => {
const db = database.active;
const usersCollection = db.get('users');
const subsCollection = db.get('subscriptions');
let record;
try {
if (username === text) {
const serversDB = database.servers;
const userCollections = serversDB.get('users');
const user = await userCollections.find(id);
record = user;
} else if (isDirect()) {
const [user] = await usersCollection.query(Q.where('username', text)).fetch();
record = user;
} else if (rid) {
record = await subsCollection.find(rid);
}
} catch {
// Record not found
}
if (record) {
const observable = record.observe() as Observable<TSubscriptionModel | TUserModel | TLoggedUserModel>;
subscription = observable.subscribe(r => {
setAvatarETag(r.avatarETag);
});
}
};
observeAvatarETag();
return () => {
if (subscription?.unsubscribe) {
subscription.unsubscribe();
}
};
}
}, [text]);
return { avatarETag };
};

View File

@ -14,7 +14,7 @@ interface IButtonProps extends PlatformTouchableProps {
loading?: boolean;
color?: string;
fontSize?: number;
styleText?: StyleProp<TextStyle>[];
styleText?: StyleProp<TextStyle> | StyleProp<TextStyle>[];
}
const styles = StyleSheet.create({

View File

@ -17,16 +17,14 @@ export interface IAvatarButton {
}
export interface IAvatar {
data: {} | string | null;
data: string | null;
url?: string;
contentType?: string;
service?: any;
}
export interface IAvatarSuggestion {
[service: string]: {
url: string;
blob: string;
contentType: string;
};
url: string;
blob: string;
contentType: string;
}

View File

@ -0,0 +1 @@
export type TChangeAvatarViewContext = 'profile' | 'room';

View File

@ -877,6 +877,14 @@
"Reply_in_direct_message": "Reply in Direct Message",
"room_archived": "archived room",
"room_unarchived": "unarchived room",
"Upload_image": "Upload image",
"Delete_image": "Delete image",
"Images_uploaded": "Images uploaded",
"Avatar": "Avatar",
"insert_Avatar_URL": "insert image URL here",
"Discard_changes":"Discard changes?",
"Discard":"Discard",
"Discard_changes_description":"All changes will be lost if you go back without saving.",
"no-videoconf-provider-app-header": "Conference call not available",
"no-videoconf-provider-app-body": "Conference call apps can be installed in the Rocket.Chat marketplace by a workspace admin.",
"admin-no-videoconf-provider-app-header": "Conference call not enabled",

View File

@ -876,6 +876,14 @@
"Reply_in_direct_message": "Responder por mensagem direta",
"room_archived": "{{username}} arquivou a sala",
"room_unarchived": "{{username}} desarquivou a sala",
"Upload_image": "Carregar imagem",
"Delete_image": "Deletar imagem",
"Images_uploaded": "Imagens carregadas",
"Avatar": "Avatar",
"insert_Avatar_URL": "insira o URL da imagem aqui",
"Discard_changes":"Descartar alterações?",
"Discard":"Descartar",
"Discard_changes_description":"Todas as alterações serão perdidas, se você sair sem salvar.",
"Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente",
"Presence_Cap_Warning_Description": "O limite de conexões ativas para a workspace foi atingido, por isso o serviço responsável pela presença dos usuários está temporariamente desabilitado. Ele pode ser reabilitado manualmente nas configurações da workspace."
}

View File

@ -59,6 +59,7 @@ export const colors = {
buttonText: '#ffffff',
passcodeBackground: '#EEEFF1',
passcodeButtonActive: '#E4E7EA',
editAndUploadButtonAvatar: '#E4E7EA',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#2F343D',
passcodeSecondary: '#6C727A',
@ -128,6 +129,7 @@ export const colors = {
buttonText: '#ffffff',
passcodeBackground: '#030C1B',
passcodeButtonActive: '#0B182C',
editAndUploadButtonAvatar: '#0B182C',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1',
@ -197,6 +199,7 @@ export const colors = {
buttonText: '#ffffff',
passcodeBackground: '#000000',
passcodeButtonActive: '#0E0D0D',
editAndUploadButtonAvatar: '#0E0D0D',
passcodeLockIcon: '#6C727A',
passcodePrimary: '#FFFFFF',
passcodeSecondary: '#CBCED1',

View File

@ -0,0 +1,10 @@
import { Image } from 'react-native';
export const isImageURL = async (url: string): Promise<boolean> => {
try {
const result = await Image.prefetch(url);
return result;
} catch {
return false;
}
};

View File

@ -14,3 +14,4 @@ export * from './server';
export * from './url';
export * from './isValidEmail';
export * from './random';
export * from './image';

View File

@ -1,7 +1,5 @@
import { Alert, Linking } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
// eslint-disable-next-line import/no-unresolved
import { RUNNING_E2E_TESTS } from '@env';
import I18n from '../../../i18n';
import { isFDroidBuild, STORE_REVIEW_LINK } from '../../constants';
@ -88,7 +86,7 @@ class ReviewApp {
positiveEventCount = 0;
pushPositiveEvent = () => {
if (isFDroidBuild || RUNNING_E2E_TESTS === 'true') {
if (isFDroidBuild || process.env.RUNNING_E2E_TESTS === 'true') {
return;
}
if (this.positiveEventCount >= numberOfPositiveEvent) {

View File

@ -291,7 +291,7 @@ export default function subscribeRooms() {
const [type, data] = ddpMessage.fields.args;
const [, ev] = ddpMessage.fields.eventName.split('/');
if (/userData/.test(ev)) {
const [{ diff }] = ddpMessage.fields.args;
const [{ diff, unset }] = ddpMessage.fields.args;
if (diff?.statusLivechat) {
store.dispatch(setUser({ statusLivechat: diff.statusLivechat }));
}
@ -301,6 +301,12 @@ export default function subscribeRooms() {
if ((['settings.preferences.alsoSendThreadToChannel'] as any) in diff) {
store.dispatch(setUser({ alsoSendThreadToChannel: diff['settings.preferences.alsoSendThreadToChannel'] }));
}
if (diff?.avatarETag) {
store.dispatch(setUser({ avatarETag: diff.avatarETag }));
}
if (unset?.avatarETag) {
store.dispatch(setUser({ avatarETag: '' }));
}
}
if (/subscriptions/.test(ev)) {
if (type === 'removed') {

View File

@ -561,7 +561,7 @@ export const saveRoomSettings = (
rid: string,
params: {
roomName?: string;
roomAvatar?: string;
roomAvatar?: string | null;
roomDescription?: string;
roomTopic?: string;
roomAnnouncement?: string;
@ -602,7 +602,7 @@ export const getRoomRoles = (
// RC 0.65.0
sdk.get(`${roomTypeToApiType(type)}.roles`, { roomId });
export const getAvatarSuggestion = (): Promise<IAvatarSuggestion> =>
export const getAvatarSuggestion = (): Promise<{ [service: string]: IAvatarSuggestion }> =>
// RC 0.51.0
sdk.methodCallWrapper('getAvatarSuggestion');

View File

@ -247,6 +247,22 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) {
};
const handleSetUser = function* handleSetUser({ user }) {
if ('avatarETag' in user) {
const userId = yield select(state => state.login.user.id);
const serversDB = database.servers;
const userCollections = serversDB.get('users');
yield serversDB.write(async () => {
try {
const userRecord = await userCollections.find(userId);
await userRecord.update(record => {
record.avatarETag = user.avatarETag;
});
} catch {
//
}
});
}
setLanguage(user?.language);
if (user?.statusLivechat && isOmnichannelModuleAvailable()) {

View File

@ -68,6 +68,7 @@ import AddChannelTeamView from '../views/AddChannelTeamView';
import AddExistingChannelView from '../views/AddExistingChannelView';
import SelectListView from '../views/SelectListView';
import DiscussionsView from '../views/DiscussionsView';
import ChangeAvatarView from '../views/ChangeAvatarView';
import {
AdminPanelStackParamList,
ChatsStackParamList,
@ -96,6 +97,7 @@ const ChatsStackNavigator = () => {
<ChatsStack.Screen name='SelectListView' component={SelectListView} options={SelectListView.navigationOptions} />
<ChatsStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} />
<ChatsStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} />
<ChatsStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ChatsStack.Screen name='RoomMembersView' component={RoomMembersView} />
<ChatsStack.Screen name='DiscussionsView' component={DiscussionsView} />
<ChatsStack.Screen
@ -151,6 +153,7 @@ const ProfileStackNavigator = () => {
>
<ProfileStack.Screen name='ProfileView' component={ProfileView} options={ProfileView.navigationOptions} />
<ProfileStack.Screen name='UserPreferencesView' component={UserPreferencesView} />
<ProfileStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ProfileStack.Screen name='UserNotificationPrefView' component={UserNotificationPrefView} />
<ProfileStack.Screen name='PickerView' component={PickerView} options={PickerView.navigationOptions} />
</ProfileStack.Navigator>

View File

@ -17,6 +17,7 @@ import RoomsListView from '../../views/RoomsListView';
import RoomActionsView from '../../views/RoomActionsView';
import RoomInfoView from '../../views/RoomInfoView';
import RoomInfoEditView from '../../views/RoomInfoEditView';
import ChangeAvatarView from '../../views/ChangeAvatarView';
import RoomMembersView from '../../views/RoomMembersView';
import SearchMessagesView from '../../views/SearchMessagesView';
import SelectedUsersView from '../../views/SelectedUsersView';
@ -128,6 +129,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => {
<ModalStack.Screen name='RoomInfoView' component={RoomInfoView} options={RoomInfoView.navigationOptions} />
<ModalStack.Screen name='SelectListView' component={SelectListView} />
<ModalStack.Screen name='RoomInfoEditView' component={RoomInfoEditView} options={RoomInfoEditView.navigationOptions} />
<ModalStack.Screen name='ChangeAvatarView' component={ChangeAvatarView} />
<ModalStack.Screen name='RoomMembersView' component={RoomMembersView} />
<ModalStack.Screen
name='SearchMessagesView'

View File

@ -6,6 +6,7 @@ import { IMessage } from '../../definitions/IMessage';
import { ISubscription, SubscriptionType, TSubscriptionModel } from '../../definitions/ISubscription';
import { ILivechatDepartment } from '../../definitions/ILivechatDepartment';
import { ILivechatTag } from '../../definitions/ILivechatTag';
import { TChangeAvatarViewContext } from '../../definitions/TChangeAvatarViewContext';
export type MasterDetailChatsStackParamList = {
RoomView: {
@ -58,6 +59,12 @@ export type ModalStackParamList = {
onSearch?: Function;
isRadio?: boolean;
};
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
RoomInfoEditView: {
rid: string;
};

View File

@ -13,6 +13,7 @@ import { ModalStackParamList } from './MasterDetailStack/types';
import { TThreadModel } from '../definitions';
import { ILivechatDepartment } from '../definitions/ILivechatDepartment';
import { ILivechatTag } from '../definitions/ILivechatTag';
import { TChangeAvatarViewContext } from '../definitions/TChangeAvatarViewContext';
export type ChatsStackParamList = {
ModalStackNavigator: NavigatorScreenParams<ModalStackParamList>;
@ -181,6 +182,12 @@ export type ChatsStackParamList = {
onlyAudio?: boolean;
videoConf?: boolean;
};
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
};
export type ProfileStackParamList = {
@ -195,6 +202,12 @@ export type ProfileStackParamList = {
goBack?: Function;
onChangeValue: Function;
};
ChangeAvatarView: {
context: TChangeAvatarViewContext;
titleHeader?: string;
room?: ISubscription;
t?: SubscriptionType;
};
};
export type SettingsStackParamList = {

View File

@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
import { Text, View } from 'react-native';
import { IAvatar } from '../../definitions';
import { Services } from '../../lib/services';
import I18n from '../../i18n';
import styles from './styles';
import { useTheme } from '../../theme';
import AvatarSuggestionItem from './AvatarSuggestionItem';
const AvatarSuggestion = ({
onPress,
username,
resetAvatar
}: {
onPress: (value: IAvatar) => void;
username?: string;
resetAvatar?: () => void;
}) => {
const [avatarSuggestions, setAvatarSuggestions] = useState<IAvatar[]>([]);
const { colors } = useTheme();
useEffect(() => {
const getAvatarSuggestion = async () => {
const result = await Services.getAvatarSuggestion();
const suggestions = Object.keys(result).map(service => {
const { url, blob, contentType } = result[service];
return {
url,
data: blob,
service,
contentType
};
});
setAvatarSuggestions(suggestions);
};
getAvatarSuggestion();
}, []);
return (
<View style={styles.containerImagesUploaded}>
<Text style={[styles.itemLabel, { color: colors.titleText }]}>{I18n.t('Images_uploaded')}</Text>
<View style={styles.containerAvatarSuggestion}>
{username && resetAvatar ? (
<AvatarSuggestionItem text={`@${username}`} testID={`reset-avatar-suggestion`} onPress={resetAvatar} />
) : null}
{avatarSuggestions.slice(0, 7).map(item => (
<AvatarSuggestionItem item={item} testID={`${item?.service}-avatar-suggestion`} onPress={onPress} />
))}
</View>
</View>
);
};
export default AvatarSuggestion;

View File

@ -0,0 +1,40 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { IAvatar } from '../../definitions';
import Avatar from '../../containers/Avatar';
import { useTheme } from '../../theme';
const styles = StyleSheet.create({
container: {
width: 64,
height: 64,
alignItems: 'center',
justifyContent: 'center',
marginRight: 20,
marginBottom: 12,
borderRadius: 4
}
});
const AvatarSuggestionItem = ({
item,
onPress,
text,
testID
}: {
item?: IAvatar;
testID?: string;
onPress: Function;
text?: string;
}) => {
const { colors } = useTheme();
return (
<View key={item?.service} testID={testID} style={[styles.container, { backgroundColor: colors.borderColor }]}>
<Avatar avatar={item?.url} text={text} size={64} onPress={() => onPress(item)} />
</View>
);
};
export default AvatarSuggestionItem;

View File

@ -0,0 +1,29 @@
import React from 'react';
import I18n from '../../i18n';
import { FormTextInput } from '../../containers/TextInput';
import { useDebounce, isImageURL } from '../../lib/methods/helpers';
const AvatarUrl = ({ submit }: { submit: (value: string) => void }) => {
const handleChangeText = useDebounce(async (value: string) => {
if (value) {
const result = await isImageURL(value);
if (result) {
return submit(value);
}
return submit('');
}
}, 500);
return (
<FormTextInput
label={I18n.t('Avatar_Url')}
placeholder={I18n.t('insert_Avatar_URL')}
onChangeText={handleChangeText}
testID='change-avatar-view-avatar-url'
containerStyle={{ marginBottom: 0 }}
/>
);
};
export default AvatarUrl;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
import ImagePicker, { Image as ImageInterface } from 'react-native-image-crop-picker';
export type Image = ImageInterface;
export default ImagePicker;

View File

@ -0,0 +1,250 @@
import React, { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { shallowEqual } from 'react-redux';
import KeyboardView from '../../containers/KeyboardView';
import sharedStyles from '../Styles';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info';
import StatusBar from '../../containers/StatusBar';
import { useTheme } from '../../theme';
import SafeAreaView from '../../containers/SafeAreaView';
import * as List from '../../containers/List';
import styles from './styles';
import { useAppSelector } from '../../lib/hooks';
import { getUserSelector } from '../../selectors/login';
import Avatar from '../../containers/Avatar';
import AvatarPresentational from '../../containers/Avatar/Avatar';
import AvatarUrl from './AvatarUrl';
import Button from '../../containers/Button';
import I18n from '../../i18n';
import { ChatsStackParamList } from '../../stacks/types';
import { IAvatar } from '../../definitions';
import AvatarSuggestion from './AvatarSuggestion';
import log from '../../lib/methods/helpers/log';
import { changeRoomsAvatar, changeUserAvatar, resetUserAvatar } from './submitServices';
import ImagePicker, { Image } from './ImagePicker';
enum AvatarStateActions {
CHANGE_AVATAR = 'CHANGE_AVATAR',
RESET_USER_AVATAR = 'RESET_USER_AVATAR',
RESET_ROOM_AVATAR = 'RESET_ROOM_AVATAR'
}
interface IReducerAction {
type: AvatarStateActions;
payload?: Partial<IState>;
}
interface IState extends IAvatar {
resetUserAvatar: string;
}
const initialState = {
data: '',
url: '',
contentType: '',
service: '',
resetUserAvatar: ''
};
function reducer(state: IState, action: IReducerAction) {
const { type, payload } = action;
if (type in AvatarStateActions) {
return {
...initialState,
...payload
};
}
return state;
}
const ChangeAvatarView = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const [saving, setSaving] = useState(false);
const { colors } = useTheme();
const { userId, username, server } = useAppSelector(
state => ({
userId: getUserSelector(state).id,
username: getUserSelector(state).username,
server: state.server.server
}),
shallowEqual
);
const isDirty = useRef<boolean>(false);
const navigation = useNavigation<StackNavigationProp<ChatsStackParamList, 'ChangeAvatarView'>>();
const { context, titleHeader, room, t } = useRoute<RouteProp<ChatsStackParamList, 'ChangeAvatarView'>>().params;
useLayoutEffect(() => {
navigation.setOptions({
title: titleHeader || I18n.t('Avatar')
});
}, [titleHeader, navigation]);
useEffect(() => {
navigation.addListener('beforeRemove', e => {
if (!isDirty.current) {
return;
}
e.preventDefault();
showConfirmationAlert({
title: I18n.t('Discard_changes'),
message: I18n.t('Discard_changes_description'),
confirmationText: I18n.t('Discard'),
onPress: () => {
navigation.dispatch(e.data.action);
}
});
});
}, [navigation]);
const dispatchAvatar = (action: IReducerAction) => {
isDirty.current = true;
dispatch(action);
};
const submit = async () => {
try {
setSaving(true);
if (context === 'room' && room?.rid) {
// Change Rooms Avatar
await changeRoomsAvatar(room.rid, state?.data);
} else if (state?.url) {
// Change User's Avatar
await changeUserAvatar(state);
} else if (state.resetUserAvatar) {
// Change User's Avatar
await resetUserAvatar(userId);
}
isDirty.current = false;
} catch (e: any) {
log(e);
return showErrorAlert(e.message, I18n.t('Oops'));
} finally {
setSaving(false);
}
return navigation.goBack();
};
const pickImage = async () => {
const options = {
cropping: true,
compressImageQuality: 0.8,
freeStyleCropEnabled: true,
cropperAvoidEmptySpaceAroundImage: false,
cropperChooseText: I18n.t('Choose'),
cropperCancelText: I18n.t('Cancel'),
includeBase64: true
};
try {
const response: Image = await ImagePicker.openPicker(options);
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: { url: response.path, data: `data:image/jpeg;base64,${response.data}`, service: 'upload' }
});
} catch (error) {
log(error);
}
};
const deletingRoomAvatar = context === 'room' && state.data === null;
return (
<KeyboardView
style={{ backgroundColor: colors.auxiliaryBackground }}
contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<StatusBar />
<SafeAreaView testID='change-avatar-view'>
<ScrollView
contentContainerStyle={sharedStyles.containerScrollView}
testID='change-avatar-view-list'
{...scrollPersistTaps}
>
<View style={styles.avatarContainer} testID='change-avatar-view-avatar'>
{deletingRoomAvatar ? (
<AvatarPresentational
text={room?.name || state.resetUserAvatar || username}
avatar={state?.url}
isStatic={state?.url}
size={120}
type={t}
server={server}
/>
) : (
<Avatar
text={room?.name || state.resetUserAvatar || username}
avatar={state?.url}
isStatic={state?.url}
size={120}
type={t}
rid={room?.rid}
/>
)}
</View>
{context === 'profile' ? (
<AvatarUrl
submit={value =>
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: { url: value, data: value, service: 'url' }
})
}
/>
) : null}
<List.Separator style={styles.separator} />
{context === 'profile' ? (
<AvatarSuggestion
resetAvatar={() =>
dispatchAvatar({
type: AvatarStateActions.RESET_USER_AVATAR,
payload: { resetUserAvatar: `@${username}` }
})
}
username={username}
onPress={value =>
dispatchAvatar({
type: AvatarStateActions.CHANGE_AVATAR,
payload: value
})
}
/>
) : null}
<Button
title={I18n.t('Upload_image')}
type='secondary'
disabled={saving}
backgroundColor={colors.editAndUploadButtonAvatar}
onPress={pickImage}
testID='change-avatar-view-upload-image'
/>
{context === 'room' ? (
<Button
title={I18n.t('Delete_image')}
type='primary'
disabled={saving}
backgroundColor={colors.dangerColor}
onPress={() => dispatchAvatar({ type: AvatarStateActions.RESET_ROOM_AVATAR, payload: { data: null } })}
testID='change-avatar-view-delete-my-account'
/>
) : null}
<Button
title={I18n.t('Save')}
disabled={!isDirty.current || saving}
type='primary'
loading={saving}
onPress={submit}
testID='change-avatar-view-submit'
/>
</ScrollView>
</SafeAreaView>
</KeyboardView>
);
};
export default ChangeAvatarView;

View File

@ -0,0 +1,27 @@
import { StyleSheet } from 'react-native';
import sharedStyles from '../Styles';
export default StyleSheet.create({
avatarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24
},
separator: {
marginVertical: 16
},
itemLabel: {
marginBottom: 12,
fontSize: 14,
...sharedStyles.textSemibold
},
containerImagesUploaded: {
flex: 1
},
containerAvatarSuggestion: {
flex: 1,
flexWrap: 'wrap',
flexDirection: 'row'
}
});

View File

@ -0,0 +1,14 @@
import I18n from '../../i18n';
export const handleError = (e: any, action: string) => {
if (e.data && e.data.error.includes('[error-too-many-requests]')) {
throw new Error(e.data.error);
}
if (e.error && e.error === 'error-avatar-invalid-url') {
throw new Error(I18n.t(e.error, { url: e.details.url }));
}
if (I18n.isTranslated(e.error)) {
throw new Error(I18n.t(e.error));
}
throw new Error(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) }));
};

View File

@ -0,0 +1,29 @@
import { Services } from '../../lib/services';
import log from '../../lib/methods/helpers/log';
import { IAvatar } from '../../definitions';
import { handleError } from './submitHelpers';
export const changeRoomsAvatar = async (rid: string, roomAvatar: string | null) => {
try {
await Services.saveRoomSettings(rid, { roomAvatar });
} catch (e) {
log(e);
return handleError(e, 'changing_avatar');
}
};
export const changeUserAvatar = async (avatarUpload: IAvatar) => {
try {
await Services.setAvatarFromService(avatarUpload);
} catch (e) {
return handleError(e, 'changing_avatar');
}
};
export const resetUserAvatar = async (userId: string) => {
try {
await Services.resetAvatar(userId);
} catch (e) {
return handleError(e, 'changing_avatar');
}
};

View File

@ -2,7 +2,6 @@ import React from 'react';
import { Keyboard, ScrollView, TextInput, View } from 'react-native';
import { connect } from 'react-redux';
import { sha256 } from 'js-sha256';
import ImagePicker, { Image } from 'react-native-image-crop-picker';
import RNPickerSelect from 'react-native-picker-select';
import { dequal } from 'dequal';
import omit from 'lodash/omit';
@ -12,16 +11,15 @@ import Touch from '../../containers/Touch';
import KeyboardView from '../../containers/KeyboardView';
import sharedStyles from '../Styles';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers';
import { showErrorAlert, showConfirmationAlert } from '../../lib/methods/helpers';
import { LISTENER } from '../../containers/Toast';
import EventEmitter from '../../lib/methods/helpers/events';
import { FormTextInput } from '../../containers/TextInput';
import log, { events, logEvent } from '../../lib/methods/helpers/log';
import { events, logEvent } from '../../lib/methods/helpers/log';
import I18n from '../../i18n';
import Button from '../../containers/Button';
import Avatar from '../../containers/Avatar';
import { AvatarWithEdit } from '../../containers/Avatar';
import { setUser } from '../../actions/login';
import { CustomIcon } from '../../containers/CustomIcon';
import * as HeaderButton from '../../containers/HeaderButton';
import StatusBar from '../../containers/StatusBar';
import { themes } from '../../lib/constants';
@ -31,15 +29,7 @@ import SafeAreaView from '../../containers/SafeAreaView';
import styles from './styles';
import { ProfileStackParamList } from '../../stacks/types';
import { Services } from '../../lib/services';
import {
IApplicationState,
IAvatar,
IAvatarButton,
IAvatarSuggestion,
IBaseScreen,
IProfileParams,
IUser
} from '../../definitions';
import { IApplicationState, IAvatarButton, IBaseScreen, IProfileParams, IUser } from '../../definitions';
import { twoFactor } from '../../lib/services/twoFactor';
import { TwoFactorMethods } from '../../definitions/ITotp';
import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet';
@ -67,9 +57,6 @@ interface IProfileViewState {
email: string | null;
newPassword: string | null;
currentPassword: string | null;
avatarUrl: string | null;
avatar: IAvatar;
avatarSuggestions: IAvatarSuggestion;
customFields: {
[key: string | number]: string;
};
@ -113,25 +100,12 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
email: '',
newPassword: '',
currentPassword: '',
avatarUrl: '',
avatar: {
data: {},
url: ''
},
avatarSuggestions: {},
customFields: {},
twoFactorCode: null
};
async componentDidMount() {
componentDidMount() {
this.init();
try {
const result = await Services.getAvatarSuggestion();
this.setState({ avatarSuggestions: result });
} catch (e) {
log(e);
}
}
UNSAFE_componentWillReceiveProps(nextProps: IProfileViewProps) {
@ -147,16 +121,6 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
}
}
setAvatar = (avatar: IAvatar) => {
const { Accounts_AllowUserAvatarChange } = this.props;
if (!Accounts_AllowUserAvatarChange) {
return;
}
this.setState({ avatar });
};
init = (user?: IUser) => {
const { user: userProps } = this.props;
const { name, username, emails, customFields } = user || userProps;
@ -167,17 +131,12 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
email: emails ? emails[0].address : null,
newPassword: null,
currentPassword: null,
avatarUrl: null,
avatar: {
data: {},
url: ''
},
customFields: customFields || {}
});
};
formIsChanged = () => {
const { name, username, email, newPassword, avatar, customFields } = this.state;
const { name, username, email, newPassword, customFields } = this.state;
const { user } = this.props;
let customFieldsChanged = false;
@ -196,21 +155,10 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
!newPassword &&
user.emails &&
user.emails[0].address === email &&
!avatar.data &&
!customFieldsChanged
);
};
handleError = (e: any, _func: string, action: string) => {
if (e.data && e.data.error.includes('[error-too-many-requests]')) {
return showErrorAlert(e.data.error);
}
if (I18n.isTranslated(e.error)) {
return showErrorAlert(I18n.t(e.error));
}
showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) }));
};
submit = async (): Promise<void> => {
Keyboard.dismiss();
@ -220,7 +168,7 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
this.setState({ saving: true });
const { name, username, email, newPassword, currentPassword, avatar, customFields, twoFactorCode } = this.state;
const { name, username, email, newPassword, currentPassword, customFields, twoFactorCode } = this.state;
const { user, dispatch } = this.props;
const params = {} as IProfileParams;
@ -273,17 +221,6 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
}
try {
if (avatar.url) {
try {
logEvent(events.PROFILE_SAVE_AVATAR);
await Services.setAvatarFromService(avatar);
} catch (e) {
logEvent(events.PROFILE_SAVE_AVATAR_F);
this.setState({ saving: false, currentPassword: null });
return this.handleError(e, 'setAvatarFromService', 'changing_avatar');
}
}
const twoFactorOptions = params.currentPassword
? {
twoFactorCode: params.currentPassword,
@ -317,7 +254,7 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
}
logEvent(events.PROFILE_SAVE_CHANGES_F);
this.setState({ saving: false, currentPassword: null, twoFactorCode: null });
this.handleError(e, 'saveUserProfile', 'saving_profile');
this.handleError(e, 'saving_profile');
}
};
@ -334,39 +271,23 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
EventEmitter.emit(LISTENER, { message: I18n.t('Avatar_changed_successfully') });
this.init();
} catch (e) {
this.handleError(e, 'resetAvatar', 'changing_avatar');
this.handleError(e, 'changing_avatar');
}
};
pickImage = async () => {
const { Accounts_AllowUserAvatarChange } = this.props;
if (!Accounts_AllowUserAvatarChange) {
return;
handleError = (e: any, action: string) => {
if (e.data && e.data.error.includes('[error-too-many-requests]')) {
return showErrorAlert(e.data.error);
}
const options = {
cropping: true,
compressImageQuality: 0.8,
freeStyleCropEnabled: true,
cropperAvoidEmptySpaceAroundImage: false,
cropperChooseText: I18n.t('Choose'),
cropperCancelText: I18n.t('Cancel'),
includeBase64: true
};
try {
logEvent(events.PROFILE_PICK_AVATAR);
const response: Image = await ImagePicker.openPicker(options);
this.setAvatar({ url: response.path, data: `data:image/jpeg;base64,${response.data}`, service: 'upload' });
} catch (error) {
logEvent(events.PROFILE_PICK_AVATAR_F);
console.warn(error);
if (I18n.isTranslated(e.error)) {
return showErrorAlert(I18n.t(e.error));
}
showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t(action) }));
};
pickImageWithURL = (avatarUrl: string) => {
logEvent(events.PROFILE_PICK_AVATAR_WITH_URL);
this.setAvatar({ url: avatarUrl, data: avatarUrl, service: 'url' });
handleEditAvatar = () => {
const { navigation } = this.props;
navigation.navigate('ChangeAvatarView', { context: 'profile' });
};
renderAvatarButton = ({ key, child, onPress, disabled = false }: IAvatarButton) => {
@ -384,49 +305,6 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
);
};
renderAvatarButtons = () => {
const { avatarUrl, avatarSuggestions } = this.state;
const { user, theme, Accounts_AllowUserAvatarChange } = this.props;
return (
<View style={styles.avatarButtons}>
{this.renderAvatarButton({
child: <Avatar text={`@${user.username}`} size={50} />,
onPress: () => this.resetAvatar(),
disabled: !Accounts_AllowUserAvatarChange,
key: 'profile-view-reset-avatar'
})}
{this.renderAvatarButton({
child: <CustomIcon name='upload' size={30} color={themes[theme].bodyText} />,
onPress: () => this.pickImage(),
disabled: !Accounts_AllowUserAvatarChange,
key: 'profile-view-upload-avatar'
})}
{this.renderAvatarButton({
child: <CustomIcon name='link' size={30} color={themes[theme].bodyText} />,
onPress: () => (avatarUrl ? this.pickImageWithURL(avatarUrl) : null),
disabled: !avatarUrl,
key: 'profile-view-avatar-url-button'
})}
{Object.keys(avatarSuggestions).map(service => {
const { url, blob, contentType } = avatarSuggestions[service];
return this.renderAvatarButton({
disabled: !Accounts_AllowUserAvatarChange,
key: `profile-view-avatar-${service}`,
child: <Avatar avatar={url} size={50} />,
onPress: () =>
this.setAvatar({
url,
data: blob,
service,
contentType
})
});
})}
</View>
);
};
renderCustomFields = () => {
const { customFields } = this.state;
const { Accounts_CustomFields } = this.props;
@ -520,7 +398,7 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
};
render() {
const { name, username, email, newPassword, avatarUrl, customFields, avatar, saving } = this.state;
const { name, username, email, newPassword, customFields, saving } = this.state;
const {
user,
theme,
@ -543,7 +421,10 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
<SafeAreaView testID='profile-view'>
<ScrollView contentContainerStyle={sharedStyles.containerScrollView} testID='profile-view-list' {...scrollPersistTaps}>
<View style={styles.avatarContainer} testID='profile-view-avatar'>
<Avatar text={user.username} avatar={avatar?.url} isStatic={avatar?.url} size={100} />
<AvatarWithEdit
text={user.username}
handleEdit={Accounts_AllowUserAvatarChange ? this.handleEditAvatar : undefined}
/>
</View>
<FormTextInput
editable={Accounts_AllowRealNameChange}
@ -615,22 +496,6 @@ class ProfileView extends React.Component<IProfileViewProps, IProfileViewState>
testID='profile-view-new-password'
/>
{this.renderCustomFields()}
<FormTextInput
editable={Accounts_AllowUserAvatarChange}
inputStyle={[!Accounts_AllowUserAvatarChange && styles.disabled]}
inputRef={e => {
if (e) {
this.avatarUrl = e;
}
}}
label={I18n.t('Avatar_Url')}
placeholder={I18n.t('Avatar_Url')}
value={avatarUrl || undefined}
onChangeText={value => this.setState({ avatarUrl: value })}
onSubmitEditing={this.submit}
testID='profile-view-avatar-url'
/>
{this.renderAvatarButtons()}
<Button
title={I18n.t('Save_Changes')}
type='primary'

View File

@ -7,7 +7,7 @@ export default StyleSheet.create({
avatarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10
marginBottom: 24
},
avatarButtons: {
flexWrap: 'wrap',

View File

@ -2,15 +2,13 @@ import React from 'react';
import { Q } from '@nozbe/watermelondb';
import { BlockContext } from '@rocket.chat/ui-kit';
import { dequal } from 'dequal';
import isEmpty from 'lodash/isEmpty';
import { Alert, Keyboard, ScrollView, Text, TextInput, TouchableOpacity, View, StyleSheet } from 'react-native';
import ImagePicker, { Image } from 'react-native-image-crop-picker';
import { connect } from 'react-redux';
import { Subscription } from 'rxjs';
import { deleteRoom } from '../../actions/room';
import { themes } from '../../lib/constants';
import Avatar from '../../containers/Avatar';
import { AvatarWithEdit } from '../../containers/Avatar';
import { sendLoadingEvent } from '../../containers/Loading';
import SafeAreaView from '../../containers/SafeAreaView';
import StatusBar from '../../containers/StatusBar';
@ -23,13 +21,11 @@ import {
IRoomSettings,
ISubscription,
SubscriptionType,
TSubscriptionModel,
IAvatar
TSubscriptionModel
} from '../../definitions';
import { ERoomType } from '../../definitions/ERoomType';
import I18n from '../../i18n';
import database from '../../lib/database';
import { CustomIcon } from '../../containers/CustomIcon';
import KeyboardView from '../../containers/KeyboardView';
import { TSupportedPermissions } from '../../reducers/permissions';
import { ModalStackParamList } from '../../stacks/MasterDetailStack/types';
@ -54,7 +50,6 @@ import { Services } from '../../lib/services';
interface IRoomInfoEditViewState {
room: ISubscription;
avatar: IAvatar;
permissions: { [key in TSupportedPermissions]?: boolean };
name: string;
description?: string;
@ -102,7 +97,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
this.room = {} as TSubscriptionModel;
this.state = {
room: {} as ISubscription,
avatar: {} as IAvatar,
permissions: {},
name: '',
description: '',
@ -192,7 +186,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
topic,
announcement,
t: t === 'p',
avatar: {} as IAvatar,
ro,
reactWhenReadOnly,
joinCode: joinCodeRequired ? this.randomValue : '',
@ -228,8 +221,7 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
joinCode,
systemMessages,
enableSysMes,
encrypted,
avatar
encrypted
} = this.state;
const { joinCodeRequired } = room;
const sysMes = room.sysMes as string[];
@ -244,28 +236,15 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
room.reactWhenReadOnly === reactWhenReadOnly &&
dequal(sysMes, systemMessages) &&
enableSysMes === (sysMes && sysMes.length > 0) &&
room.encrypted === encrypted &&
isEmpty(avatar)
room.encrypted === encrypted
);
};
submit = async () => {
logEvent(events.RI_EDIT_SAVE);
Keyboard.dismiss();
const {
room,
name,
description,
topic,
announcement,
t,
ro,
reactWhenReadOnly,
joinCode,
systemMessages,
encrypted,
avatar
} = this.state;
const { room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, encrypted } =
this.state;
sendLoadingEvent({ visible: true });
let error = false;
@ -284,10 +263,6 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
if (getRoomTitle(room) !== name) {
params.roomName = name;
}
// Avatar
if (!isEmpty(avatar)) {
params.roomAvatar = avatar.data as string;
}
// Description
if (room.description !== description) {
params.roomDescription = description;
@ -487,26 +462,10 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
);
};
changeAvatar = async () => {
const options = {
cropping: true,
compressImageQuality: 0.8,
cropperAvoidEmptySpaceAroundImage: false,
cropperChooseText: I18n.t('Choose'),
cropperCancelText: I18n.t('Cancel'),
includeBase64: true
};
try {
const response: Image = await ImagePicker.openPicker(options);
this.setState({ avatar: { url: response.path, data: `data:image/jpeg;base64,${response.data}`, service: 'upload' } });
} catch (e) {
console.log(e);
}
};
resetAvatar = () => {
this.setState({ avatar: { data: null } });
handleEditAvatar = () => {
const { navigation } = this.props;
const { room } = this.state;
navigation.navigate('ChangeAvatarView', { titleHeader: I18n.t('Room_Info'), room, t: room.t, context: 'room' });
};
toggleRoomType = (value: boolean) => {
@ -549,8 +508,7 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
permissions,
archived,
enableSysMes,
encrypted,
avatar
encrypted
} = this.state;
const { serverVersion, encryptionEnabled, theme } = this.props;
const { dangerColor } = themes[theme];
@ -568,29 +526,9 @@ class RoomInfoEditView extends React.Component<IRoomInfoEditViewProps, IRoomInfo
testID='room-info-edit-view-list'
{...scrollPersistTaps}
>
<TouchableOpacity
style={styles.avatarContainer}
onPress={this.changeAvatar}
disabled={compareServerVersion(serverVersion || '', 'lowerThan', '3.6.0')}
>
<Avatar
type={room.t}
text={room.name}
avatar={avatar?.url}
isStatic={avatar?.url}
rid={isEmpty(avatar) ? room.rid : undefined}
size={100}
>
{serverVersion && compareServerVersion(serverVersion, 'lowerThan', '3.6.0') ? undefined : (
<TouchableOpacity
style={[styles.resetButton, { backgroundColor: themes[theme].dangerColor }]}
onPress={this.resetAvatar}
>
<CustomIcon name='delete' color={themes[theme].backgroundColor} size={24} />
</TouchableOpacity>
)}
</Avatar>
</TouchableOpacity>
<View style={styles.avatarContainer}>
<AvatarWithEdit type={room.t} text={room.name} rid={room.rid} handleEdit={this.handleEditAvatar} />
</View>
<FormTextInput
inputRef={e => {
this.name = e;

View File

@ -76,7 +76,8 @@ export default StyleSheet.create({
avatarContainer: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10
marginBottom: 32,
marginTop: 16
},
resetButton: {
padding: 4,

View File

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { Observable, Subscription } from 'rxjs';
import UAParser from 'ua-parser-js';
import Avatar from '../../containers/Avatar';
import { AvatarWithEdit } from '../../containers/Avatar';
import { CustomIcon, TIconsName } from '../../containers/CustomIcon';
import * as HeaderButton from '../../containers/HeaderButton';
import { MarkdownPreview } from '../../containers/markdown';
@ -394,17 +394,32 @@ class RoomInfoView extends React.Component<IRoomInfoViewProps, IRoomInfoViewStat
log(e);
}
};
handleEditAvatar = () => {
const { navigation } = this.props;
const { room } = this.state;
navigation.navigate('ChangeAvatarView', { titleHeader: I18n.t('Room_Info'), room, t: this.t, context: 'room' });
};
renderAvatar = (room: ISubscription, roomUser: IUserParsed) => {
const { theme } = this.props;
const { showEdit } = this.state;
const showAvatarEdit = showEdit && this.t !== SubscriptionType.OMNICHANNEL;
return (
<Avatar text={room.name || roomUser.username} style={styles.avatar} type={this.t} size={100} rid={room?.rid}>
<AvatarWithEdit
text={room.name || roomUser.username}
style={styles.avatar}
type={this.t}
rid={room?.rid}
handleEdit={showAvatarEdit ? this.handleEditAvatar : undefined}
>
{this.t === SubscriptionType.DIRECT && roomUser._id ? (
<View style={[sharedStyles.status, { backgroundColor: themes[theme].auxiliaryBackground }]}>
<Status size={20} id={roomUser._id} />
</View>
) : null}
</Avatar>
</AvatarWithEdit>
);
};

View File

@ -21,13 +21,14 @@ export default StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
paddingVertical: 8
paddingBottom: 8,
paddingTop: 32
},
avatar: {
marginHorizontal: 10
},
roomTitleContainer: {
paddingTop: 20,
paddingTop: 32,
marginHorizontal: 16,
alignItems: 'center'
},

View File

@ -24,6 +24,7 @@ const styles = StyleSheet.create({
});
interface ILeftButtonsProps {
rid?: string;
tmid?: string;
unreadsCount: number | null;
navigation: StackNavigationProp<ChatsStackParamList, 'RoomView'>;
@ -38,6 +39,7 @@ interface ILeftButtonsProps {
}
const LeftButtons = ({
rid,
tmid,
unreadsCount,
navigation,
@ -78,7 +80,7 @@ const LeftButtons = ({
}
if (baseUrl && userId && token) {
return <Avatar text={title} size={30} type={t} style={styles.avatar} onPress={onPress} />;
return <Avatar rid={rid} text={title} size={30} type={t} style={styles.avatar} onPress={onPress} />;
}
return null;
};

View File

@ -589,6 +589,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
headerRightContainerStyle: { flexGrow: undefined, flexBasis: undefined },
headerLeft: () => (
<LeftButtons
rid={rid}
tmid={tmid}
unreadsCount={unreadsCount}
navigation={navigation}

View File

@ -33,8 +33,7 @@ WIP: End-to-end tests are a work in progress and they're going to change.
- Ask Diego Mello for credentials
## Shared config
- Change `.env` to `RUNNING_E2E_TESTS=true`
- You can also `RUNNING_E2E_TESTS=true yarn start reset-cache`, but it's easier to change the file as long as you don't commit it
- To start the Metro bundler in the mocked mode, you should run `yarn e2e:start`
## Setup and run iOS

View File

@ -136,3 +136,8 @@ export const post = async (endpoint: string, body: any, user: ITestUser) => {
console.log(`POST /${endpoint} ${JSON.stringify(body)}`);
return rocketchat.post(endpoint, body);
};
export const getProfileInfo = async (userId: string) => {
const result = await get(`users.info?userId=${userId}`);
return result.data.user;
};

View File

@ -11,14 +11,13 @@ async function waitForToast() {
}
describe('Profile screen', () => {
let scrollViewType: string;
let textMatcher: TTextMatcher;
let user: ITestUser;
beforeAll(async () => {
user = await createRandomUser();
await device.launchApp({ permissions: { notifications: 'YES' }, delete: true });
({ scrollViewType, textMatcher } = platformTypes[device.getPlatform()]);
({ textMatcher } = platformTypes[device.getPlatform()]);
await navigateToLogin();
await login(user.username, user.password);
await element(by.id('rooms-list-view-sidebar')).tap();
@ -43,6 +42,10 @@ describe('Profile screen', () => {
await expect(element(by.id('profile-view-avatar')).atIndex(0)).toExist();
});
it('should have avatar edit button', async () => {
await expect(element(by.id('avatar-edit-button'))).toExist();
});
it('should have name', async () => {
await expect(element(by.id('profile-view-name'))).toExist();
});
@ -59,31 +62,6 @@ describe('Profile screen', () => {
await expect(element(by.id('profile-view-new-password'))).toExist();
});
it('should have avatar url', async () => {
await expect(element(by.id('profile-view-avatar-url'))).toExist();
});
it('should have reset avatar button', async () => {
await waitFor(element(by.id('profile-view-reset-avatar')))
.toExist()
.whileElement(by.id('profile-view-list'))
.scroll(scrollDown, 'down');
});
it('should have upload avatar button', async () => {
await waitFor(element(by.id('profile-view-upload-avatar')))
.toExist()
.whileElement(by.id('profile-view-list'))
.scroll(scrollDown, 'down');
});
it('should have avatar url button', async () => {
await waitFor(element(by.id('profile-view-avatar-url-button')))
.toExist()
.whileElement(by.id('profile-view-list'))
.scroll(scrollDown, 'down');
});
it('should have submit button', async () => {
await waitFor(element(by.id('profile-view-submit')))
.toExist()
@ -96,17 +74,23 @@ describe('Profile screen', () => {
it('should change name and username', async () => {
await element(by.id('profile-view-name')).replaceText(`${user.username}new`);
await element(by.id('profile-view-username')).replaceText(`${user.username}new`);
// dismiss keyboard
await element(by.id('profile-view-list')).swipe('down');
await element(by.id('profile-view-submit')).tap();
await waitForToast();
});
it('should change email and password', async () => {
await element(by.id('profile-view-list')).swipe('up');
await waitFor(element(by.id('profile-view-email')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('profile-view-email')).replaceText(`mobile+profileChangesNew${random()}@rocket.chat`);
// dismiss keyboard
await element(by.id('profile-view-list')).swipe('down');
await element(by.id('profile-view-new-password')).replaceText(`${user.password}new`);
// dismiss keyboard
await element(by.id('profile-view-list')).swipe('down');
await waitFor(element(by.id('profile-view-submit')))
.toExist()
.withTimeout(2000);
@ -120,11 +104,5 @@ describe('Profile screen', () => {
.tap();
await waitForToast();
});
it('should reset avatar', async () => {
await element(by.type(scrollViewType)).atIndex(1).swipe('up');
await element(by.id('profile-view-reset-avatar')).tap();
await waitForToast();
});
});
});

View File

@ -0,0 +1,94 @@
import { device, waitFor, element, by } from 'detox';
import { navigateToLogin, login, sleep, platformTypes, TTextMatcher, tapBack } from '../../helpers/app';
import { createRandomUser, getProfileInfo, ITestUser, login as loginSetup } from '../../helpers/data_setup';
describe('Profile screen', () => {
let scrollViewType: string;
let textMatcher: TTextMatcher;
let user: ITestUser;
let userId: string;
beforeAll(async () => {
user = await createRandomUser();
const result = await loginSetup(user.username, user.password);
userId = result.userId;
await device.launchApp({ permissions: { notifications: 'YES' }, delete: true });
({ scrollViewType, textMatcher } = platformTypes[device.getPlatform()]);
await navigateToLogin();
await login(user.username, user.password);
await element(by.id('rooms-list-view-sidebar')).tap();
await waitFor(element(by.id('sidebar-view')))
.toBeVisible()
.withTimeout(2000);
await waitFor(element(by.id('sidebar-profile')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('sidebar-profile')).tap();
await waitFor(element(by.id('profile-view')))
.toBeVisible()
.withTimeout(2000);
});
describe('Usage', () => {
it('should click on the reset avatar button', async () => {
await element(by.type(scrollViewType)).atIndex(1).swipe('down');
await element(by.id('avatar-edit-button')).tap();
await waitFor(element(by.id('change-avatar-view-avatar')))
.toBeVisible()
.withTimeout(2000);
await waitFor(element(by.id('reset-avatar-suggestion')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('reset-avatar-suggestion')).tap();
await sleep(300);
});
it('should appear the discard alert when click the back icon ', async () => {
await tapBack();
await waitFor(element(by[textMatcher]('Discard changes?')).atIndex(0))
.toBeVisible()
.withTimeout(2000);
await waitFor(element(by[textMatcher]('Cancel')).atIndex(0))
.toBeVisible()
.withTimeout(2000);
await element(by[textMatcher]('Cancel')).atIndex(0).tap();
await sleep(200);
await tapBack();
await waitFor(element(by[textMatcher]('Discard changes?')).atIndex(0))
.toBeVisible()
.withTimeout(2000);
await waitFor(element(by[textMatcher]('Discard')).atIndex(0))
.toBeVisible()
.withTimeout(2000);
await element(by[textMatcher]('Discard')).atIndex(0).tap();
await sleep(200);
});
it('should change the avatar through a base64 image mocked', async () => {
await element(by.type(scrollViewType)).atIndex(1).swipe('down');
await element(by.id('avatar-edit-button')).tap();
const previousUserInfo = await getProfileInfo(userId);
const previousAvatarEtag = previousUserInfo.avatarETag;
await sleep(500);
await waitFor(element(by.id('change-avatar-view-upload-image')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('change-avatar-view-upload-image')).tap();
await waitFor(element(by.id('change-avatar-view-submit')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('change-avatar-view-submit')).tap();
await waitFor(element(by.id('profile-view')))
.toBeVisible()
.withTimeout(2000);
await sleep(300);
const newUserInfo = await getProfileInfo(userId);
const newAvatarEtag = newUserInfo.avatarETag;
await sleep(500);
if (previousAvatarEtag === newAvatarEtag) {
throw new Error('Failed to update the avatar');
}
});
});
});

View File

@ -16,7 +16,7 @@ async function navigateToRoomInfo(room: string) {
}
async function swipe(direction: Detox.Direction) {
await element(by.id('room-info-edit-view-list')).swipe(direction, 'fast', 0.8, 0.2);
await element(by.id('room-info-edit-view-list')).swipe(direction);
}
async function waitForToast() {
@ -185,6 +185,8 @@ describe('Room info screen', () => {
await sleep(5000); // wait for changes to be applied from socket
await element(by.id('room-info-edit-view-description')).replaceText('new description');
await element(by.id('room-info-edit-view-topic')).replaceText('new topic');
await swipe('down'); // dismiss keyboard
// announcement is hide by the keyboard
await element(by.id('room-info-edit-view-announcement')).replaceText('new announcement');
await element(by.id('room-info-edit-view-announcement')).tapReturnKey();
await element(by.id('room-info-edit-view-password')).tapReturnKey();
@ -243,6 +245,7 @@ describe('Room info screen', () => {
});
it('should delete room', async () => {
await element(by.id('room-info-edit-view-list')).swipe('up');
await element(by.id('room-info-edit-view-delete')).tap();
await waitFor(element(by[textMatcher]('Yes, delete it!')))
.toExist()

View File

@ -85,7 +85,7 @@ describe('Create team screen', () => {
await waitFor(element(by.id('room-info-edit-view-list')))
.toBeVisible()
.withTimeout(2000);
await element(by.id('room-info-edit-view-list')).swipe('up', 'fast', 0.5);
await element(by.id('room-info-edit-view-list')).swipe('up');
await waitFor(element(by.id('room-info-edit-view-delete')))
.toBeVisible()
.withTimeout(2000);

View File

@ -8,6 +8,8 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const blocklist = require('metro-config/src/defaults/exclusionList');
const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts;
module.exports = {
transformer: {
getTransformOptions: () => ({
@ -20,6 +22,7 @@ module.exports = {
maxWorkers: 2,
resolver: {
blocklistRE: blocklist([/ios\/Pods\/JitsiMeetSDK\/Frameworks\/JitsiMeet.framework\/assets\/node_modules\/react-native\/.*/]),
resolverMainFields: ['sbmodern', 'react-native', 'browser', 'main']
resolverMainFields: ['sbmodern', 'react-native', 'browser', 'main'],
sourceExts: process.env.RUNNING_E2E_TESTS ? ['mock.ts', ...defaultSourceExts] : defaultSourceExts
}
};

View File

@ -26,7 +26,8 @@
"e2e:ios-build-debug": "yarn detox build -c ios.sim.debug",
"e2e:ios-test-debug": "yarn detox test -c ios.sim.debug",
"e2e:ios-build": "yarn detox build -c ios.sim.release",
"e2e:ios-test": "yarn detox test -c ios.sim.release"
"e2e:ios-test": "yarn detox test -c ios.sim.release",
"e2e:start": "RUNNING_E2E_TESTS=true npx react-native start"
},
"lint-staged": {
"*.{js,ts,tsx}": [

View File

@ -17413,7 +17413,7 @@ react-native-gradle-plugin@^0.0.6:
react-native-image-crop-picker@RocketChat/react-native-image-crop-picker:
version "0.36.3"
resolved "https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/f347776247afb5cbd1400dde215689d7ca8fd6f2"
resolved "https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/35f5c90576cadcdd4c0eb5cef83a9f1b7b7e26e3"
react-native-image-progress@^1.1.1:
version "1.1.1"