diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 5b4fb9850..0a42578de 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -6,7 +6,7 @@ import '../olm-loader'; import { logger } from '../../src/logger'; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; -import { ClientEvent, EventType, MatrixClient } from "../../src"; +import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src"; import { SyncState } from "../../src/sync"; import { eventMapperFor } from "../../src/event-mapper"; @@ -225,7 +225,7 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M ...opts, type: EventType.RoomMessage, content: { - msgtype: "m.text", + msgtype: MsgType.Text, body: opts.msg, }, }; @@ -236,6 +236,45 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M return mkEvent(eventOpts, client); } +interface IReplyMessageOpts extends IMessageOpts { + replyToMessage: MatrixEvent; +} + +/** + * Create a reply message. + * + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {MatrixEvent} opts.replyToMessage The replied message + * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. + * @return {Object|MatrixEvent} The event + */ +export function mkReplyMessage(opts: IReplyMessageOpts, client?: MatrixClient): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMessage, + content: { + "msgtype": MsgType.Text, + "body": opts.msg, + "m.relates_to": { + "rel_type": "m.in_reply_to", + "event_id": opts.replyToMessage.getId(), + "m.in_reply_to": { + "event_id": opts.replyToMessage.getId(), + }, + }, + }, + }; + + if (!eventOpts.content.body) { + eventOpts.content.body = "Random->" + Math.random(); + } + return mkEvent(eventOpts, client); +} + /** * A mock implementation of webstorage * diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts new file mode 100644 index 000000000..82cadddf8 --- /dev/null +++ b/spec/unit/event-timeline-set.spec.ts @@ -0,0 +1,154 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as utils from "../test-utils/test-utils"; +import { + EventTimeline, + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + Room, +} from '../../src'; + +describe('EventTimelineSet', () => { + const roomId = '!foo:bar'; + const userA = "@alice:bar"; + + let room: Room; + let eventTimeline: EventTimeline; + let eventTimelineSet: EventTimelineSet; + let client: MatrixClient; + + let messageEvent: MatrixEvent; + let replyEvent: MatrixEvent; + + const itShouldReturnTheRelatedEvents = () => { + it('should return the related events', () => { + eventTimelineSet.aggregateRelations(messageEvent); + const relations = eventTimelineSet.getRelationsForEvent( + messageEvent.getId(), + "m.in_reply_to", + EventType.RoomMessage, + ); + expect(relations).toBeDefined(); + expect(relations.getRelations().length).toBe(1); + expect(relations.getRelations()[0].getId()).toBe(replyEvent.getId()); + }); + }; + + beforeEach(() => { + client = utils.mock(MatrixClient, 'MatrixClient'); + room = new Room(roomId, client, userA); + eventTimelineSet = new EventTimelineSet(room, { + unstableClientRelationAggregation: true, + }); + eventTimeline = new EventTimeline(eventTimelineSet); + messageEvent = utils.mkMessage({ + room: roomId, + user: userA, + msg: 'Hi!', + event: true, + }) as MatrixEvent; + replyEvent = utils.mkReplyMessage({ + room: roomId, + user: userA, + msg: 'Hoo!', + event: true, + replyToMessage: messageEvent, + }) as MatrixEvent; + }); + + describe('aggregateRelations', () => { + describe('with unencrypted events', () => { + beforeEach(() => { + eventTimelineSet.addEventsToTimeline( + [ + messageEvent, + replyEvent, + ], + true, + eventTimeline, + 'foo', + ); + }); + + itShouldReturnTheRelatedEvents(); + }); + + describe('with events to be decrypted', () => { + let messageEventShouldAttemptDecryptionSpy: jest.SpyInstance; + let messageEventIsDecryptionFailureSpy: jest.SpyInstance; + + let replyEventShouldAttemptDecryptionSpy: jest.SpyInstance; + let replyEventIsDecryptionFailureSpy: jest.SpyInstance; + + beforeEach(() => { + messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, 'shouldAttemptDecryption'); + messageEventShouldAttemptDecryptionSpy.mockReturnValue(true); + messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); + + replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, 'shouldAttemptDecryption'); + replyEventShouldAttemptDecryptionSpy.mockReturnValue(true); + replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); + + eventTimelineSet.addEventsToTimeline( + [ + messageEvent, + replyEvent, + ], + true, + eventTimeline, + 'foo', + ); + }); + + it('should not return the related events', () => { + eventTimelineSet.aggregateRelations(messageEvent); + const relations = eventTimelineSet.getRelationsForEvent( + messageEvent.getId(), + "m.in_reply_to", + EventType.RoomMessage, + ); + expect(relations).toBeUndefined(); + }); + + describe('after decryption', () => { + beforeEach(() => { + // simulate decryption failure once + messageEventIsDecryptionFailureSpy.mockReturnValue(true); + replyEventIsDecryptionFailureSpy.mockReturnValue(true); + + messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent); + replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent); + + // simulate decryption + messageEventIsDecryptionFailureSpy.mockReturnValue(false); + replyEventIsDecryptionFailureSpy.mockReturnValue(false); + + messageEventShouldAttemptDecryptionSpy.mockReturnValue(false); + replyEventShouldAttemptDecryptionSpy.mockReturnValue(false); + + messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent); + replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent); + }); + + itShouldReturnTheRelatedEvents(); + }); + }); + }); +}); diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index aeb019112..8e5049c5f 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -822,11 +822,20 @@ export class EventTimelineSet extends TypedEventEmitter { + if (event.isDecryptionFailure()) { + // This could for example happen if the encryption keys are not yet available. + // The event may still be decrypted later. Register the listener again. + event.once(MatrixEventEvent.Decrypted, onEventDecrypted); + return; + } + + this.aggregateRelations(event); + }; + // If the event is currently encrypted, wait until it has been decrypted. if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once(MatrixEventEvent.Decrypted, () => { - this.aggregateRelations(event); - }); + event.once(MatrixEventEvent.Decrypted, onEventDecrypted); return; }