From 386aa8954753669241c07b84ce120704f09ec160 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Jun 2025 17:46:56 -0600 Subject: [PATCH 1/9] First pass implementation --- src/models/room.ts | 124 ++++++++++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 47 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index c02ed072d6..e8a4ca72f1 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -17,15 +17,15 @@ limitations under the License. import { M_POLL_START, type Optional } from "matrix-events-sdk"; import { - EventTimelineSet, DuplicateStrategy, - type IAddLiveEventOptions, + EventTimelineSet, type EventTimelineSetHandlerMap, + type IAddLiveEventOptions, } from "./event-timeline-set.ts"; import { Direction, EventTimeline } from "./event-timeline.ts"; import { getHttpUriForMxc } from "../content-repo.ts"; -import { removeElement } from "../utils.ts"; -import { normalize, noUnsafeEventProps } from "../utils.ts"; +import * as utils from "../utils.ts"; +import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts"; import { type IEvent, type IThreadBundledRelationship, @@ -35,17 +35,17 @@ import { } from "./event.ts"; import { EventStatus } from "./event-status.ts"; import { RoomMember } from "./room-member.ts"; -import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts"; +import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts"; import { logger } from "../logger.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { + EVENT_VISIBILITY_CHANGE_TYPE, EventType, + RelationType, RoomCreateTypeField, RoomType, - UNSTABLE_ELEMENT_FUNCTIONAL_USERS, - EVENT_VISIBILITY_CHANGE_TYPE, - RelationType, UNSIGNED_THREAD_ID_FIELD, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, } from "../@types/event.ts"; import { type MatrixClient, PendingEventOrdering } from "../client.ts"; import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts"; @@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts"; import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts"; import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts"; import { + FILTER_RELATED_BY_REL_TYPES, + FILTER_RELATED_BY_SENDERS, Thread, + THREAD_RELATION_TYPE, ThreadEvent, type ThreadEventHandlerMap as ThreadHandlerMap, - FILTER_RELATED_BY_REL_TYPES, - THREAD_RELATION_TYPE, - FILTER_RELATED_BY_SENDERS, ThreadFilterType, } from "./thread.ts"; import { @@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts"; import { isPollEvent, Poll, PollEvent } from "./poll.ts"; import { RoomReceipts } from "./room-receipts.ts"; import { compareEventOrdering } from "./compare-event-ordering.ts"; -import * as utils from "../utils.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; import { type MSC4186Hero } from "../sliding-sync.ts"; @@ -2580,50 +2579,81 @@ export class Room extends ReadReceipt { return thread; } + private blindlyApplyRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void { + const threadRootId = redactedEvent.threadRootId; + redactedEvent.makeRedacted(redactionEvent, this); + + // If this is in the current state, replace it with the redacted version + if (redactedEvent.isState()) { + const currentStateEvent = this.currentState.getStateEvents( + redactedEvent.getType(), + redactedEvent.getStateKey()!, + ); + if (currentStateEvent?.getId() === redactedEvent.getId()) { + this.currentState.setStateEvents([redactedEvent]); + } + } + + this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId); + + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // they are based on are changed. + + // Remove any visibility change on this event. + this.visibilityEvents.delete(redactedEvent.getId()!); + + // If this event is a visibility change event, remove it from the + // list of visibility changes and update any event affected by it. + if (redactedEvent.isVisibilityEvent()) { + this.redactVisibilityChangeEvent(redactionEvent); + } + } + private applyRedaction = (event: MatrixEvent): void => { + // FIXME: apply redactions to notification list + + // NB: We continue to add the redaction event to the timeline at the + // end of this function so clients can say "so and so redacted an event" + // if they wish to. Also this may be needed to trigger an update. + if (event.isRedaction()) { const redactId = event.event.redacts; // if we know about this event, redact its contents now. const redactedEvent = redactId ? this.findEventById(redactId) : undefined; if (redactedEvent) { - const threadRootId = redactedEvent.threadRootId; - redactedEvent.makeRedacted(event, this); - - // If this is in the current state, replace it with the redacted version - if (redactedEvent.isState()) { - const currentStateEvent = this.currentState.getStateEvents( - redactedEvent.getType(), - redactedEvent.getStateKey()!, - ); - if (currentStateEvent?.getId() === redactedEvent.getId()) { - this.currentState.setStateEvents([redactedEvent]); - } - } - - this.emit(RoomEvent.Redaction, event, this, threadRootId); - - // TODO: we stash user displaynames (among other things) in - // RoomMember objects which are then attached to other events - // (in the sender and target fields). We should get those - // RoomMember objects to update themselves when the events that - // they are based on are changed. - - // Remove any visibility change on this event. - this.visibilityEvents.delete(redactId!); - - // If this event is a visibility change event, remove it from the - // list of visibility changes and update any event affected by it. - if (redactedEvent.isVisibilityEvent()) { - this.redactVisibilityChangeEvent(event); - } + this.blindlyApplyRedaction(event, redactedEvent); + } + } else if (event.getType() === EventType.RoomMember) { + const membership = event.getContent()["membership"]; + if (membership !== KnownMembership.Ban && !(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())) { + // Not a ban or kick, therefore not a membership event we care about here. + return; + } + const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"]; + if (redactEvents !== true) { + // Invalid or not set - nothing to redact. + return; + } + const state = this.getLiveTimeline().getState(Direction.Forward)!; + if (!state.maySendRedactionForEvent(event, event.getSender()!)) { + // If the sender can't redact the membership event, then they won't be able to + // redact any of the target's events either, so skip. + return; } - // FIXME: apply redactions to notification list - - // NB: We continue to add the redaction event to the timeline so - // clients can say "so and so redacted an event" if they wish to. Also - // this may be needed to trigger an update. + // The redaction is possible, so let's find all the events and apply it. + const events = this.getTimelineSets() + .map(s => s.getTimelines()) + .reduce((p, c) => {p.push(...c); return p;}, []) + .map(t => t.getEvents().filter(e => e.getSender() === event.getStateKey())) + .reduce((p,c)=>{p.push(...c);return c;}, []); + for (const toRedact of events) { + this.blindlyApplyRedaction(event, toRedact); + } } }; From 2ce2c8e277d15d9ba5da9b5fe78042484ba0a4b7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 13:35:59 -0600 Subject: [PATCH 2/9] fix naming/docs --- src/models/room.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index e8a4ca72f1..e1fa0f5846 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2468,7 +2468,7 @@ export class Room extends ReadReceipt { * Adds events to a thread's timeline. Will fire "Thread.update" */ public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { - events.forEach(this.applyRedaction); + events.forEach(this.tryApplyRedaction); const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; for (const event of events) { @@ -2579,7 +2579,17 @@ export class Room extends ReadReceipt { return thread; } - private blindlyApplyRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void { + /** + * Applies an event as a redaction of another event, regardless of whether the redacting + * event is actually a redaction. + * + * Callers should use tryApplyRedaction instead. + * + * @param redactionEvent The event which redacts an event. + * @param redactedEvent The event being redacted. + * @private + */ + private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void { const threadRootId = redactedEvent.threadRootId; redactedEvent.makeRedacted(redactionEvent, this); @@ -2612,7 +2622,7 @@ export class Room extends ReadReceipt { } } - private applyRedaction = (event: MatrixEvent): void => { + private tryApplyRedaction = (event: MatrixEvent): void => { // FIXME: apply redactions to notification list // NB: We continue to add the redaction event to the timeline at the @@ -2625,7 +2635,7 @@ export class Room extends ReadReceipt { // if we know about this event, redact its contents now. const redactedEvent = redactId ? this.findEventById(redactId) : undefined; if (redactedEvent) { - this.blindlyApplyRedaction(event, redactedEvent); + this.applyEventAsRedaction(event, redactedEvent); } } else if (event.getType() === EventType.RoomMember) { const membership = event.getContent()["membership"]; @@ -2652,13 +2662,13 @@ export class Room extends ReadReceipt { .map(t => t.getEvents().filter(e => e.getSender() === event.getStateKey())) .reduce((p,c)=>{p.push(...c);return c;}, []); for (const toRedact of events) { - this.blindlyApplyRedaction(event, toRedact); + this.applyEventAsRedaction(event, toRedact); } } }; private processLiveEvent(event: MatrixEvent): void { - this.applyRedaction(event); + this.tryApplyRedaction(event); // Implement MSC3531: hiding messages. if (event.isVisibilityEvent()) { From 4ddaf9af828dc0ad4a75c3b777253485a0e2a4af Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 13:40:13 -0600 Subject: [PATCH 3/9] apply lint --- src/models/room.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index e1fa0f5846..9572f55d82 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2639,7 +2639,10 @@ export class Room extends ReadReceipt { } } else if (event.getType() === EventType.RoomMember) { const membership = event.getContent()["membership"]; - if (membership !== KnownMembership.Ban && !(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())) { + if ( + membership !== KnownMembership.Ban && + !(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender()) + ) { // Not a ban or kick, therefore not a membership event we care about here. return; } @@ -2657,10 +2660,16 @@ export class Room extends ReadReceipt { // The redaction is possible, so let's find all the events and apply it. const events = this.getTimelineSets() - .map(s => s.getTimelines()) - .reduce((p, c) => {p.push(...c); return p;}, []) - .map(t => t.getEvents().filter(e => e.getSender() === event.getStateKey())) - .reduce((p,c)=>{p.push(...c);return c;}, []); + .map((s) => s.getTimelines()) + .reduce((p, c) => { + p.push(...c); + return p; + }, []) + .map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey())) + .reduce((p, c) => { + p.push(...c); + return c; + }, []); for (const toRedact of events) { this.applyEventAsRedaction(event, toRedact); } From 4e44c9530dd1ca234bb88591522e8a1a75410627 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 14:33:21 -0600 Subject: [PATCH 4/9] Add test for existing behaviour --- spec/unit/models/room.spec.ts | 74 +++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 spec/unit/models/room.spec.ts diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts new file mode 100644 index 0000000000..5abf23b71e --- /dev/null +++ b/spec/unit/models/room.spec.ts @@ -0,0 +1,74 @@ +/* +Copyright 2025 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 {MatrixClient, MatrixEvent, MatrixEventEvent, Room} from "../../../src"; +import type {MockedObject} from "jest-mock"; +import exp from "node:constants"; + +describe("Room", () => { + 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): MatrixEvent { + return new MatrixEvent({ + type: "m.room.message", + content: { + body: eventId, // we do this for ease of use, not practicality + }, + event_id: eventId, + }); + } + + function createRedaction(redactsEventId: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.redaction", + redacts: redactsEventId, + event_id: "$redacts_" + redactsEventId.substring(1), + }); + } + + function getNonStateMainTimelineLiveEvents(room: Room): Array { + return room.getLiveTimeline().getEvents().filter(e => !e.isState()); + } + + it("should apply redactions locally", async () => { + const mockClient = createMockClient(); + const room = new Room("!room:example.org", mockClient, "name"); + const messageEvent = createEvent("$message_event"); + + // Set up the room + await room.addLiveEvents([messageEvent], {addToState: false}); + let timeline = getNonStateMainTimelineLiveEvents(room); + expect(timeline.length).toEqual(1); + expect(timeline[0].getId()).toEqual(messageEvent.getId()); + expect(timeline[0].isRedacted()).toEqual(false); // "should never happen" + + // Now redact + const redactionEvent = createRedaction(messageEvent.getId()!); + await room.addLiveEvents([redactionEvent], {addToState: false}); + timeline = getNonStateMainTimelineLiveEvents(room); + expect(timeline.length).toEqual(2); + expect(timeline[0].getId()).toEqual(messageEvent.getId()); + expect(timeline[0].isRedacted()).toEqual(true); // test case + expect(timeline[1].getId()).toEqual(redactionEvent.getId()); + expect(timeline[1].isRedacted()).toEqual(false); // "should never happen" + }); +}); From c004ea263eb6764704e9716bf2970c8493475a63 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 14:56:58 -0600 Subject: [PATCH 5/9] Add happy path tests --- spec/unit/models/room.spec.ts | 68 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index 5abf23b71e..924271a321 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -14,16 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClient, MatrixEvent, MatrixEventEvent, Room} from "../../../src"; +import {Direction, MatrixClient, MatrixEvent, Room} from "../../../src"; import type {MockedObject} from "jest-mock"; -import exp from "node:constants"; + +const CREATOR_USER_ID = "@creator:example.org"; describe("Room", () => { function createMockClient(): MatrixClient { return { supportsThreads: jest.fn().mockReturnValue(true), decryptEventIfNeeded: jest.fn().mockReturnThis(), - getUserId: jest.fn().mockReturnValue("@user:server"), + getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID), } as unknown as MockedObject; } @@ -34,6 +35,7 @@ describe("Room", () => { body: eventId, // we do this for ease of use, not practicality }, event_id: eventId, + sender: CREATOR_USER_ID, }); } @@ -42,6 +44,7 @@ describe("Room", () => { type: "m.room.redaction", redacts: redactsEventId, event_id: "$redacts_" + redactsEventId.substring(1), + sender: CREATOR_USER_ID, }); } @@ -51,7 +54,7 @@ describe("Room", () => { it("should apply redactions locally", async () => { const mockClient = createMockClient(); - const room = new Room("!room:example.org", mockClient, "name"); + const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID); const messageEvent = createEvent("$message_event"); // Set up the room @@ -71,4 +74,61 @@ describe("Room", () => { expect(timeline[1].getId()).toEqual(redactionEvent.getId()); expect(timeline[1].isRedacted()).toEqual(false); // "should never happen" }); + + describe("MSC4293: Redact on ban", () => { + async function setupRoom(andGrantPermissions: boolean): Promise<{room: Room, messageEvents: MatrixEvent[]}> { + const mockClient = createMockClient(); + const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID); + + // Pre-populate room + const messageEvents: MatrixEvent[] = []; + for (let i = 0; i < 3; i++) { + messageEvents.push(createEvent(`$message_${i}`)); + } + await room.addLiveEvents(messageEvents, {addToState: false}); + + if (andGrantPermissions) { + room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => { + return true; + }; + } + + return {room, messageEvents}; + } + + function createRedactOnMembershipChange(targetUserId: string, membership: string): MatrixEvent { + return new MatrixEvent({ + type: "m.room.member", + state_key: targetUserId, + content: { + membership: membership, + "org.matrix.msc4293.redact_events": true, + }, + sender: CREATOR_USER_ID, + }); + } + + function expectRedacted(messageEvents: MatrixEvent[], room: Room) { + const actualEvents = getNonStateMainTimelineLiveEvents(room).filter(e => messageEvents.find(e2 => e2.getId() === e.getId())); + expect(actualEvents.length).toEqual(messageEvents.length); + const redactedEvents = actualEvents.filter(e => e.isRedacted()); + expect(redactedEvents.length).toEqual(messageEvents.length); + } + + it("should apply on ban", async () => { + const {room, messageEvents} = await setupRoom(true); + const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, "ban"); + await room.addLiveEvents([banEvent], {addToState: true}); + + expectRedacted(messageEvents, room); + }); + + it("should apply on kick", async () => { + const {room, messageEvents} = await setupRoom(true); + const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, "leave"); + await room.addLiveEvents([kickEvent], {addToState: true}); + + expectRedacted(messageEvents, room); + }); + }); }); From bfe91a917b854b35bfc691effa315469360d1231 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 14:57:10 -0600 Subject: [PATCH 6/9] Fix bug identified by tests --- src/models/room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/room.ts b/src/models/room.ts index 9572f55d82..57fa51c26f 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2641,7 +2641,7 @@ export class Room extends ReadReceipt { const membership = event.getContent()["membership"]; if ( membership !== KnownMembership.Ban && - !(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender()) + !(membership === KnownMembership.Leave && event.getStateKey() === event.getSender()) ) { // Not a ban or kick, therefore not a membership event we care about here. return; From 19ea3c23a57fb4bd251a7297b484c1d3490485ee Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 15:01:14 -0600 Subject: [PATCH 7/9] ... and this is why we add negative tests too --- spec/unit/models/room.spec.ts | 37 +++++++++++++++++++++++++++-------- src/models/room.ts | 2 +- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index 924271a321..0405cae2ea 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -18,6 +18,7 @@ import {Direction, MatrixClient, MatrixEvent, Room} from "../../../src"; import type {MockedObject} from "jest-mock"; const CREATOR_USER_ID = "@creator:example.org"; +const MODERATOR_USER_ID = "@moderator:example.org"; describe("Room", () => { function createMockClient(): MatrixClient { @@ -96,7 +97,7 @@ describe("Room", () => { return {room, messageEvents}; } - function createRedactOnMembershipChange(targetUserId: string, membership: string): MatrixEvent { + function createRedactOnMembershipChange(targetUserId: string, senderUserId: string, membership: string): MatrixEvent { return new MatrixEvent({ type: "m.room.member", state_key: targetUserId, @@ -104,31 +105,51 @@ describe("Room", () => { membership: membership, "org.matrix.msc4293.redact_events": true, }, - sender: CREATOR_USER_ID, + sender: senderUserId, }); } - function expectRedacted(messageEvents: MatrixEvent[], room: Room) { + function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) { const actualEvents = getNonStateMainTimelineLiveEvents(room).filter(e => messageEvents.find(e2 => e2.getId() === e.getId())); expect(actualEvents.length).toEqual(messageEvents.length); const redactedEvents = actualEvents.filter(e => e.isRedacted()); - expect(redactedEvents.length).toEqual(messageEvents.length); + if (shouldAllBeRedacted) { + expect(redactedEvents.length).toEqual(messageEvents.length); + } else { + expect(redactedEvents.length).toEqual(0); + } } it("should apply on ban", async () => { const {room, messageEvents} = await setupRoom(true); - const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, "ban"); + const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban"); await room.addLiveEvents([banEvent], {addToState: true}); - expectRedacted(messageEvents, room); + expectRedacted(messageEvents, room, true); }); it("should apply on kick", async () => { const {room, messageEvents} = await setupRoom(true); - const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, "leave"); + const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID,"leave"); await room.addLiveEvents([kickEvent], {addToState: true}); - expectRedacted(messageEvents, room); + expectRedacted(messageEvents, room, true); + }); + + it("should not apply if the user doesn't have permission to redact", async () => { + const {room, messageEvents} = await setupRoom(false); // difference from other tests here + const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID,"ban"); + await room.addLiveEvents([banEvent], {addToState: true}); + + expectRedacted(messageEvents, room, false); // difference from other tests here }); + + it("should not apply to self-leaves", async () => { + const {room, messageEvents} = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"leave"); + await room.addLiveEvents([leaveEvent], {addToState: true}); + + expectRedacted(messageEvents, room, false); // difference from other tests here + }) }); }); diff --git a/src/models/room.ts b/src/models/room.ts index 57fa51c26f..9572f55d82 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2641,7 +2641,7 @@ export class Room extends ReadReceipt { const membership = event.getContent()["membership"]; if ( membership !== KnownMembership.Ban && - !(membership === KnownMembership.Leave && event.getStateKey() === event.getSender()) + !(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender()) ) { // Not a ban or kick, therefore not a membership event we care about here. return; From 458fc91cae989d6cd40bfe315a3df457ddcc0bf7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 15:03:41 -0600 Subject: [PATCH 8/9] Add some sanity tests --- spec/unit/models/room.spec.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index 0405cae2ea..59108379c3 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -141,7 +141,7 @@ describe("Room", () => { const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID,"ban"); await room.addLiveEvents([banEvent], {addToState: true}); - expectRedacted(messageEvents, room, false); // difference from other tests here + expectRedacted(messageEvents, room, false); }); it("should not apply to self-leaves", async () => { @@ -149,7 +149,31 @@ describe("Room", () => { const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"leave"); await room.addLiveEvents([leaveEvent], {addToState: true}); - expectRedacted(messageEvents, room, false); // difference from other tests here - }) + expectRedacted(messageEvents, room, false); + }); + + it("should not apply to invites", async () => { + const {room, messageEvents} = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"invite"); + await room.addLiveEvents([leaveEvent], {addToState: true}); + + expectRedacted(messageEvents, room, false); + }); + + it("should not apply to joins", async () => { + const {room, messageEvents} = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"join"); + await room.addLiveEvents([leaveEvent], {addToState: true}); + + expectRedacted(messageEvents, room, false); + }); + + it("should not apply to knocks", async () => { + const {room, messageEvents} = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"knock"); + await room.addLiveEvents([leaveEvent], {addToState: true}); + + expectRedacted(messageEvents, room, false); + }); }); }); From d6216406ca46ddd7748aa8548e1dcf40fcf530b2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 9 Jun 2025 15:04:49 -0600 Subject: [PATCH 9/9] Apply linter --- spec/unit/models/room.spec.ts | 73 ++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index 59108379c3..6efa9d22e3 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Direction, MatrixClient, MatrixEvent, Room} from "../../../src"; -import type {MockedObject} from "jest-mock"; +import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src"; +import type { MockedObject } from "jest-mock"; const CREATOR_USER_ID = "@creator:example.org"; const MODERATOR_USER_ID = "@moderator:example.org"; @@ -50,7 +50,10 @@ describe("Room", () => { } function getNonStateMainTimelineLiveEvents(room: Room): Array { - return room.getLiveTimeline().getEvents().filter(e => !e.isState()); + return room + .getLiveTimeline() + .getEvents() + .filter((e) => !e.isState()); } it("should apply redactions locally", async () => { @@ -59,7 +62,7 @@ describe("Room", () => { const messageEvent = createEvent("$message_event"); // Set up the room - await room.addLiveEvents([messageEvent], {addToState: false}); + await room.addLiveEvents([messageEvent], { addToState: false }); let timeline = getNonStateMainTimelineLiveEvents(room); expect(timeline.length).toEqual(1); expect(timeline[0].getId()).toEqual(messageEvent.getId()); @@ -67,7 +70,7 @@ describe("Room", () => { // Now redact const redactionEvent = createRedaction(messageEvent.getId()!); - await room.addLiveEvents([redactionEvent], {addToState: false}); + await room.addLiveEvents([redactionEvent], { addToState: false }); timeline = getNonStateMainTimelineLiveEvents(room); expect(timeline.length).toEqual(2); expect(timeline[0].getId()).toEqual(messageEvent.getId()); @@ -77,7 +80,7 @@ describe("Room", () => { }); describe("MSC4293: Redact on ban", () => { - async function setupRoom(andGrantPermissions: boolean): Promise<{room: Room, messageEvents: MatrixEvent[]}> { + async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> { const mockClient = createMockClient(); const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID); @@ -86,7 +89,7 @@ describe("Room", () => { for (let i = 0; i < 3; i++) { messageEvents.push(createEvent(`$message_${i}`)); } - await room.addLiveEvents(messageEvents, {addToState: false}); + await room.addLiveEvents(messageEvents, { addToState: false }); if (andGrantPermissions) { room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => { @@ -94,15 +97,19 @@ describe("Room", () => { }; } - return {room, messageEvents}; + return { room, messageEvents }; } - function createRedactOnMembershipChange(targetUserId: string, senderUserId: string, membership: string): MatrixEvent { + function createRedactOnMembershipChange( + targetUserId: string, + senderUserId: string, + membership: string, + ): MatrixEvent { return new MatrixEvent({ type: "m.room.member", state_key: targetUserId, content: { - membership: membership, + "membership": membership, "org.matrix.msc4293.redact_events": true, }, sender: senderUserId, @@ -110,9 +117,11 @@ describe("Room", () => { } function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) { - const actualEvents = getNonStateMainTimelineLiveEvents(room).filter(e => messageEvents.find(e2 => e2.getId() === e.getId())); + const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) => + messageEvents.find((e2) => e2.getId() === e.getId()), + ); expect(actualEvents.length).toEqual(messageEvents.length); - const redactedEvents = actualEvents.filter(e => e.isRedacted()); + const redactedEvents = actualEvents.filter((e) => e.isRedacted()); if (shouldAllBeRedacted) { expect(redactedEvents.length).toEqual(messageEvents.length); } else { @@ -121,57 +130,57 @@ describe("Room", () => { } it("should apply on ban", async () => { - const {room, messageEvents} = await setupRoom(true); + const { room, messageEvents } = await setupRoom(true); const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban"); - await room.addLiveEvents([banEvent], {addToState: true}); + await room.addLiveEvents([banEvent], { addToState: true }); expectRedacted(messageEvents, room, true); }); it("should apply on kick", async () => { - const {room, messageEvents} = await setupRoom(true); - const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID,"leave"); - await room.addLiveEvents([kickEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(true); + const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave"); + await room.addLiveEvents([kickEvent], { addToState: true }); expectRedacted(messageEvents, room, true); }); it("should not apply if the user doesn't have permission to redact", async () => { - const {room, messageEvents} = await setupRoom(false); // difference from other tests here - const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID,"ban"); - await room.addLiveEvents([banEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(false); // difference from other tests here + const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban"); + await room.addLiveEvents([banEvent], { addToState: true }); expectRedacted(messageEvents, room, false); }); it("should not apply to self-leaves", async () => { - const {room, messageEvents} = await setupRoom(true); - const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"leave"); - await room.addLiveEvents([leaveEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave"); + await room.addLiveEvents([leaveEvent], { addToState: true }); expectRedacted(messageEvents, room, false); }); it("should not apply to invites", async () => { - const {room, messageEvents} = await setupRoom(true); - const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"invite"); - await room.addLiveEvents([leaveEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite"); + await room.addLiveEvents([leaveEvent], { addToState: true }); expectRedacted(messageEvents, room, false); }); it("should not apply to joins", async () => { - const {room, messageEvents} = await setupRoom(true); - const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"join"); - await room.addLiveEvents([leaveEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join"); + await room.addLiveEvents([leaveEvent], { addToState: true }); expectRedacted(messageEvents, room, false); }); it("should not apply to knocks", async () => { - const {room, messageEvents} = await setupRoom(true); - const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID,"knock"); - await room.addLiveEvents([leaveEvent], {addToState: true}); + const { room, messageEvents } = await setupRoom(true); + const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock"); + await room.addLiveEvents([leaveEvent], { addToState: true }); expectRedacted(messageEvents, room, false); });