Skip to content

Commit 9d43512

Browse files
chore: use REST to upload avatar (#7107)
* feat: use file upload rest * code improvements * fix: tests * code improvements * fix: e2e test * chore: cleanup * fix: backward compatibilities * fix: tests * fix: tests * update mocks * comments
1 parent d95ed13 commit 9d43512

9 files changed

Lines changed: 288 additions & 25 deletions

File tree

.maestro/tests/assorted/change-avatar.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,14 @@ tags:
9292
- extendedWaitUntil:
9393
visible:
9494
id: 'change-avatar-view-submit'
95-
enabled: false
9695
timeout: 60000
96+
# Before selecting/changing avatar, Save should not proceed
97+
- tapOn:
98+
id: 'change-avatar-view-submit'
99+
- extendedWaitUntil:
100+
visible:
101+
id: 'change-avatar-view-submit'
102+
timeout: 3000
97103
- extendedWaitUntil:
98104
visible:
99105
text: 'Upload image'
@@ -103,7 +109,6 @@ tags:
103109
- extendedWaitUntil:
104110
visible:
105111
id: 'change-avatar-view-submit'
106-
enabled: true
107112
timeout: 60000
108113
- tapOn:
109114
id: 'change-avatar-view-submit'
@@ -125,7 +130,6 @@ tags:
125130
- extendedWaitUntil:
126131
visible:
127132
id: 'change-avatar-view-submit'
128-
enabled: false
129133
timeout: 60000
130134
- extendedWaitUntil:
131135
visible:
@@ -136,7 +140,6 @@ tags:
136140
- extendedWaitUntil:
137141
visible:
138142
id: 'change-avatar-view-submit'
139-
enabled: true
140143
timeout: 60000
141144
- tapOn:
142145
id: 'change-avatar-view-submit'
@@ -158,7 +161,6 @@ tags:
158161
- extendedWaitUntil:
159162
visible:
160163
id: 'change-avatar-view-submit'
161-
enabled: false
162164
timeout: 60000
163165
- extendedWaitUntil:
164166
visible:
@@ -173,11 +175,9 @@ tags:
173175
timeout: 60000
174176
- tapOn:
175177
text: 'Fetch image from URL'
176-
- runFlow: '../../helpers/hide-keyboard.yaml'
177178
- extendedWaitUntil:
178179
visible:
179180
id: 'change-avatar-view-submit'
180-
enabled: true
181181
timeout: 60000
182182
- tapOn:
183183
id: 'change-avatar-view-submit'

app/definitions/rest/v1/users.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export type UsersEndpoints = {
5757
'users.resetAvatar': {
5858
POST: (params: { userId: string }) => {};
5959
};
60+
'users.setAvatar': {
61+
POST: (params: { avatarUrl: string }) => { success: boolean };
62+
};
6063
'users.removeOtherTokens': {
6164
POST: (params: { userId: string }) => {};
6265
};

app/lib/methods/helpers/ImagePicker/ImagePicker.mock.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { random } from 'lodash';
22
import { type Image as ImageType, type ImageOrVideo as ImageOrVideoType } from 'react-native-image-crop-picker';
3+
import * as FileSystem from 'expo-file-system/legacy';
34

45
export type Image = ImageType;
56
export type ImageOrVideo = ImageOrVideoType;
@@ -29,14 +30,31 @@ const image = {
2930
creationDate: '1679327100'
3031
};
3132

32-
export function openPicker(options: any): Promise<any> {
33-
const mockImageRocketBase64 = options?.multiple ? [image] : image;
34-
return Promise.resolve(mockImageRocketBase64);
33+
const createMockImage = async () => {
34+
if (!FileSystem.cacheDirectory) {
35+
return image;
36+
}
37+
38+
const filePath = `${FileSystem.cacheDirectory}e2e-avatar-${random(1000000)}.jpg`;
39+
await FileSystem.writeAsStringAsync(filePath, mockImageRocketBase64, {
40+
encoding: FileSystem.EncodingType.Base64
41+
});
42+
43+
return {
44+
...image,
45+
path: filePath,
46+
sourceURL: filePath
47+
};
48+
};
49+
50+
export async function openPicker(options: any): Promise<any> {
51+
const mockedImage = await createMockImage();
52+
return options?.multiple ? [mockedImage] : mockedImage;
3553
}
3654

37-
export function openCamera(options: any): Promise<any> {
38-
const mockImageRocketBase64 = options?.multiple ? [image] : image;
39-
return Promise.resolve(mockImageRocketBase64);
55+
export async function openCamera(options: any): Promise<any> {
56+
const mockedImage = await createMockImage();
57+
return options?.multiple ? [mockedImage] : mockedImage;
4058
}
4159

4260
export default {

app/lib/methods/helpers/fileUpload/Upload.android.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class Upload {
99
uri: string;
1010
type: string | undefined;
1111
name: string | undefined;
12+
fieldName?: string;
1213
} | null;
1314
private headers: { [key: string]: string };
1415
private formData: any;
@@ -37,7 +38,7 @@ export class Upload {
3738

3839
public appendFile(item: IFormData): void {
3940
if (item.uri) {
40-
this.file = { uri: item.uri, type: item.type, name: item.filename };
41+
this.file = { uri: item.uri, type: item.type, name: item.filename, fieldName: item.name };
4142
} else {
4243
this.formData[item.name] = item.data;
4344
}
@@ -56,7 +57,7 @@ export class Upload {
5657
headers: this.headers,
5758
httpMethod: 'POST',
5859
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
59-
fieldName: 'file',
60+
fieldName: this.file.fieldName || 'file',
6061
mimeType: this.file.type,
6162
parameters: this.formData
6263
},
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import FileUpload from '../helpers/fileUpload';
2+
import { copyFileToCacheDirectoryIfNeeded } from '../sendFileMessage/utils';
3+
import { store as reduxStore } from '../../store/auxStore';
4+
import { uploadUserAvatarMultipart } from './uploadAvatar';
5+
6+
jest.mock('../../store/auxStore', () => ({
7+
store: {
8+
getState: jest.fn()
9+
}
10+
}));
11+
12+
jest.mock('../helpers/fileUpload', () => ({
13+
__esModule: true,
14+
default: jest.fn().mockImplementation(() => ({
15+
send: jest.fn().mockResolvedValue({ success: true })
16+
}))
17+
}));
18+
19+
jest.mock('../sendFileMessage/utils', () => ({
20+
copyFileToCacheDirectoryIfNeeded: jest.fn((p: string) => Promise.resolve(`cached:${p}`))
21+
}));
22+
23+
jest.mock('@rocket.chat/sdk', () => ({
24+
settings: { customHeaders: { 'X-Custom': 'custom' } }
25+
}));
26+
27+
const baseState = {
28+
server: { server: 'https://open.rocket.chat' },
29+
login: { user: { id: 'uid1', token: 'tok1' } }
30+
};
31+
32+
describe('uploadAvatar helper', () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
(reduxStore.getState as jest.Mock).mockReturnValue(baseState);
36+
});
37+
38+
it('uploads multipart avatar using cached local file and auth headers', async () => {
39+
await uploadUserAvatarMultipart('file:///tmp/avatar.jpg', 'image/jpeg', 'avatar.jpg');
40+
41+
expect(copyFileToCacheDirectoryIfNeeded).toHaveBeenCalledWith('file:///tmp/avatar.jpg', 'avatar.jpg');
42+
expect(FileUpload).toHaveBeenCalledTimes(1);
43+
const [uploadUrl, headers, formData] = (FileUpload as jest.Mock).mock.calls[0];
44+
expect(uploadUrl).toBe('https://open.rocket.chat/api/v1/users.setAvatar');
45+
expect(headers).toEqual(
46+
expect.objectContaining({
47+
'X-Custom': 'custom',
48+
'Content-Type': 'multipart/form-data',
49+
'X-Auth-Token': 'tok1',
50+
'X-User-Id': 'uid1'
51+
})
52+
);
53+
expect(formData).toEqual([
54+
{
55+
name: 'image',
56+
uri: 'cached:file:///tmp/avatar.jpg',
57+
type: 'image/jpeg',
58+
filename: 'avatar.jpg'
59+
}
60+
]);
61+
});
62+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { settings as RocketChatSettings } from '@rocket.chat/sdk';
2+
3+
import FileUpload from '../helpers/fileUpload';
4+
import { type IFormData } from '../helpers/fileUpload/definitions';
5+
import { copyFileToCacheDirectoryIfNeeded } from '../sendFileMessage/utils';
6+
import { store as reduxStore } from '../../store/auxStore';
7+
8+
export const uploadUserAvatarMultipart = async (localUri: string, mimeType: string, filename: string): Promise<void> => {
9+
const { server } = reduxStore.getState().server;
10+
const { id, token } = reduxStore.getState().login.user;
11+
const filePath = await copyFileToCacheDirectoryIfNeeded(localUri, filename);
12+
const formData: IFormData[] = [{ name: 'image', uri: filePath, type: mimeType, filename }];
13+
const headers = {
14+
...RocketChatSettings.customHeaders,
15+
'Content-Type': 'multipart/form-data',
16+
'X-Auth-Token': token,
17+
'X-User-Id': id
18+
};
19+
const upload = new FileUpload(`${server}/api/v1/users.setAvatar`, headers, formData);
20+
await upload.send();
21+
};

app/lib/services/restApi.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { type OperationParams, type ResultFor } from '../../definitions/rest/hel
2020
import { type SubscriptionsEndpoints } from '../../definitions/rest/v1/subscriptions';
2121
import { Encryption } from '../encryption';
2222
import { type RoomTypes, roomTypeToApiType } from '../methods/roomTypeToApiType';
23+
import { uploadUserAvatarMultipart } from '../methods/uploadAvatar/uploadAvatar';
2324
import { unsubscribeRooms } from '../methods/subscribeRooms';
2425
import { compareServerVersion, getBundleId, isIOS } from '../methods/helpers';
2526
import { getDeviceToken } from '../notifications';
@@ -721,17 +722,49 @@ export const resetAvatar = (userId: string) =>
721722
// RC 0.55.0
722723
sdk.post('users.resetAvatar', { userId });
723724

724-
export const setAvatarFromService = ({
725+
const isHttpAvatarUrl = (value: string | undefined): value is string =>
726+
typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'));
727+
728+
export const setAvatarFromService = async ({
725729
data,
726730
contentType = '',
727-
service = null
731+
service = null,
732+
url
728733
}: {
729734
data: any;
730735
contentType?: string;
731736
service?: string | null;
732-
}): Promise<void> =>
733-
// RC 0.51.0
734-
sdk.methodCallWrapper('setAvatarFromService', data, contentType, service);
737+
url?: string;
738+
}): Promise<void> => {
739+
const serverVersion = reduxStore.getState().server.version;
740+
const isHttpUrl = isHttpAvatarUrl(url);
741+
// In ChangeAvatarView, `url` can be:
742+
// - a remote http(s) URL from "Fetch image from URL"
743+
// - a local filesystem URI/path from camera/gallery upload (`response.path`)
744+
// Only remote URLs should be sent as `avatarUrl`; local paths must go through multipart upload.
745+
// RC 0.51.0 — keep DDP + payload shape unchanged below 8.0.0
746+
if (compareServerVersion(serverVersion, 'lowerThan', '8.0.0')) {
747+
return sdk.methodCallWrapper('setAvatarFromService', data, contentType, service);
748+
}
749+
750+
// RC 8.0.0 — REST users.setAvatar (multipart image or JSON avatarUrl)
751+
if (service === 'url' && typeof data === 'string') {
752+
await sdk.post('users.setAvatar', { avatarUrl: data });
753+
return;
754+
}
755+
756+
if (service === 'upload' && url && !isHttpUrl) {
757+
await uploadUserAvatarMultipart(url, contentType || 'image/jpeg', 'avatar.jpg');
758+
return;
759+
}
760+
761+
if (isHttpUrl) {
762+
await sdk.post('users.setAvatar', { avatarUrl: url });
763+
return;
764+
}
765+
766+
throw new Error('Invalid avatar payload');
767+
};
735768

736769
export const getUsernameSuggestion = () =>
737770
// RC 0.65.0

0 commit comments

Comments
 (0)