feat: quotes on E2EE Messages (#4880)
* [NEW] Quotes on E2EE Messages * parser inside the model * refactor and tweaks * minor tweak * add e2e tests to test the e2e quotes * update storyshot * added the test for quoted message --------- Co-authored-by: Gleidson Daniel Silva <gleidson10daniel@hotmail.com>
This commit is contained in:
parent
3b41917d8a
commit
88144441fd
File diff suppressed because one or more lines are too long
|
@ -232,7 +232,9 @@ const Reply = React.memo(
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* The testID is to test properly quoted messages using it as ancestor */}
|
||||
<Touchable
|
||||
testID={`reply-${attachment?.author_name}-${attachment?.text}`}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.button,
|
||||
|
|
|
@ -147,7 +147,10 @@ export interface IMessage extends IMessageFromServer {
|
|||
editedAt?: string | Date;
|
||||
}
|
||||
|
||||
export type TMessageModel = IMessage & Model;
|
||||
export type TMessageModel = IMessage &
|
||||
Model & {
|
||||
asPlain: () => IMessage;
|
||||
};
|
||||
|
||||
export type TAnyMessageModel = TMessageModel | TThreadModel | TThreadMessageModel;
|
||||
export type TTypeMessages = IMessageFromServer | ILoadMoreMessage | IMessage;
|
||||
|
|
|
@ -38,4 +38,7 @@ export interface IThread extends IMessage {
|
|||
draftMessage?: string;
|
||||
}
|
||||
|
||||
export type TThreadModel = IThread & Model;
|
||||
export type TThreadModel = IThread &
|
||||
Model & {
|
||||
asPlain: () => IMessage;
|
||||
};
|
||||
|
|
|
@ -6,4 +6,7 @@ export interface IThreadMessage extends IMessage {
|
|||
tmsg?: string;
|
||||
}
|
||||
|
||||
export type TThreadMessageModel = IThreadMessage & Model;
|
||||
export type TThreadMessageModel = IThreadMessage &
|
||||
Model & {
|
||||
asPlain: () => IMessage;
|
||||
};
|
||||
|
|
|
@ -85,4 +85,47 @@ export default class Message extends Model {
|
|||
@json('md', sanitizer) md;
|
||||
|
||||
@field('comment') comment;
|
||||
|
||||
asPlain() {
|
||||
return {
|
||||
id: this.id,
|
||||
rid: this.subscription.id,
|
||||
msg: this.msg,
|
||||
t: this.t,
|
||||
ts: this.ts,
|
||||
u: this.u,
|
||||
alias: this.alias,
|
||||
parseUrls: this.parseUrls,
|
||||
groupable: this.groupable,
|
||||
avatar: this.avatar,
|
||||
emoji: this.emoji,
|
||||
attachments: this.attachments,
|
||||
urls: this.urls,
|
||||
_updatedAt: this._updatedAt,
|
||||
status: this.status,
|
||||
pinned: this.pinned,
|
||||
starred: this.starred,
|
||||
editedBy: this.editedBy,
|
||||
reactions: this.reactions,
|
||||
role: this.role,
|
||||
drid: this.drid,
|
||||
dcount: this.dcount,
|
||||
dlm: this.dlm,
|
||||
tmid: this.tmid,
|
||||
tcount: this.tcount,
|
||||
tlm: this.tlm,
|
||||
replies: this.replies,
|
||||
mentions: this.mentions,
|
||||
channels: this.channels,
|
||||
unread: this.unread,
|
||||
autoTranslate: this.autoTranslate,
|
||||
translations: this.translations,
|
||||
tmsg: this.tmsg,
|
||||
blocks: this.blocks,
|
||||
e2e: this.e2e,
|
||||
tshow: this.tshow,
|
||||
md: this.md,
|
||||
comment: this.comment
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,4 +77,42 @@ export default class Thread extends Model {
|
|||
@field('e2e') e2e;
|
||||
|
||||
@field('draft_message') draftMessage;
|
||||
|
||||
asPlain() {
|
||||
return {
|
||||
id: this.id,
|
||||
msg: this.msg,
|
||||
t: this.t,
|
||||
ts: this.ts,
|
||||
u: this.u,
|
||||
alias: this.alias,
|
||||
parseUrls: this.parseUrls,
|
||||
groupable: this.groupable,
|
||||
avatar: this.avatar,
|
||||
emoji: this.emoji,
|
||||
attachments: this.attachments,
|
||||
urls: this.urls,
|
||||
_updatedAt: this._updatedAt,
|
||||
status: this.status,
|
||||
pinned: this.pinned,
|
||||
starred: this.starred,
|
||||
editedBy: this.editedBy,
|
||||
reactions: this.reactions,
|
||||
role: this.role,
|
||||
drid: this.drid,
|
||||
dcount: this.dcount,
|
||||
dlm: this.dlm,
|
||||
tmid: this.tmid,
|
||||
tcount: this.tcount,
|
||||
tlm: this.tlm,
|
||||
replies: this.replies,
|
||||
mentions: this.mentions,
|
||||
channels: this.channels,
|
||||
unread: this.unread,
|
||||
autoTranslate: this.autoTranslate,
|
||||
translations: this.translations,
|
||||
e2e: this.e2e,
|
||||
draftMessage: this.draftMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -77,4 +77,42 @@ export default class ThreadMessage extends Model {
|
|||
@field('draft_message') draftMessage;
|
||||
|
||||
@field('e2e') e2e;
|
||||
|
||||
asPlain() {
|
||||
return {
|
||||
id: this.id,
|
||||
msg: this.msg,
|
||||
t: this.t,
|
||||
ts: this.ts,
|
||||
u: this.u,
|
||||
rid: this.rid,
|
||||
alias: this.alias,
|
||||
parseUrls: this.parseUrls,
|
||||
groupable: this.groupable,
|
||||
avatar: this.avatar,
|
||||
emoji: this.emoji,
|
||||
attachments: this.attachments,
|
||||
urls: this.urls,
|
||||
_updatedAt: this._updatedAt,
|
||||
status: this.status,
|
||||
pinned: this.pinned,
|
||||
starred: this.starred,
|
||||
editedBy: this.editedBy,
|
||||
reactions: this.reactions,
|
||||
role: this.role,
|
||||
drid: this.drid,
|
||||
dcount: this.dcount,
|
||||
dlm: this.dlm,
|
||||
tcount: this.tcount,
|
||||
tlm: this.tlm,
|
||||
replies: this.replies,
|
||||
mentions: this.mentions,
|
||||
channels: this.channels,
|
||||
unread: this.unread,
|
||||
autoTranslate: this.autoTranslate,
|
||||
translations: this.translations,
|
||||
draftMessage: this.draftMessage,
|
||||
e2e: this.e2e
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export const getMessageById = async (messageId: string | null) => {
|
|||
try {
|
||||
const result = await messageCollection.find(messageId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { store } from '../../store/auxStore';
|
||||
import { IAttachment, IMessage } from '../../../definitions';
|
||||
import { getAvatarURL } from '../../methods/helpers';
|
||||
|
||||
export function createQuoteAttachment(message: IMessage, messageLink: string): IAttachment {
|
||||
const { server, version: serverVersion } = store.getState().server;
|
||||
const externalProviderUrl = (store.getState().settings?.Accounts_AvatarExternalProviderUrl as string) || '';
|
||||
|
||||
return {
|
||||
text: message.msg,
|
||||
...('translations' in message && { translations: message?.translations }),
|
||||
message_link: messageLink,
|
||||
author_name: message.alias || message.u.username,
|
||||
author_icon: getAvatarURL({
|
||||
avatar: message.u?.username && `/avatar/${message.u?.username}`,
|
||||
type: message.t,
|
||||
userId: message.u?._id,
|
||||
server,
|
||||
serverVersion,
|
||||
externalProviderUrl
|
||||
}),
|
||||
attachments: message.attachments || [],
|
||||
ts: message.ts
|
||||
};
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { getMessageUrlRegex } from './getMessageUrlRegex';
|
||||
|
||||
describe('Should regex', () => {
|
||||
test('a common quote separated by space', () => {
|
||||
const quote = '[ ](https://open.rocket.chat/group/room?msg=rid) test';
|
||||
expect(quote.match(getMessageUrlRegex())).toStrictEqual(['https://open.rocket.chat/group/room?msg=rid']);
|
||||
});
|
||||
test('a quote separated by break line', () => {
|
||||
const quote = '[ ](https://open.rocket.chat/group/room?msg=rid)\ntest';
|
||||
expect(quote.match(getMessageUrlRegex())).toStrictEqual(['https://open.rocket.chat/group/room?msg=rid']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
// https://github.com/RocketChat/Rocket.Chat/blob/0226236b871d12c62338111c70b65d5d406447a3/apps/meteor/lib/getMessageUrlRegex.ts#L1-L2
|
||||
export const getMessageUrlRegex = (): RegExp =>
|
||||
/([A-Za-z]{3,9}):\/\/([-;:&=\+\$,\w]+@{1})?([-A-Za-z0-9\.]+)+:?(\d+)?((\/[-\+=!:~%\/\.@\,\w]*)?\??([-\+=&!:;%@\/\.\,\w]+)?(?:#([^\s\)]+))?)?/g;
|
|
@ -0,0 +1,14 @@
|
|||
import { IMessage } from '../../../definitions';
|
||||
|
||||
export const mapMessageFromAPI = ({ attachments, tlm, ts, _updatedAt, ...message }: IMessage) => ({
|
||||
...message,
|
||||
ts: new Date(ts),
|
||||
...(tlm && { tlm: new Date(tlm) }),
|
||||
_updatedAt: new Date(_updatedAt),
|
||||
...(attachments && {
|
||||
attachments: attachments.map(({ ts, ...attachment }) => ({
|
||||
...(ts && { ts: new Date(ts) }),
|
||||
...(attachment as any)
|
||||
}))
|
||||
})
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import { TMessageModel } from '../../../definitions';
|
||||
|
||||
export const mapMessageFromDB = (messageModel: TMessageModel) => {
|
||||
const parsedMessage = messageModel.asPlain();
|
||||
return {
|
||||
...parsedMessage,
|
||||
ts: new Date(parsedMessage.ts),
|
||||
...(parsedMessage.tlm && { tlm: new Date(parsedMessage.tlm) }),
|
||||
_updatedAt: new Date(parsedMessage._updatedAt),
|
||||
...(parsedMessage.attachments && {
|
||||
attachments: parsedMessage.attachments.map(({ ts, ...attachment }) => ({
|
||||
...(ts && { ts: new Date(ts) }),
|
||||
...(attachment as any)
|
||||
}))
|
||||
})
|
||||
};
|
||||
};
|
|
@ -2,7 +2,9 @@ import EJSON from 'ejson';
|
|||
import { Base64 } from 'js-base64';
|
||||
import SimpleCrypto from 'react-native-simple-crypto';
|
||||
import ByteBuffer from 'bytebuffer';
|
||||
import parse from 'url-parse';
|
||||
|
||||
import getSingleMessage from '../methods/getSingleMessage';
|
||||
import { IMessage, IUser } from '../../definitions';
|
||||
import Deferred from './helpers/deferred';
|
||||
import { debounce } from '../methods/helpers';
|
||||
|
@ -21,6 +23,11 @@ import {
|
|||
import { Encryption } from './index';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants';
|
||||
import { Services } from '../services';
|
||||
import { getMessageUrlRegex } from './helpers/getMessageUrlRegex';
|
||||
import { mapMessageFromAPI } from './helpers/mapMessageFromApi';
|
||||
import { mapMessageFromDB } from './helpers/mapMessageFromDB';
|
||||
import { createQuoteAttachment } from './helpers/createQuoteAttachment';
|
||||
import { getMessageById } from '../database/services/Message';
|
||||
|
||||
export default class EncryptionRoom {
|
||||
ready: boolean;
|
||||
|
@ -268,12 +275,15 @@ export default class EncryptionRoom {
|
|||
tmsg = await this.decryptText(tmsg);
|
||||
}
|
||||
|
||||
return {
|
||||
const decryptedMessage: IMessage = {
|
||||
...message,
|
||||
tmsg,
|
||||
msg,
|
||||
e2e: E2E_STATUS.DONE
|
||||
e2e: 'done'
|
||||
};
|
||||
|
||||
const decryptedMessageWithQuote = await this.decryptQuoteAttachment(decryptedMessage);
|
||||
return decryptedMessageWithQuote;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
|
@ -281,4 +291,37 @@ export default class EncryptionRoom {
|
|||
|
||||
return message;
|
||||
};
|
||||
|
||||
async decryptQuoteAttachment(message: IMessage) {
|
||||
const urls = message?.msg?.match(getMessageUrlRegex()) || [];
|
||||
await Promise.all(
|
||||
urls.map(async (url: string) => {
|
||||
const parsedUrl = parse(url, true);
|
||||
const messageId = parsedUrl.query?.msg;
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// From local db
|
||||
const messageFromDB = await getMessageById(messageId);
|
||||
if (messageFromDB && messageFromDB.e2e === 'done') {
|
||||
const decryptedQuoteMessage = mapMessageFromDB(messageFromDB);
|
||||
message.attachments = message.attachments || [];
|
||||
const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url);
|
||||
return message.attachments.push(quoteAttachment);
|
||||
}
|
||||
|
||||
// From API
|
||||
const quotedMessageObject = await getSingleMessage(messageId);
|
||||
if (!quotedMessageObject) {
|
||||
return;
|
||||
}
|
||||
const decryptedQuoteMessage = await this.decrypt(mapMessageFromAPI(quotedMessageObject));
|
||||
message.attachments = message.attachments || [];
|
||||
const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url);
|
||||
return message.attachments.push(quoteAttachment);
|
||||
})
|
||||
);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ import {
|
|||
TTextMatcher,
|
||||
tapAndWaitFor,
|
||||
expectValidRegisterOrRetry,
|
||||
mockMessage
|
||||
mockMessage,
|
||||
tryTapping
|
||||
} from '../../helpers/app';
|
||||
import data from '../../data';
|
||||
import { createRandomUser, ITestUser } from '../../helpers/data_setup';
|
||||
|
@ -160,6 +161,33 @@ describe('E2E Encryption', () => {
|
|||
|
||||
it('should send message and be able to read it', async () => {
|
||||
mockedMessageText = await mockMessage('message');
|
||||
});
|
||||
|
||||
it('should quote a message and be able to read both', async () => {
|
||||
const mockedMessageTextToQuote = await mockMessage('message to be quote');
|
||||
const quotedMessage = `${mockedMessageTextToQuote}d`;
|
||||
await tryTapping(element(by[textMatcher](mockedMessageTextToQuote)).atIndex(0), 2000, true);
|
||||
await waitFor(element(by.id('action-sheet')))
|
||||
.toExist()
|
||||
.withTimeout(2000);
|
||||
await expect(element(by.id('action-sheet-handle'))).toBeVisible();
|
||||
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
|
||||
await element(by[textMatcher]('Quote')).atIndex(0).tap();
|
||||
await element(by.id('messagebox-input')).replaceText(quotedMessage);
|
||||
await waitFor(element(by.id('messagebox-send-message')))
|
||||
.toExist()
|
||||
.withTimeout(2000);
|
||||
await element(by.id('messagebox-send-message')).tap();
|
||||
await waitFor(element(by[textMatcher](quotedMessage)).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
await waitFor(
|
||||
element(
|
||||
by.id(`reply-${user.name}-${mockedMessageTextToQuote}`).withDescendant(by[textMatcher](mockedMessageTextToQuote))
|
||||
)
|
||||
)
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
await tapBack();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -453,7 +453,12 @@ describe('Room screen', () => {
|
|||
.toExist()
|
||||
.withTimeout(2000);
|
||||
await element(by.id('messagebox-send-message')).tap();
|
||||
// TODO: test if quote was sent
|
||||
await waitFor(element(by[textMatcher](quotedMessage)).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
await waitFor(element(by.id(`reply-${user.name}-${quoteMessage}`).withDescendant(by[textMatcher](quoteMessage))))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
});
|
||||
|
||||
it('should delete message', async () => {
|
||||
|
|
Loading…
Reference in New Issue