Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(posts-saga/matrix-client): update post saga to support media uploads, create optimistic posts, handle media mapping in events, and add test coverage for full functionality #2241

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/lib/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,14 @@ export async function getMessageReadReceipts(roomId: string, messageId: string)
return await chat.get().matrix.getMessageReadReceipts(roomId, messageId);
}

export async function uploadFileMessage(channelId: string, media: File, rootMessageId: string = '', optimisticId = '') {
return chat.get().matrix.uploadFileMessage(channelId, media, rootMessageId, optimisticId);
export async function uploadFileMessage(
channelId: string,
media: File,
rootMessageId: string = '',
optimisticId = '',
isPost: boolean = false
) {
return chat.get().matrix.uploadFileMessage(channelId, media, rootMessageId, optimisticId, isPost);
}

export async function addRoomToLabel(roomId: string, label: string) {
Expand Down
13 changes: 9 additions & 4 deletions src/lib/chat/matrix-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ export class MatrixClient implements IChatClient {
};
}

async uploadFileMessage(roomId: string, media: File, rootMessageId: string = '', optimisticId = '') {
async uploadFileMessage(roomId: string, media: File, rootMessageId: string = '', optimisticId = '', isPost = false) {
const isEncrypted = this.matrix.isRoomEncrypted(roomId);

const { width, height } = await getImageDimensions(media);
Expand Down Expand Up @@ -671,8 +671,13 @@ export class MatrixClient implements IChatClient {
optimisticId,
} as any;

const messageResult = await this.matrix.sendMessage(roomId, content);
this.recordMessageSent(roomId);
let messageResult;
if (isPost) {
messageResult = await this.matrix.sendEvent(roomId, CustomEventType.ROOM_POST as any, content);
} else {
messageResult = await this.matrix.sendMessage(roomId, content);
this.recordMessageSent(roomId);
}

// Don't return a full message, only the pertinent attributes that changed.
return {
Expand Down Expand Up @@ -1188,7 +1193,7 @@ export class MatrixClient implements IChatClient {
}

private async publishPostEvent(event) {
this.events.receiveNewMessage(event.room_id, mapEventToPostMessage(event, this.matrix) as any);
this.events.receiveNewMessage(event.room_id, (await mapEventToPostMessage(event, this.matrix)) as any);
}

private publishRoomNameChange = (room: Room) => {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/chat/matrix/chat-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,11 @@ export function mapEventToAdminMessage(matrixMessage): Message {
};
}

export function mapEventToPostMessage(matrixMessage, sdkMatrixClient: SDKMatrixClient) {
export async function mapEventToPostMessage(matrixMessage, sdkMatrixClient: SDKMatrixClient) {
const { event_id, content, origin_server_ts, sender: senderId } = matrixMessage;

const senderData = sdkMatrixClient.getUser(senderId);
const { media, image, rootMessageId } = await parseMediaData(matrixMessage);

return {
id: event_id,
Expand All @@ -134,6 +135,9 @@ export function mapEventToPostMessage(matrixMessage, sdkMatrixClient: SDKMatrixC
preview: null,
sendStatus: MessageSendStatus.SUCCESS,
isPost: true,
media,
image,
rootMessageId,
};
}

Expand Down
7 changes: 4 additions & 3 deletions src/store/messages/uploadable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,21 @@ export const createUploadableFile = (file): Uploadable => {
export interface Uploadable {
file: any;
optimisticMessage: Message;
upload: (channelId, rootMessageId) => Generator<CallEffect<Message | unknown>>;
upload: (channelId, rootMessageId, isPost?) => Generator<CallEffect<Message | unknown>>;
}

export class UploadableMedia implements Uploadable {
public optimisticMessage: Message;

constructor(public file) {}
*upload(channelId, rootMessageId) {
*upload(channelId, rootMessageId, isPost = false) {
return yield call(
uploadFileMessage,
channelId,
this.file.nativeFile,
rootMessageId,
this.optimisticMessage?.id?.toString()
this.optimisticMessage?.id?.toString(),
isPost
);
}
}
Expand Down
158 changes: 139 additions & 19 deletions src/store/posts/saga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { call, select } from 'redux-saga/effects';
import { expectSaga, testSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';

import { sendPost, createOptimisticPost } from './saga';
import { sendPost, createOptimisticPost, createOptimisticPosts, uploadFileMessages } from './saga';
import { sendPostByChannelId } from '../../lib/chat';
import { rawMessagesSelector } from '../messages/saga';
import { currentUserSelector } from '../authentication/saga';
import { MessageSendStatus, receive as receiveMessage } from '../messages';
import { fetchPosts } from './saga';
import { rootReducer } from '../reducer';
import { StoreBuilder } from '../test/store';
Expand All @@ -15,45 +14,120 @@ import { ConversationStatus, denormalize } from '../channels';
import { getPostMessagesByChannelId } from '../../lib/chat';
import { mapMessageSenders } from '../messages/utils.matrix';

const mockCreateUploadableFile = jest.fn();
jest.mock('../messages/uploadable', () => ({
createUploadableFile: (file) => mockCreateUploadableFile(file),
}));

describe(sendPost, () => {
it('creates optimistic post then sends the post successfully', async () => {
it('creates optimistic post and sends the post successfully', () => {
const channelId = 'channel-id';
const message = 'New post content';

const optimisticPost = { optimisticId: 'optimistic-id', message };
const uploadableFiles = [];

testSaga(sendPost, { payload: { channelId, message } })
.next()
.call(createOptimisticPost, channelId, message)
.next({ optimisticPost })
.call(sendPostByChannelId, channelId, message, 'optimistic-id')
.call(createOptimisticPosts, channelId, message, uploadableFiles)
.next({ optimisticPost, uploadableFiles })
.call(sendPostByChannelId, channelId, message, optimisticPost.optimisticId)
.next({ id: 'sent-post-id' })
.next()
.next()
.isDone();
});

it('handles failure when sending the post', async () => {
it('creates optimistic file posts then sends files', async () => {
const channelId = 'channel-id';
const message = 'New post content';
const optimisticPost = { optimisticId: 'optimistic-id', message };
const uploadableFile = { file: { nativeFile: {} } };
const files = [{ id: 'file-id' }];

const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockCreateUploadableFile.mockReturnValue(uploadableFile);

testSaga(sendPost, { payload: { channelId, message } })
testSaga(sendPost, { payload: { channelId, files } })
.next()
.call(createOptimisticPost, channelId, message)
.next({ optimisticPost })
.call(sendPostByChannelId, channelId, message, 'optimistic-id')
.throw(new Error('Failed to send post'))
.put(receiveMessage({ id: 'optimistic-id', sendStatus: MessageSendStatus.FAILED }))
.call(createOptimisticPosts, channelId, undefined, [uploadableFile])
.next({ uploadableFiles: [uploadableFile] })
.call(uploadFileMessages, channelId, '', [uploadableFile], true)
.next()
.isDone();
});

consoleErrorSpy.mockRestore();
it('sends files with a rootMessageId if text is included', async () => {
const channelId = 'channel-id';
const uploadableFile = { nativeFile: {} };
const files = [{ id: 'file-id' }];

testSaga(sendPost, { payload: { channelId, files } })
.next()
.next({ optimisticPost: { id: 'root-id' }, uploadableFiles: [uploadableFile] })
.next({ id: 'root-id' })
.call(uploadFileMessages, channelId, 'root-id', [uploadableFile], true)
.next()
.isDone();
});

it('sends all but the first file if the text root message fails', async () => {
const channelId = 'channel-id';
const uploadableFile1 = { nativeFile: {} };
const uploadableFile2 = { nativeFile: {} };
const files = [{ id: 'file-id' }];

testSaga(sendPost, { payload: { channelId, files } })
.next()
.next({
optimisticPost: { id: 'root-id' },
uploadableFiles: [
uploadableFile1,
uploadableFile2,
],
})
.next(null) // Fail
.call(uploadFileMessages, channelId, '', [uploadableFile2], true)
.next()
.isDone();
});
});

describe(createOptimisticPosts, () => {
it('creates the root post', async () => {
const channelId = 'channel-id';
const message = 'test message';

const { returnValue, storeState } = await expectSaga(createOptimisticPosts, channelId, message, [])
.withReducer(rootReducer, new StoreBuilder().build())
.run();

const channel = denormalizeChannel(channelId, storeState);
expect(channel.messages[0].message).toEqual(message);
expect(channel.messages[0].id).not.toBeNull();
expect(channel.messages[0].sender).not.toBeNull();
expect(returnValue.optimisticPost).toEqual(expect.objectContaining({ message: 'test message' }));
});

it('creates the uploadable files with optimistic message', async () => {
const channelId = 'channel-id';
const message = 'test message';
const uploadableFile = { file: { name: 'media-file' } };

const { returnValue, storeState } = await expectSaga(createOptimisticPosts, channelId, message, [
uploadableFile,
])
.withReducer(rootReducer, new StoreBuilder().build())
.run();

const channel = denormalizeChannel(channelId, storeState);
expect(channel.messages[0].message).toEqual(message);
expect(channel.messages[0].id).not.toBeNull();
expect(channel.messages[0].sender).not.toBeNull();
expect(returnValue.uploadableFiles[0].optimisticMessage.media).toEqual(
expect.objectContaining({ name: 'media-file' })
);
});
});

describe(createOptimisticPost, () => {
it('creates an optimistic post and updates the channel state', async () => {
it('creates an optimistic post', async () => {
const channelId = 'channel-id';
const postText = 'This is an optimistic post';

Expand All @@ -74,6 +148,52 @@ describe(createOptimisticPost, () => {
});
});

describe(uploadFileMessages, () => {
it('uploads an uploadable file', async () => {
const imageCreationResponse = { id: 'image-message-id', optimisticId: 'optimistic-id' };
const upload = jest.fn().mockReturnValue(imageCreationResponse);
const uploadable = { upload, optimisticMessage: { id: 'optimistic-id' } } as any;
const channelId = 'channel-id';

const initialState = new StoreBuilder().withConversationList({
id: channelId,
messages: [{ id: 'optimistic-id' } as any],
});

const { storeState } = await expectSaga(uploadFileMessages, channelId, '', [uploadable], true)
.withReducer(rootReducer, initialState.build())
.run();

const channel = denormalizeChannel(channelId, storeState);
expect(channel.messages).toHaveLength(1);
expect(channel.messages[0].id).toEqual('optimistic-id');
});

it('first media file sets its rootMessageId', async () => {
const imageCreationResponse = { id: 'image-message-id' };
const upload1 = jest.fn().mockReturnValue(imageCreationResponse);
const upload2 = jest.fn().mockReturnValue(imageCreationResponse);
const uploadable1 = { upload: upload1, optimisticMessage: { id: 'id-1' } } as any;
const uploadable2 = { upload: upload2, optimisticMessage: { id: 'id-2' } } as any;
const channelId = 'channel-id';
const rootMessageId = 'root-message-id';

await expectSaga(
uploadFileMessages,
channelId,
rootMessageId,
[
uploadable1,
uploadable2,
],
true
).run();

expect(upload1).toHaveBeenCalledWith(channelId, rootMessageId, true);
expect(upload2).toHaveBeenCalledWith(channelId, '', true);
});
});

describe(fetchPosts, () => {
function subject(...args: Parameters<typeof expectSaga>) {
return expectSaga(...args).provide([
Expand Down
Loading