diff --git a/src/fixtures/matrix/invites/dm.js b/src/fixtures/matrix/invites/dm.js new file mode 100644 index 00000000..cc63ddce --- /dev/null +++ b/src/fixtures/matrix/invites/dm.js @@ -0,0 +1,52 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": "m.megolm.v1.aes-sha2" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "is_direct": true, + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + } + ] + } + }; +export default inviteFixture; diff --git a/src/fixtures/matrix/invites/room.js b/src/fixtures/matrix/invites/room.js new file mode 100644 index 00000000..41835d42 --- /dev/null +++ b/src/fixtures/matrix/invites/room.js @@ -0,0 +1,59 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.name", + "state_key": "", + "content": { + "name": "Invite example" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + }, + { + "content": { + "url": "mxc://hs.tld/roomavatar" + }, + "sender": "@alice:hs.tld", + "state_key": "", + "type": "m.room.avatar", + } + ] + } +}; +export default inviteFixture; \ No newline at end of file diff --git a/src/logging/NullLogger.js b/src/logging/NullLogger.js index 1860e697..614dc291 100644 --- a/src/logging/NullLogger.js +++ b/src/logging/NullLogger.js @@ -50,7 +50,7 @@ export class NullLogger { } } -class NullLogItem { +export class NullLogItem { wrap(_, callback) { return callback(this); } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 0ddf44ae..c510bfb8 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -15,6 +15,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher.js"; import { ObservableMap } from "../observable/index.js"; import {User} from "./User.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js new file mode 100644 index 00000000..6215498a --- /dev/null +++ b/src/matrix/room/Invite.js @@ -0,0 +1,252 @@ +/* +Copyright 2021 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 {EventEmitter} from "../../utils/EventEmitter.js"; +import {SummaryData, processStateEvent} from "./RoomSummary.js"; +import {Heroes} from "./members/Heroes.js"; +import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; + +export class Invite extends EventEmitter { + constructor({roomId, user, hsApi, emitCollectionRemove, clock}) { + super(); + this._roomId = roomId; + this._user = user; + this._hsApi = hsApi; + this._emitCollectionRemove = emitCollectionRemove; + this._clock = clock; + this._inviteData = null; + } + + get id() { + return this._roomId; + } + + get name() { + return this._inviteData.name || this._inviteData.canonicalAlias; + } + + get isDirectMessage() { + return this._inviteData.isDirectMessage; + } + + get avatarUrl() { + return this._inviteData.avatarUrl; + } + + get timestamp() { + return this._inviteData.timestamp; + } + + get isEncrypted() { + return this._inviteData.isEncrypted; + } + + get inviter() { + return this._inviter; + } + + get joinRule() { + return this._inviteData.joinRule; + } + + async accept() { + + } + + async reject() { + + } + + load(inviteData) { + this._inviteData = inviteData; + this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; + } + + async writeSync(membership, roomResponse, txn, log) { + if (membership === "invite") { + return log.wrap("new invite", async log => { + log.set("id", this.id); + const inviteState = roomResponse["invite_state"]?.events; + if (!Array.isArray(inviteState)) { + return null; + } + const summaryData = this._createSummaryData(inviteState); + let heroes; + if (!summaryData.name && !summaryData.canonicalAlias) { + heroes = await this._createHeroes(inviteState); + } + const myInvite = this._getMyInvite(inviteState); + if (!myInvite) { + return null; + } + const inviter = this._getInviter(myInvite, inviteState); + const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); + txn.invites.set(inviteData); + return {inviteData, inviter}; + }); + } else { + return log.wrap("remove invite", log => { + log.set("id", this.id); + log.set("membership", membership); + txn.invites.remove(this.id); + return null; + }); + } + } + + afterSync(changes, room) { + if (changes) { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // emit update/add + } else { + this._emitCollectionRemove(this); + this.emit("change"); + } + } + + _createData(inviteState, myInvite, inviter, summaryData, heroes) { + const name = heroes ? heroes.roomName : summaryData.name; + const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; + return { + roomId: this.id, + isEncrypted: !!summaryData.encryption, + isDirectMessage: this._isDirectMessage(myInvite), +// type: + name, + avatarUrl, + canonicalAlias: summaryData.canonicalAlias, + timestamp: this._clock.now(), + joinRule: this._getJoinRule(inviteState), + inviter: inviter?.serialize(), + }; + } + + _isDirectMessage(myInvite) { + return !!(myInvite?.content?.is_direct); + } + + _createSummaryData(inviteState) { + return inviteState.reduce(processStateEvent, new SummaryData(null, this.id)); + } + + async _createHeroes(inviteState) { + const members = inviteState.filter(e => e.type === MEMBER_EVENT_TYPE); + const otherMembers = members.filter(e => e.state_key !== this._user.id); + const memberChanges = otherMembers.reduce((map, e) => { + const member = RoomMember.fromMemberEvent(this.id, e); + map.set(member.userId, new MemberChange(member, null)); + return map; + }, new Map()); + const otherUserIds = otherMembers.map(e => e.state_key); + const heroes = new Heroes(this.id); + const changes = await heroes.calculateChanges(otherUserIds, memberChanges, null); + // we don't get an actual lazy-loading m.heroes summary on invites, + // so just count the members by hand + const countSummary = new SummaryData(null, this.id); + countSummary.joinCount = members.reduce((sum, e) => sum + (e.content?.membership === "join" ? 1 : 0), 0); + countSummary.inviteCount = members.reduce((sum, e) => sum + (e.content?.membership === "invite" ? 1 : 0), 0); + heroes.applyChanges(changes, countSummary); + return heroes; + } + + _getMyInvite(inviteState) { + return inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === this._user.id); + } + + _getInviter(myInvite, inviteState) { + const inviterMemberEvent = inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === myInvite.sender); + if (inviterMemberEvent) { + return RoomMember.fromMemberEvent(this.id, inviterMemberEvent); + } + } + + _getJoinRule(inviteState) { + const event = inviteState.find(e => e.type === "m.room.join_rules"); + if (event) { + return event.content?.join_rule; + } + return null; + } +} + +import {NullLogItem} from "../../logging/NullLogger.js"; +import {Clock as MockClock} from "../../mocks/Clock.js"; +import {default as roomInviteFixture} from "../../fixtures/matrix/invites/room.js"; +import {default as dmInviteFixture} from "../../fixtures/matrix/invites/dm.js"; + +export function tests() { + + function createStorage() { + const invitesMap = new Map(); + return { + invitesMap, + invites: { + set(invite) { + invitesMap.set(invite.roomId, invite); + } + } + } + } + + const roomId = "!123:hs.tld"; + const aliceAvatarUrl = "mxc://hs.tld/def456"; + const roomAvatarUrl = "mxc://hs.tld/roomavatar"; + + return { + "invite for room has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1001), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Invite example"); + assert.equal(invite.avatarUrl, roomAvatarUrl); + assert.equal(invite.joinRule, "invite"); + assert.equal(invite.timestamp, 1001); + assert.equal(invite.isEncrypted, false); + assert.equal(invite.isDirectMessage, false); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + "invite for encrypted DM has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Alice"); + assert.equal(invite.avatarUrl, aliceAvatarUrl); + assert.equal(invite.timestamp, 1003); + assert.equal(invite.isEncrypted, true); + assert.equal(invite.isDirectMessage, true); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + } +} diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 759b275a..d385c0a3 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -85,7 +85,7 @@ function processRoomAccountData(data, event) { return data; } -function processStateEvent(data, event) { +export function processStateEvent(data, event) { if (event.type === "m.room.encryption") { const algorithm = event.content?.algorithm; if (!data.encryption && algorithm === MEGOLM_ALGORITHM) { @@ -148,7 +148,7 @@ function updateSummary(data, summary) { return data; } -class SummaryData { +export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; this.name = copy ? copy.name : null;