diff --git a/src/matrix/Session.js b/src/matrix/Session.js index a2725bd4..f8d42960 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -18,6 +18,7 @@ limitations under the License. import {Room} from "./room/Room.js"; import {ArchivedRoom} from "./room/ArchivedRoom.js"; import {RoomStatus} from "./room/RoomStatus.js"; +import {RoomBeingCreated} from "./room/create"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher"; import { ObservableMap } from "../observable/index.js"; @@ -63,6 +64,7 @@ export class Session { this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); + this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -421,7 +423,7 @@ export class Session { // load rooms const rooms = await txn.roomSummary.getAll(); const roomLoadPromise = Promise.all(rooms.map(async summary => { - const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); + const room = this.createJoinedRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); await log.wrap("room", log => room.load(summary, txn, log)); this._rooms.add(room.id, room); })); @@ -530,7 +532,7 @@ export class Session { } /** @internal */ - createRoom(roomId, pendingEvents) { + createJoinedRoom(roomId, pendingEvents) { return new Room({ roomId, getSyncToken: this._getSyncToken, @@ -580,6 +582,20 @@ export class Session { }); } + get roomsBeingCreated() { + return this._roomsBeingCreated; + } + + createRoom(type, isEncrypted, explicitName, topic, invites) { + const localId = `room-being-created-${this.platform.random()}`; + const roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, explicitName, topic, invites); + this._roomsBeingCreated.set(localId, roomBeingCreated); + this._platform.logger.runDetached("create room", async log => { + roomBeingCreated.start(this._hsApi, log); + }); + return localId; + } + async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { @@ -667,6 +683,13 @@ export class Session { for (const rs of roomStates) { if (rs.shouldAdd) { this._rooms.add(rs.id, rs.room); + for (const roomBeingCreated of this._roomsBeingCreated) { + if (roomBeingCreated.roomId === rs.id) { + roomBeingCreated.notifyJoinedRoom(); + this._roomsBeingCreated.delete(roomBeingCreated.localId); + break; + } + } } else if (rs.shouldRemove) { this._rooms.remove(rs.id); } diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index de09a96d..e9faa89b 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -392,7 +392,7 @@ export class Sync { // we receive also gets written. // In any case, don't create a room for a rejected invite if (!room && (membership === "join" || (isInitialSync && membership === "leave"))) { - room = this._session.createRoom(roomId); + room = this._session.createJoinedRoom(roomId); isNewRoom = true; } if (room) { diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js index 8b137c76..2b9d46b9 100644 --- a/src/matrix/e2ee/common.js +++ b/src/matrix/e2ee/common.js @@ -57,3 +57,15 @@ export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Ke return false; } } + +export function createRoomEncryptionEvent() { + return { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": MEGOLM_ALGORITHM, + "rotation_period_ms": 604800000, + "rotation_period_msgs": 100 + } + } +} diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index a23321f9..bf2c2c21 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -263,20 +263,29 @@ export class HomeServerApi { return this._post(`/logout`, {}, {}, options); } - getDehydratedDevice(options: IRequestOptions): IHomeServerRequest { + getDehydratedDevice(options: IRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); } - createDehydratedDevice(payload: Record, options: IRequestOptions): IHomeServerRequest { + createDehydratedDevice(payload: Record, options: IRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._put(`/dehydrated_device`, {}, payload, options); } - claimDehydratedDevice(deviceId: string, options: IRequestOptions): IHomeServerRequest { + claimDehydratedDevice(deviceId: string, options: IRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._post(`/dehydrated_device/claim`, {}, {device_id: deviceId}, options); } + + profile(userId: string, options?: IRequestOptions): IHomeServerRequest { + return this._get(`/profile/${encodeURIComponent(userId)}`); + } + + createRoom(payload: Record, options?: IRequestOptions): IHomeServerRequest { + return this._post(`/createRoom`, {}, payload, options); + } + } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts new file mode 100644 index 00000000..934650c0 --- /dev/null +++ b/src/matrix/room/create.ts @@ -0,0 +1,165 @@ +/* +Copyright 2020 Bruno Windels + +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 {calculateRoomName} from "./members/Heroes"; +import {createRoomEncryptionEvent} from "../e2ee/common"; +import {EventEmitter} from "../../utils/EventEmitter"; + +import type {StateEvent} from "../storage/types"; +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {ILogItem} from "../../logging/types"; + +type CreateRoomPayload = { + is_direct?: boolean; + preset?: string; + name?: string; + topic?: string; + invite?: string[]; + initial_state?: StateEvent[] +} + +export enum RoomType { + DirectMessage, + Private, + Public +} + +function defaultE2EEStatusForType(type: RoomType): boolean { + switch (type) { + case RoomType.DirectMessage: + case RoomType.Private: + return true; + case RoomType.Public: + return false; + } +} + +function presetForType(type: RoomType): string { + switch (type) { + case RoomType.DirectMessage: + return "trusted_private_chat"; + case RoomType.Private: + return "private_chat"; + case RoomType.Public: + return "public_chat"; + } +} + +export class RoomBeingCreated extends EventEmitter<{change: never, joined: string}> { + private _roomId?: string; + private profiles: Profile[] = []; + + public readonly isEncrypted: boolean; + public readonly name: string; + + constructor( + private readonly localId: string, + private readonly type: RoomType, + isEncrypted: boolean | undefined, + private readonly explicitName: string | undefined, + private readonly topic: string | undefined, + private readonly inviteUserIds: string[] | undefined, + log: ILogItem + ) { + super(); + this.isEncrypted = isEncrypted === undefined ? defaultE2EEStatusForType(this.type) : isEncrypted; + if (explicitName) { + this.name = explicitName; + } else { + const summaryData = { + joinCount: 1, // ourselves + inviteCount: (this.inviteUserIds?.length || 0) + }; + this.name = calculateRoomName(this.profiles, summaryData, log); + } + } + + public async start(hsApi: HomeServerApi, log: ILogItem): Promise { + await Promise.all([ + this.loadProfiles(hsApi, log), + this.create(hsApi, log), + ]); + } + + private async create(hsApi: HomeServerApi, log: ILogItem): Promise { + const options: CreateRoomPayload = { + is_direct: this.type === RoomType.DirectMessage, + preset: presetForType(this.type) + }; + if (this.explicitName) { + options.name = this.explicitName; + } + if (this.topic) { + options.topic = this.topic; + } + if (this.inviteUserIds) { + options.invite = this.inviteUserIds; + } + if (this.isEncrypted) { + options.initial_state = [createRoomEncryptionEvent()]; + } + + const response = await hsApi.createRoom(options, {log}).response(); + this._roomId = response["room_id"]; + this.emit("change"); + } + + private async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise { + // only load profiles if we need it for the room name and avatar + if (!this.explicitName && this.inviteUserIds) { + this.profiles = await loadProfiles(this.inviteUserIds, hsApi, log); + this.emit("change"); + } + } + + notifyJoinedRoom() { + this.emit("joined", this._roomId); + } + + get avatarUrl(): string | undefined { + return this.profiles[0]?.avatarUrl; + } + + get roomId(): string | undefined { + return this._roomId; + } +} + +export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { + const profiles = await Promise.all(userIds.map(async userId => { + const response = await hsApi.profile(userId, {log}).response(); + return new Profile(userId, response.displayname as string, response.avatar_url as string); + })); + profiles.sort((a, b) => a.name.localeCompare(b.name)); + return profiles; +} + +interface IProfile { + get userId(): string; + get displayName(): string; + get avatarUrl(): string; + get name(): string; +} + +export class Profile implements IProfile { + constructor( + public readonly userId: string, + public readonly displayName: string, + public readonly avatarUrl: string + ) {} + + get name() { return this.displayName || this.userId; } +} diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index ce2fe587..1d2ab39e 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -16,7 +16,7 @@ limitations under the License. import {RoomMember} from "./RoomMember.js"; -function calculateRoomName(sortedMembers, summaryData, log) { +export function calculateRoomName(sortedMembers, summaryData, log) { const countWithoutMe = summaryData.joinCount + summaryData.inviteCount - 1; if (sortedMembers.length >= countWithoutMe) { if (sortedMembers.length > 1) {