From e119bf9040e87ec16a6bcd710b9e933bcf0c0eb7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 8 Aug 2025 11:23:49 +0100 Subject: [PATCH] Support for creator power level (#4937) * Support for creator power level Adds support for infinite power level specified by [MSC4289](https://github.com/matrix-org/matrix-spec-proposals/pull/4289). * Update unit test * Hardcode versions as room versions strings aren't ordered * Add test for v12 rooms * Use more compact syntax Co-authored-by: R Midhun Suresh * Fix doc Co-authored-by: R Midhun Suresh * Fix additionalCreators from PR edit * Split out hydra room version check * Move power level logic into room state Which already has knowledge of the room create event * Add docs * Fix unused bits * Fix docs * Fix lying docstring * Reverse logic for hydra semantics Assume unknown room versions do use hydra * Use backticks Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Switch back to hardcoding just the two hydra versions --------- Co-authored-by: R Midhun Suresh Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- spec/unit/room-member.spec.ts | 166 ++++------------------------------ spec/unit/room-state.spec.ts | 161 ++++++++++++++++++++++++++++++++- src/models/room-member.ts | 42 ++------- src/models/room-state.ts | 84 ++++++++++++++++- src/models/room.ts | 13 +-- src/utils/roomVersion.ts | 40 ++++++++ 6 files changed, 305 insertions(+), 201 deletions(-) create mode 100644 src/utils/roomVersion.ts diff --git a/spec/unit/room-member.spec.ts b/spec/unit/room-member.spec.ts index 55e77558e..69d75dea8 100644 --- a/spec/unit/room-member.spec.ts +++ b/spec/unit/room-member.spec.ts @@ -20,8 +20,8 @@ import * as utils from "../test-utils/test-utils"; import { RoomMember, RoomMemberEvent } from "../../src/models/room-member"; import { createClient, - EventType, type MatrixClient, + MatrixEvent, type RoomState, UNSTABLE_MSC2666_MUTUAL_ROOMS, UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS, @@ -101,158 +101,32 @@ describe("RoomMember", function () { }); }); - describe("setPowerLevelEvent", function () { - it("should set 'powerLevel' and 'powerLevelNorm'.", function () { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@bertha:bar": 200, - "@invalid:user": 10, // shouldn't barf on this. - }, - }, - event: true, - }); - member.setPowerLevelEvent(event); - expect(member.powerLevel).toEqual(20); - expect(member.powerLevelNorm).toEqual(10); - - const memberB = new RoomMember(roomId, userB); - memberB.setPowerLevelEvent(event); - expect(memberB.powerLevel).toEqual(200); - expect(memberB.powerLevelNorm).toEqual(100); - }); - - it("should emit 'RoomMember.powerLevel' if the power level changes.", function () { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@bertha:bar": 200, - "@invalid:user": 10, // shouldn't barf on this. - }, - }, - event: true, - }); - let emitCount = 0; - - member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { - emitCount += 1; - expect(emitMember).toEqual(member); - expect(emitEvent).toEqual(event); - }); - - member.setPowerLevelEvent(event); - expect(emitCount).toEqual(1); - member.setPowerLevelEvent(event); // no-op - expect(emitCount).toEqual(1); - }); - - it("should honour power levels of zero.", function () { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": 0, - }, - }, - event: true, - }); - let emitCount = 0; - - // set the power level to something other than zero or we - // won't get an event - member.powerLevel = 1; - member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { - emitCount += 1; - expect(emitMember.userId).toEqual("@alice:bar"); - expect(emitMember.powerLevel).toEqual(0); - expect(emitEvent).toEqual(event); - }); - - member.setPowerLevelEvent(event); + describe("setPowerLevel", function () { + it("should set 'powerLevel'.", function () { + member.setPowerLevel(0, new MatrixEvent()); expect(member.powerLevel).toEqual(0); - expect(emitCount).toEqual(1); + member.setPowerLevel(200, new MatrixEvent()); + expect(member.powerLevel).toEqual(200); }); - it("should not honor string power levels.", function () { - const event = utils.mkEvent({ - type: "m.room.power_levels", - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": "5", - }, - }, - event: true, - }); - let emitCount = 0; + it("should emit when power level set", function () { + const onEmit = jest.fn(); + member.on(RoomMemberEvent.PowerLevel, onEmit); - member.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { - emitCount += 1; - expect(emitMember.userId).toEqual("@alice:bar"); - expect(emitMember.powerLevel).toEqual(20); - expect(emitEvent).toEqual(event); - }); + const aMatrixEvent = new MatrixEvent(); + member.setPowerLevel(10, aMatrixEvent); - member.setPowerLevelEvent(event); - expect(member.powerLevel).toEqual(20); - expect(emitCount).toEqual(1); + expect(onEmit).toHaveBeenCalledWith(aMatrixEvent, member); }); - it("should no-op if given a non-state or unrelated event", () => { - const fn = jest.spyOn(member, "emit"); - expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); - member.setPowerLevelEvent( - utils.mkEvent({ - type: EventType.RoomPowerLevels, - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": "5", - }, - }, - skey: "invalid", - event: true, - }), - ); - const nonStateEv = utils.mkEvent({ - type: EventType.RoomPowerLevels, - room: roomId, - user: userA, - content: { - users_default: 20, - users: { - "@alice:bar": "5", - }, - }, - event: true, - }); - delete nonStateEv.event.state_key; - member.setPowerLevelEvent(nonStateEv); - member.setPowerLevelEvent( - utils.mkEvent({ - type: EventType.Sticker, - room: roomId, - user: userA, - content: {}, - event: true, - }), - ); - expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); + it("should not emit if new power level is the same", function () { + const onEmit = jest.fn(); + member.on(RoomMemberEvent.PowerLevel, onEmit); + + const aMatrixEvent = new MatrixEvent(); + member.setPowerLevel(0, aMatrixEvent); + + expect(onEmit).not.toHaveBeenCalled(); }); }); diff --git a/spec/unit/room-state.spec.ts b/spec/unit/room-state.spec.ts index da3f5de6c..2d986104b 100644 --- a/spec/unit/room-state.spec.ts +++ b/spec/unit/room-state.spec.ts @@ -20,6 +20,7 @@ import * as utils from "../test-utils/test-utils"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; +import { RoomMemberEvent } from "../../src/models/room-member"; import { type Beacon, BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; @@ -259,7 +260,7 @@ describe("RoomState", function () { expect(emitCount).toEqual(2); }); - it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function () { + it("should call setPowerLevel on each RoomMember for m.room.power_levels", function () { const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, @@ -273,12 +274,12 @@ describe("RoomState", function () { }); // spy on the room members - jest.spyOn(state.members[userA], "setPowerLevelEvent"); - jest.spyOn(state.members[userB], "setPowerLevelEvent"); + jest.spyOn(state.members[userA], "setPowerLevel"); + jest.spyOn(state.members[userB], "setPowerLevel"); state.setStateEvents([powerLevelEvent]); - expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); - expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); + expect(state.members[userA].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent); + expect(state.members[userB].setPowerLevel).toHaveBeenCalledWith(10, powerLevelEvent); }); it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function () { @@ -310,6 +311,156 @@ describe("RoomState", function () { expect(state.members[userC].powerLevel).toEqual(10); }); + it("should calculate power level correctly", function () { + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + [userB]: 200, + "@invalid:user": 10, // shouldn't barf on this. + }, + }, + event: true, + }); + state.setStateEvents([powerLevelEvent]); + + expect(state.getMember(userA)?.powerLevel).toEqual(20); + expect(state.getMember(userB)?.powerLevel).toEqual(200); + }); + + it("should set 'powerLevel' with a v12 room.", function () { + const createEventV12 = utils.mkEvent({ + type: "m.room.create", + room: roomId, + sender: userA, + content: { room_version: "12" }, + event: true, + }); + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + [userB]: 200, + "@invalid:user": 10, // shouldn't barf on this. + }, + }, + event: true, + }); + state.setStateEvents([createEventV12, powerLevelEvent]); + expect(state.getMember(userA)?.powerLevel).toEqual(Infinity); + }); + + it("should honour power levels of zero.", function () { + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": 0, + }, + }, + event: true, + }); + let emitCount = 0; + + const memberA = state.getMember(userA)!; + // set the power level to something other than zero or we + // won't get an event + memberA.powerLevel = 1; + memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual("@alice:bar"); + expect(emitMember.powerLevel).toEqual(0); + expect(emitEvent).toEqual(powerLevelEvent); + }); + + state.setStateEvents([powerLevelEvent]); + expect(memberA.powerLevel).toEqual(0); + expect(emitCount).toEqual(1); + }); + + it("should not honor string power levels.", function () { + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + event: true, + }); + let emitCount = 0; + + const memberA = state.getMember(userA)!; + memberA.on(RoomMemberEvent.PowerLevel, function (emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual("@alice:bar"); + expect(emitMember.powerLevel).toEqual(20); + expect(emitEvent).toEqual(powerLevelEvent); + }); + + state.setStateEvents([powerLevelEvent]); + expect(memberA.powerLevel).toEqual(20); + expect(emitCount).toEqual(1); + }); + + it("should no-op if given a non-state or unrelated event", () => { + const memberA = state.getMember(userA)!; + const fn = jest.spyOn(memberA, "emit"); + expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); + + const powerLevelEvent = utils.mkEvent({ + type: EventType.RoomPowerLevels, + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + skey: "invalid", + event: true, + }); + + state.setStateEvents([powerLevelEvent]); + const nonStateEv = utils.mkEvent({ + type: EventType.RoomPowerLevels, + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + event: true, + }); + delete nonStateEv.event.state_key; + state.setStateEvents([nonStateEv]); + state.setStateEvents([ + utils.mkEvent({ + type: EventType.Sticker, + room: roomId, + user: userA, + content: {}, + event: true, + }), + ]); + expect(fn).not.toHaveBeenCalledWith(RoomMemberEvent.PowerLevel); + }); + it("should call setMembershipEvent on the right RoomMember", function () { const memberEvent = utils.mkMembership({ user: userB, diff --git a/src/models/room-member.ts b/src/models/room-member.ts index 49ac4abce..6cf702e8a 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -66,7 +66,6 @@ export type RoomMemberEventHandlerMap = { * ``` * matrixClient.on("RoomMember.powerLevel", function(event, member){ * var newPowerLevel = member.powerLevel; - * var newNormPowerLevel = member.powerLevelNorm; * }); * ``` */ @@ -109,10 +108,6 @@ export class RoomMember extends TypedEventEmitter { - maxLevel = Math.max(maxLevel, lvl); - }); + public setPowerLevel(powerLevel: number, powerLevelEvent: MatrixEvent): void { const oldPowerLevel = this.powerLevel; - const oldPowerLevelNorm = this.powerLevelNorm; + this.powerLevel = powerLevel; - if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) { - this.powerLevel = users[this.userId]; - } else if (evContent.users_default !== undefined) { - this.powerLevel = evContent.users_default; - } else { - this.powerLevel = 0; - } - this.powerLevelNorm = 0; - if (maxLevel > 0) { - this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; - } - - // emit for changes in powerLevelNorm as well (since the app will need to - // redraw everyone's level if the max has changed) - if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + if (oldPowerLevel !== this.powerLevel) { this.updateModifiedTime(); this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this); } diff --git a/src/models/room-state.ts b/src/models/room-state.ts index a2f97bf97..445f83920 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -33,6 +33,7 @@ import { TypedReEmitter } from "../ReEmitter.ts"; import { M_BEACON, M_BEACON_INFO } from "../@types/beacon.ts"; import { KnownMembership } from "../@types/membership.ts"; import { type RoomJoinRulesEventContent } from "../@types/state_events.ts"; +import { shouldUseHydraForRoomVersion } from "../utils/roomVersion.ts"; export interface IMarkerFoundOptions { /** Whether the timeline was empty before the marker event arrived in the @@ -173,6 +174,9 @@ export class RoomState extends TypedEventEmitter public readonly beacons = new Map(); private _liveBeaconIds: BeaconIdentifier[] = []; + // We only wants to print warnings about bad room state once. + private getVersionWarning = false; + /** * Construct room state. * @@ -209,6 +213,22 @@ export class RoomState extends TypedEventEmitter this.updateModifiedTime(); } + /** + * Gets the version of the room + * @returns The version of the room + */ + public getRoomVersion(): string { + const createEvent = this.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getVersionWarning) { + logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); + this.getVersionWarning = true; + } + return "1"; + } + return createEvent.getContent()["room_version"] ?? "1"; + } + /** * Returns the number of joined members in this room * This method caches the result. @@ -468,12 +488,20 @@ export class RoomState extends TypedEventEmitter return; } const members = Object.values(this.members); + + const createEvent = this.getStateEvents(EventType.RoomCreate, ""); + const creators = getCreators(this.getRoomVersion(), createEvent); + members.forEach((member) => { // We only propagate `RoomState.members` event if the // power levels has been changed // large room suffer from large re-rendering especially when not needed const oldLastModified = member.getLastModifiedTime(); - member.setPowerLevelEvent(event); + + if (createEvent) { + const pl = powerLevelForUserId(member.userId, event, creators); + member.setPowerLevel(pl, event); + } if (oldLastModified !== member.getLastModifiedTime()) { this.emit(RoomStateEvent.Members, event, this, member); } @@ -625,9 +653,16 @@ export class RoomState extends TypedEventEmitter private updateMember(member: RoomMember): void { // this member may have a power level already, so set it. + const createEvent = this.getStateEvents(EventType.RoomCreate, ""); const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); - if (pwrLvlEvent) { - member.setPowerLevelEvent(pwrLvlEvent); + if (pwrLvlEvent && createEvent) { + const powerLevel = powerLevelForUserId( + member.userId, + pwrLvlEvent, + getCreators(this.getRoomVersion(), createEvent), + ); + + member.setPowerLevel(powerLevel, pwrLvlEvent); } // blow away the sentinel which is now outdated @@ -1106,3 +1141,46 @@ export class RoomState extends TypedEventEmitter } } } + +/** + * Get the set of creator user IDs for a room: empty if the room is not a 'hydra' room, otherwise + * computed from the sender of the m.room.create event plus the additional_creators field. + * @param roomVersion The version of the room + * @param roomCreateEvent The m.room.create event for the room + * @returns A set of user IDs of the creators of the room. + */ +function getCreators(roomVersion: string, roomCreateEvent: MatrixEvent | null): Set { + const creators = new Set(); + if (shouldUseHydraForRoomVersion(roomVersion) && roomCreateEvent) { + const roomCreateSender = roomCreateEvent.getSender(); + if (roomCreateSender) creators.add(roomCreateSender); + const additionalCreators = roomCreateEvent.getDirectionalContent().additional_creators; + if (Array.isArray(additionalCreators)) additionalCreators.forEach((c) => creators.add(c)); + } + return creators; +} + +/** + * + * @param userId The user ID to compute the power level for + * @param powerLevelEvents The power level event for the room + * @param creators The set of creator user IDs for the room if the room is a 'hydra' room, otherwise the empty set. + */ +function powerLevelForUserId(userId: string, powerLevelEvent: MatrixEvent, creators: Set): number { + if (creators.has(userId)) { + // As of "Hydra", If the user is a creator, they always have the highest power level + return Infinity; + } else { + const evContent = powerLevelEvent.getDirectionalContent(); + + const users: { [userId: string]: number } = evContent.users || {}; + + if (users[userId] !== undefined && Number.isInteger(users[userId])) { + return users[userId]; + } else if (evContent.users_default !== undefined) { + return evContent.users_default; + } else { + return 0; + } + } +} diff --git a/src/models/room.ts b/src/models/room.ts index 9572f55d8..515eb72f3 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -374,7 +374,6 @@ export class Room extends ReadReceipt { private heroes: Hero[] | null = null; // flags to stop logspam about missing m.room.create events private getTypeWarning = false; - private getVersionWarning = false; private membersPromise?: Promise; // XXX: These should be read-only @@ -606,18 +605,10 @@ export class Room extends ReadReceipt { /** * Gets the version of the room - * @returns The version of the room, or null if it could not be determined + * @returns The version of the room */ public getVersion(): string { - const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!createEvent) { - if (!this.getVersionWarning) { - logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); - this.getVersionWarning = true; - } - return "1"; - } - return createEvent.getContent()["room_version"] ?? "1"; + return this.currentState.getRoomVersion(); } /** diff --git a/src/utils/roomVersion.ts b/src/utils/roomVersion.ts new file mode 100644 index 000000000..e90b0ab92 --- /dev/null +++ b/src/utils/roomVersion.ts @@ -0,0 +1,40 @@ +/* +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. +*/ + +/** + * Room versions strings that we know about and do not use hydra semantics. + */ +const HYDRA_ROOM_VERSIONS = ["org.matrix.hydra.11", "12"]; + +/** + * Checks if the given room version is one where new "hydra" power level + * semantics (ie. room version 12 or later) should be used + * (see https://github.com/matrix-org/matrix-spec-proposals/pull/4289). + * This will return `true` for versions that are known to the js-sdk and + * use hydra: any room versions unknown to the js-sdk (experimental or + * otherwise) will cause the function to return `false`. + * + * @param roomVersion - The version of the room to check. + * @returns `true` if hydra semantics should be used for the room version, `false` otherwise. + */ +export function shouldUseHydraForRoomVersion(roomVersion: string): boolean { + // Future new room versions must obviously be added to the constant above, + // otherwise the js-sdk will use the old, pre-hydra semantics. At some point + // it would make sense to assume hydra for unknown versions but this will break + // any rooms using unknown versions, so at hydra switch time we've agreed all + // Element clients will only use hydra for the two specific hydra versions. + return HYDRA_ROOM_VERSIONS.includes(roomVersion); +}