From 6b1d53cc14eaf865b7400a3d5992c7a2b477c754 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 19 Oct 2023 08:58:46 +0100 Subject: [PATCH] Move events related to a redacted event into the main timeline (#3800) * Move redaction event tests into their own describe block * Factor out utils in redaction tests * Factor out the code for moving an event to the main timeline * Move all related messages into main timeline on redaction --- spec/unit/models/event.spec.ts | 307 +++++++++++++++++++++++---------- src/models/event.ts | 39 +++-- 2 files changed, 246 insertions(+), 100 deletions(-) diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index bcc2b42a2..9e7720a14 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -70,112 +70,241 @@ describe("MatrixEvent", () => { expect(a.toSnapshot().isEquivalentTo(b)).toBe(false); }); - it("should prune clearEvent when being redacted", () => { - const ev = new MatrixEvent({ - type: "m.room.message", - content: { - body: "Test", - }, - event_id: "$event1:server", + describe("redaction", () => { + it("should prune clearEvent when being redacted", () => { + const ev = createEvent("$event1:server", "Test"); + + expect(ev.getContent().body).toBe("Test"); + expect(ev.getWireContent().body).toBe("Test"); + ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", ""); + expect(ev.getContent().body).toBe("Test"); + expect(ev.getWireContent().body).toBeUndefined(); + expect(ev.getWireContent().ciphertext).toBe("xyz"); + + const mockClient = {} as unknown as MockedObject; + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const redaction = createRedaction(ev.getId()!); + + ev.makeRedacted(redaction, room); + expect(ev.getContent().body).toBeUndefined(); + expect(ev.getWireContent().body).toBeUndefined(); + expect(ev.getWireContent().ciphertext).toBeUndefined(); }); - expect(ev.getContent().body).toBe("Test"); - expect(ev.getWireContent().body).toBe("Test"); - ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", ""); - expect(ev.getContent().body).toBe("Test"); - expect(ev.getWireContent().body).toBeUndefined(); - expect(ev.getWireContent().ciphertext).toBe("xyz"); + it("should remain in the main timeline when redacted", async () => { + // Given an event in the main timeline + const mockClient = createMockClient(); + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const ev = createEvent("$event1:server"); - const mockClient = {} as unknown as MockedObject; - const room = new Room("!roomid:e.xyz", mockClient, "myname"); - const redaction = new MatrixEvent({ - type: "m.room.redaction", - redacts: ev.getId(), + await room.addLiveEvents([ev]); + await room.createThreadsTimelineSets(); + expect(ev.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]); + + // When I redact it + const redaction = createRedaction(ev.getId()!); + ev.makeRedacted(redaction, room); + + // Then it remains in the main timeline + expect(ev.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]); }); - ev.makeRedacted(redaction, room); - expect(ev.getContent().body).toBeUndefined(); - expect(ev.getWireContent().body).toBeUndefined(); - expect(ev.getWireContent().ciphertext).toBeUndefined(); - }); + it("should move into the main timeline when redacted", async () => { + // Given an event in a thread + const mockClient = createMockClient(); + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const threadRoot = createEvent("$threadroot:server"); + const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); - it("should remain in the main timeline when redacted", async () => { - // Given an event in the main timeline - const mockClient = { - supportsThreads: jest.fn().mockReturnValue(true), - decryptEventIfNeeded: jest.fn().mockReturnThis(), - getUserId: jest.fn().mockReturnValue("@user:server"), - } as unknown as MockedObject; - const room = new Room("!roomid:e.xyz", mockClient, "myname"); - const ev = new MatrixEvent({ - type: "m.room.message", - content: { - body: "Test", - }, - event_id: "$event1:server", + await room.addLiveEvents([threadRoot, ev]); + await room.createThreadsTimelineSets(); + expect(ev.threadRootId).toEqual(threadRoot.getId()); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); + expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]); + + // When I redact it + const redaction = createRedaction(ev.getId()!); + ev.makeRedacted(redaction, room); + + // Then it disappears from the thread and appears in the main timeline + expect(ev.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId()]); + expect(threadLiveEventIds(room, 0)).not.toContain(ev.getId()); }); - await room.addLiveEvents([ev]); - await room.createThreadsTimelineSets(); - expect(ev.threadRootId).toBeUndefined(); - expect(mainTimelineLiveEventIds(room)).toEqual(["$event1:server"]); + it("should move reactions to a redacted event into the main timeline", async () => { + // Given an event in a thread with a reaction + const mockClient = createMockClient(); + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const threadRoot = createEvent("$threadroot:server"); + const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); + const reaction = createReactionEvent("$reaction:server", ev.getId()!); - // When I redact it - const redaction = new MatrixEvent({ - type: "m.room.redaction", - redacts: ev.getId(), + await room.addLiveEvents([threadRoot, ev, reaction]); + await room.createThreadsTimelineSets(); + expect(reaction.threadRootId).toEqual(threadRoot.getId()); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); + expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]); + + // When I redact the event + const redaction = createRedaction(ev.getId()!); + ev.makeRedacted(redaction, room); + + // Then the reaction moves into the main timeline + expect(reaction.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]); + expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId()); }); - ev.makeRedacted(redaction, room); - // Then it remains in the main timeline - expect(ev.threadRootId).toBeUndefined(); - expect(mainTimelineLiveEventIds(room)).toEqual(["$event1:server"]); - }); + it("should move edits of a redacted event into the main timeline", async () => { + // Given an event in a thread with a reaction + const mockClient = createMockClient(); + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const threadRoot = createEvent("$threadroot:server"); + const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); + const edit = createEditEvent("$edit:server", ev.getId()!); - it("should move into the main timeline when redacted", async () => { - // Given an event in a thread - const mockClient = { - supportsThreads: jest.fn().mockReturnValue(true), - decryptEventIfNeeded: jest.fn().mockReturnThis(), - getUserId: jest.fn().mockReturnValue("@user:server"), - } as unknown as MockedObject; - const room = new Room("!roomid:e.xyz", mockClient, "myname"); - const threadRoot = new MatrixEvent({ - type: "m.room.message", - content: { - body: "threadRoot", - }, - event_id: "$threadroot:server", + await room.addLiveEvents([threadRoot, ev, edit]); + await room.createThreadsTimelineSets(); + expect(edit.threadRootId).toEqual(threadRoot.getId()); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); + expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]); + + // When I redact the event + const redaction = createRedaction(ev.getId()!); + ev.makeRedacted(redaction, room); + + // Then the edit moves into the main timeline + expect(edit.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]); + expect(threadLiveEventIds(room, 0)).not.toContain(edit.getId()); }); - const ev = new MatrixEvent({ - type: "m.room.message", - content: { - "body": "Test", - "m.relates_to": { - rel_type: THREAD_RELATION_TYPE.name, - event_id: "$threadroot:server", + + it("should move reactions to replies to replies a redacted event into the main timeline", async () => { + // Given an event in a thread with a reaction + const mockClient = createMockClient(); + const room = new Room("!roomid:e.xyz", mockClient, "myname"); + const threadRoot = createEvent("$threadroot:server"); + const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); + const reply1 = createReplyEvent("$reply1:server", ev.getId()!); + const reply2 = createReplyEvent("$reply2:server", reply1.getId()!); + const reaction = createReactionEvent("$reaction:server", reply2.getId()!); + + await room.addLiveEvents([threadRoot, ev, reply1, reply2, reaction]); + await room.createThreadsTimelineSets(); + expect(reaction.threadRootId).toEqual(threadRoot.getId()); + expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); + expect(threadLiveEventIds(room, 0)).toEqual([ + threadRoot.getId(), + ev.getId(), + reply1.getId(), + reply2.getId(), + reaction.getId(), + ]); + + // When I redact the event + const redaction = createRedaction(ev.getId()!); + ev.makeRedacted(redaction, room); + + // Then the replies move to the main thread and the reaction disappears + expect(reaction.threadRootId).toBeUndefined(); + expect(mainTimelineLiveEventIds(room)).toEqual([ + threadRoot.getId(), + ev.getId(), + reply1.getId(), + reply2.getId(), + reaction.getId(), + ]); + expect(threadLiveEventIds(room, 0)).not.toContain(reply1.getId()); + expect(threadLiveEventIds(room, 0)).not.toContain(reply2.getId()); + expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId()); + }); + + function createMockClient(): MatrixClient { + return { + supportsThreads: jest.fn().mockReturnValue(true), + decryptEventIfNeeded: jest.fn().mockReturnThis(), + getUserId: jest.fn().mockReturnValue("@user:server"), + } as unknown as MockedObject; + } + + function createEvent(eventId: string, body?: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.message", + content: { + body: body ?? eventId, }, - }, - event_id: "$event1:server", - }); + event_id: eventId, + }); + } - await room.addLiveEvents([threadRoot, ev]); - await room.createThreadsTimelineSets(); - expect(ev.threadRootId).toEqual("$threadroot:server"); - expect(mainTimelineLiveEventIds(room)).toEqual(["$threadroot:server"]); - expect(threadLiveEventIds(room, 0)).toEqual(["$threadroot:server", "$event1:server"]); + function createThreadedEvent(eventId: string, threadRootId: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.message", + content: { + "body": eventId, + "m.relates_to": { + rel_type: THREAD_RELATION_TYPE.name, + event_id: threadRootId, + }, + }, + event_id: eventId, + }); + } - // When I redact it - const redaction = new MatrixEvent({ - type: "m.room.redaction", - redacts: ev.getId(), - }); - ev.makeRedacted(redaction, room); + function createEditEvent(eventId: string, repliedToId: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.message", + content: { + "body": "Edited", + "m.new_content": { + body: "Edited", + }, + "m.relates_to": { + event_id: repliedToId, + rel_type: "m.replace", + }, + }, + event_id: eventId, + }); + } - // Then it disappears from the thread and appears in the main timeline - expect(ev.threadRootId).toBeUndefined(); - expect(mainTimelineLiveEventIds(room)).toEqual(["$threadroot:server", "$event1:server"]); - expect(threadLiveEventIds(room, 0)).not.toContain("$event1:server"); + function createReplyEvent(eventId: string, repliedToId: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.message", + content: { + "m.relates_to": { + event_id: repliedToId, + key: "x", + rel_type: "m.in_reply_to", + }, + }, + event_id: eventId, + }); + } + + function createReactionEvent(eventId: string, reactedToId: string): MatrixEvent { + return new MatrixEvent({ + type: "m.reaction", + content: { + "m.relates_to": { + event_id: reactedToId, + key: "x", + rel_type: "m.annotation", + }, + }, + event_id: eventId, + }); + } + + function createRedaction(redactedEventid: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.redaction", + redacts: redactedEventid, + }); + } }); describe("applyVisibilityEvent", () => { diff --git a/src/models/event.ts b/src/models/event.ts index b618a2c71..524b42f32 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -1195,22 +1195,39 @@ export class MatrixEvent extends TypedEventEmitter