1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-12-04 05:02:41 +03:00

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 <hi@midhun.dev>

* Fix doc

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* 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 <hi@midhun.dev>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
David Baker
2025-08-08 11:23:49 +01:00
committed by GitHub
parent c7f982e190
commit e119bf9040
6 changed files with 305 additions and 201 deletions

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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<RoomMemberEvent, RoomMemberEve
* The power level for this room member.
*/
public powerLevel = 0;
/**
* The normalised power level (0-100) for this room member.
*/
public powerLevelNorm = 0;
/**
* The User object for this room member, if one exists.
*/
@@ -226,43 +221,18 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
/**
* Update this room member's power level event. May fire
* "RoomMember.powerLevel" if this event updates this member's power levels.
* @param powerLevelEvent - The `m.room.power_levels` event
* Update this room member's power level event. Will fire
* "RoomMember.powerLevel" if the new power level is different
* @param powerLevel - The power level of the room member.
*
* @remarks
* Fires {@link RoomMemberEvent.PowerLevel}
*/
public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void {
if (powerLevelEvent.getType() !== EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") {
return;
}
const evContent = powerLevelEvent.getDirectionalContent();
let maxLevel = evContent.users_default || 0;
const users: { [userId: string]: number } = evContent.users || {};
Object.values(users).forEach((lvl: number) => {
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);
}

View File

@@ -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<EmittedEvents, EventHandlerMap>
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
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<EmittedEvents, EventHandlerMap>
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<EmittedEvents, EventHandlerMap>
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<EmittedEvents, EventHandlerMap>
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<EmittedEvents, EventHandlerMap>
}
}
}
/**
* 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<string> {
const creators = new Set<string>();
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<string>): 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;
}
}
}

View File

@@ -374,7 +374,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private heroes: Hero[] | null = null;
// flags to stop logspam about missing m.room.create events
private getTypeWarning = false;
private getVersionWarning = false;
private membersPromise?: Promise<boolean>;
// XXX: These should be read-only
@@ -606,18 +605,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
/**
* 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();
}
/**

40
src/utils/roomVersion.ts Normal file
View File

@@ -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);
}