diff --git a/spec/test-utils.js b/spec/test-utils.js index 47b2624f61c..1f5db16c7d9 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -91,7 +91,7 @@ export function mkEvent(opts) { event.state_key = opts.skey; } else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", "m.room.power_levels", "m.room.topic", - "com.example.state"].indexOf(opts.type) !== -1) { + "com.example.state"].includes(opts.type)) { event.state_key = ""; } return opts.event ? new MatrixEvent(event) : event; diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 7675609d319..be746a2371f 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -3,6 +3,7 @@ import { EventStatus, MatrixEvent } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; import { RoomState } from "../../src"; import { Room } from "../../src"; +import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; describe("Room", function() { @@ -1456,4 +1457,291 @@ describe("Room", function() { expect(room.maySendMessage()).toEqual(true); }); }); + + describe("getDefaultRoomName", function() { + it("should return 'Empty room' if a user is the only member", + function() { + const room = new Room(roomId, null, userA); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should return a display name if one other member is in the room", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name if one other member is banned", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "ban", + room: roomId, event: true, name: "User B", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); + + it("should return a display name if one other member is invited", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "invite", + room: roomId, event: true, name: "User B", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room (was User B)' if User B left the room", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "leave", + room: roomId, event: true, name: "User B", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); + + it("should return 'User B and User C' if in a room with two other users", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); + }); + + it("should return 'User B and 2 others' if in a room with three other users", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + utils.mkMembership({ + user: userD, mship: "join", + room: roomId, event: true, name: "User D", + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); + }); + + describe("io.element.functional_users", function() { + it("should return a display name (default behaviour) if no one is marked as a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a number (invalid)", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: 1, + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a string (invalid)", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: userB, + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room' if the only other member is a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [userB], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should return 'User B' if User B is the only other member who isn't a functional member", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room' if all other members are functional members", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userB, userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should not break if an unjoined user is marked as a service user", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }), + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }), + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + }); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index f7b22992eaa..42c7d0529c7 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -144,6 +144,28 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc */ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); +/** + * Functional members type for declaring a purpose of room members (e.g. helpful bots). + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + * + * Schema (TypeScript): + * { + * service_members?: string[] + * } + * + * Example: + * { + * "service_members": [ + * "@helperbot:localhost", + * "@reminderbot:alice.tdl" + * ] + * } + */ +export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue( + "io.element.functional_members", + "io.element.functional_members"); + export interface IEncryptedFile { url: string; mimetype?: string; diff --git a/src/models/room.ts b/src/models/room.ts index 089e60fa8a7..ec2e6b0b652 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -30,7 +30,7 @@ import { RoomMember } from "./room-member"; import { IRoomSummary, RoomSummary } from "./room-summary"; import { logger } from '../logger'; import { ReEmitter } from '../ReEmitter'; -import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; +import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../@types/event"; import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { ResizeMethod } from "../@types/partials"; import { Filter } from "../filter"; @@ -2036,22 +2036,43 @@ export class Room extends EventEmitter { const joinedMemberCount = this.currentState.getJoinedMemberCount(); const invitedMemberCount = this.currentState.getInvitedMemberCount(); // -1 because these numbers include the syncing user - const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + let inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get service members (e.g. helper bots) for exclusion + let excludedUserIds: string[] = []; + const mFunctionalMembers = this.currentState.getStateEvents(UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, ""); + if (Array.isArray(mFunctionalMembers?.getContent().service_members)) { + excludedUserIds = mFunctionalMembers.getContent().service_members; + } // get members that are NOT ourselves and are actually in the room. let otherNames = null; if (this.summaryHeroes) { // if we have a summary, the member state events // should be in the room state - otherNames = this.summaryHeroes.map((userId) => { + otherNames = []; + this.summaryHeroes.forEach((userId) => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return; + } const member = this.getMember(userId); - return member ? member.name : userId; + otherNames.push(member ? member.name : userId); }); } else { let otherMembers = this.currentState.getMembers().filter((m) => { return m.userId !== userId && (m.membership === "invite" || m.membership === "join"); }); + otherMembers = otherMembers.filter(({ userId }) => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return false; + } + return true; + }); // make sure members have stable order otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); // only 5 first members, immitate summaryHeroes @@ -2064,7 +2085,7 @@ export class Room extends EventEmitter { } const myMembership = this.getMyMembership(); - // if I have created a room and invited people throuh + // if I have created a room and invited people through // 3rd party invites if (myMembership == 'join') { const thirdPartyInvites =