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: improve quote event loading [SQSERVICES-1834] #14632

Merged
merged 10 commits into from
Feb 16, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ export const MessageWrapper: React.FC<MessageParams & {hasMarker: boolean; isMes
totalMessage,
isMsgElementsFocusable,
}) => {
const findMessage = (conversation: Conversation, messageId: string) => {
return messageRepository.getMessageInConversationById(conversation, messageId, true, true);
const findMessage = async (conversation: Conversation, messageId: string) => {
const event =
(await messageRepository.getMessageInConversationById(conversation, messageId)) ||
(await messageRepository.getMessageInConversationByReplacementId(conversation, messageId));
return await messageRepository.ensureMessageSender(event);
};
const clickButton = (message: CompositeMessage, buttonId: string) => {
if (message.selectedButtonId() !== buttonId && message.waitingButtonId() !== buttonId) {
Expand Down
43 changes: 31 additions & 12 deletions src/script/conversation/MessageRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ export class MessageRepository {
if (!message.user().isMe && !message.ephemeral_expires()) {
throw new ConversationError(ConversationError.TYPE.WRONG_USER, ConversationError.MESSAGE.WRONG_USER);
}
const userIds = options.targetedUsers || conversation.allUserEntities.map(user => user.qualifiedId);
const userIds = options.targetedUsers || conversation.allUserEntities.map(user => user!.qualifiedId);
const payload = MessageBuilder.buildDeleteMessage({
messageId: message.id,
});
Expand Down Expand Up @@ -1173,17 +1173,10 @@ export class MessageRepository {
*
* @param conversation Conversation message belongs to
* @param messageId ID of message
* @param skipConversationMessages Don't use message entity from conversation
* @param ensureUser Make sure message entity has a valid user
* @returns Resolves with the message
*/
async getMessageInConversationById(
conversation: Conversation,
messageId: string,
skipConversationMessages = false,
ensureUser = false,
): Promise<StoredContentMessage> {
const messageEntity = !skipConversationMessages && conversation.getMessage(messageId);
async getMessageInConversationById(conversation: Conversation, messageId: string): Promise<StoredContentMessage> {
const messageEntity = conversation.getMessage(messageId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the default value of this method is not to look up the local state. This might be a regression here (since now it always lookup to local state)

Copy link
Contributor Author

@tlebon tlebon Feb 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default value is false and this is negated. so the default case is always to look up.

const message =
messageEntity ||
(await this.eventService.loadEvent(conversation.id, messageId).then(event => {
Expand All @@ -1197,7 +1190,33 @@ export class MessageRepository {
);
}

if (ensureUser && message.from && !message.user().id) {
return message as StoredContentMessage;
}

/**
* Get Message with given ID or a replacementId from the database.
*
* @param conversation Conversation message belongs to
* @param messageId ID of message
* @returns Resolves with the message
*/
async getMessageInConversationByReplacementId(
conversation: Conversation,
messageId: string,
): Promise<StoredContentMessage> {
const message = conversation.getMessageByReplacementId(messageId);
if (!message) {
throw new ConversationError(
ConversationError.TYPE.MESSAGE_NOT_FOUND,
ConversationError.MESSAGE.MESSAGE_NOT_FOUND,
);
}

return message as StoredContentMessage;
}

async ensureMessageSender(message: Message) {
if (message.from && !message.user().id) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's extract this logic to a different method and have a single responsibility for the getMessageInConversationByReplacementId.
This way the consumer can do:

const event = getMessageInConverstation(..) || getMessageInConversationByReplacementId()
const event = loadUsers(event);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i did something but i think based on your comment you would like something different

const user = await this.userRepository.getUserById({domain: message.user().domain, id: message.from});
message.user(user);
return message as StoredContentMessage;
Expand All @@ -1210,7 +1229,7 @@ export class MessageRepository {
// Since this is a bare API client user we use `.deleted`
const isDeleted = user.deleted === true;
if (isDeleted) {
await this.conversationRepositoryProvider().teamMemberLeave(this.teamState.team().id, {
await this.conversationRepositoryProvider().teamMemberLeave(this.teamState.team().id ?? '', {
domain: this.userState.self().domain,
id: user.id,
});
Expand Down
12 changes: 12 additions & 0 deletions src/script/entity/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {ConversationStatus} from '../conversation/ConversationStatus';
import {ConversationVerificationState} from '../conversation/ConversationVerificationState';
import {NOTIFICATION_STATE} from '../conversation/NotificationSetting';
import {ConversationError} from '../error/ConversationError';
import {isContentMessage} from '../guards/Message';
import {StatusType} from '../message/StatusType';
import {ConversationRecord} from '../storage/record/ConversationRecord';
import {TeamState} from '../team/TeamState';
Expand Down Expand Up @@ -968,6 +969,17 @@ export class Conversation {
getMessage(messageId: string): Message | ContentMessage | MemberMessage | SystemMessage | undefined {
return this.messages().find(messageEntity => messageEntity.id === messageId);
}
/**
* Get a message by its replacing message id. Useful if the message in question is an edit and has replaced the original message.
* Only lookup in the loaded message list which is a limited view of all the messages in DB.
*
* @param messageId ID of message to be retrieved
*/
getMessageByReplacementId(messageId: string): Message | ContentMessage | MemberMessage | SystemMessage | undefined {
return this.messages().find(
messageEntity => isContentMessage(messageEntity) && messageEntity.replacing_message_id === messageId,
);
}

updateGuests(): void {
this.getTemporaryGuests().forEach(userEntity => userEntity.checkGuestExpiration());
Expand Down
40 changes: 37 additions & 3 deletions src/script/event/EventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,52 @@ export class EventService {

try {
if (this.storageService.db) {
const entry = await this.storageService.db
return await this.storageService.db
.table(StorageSchemata.OBJECT_STORE.EVENTS)
.where('conversation')
.equals(conversationId)
.filter(item => item.data?.replacing_message_id === eventId || item.id === eventId)
.first();
}

const records = (await this.storageService.getAll(StorageSchemata.OBJECT_STORE.EVENTS)) as EventRecord[];
return records
.filter(record => record.id === eventId && record.conversation === conversationId)
.sort(compareEventsById)
.shift();
} catch (error) {
const logMessage = `Failed to get event '${eventId}' for conversation '${conversationId}': ${error.message}`;
this.logger.error(logMessage, error);
throw error;
}
}

/**
* Load a replacing event from database.
* To be used in certain cases (during some cases with quoting) when the original message cannot be found.
*
* @param conversationId ID of conversation
* @param eventId ID of event to retrieve
*/
async loadReplacingEvent(conversationId: string, eventId: string): Promise<EventRecord> {
if (!conversationId || !eventId) {
this.logger.error(`Cannot get event '${eventId}' in conversation '${conversationId}' without IDs`);
throw new ConversationError(BASE_ERROR_TYPE.MISSING_PARAMETER, BaseError.MESSAGE.MISSING_PARAMETER);
}

try {
if (this.storageService.db) {
return await this.storageService.db
.table(StorageSchemata.OBJECT_STORE.EVENTS)
.where('id')
.equals(eventId)
.filter(record => record.conversation === conversationId)
.first();
return entry;
}

const records = (await this.storageService.getAll(StorageSchemata.OBJECT_STORE.EVENTS)) as EventRecord[];
return records
.filter(record => record.id === eventId && record.conversation === conversationId)
.filter(record => record.conversation === conversationId && record.data?.replacing_message_id === eventId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's only lookup by replacing id at this point. The consumer is responsible for falling back to something else if it's not found

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the other function on L140, we are also checking by conversation && then id. (This was also the previous implementation) Should we change that as well or leave both the same?

I assume there was some logic to checking both, although the chances of an overlap here seem relatively astronomical to me. Still sort of unclear when this section of the function would be called anyway.

.sort(compareEventsById)
.shift();
} catch (error) {
Expand Down
49 changes: 20 additions & 29 deletions src/script/event/preprocessor/QuotedMessageMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {Quote} from '@wireapp/protocol-messaging';
import {getLogger, Logger} from 'Util/Logger';
import {base64ToArray} from 'Util/util';

import {MessageHasher} from '../../message/MessageHasher';
import {QuoteEntity} from '../../message/QuoteEntity';
import {EventRecord} from '../../storage/record/EventRecord';
import {ClientEvent} from '../Client';
Expand Down Expand Up @@ -104,37 +103,29 @@ export class QuotedMessageMiddleware {

const messageId = quote.quotedMessageId;

const quotedMessage = await this.eventService.loadEvent(event.conversation, messageId);
let quotedMessage =
(await this.eventService.loadEvent(event.conversation, messageId)) ??
(await this.eventService.loadReplacingEvent(event.conversation, messageId));
if (!quotedMessage) {
this.logger.warn(`Quoted message with ID "${messageId}" not found.`);
const quoteData = {
error: {
type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND,
},
};

const decoratedData = {...event.data, quote: quoteData};
return {...event, data: decoratedData};
const replacedMessage = await this.eventService.loadReplacingEvent(event.conversation, messageId);
if (!replacedMessage) {
this.logger.warn(`Quoted message with ID "${messageId}" not found.`);
const quoteData = {
error: {
type: QuoteEntity.ERROR.MESSAGE_NOT_FOUND,
},
};

const decoratedData = {...event.data, quote: quoteData};
return {...event, data: decoratedData};
}
quotedMessage = replacedMessage;
}

const quotedMessageHash = new Uint8Array(quote.quotedMessageSha256).buffer;

const isValid = await MessageHasher.validateHash(quotedMessage, quotedMessageHash);
atomrc marked this conversation as resolved.
Show resolved Hide resolved
let quoteData;

if (!isValid) {
this.logger.warn(`Quoted message hash for message ID "${messageId}" does not match.`);
quoteData = {
error: {
type: QuoteEntity.ERROR.INVALID_HASH,
},
};
} else {
quoteData = {
message_id: messageId,
user_id: quotedMessage.from,
};
}
const quoteData = {
message_id: messageId,
user_id: quotedMessage.from,
};

const decoratedData = {...event.data, quote: quoteData};
return {...event, data: decoratedData};
Expand Down
1 change: 0 additions & 1 deletion src/script/message/QuoteEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export class QuoteEntity {
userId: string;

static ERROR = {
INVALID_HASH: 'INVALID_HASH',
MESSAGE_NOT_FOUND: 'MESSAGE_NOT_FOUND',
};

Expand Down
19 changes: 13 additions & 6 deletions src/script/notification/NotificationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ export class NotificationRepository {
const messageInfo = messageId
? `message '${messageId}' of type '${messageType}'`
: `'${messageType}' message`;
this.logger.info(`Removed read notification for ${messageInfo} in '${conversationId}'.`);
this.logger.info(
`Removed read notification for ${messageInfo} in '${conversationId?.id || conversationId}'.`,
);
}
});
}
Expand Down Expand Up @@ -856,30 +858,35 @@ export class NotificationRepository {
this.contentViewModelState.multitasking.isMinimized(true);
notificationContent.trigger();

this.logger.info(`Notification for ${messageInfo} in '${conversationId}' closed by click.`);
this.logger.info(`Notification for ${messageInfo} in '${conversationId?.id || conversationId}' closed by click.`);
notification.close();
};

notification.onclose = () => {
window.clearTimeout(timeoutTriggerId);
this.notifications.splice(this.notifications.indexOf(notification), 1);
this.logger.info(`Removed notification for ${messageInfo} in '${conversationId}' locally.`);
this.logger.info(`Removed notification for ${messageInfo} in '${conversationId?.id || conversationId}' locally.`);
};

notification.onerror = error => {
this.logger.error(`Notification for ${messageInfo} in '${conversationId}' closed by error.`, error);
this.logger.error(
`Notification for ${messageInfo} in '${conversationId?.id || conversationId}' closed by error.`,
error,
);
notification.close();
};

notification.onshow = () => {
timeoutTriggerId = window.setTimeout(() => {
this.logger.info(`Notification for ${messageInfo} in '${conversationId}' closed by timeout.`);
this.logger.info(
`Notification for ${messageInfo} in '${conversationId?.id || conversationId}' closed by timeout.`,
);
notification.close();
}, notificationContent.timeout);
};

this.notifications.push(notification);
this.logger.info(`Added notification for ${messageInfo} in '${conversationId}' to queue.`);
this.logger.info(`Added notification for ${messageInfo} in '${conversationId?.id || conversationId}' to queue.`);
}

/**
Expand Down
10 changes: 4 additions & 6 deletions src/script/util/DraftStateUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,10 @@ export const loadDraftState = async (
const replyMessageId = storageValue.reply.messageId;

if (replyMessageId) {
draftMessage.replyEntityPromise = messageRepository.getMessageInConversationById(
conversationEntity,
replyMessageId,
false,
true,
);
const message =
(await messageRepository.getMessageInConversationById(conversationEntity, replyMessageId)) ||
(await messageRepository.getMessageInConversationByReplacementId(conversationEntity, replyMessageId));
draftMessage.replyEntityPromise = messageRepository.ensureMessageSender(message) as Promise<ContentMessage>;
}

return draftMessage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,44 +81,6 @@ describe('QuotedMessageMiddleware', () => {
expect(parsedEvent.data.quote.error).toEqual(expectedError);
});

it('adds an error if hashes do not match', async () => {
const expectedError = {
type: QuoteEntity.ERROR.INVALID_HASH,
};

const quotedMessage = {
data: {
content: 'pas salut',
},
time: 100,
type: ClientEvent.CONVERSATION.MESSAGE_ADD,
};

spyOn(quotedMessageMiddleware.eventService, 'loadEvent').and.returnValue(Promise.resolve(quotedMessage));

const quote = new Quote({
quotedMessageId: 'message-uuid',
quotedMessageSha256: '7fec6710751f67587b6f6109782257cd7c56b5d29570824132e8543e18242f1b',
});

const base64Quote = arrayToBase64(Quote.encode(quote).finish());

const event = {
conversation: 'conversation-uuid',
data: {
content: 'salut',
quote: base64Quote,
},
time: 100,
type: ClientEvent.CONVERSATION.MESSAGE_ADD,
};

const parsedEvent = await quotedMessageMiddleware.processEvent(event);

expect(parsedEvent.data.quote.message_id).toBeUndefined();
expect(parsedEvent.data.quote.error).toEqual(expectedError);
});

it('decorates event with the quote metadata if validation is successful', async () => {
const quotedMessage = {
data: {
Expand Down