From 348de312f9a69f1a8550cc50d1b94bdcf65edbf0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 2 Feb 2022 10:19:49 +0100 Subject: [PATCH 01/43] draft code in matrix layer to create room --- src/matrix/Session.js | 27 ++++- src/matrix/Sync.js | 2 +- src/matrix/e2ee/common.js | 12 +++ src/matrix/net/HomeServerApi.ts | 15 ++- src/matrix/room/create.ts | 165 ++++++++++++++++++++++++++++++ src/matrix/room/members/Heroes.js | 2 +- 6 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 src/matrix/room/create.ts 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) { From bc09ede09f6e9529ae367e864acb9617347cffa4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 3 Feb 2022 17:57:35 +0100 Subject: [PATCH 02/43] WIP --- src/domain/session/RoomViewModelObservable.js | 24 ++++-- src/domain/session/SessionViewModel.js | 19 +++-- .../session/leftpanel/LeftPanelViewModel.js | 19 +++-- .../RoomBeingCreatedTileViewModel.js | 53 +++++++++++++ .../rightpanel/MemberDetailsViewModel.js | 7 ++ .../session/rightpanel/RightPanelViewModel.js | 9 ++- src/domain/session/room/README.md | 14 +++- .../session/room/RoomBeingCreatedViewModel.js | 65 +++++++++++++++ src/matrix/Session.js | 79 +++++++++++++------ src/matrix/room/Room.js | 1 + src/matrix/room/RoomStatus.js | 61 -------------- src/matrix/room/RoomStatus.ts | 24 ++++++ src/matrix/room/create.ts | 61 ++++++++++---- src/platform/web/ui/session/RoomGridView.js | 5 +- src/platform/web/ui/session/SessionView.js | 3 + .../ui/session/leftpanel/InviteTileView.js | 12 ++- .../web/ui/session/leftpanel/LeftPanelView.js | 2 +- .../session/rightpanel/MemberDetailsView.js | 3 +- .../web/ui/session/room/MessageComposer.js | 2 +- .../ui/session/room/RoomBeingCreatedView.js | 25 ++++++ 20 files changed, 360 insertions(+), 128 deletions(-) create mode 100644 src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js create mode 100644 src/domain/session/room/RoomBeingCreatedViewModel.js delete mode 100644 src/matrix/room/RoomStatus.js create mode 100644 src/matrix/room/RoomStatus.ts create mode 100644 src/platform/web/ui/session/room/RoomBeingCreatedView.js diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 8d576a98..c16aaf83 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ObservableValue} from "../../observable/ObservableValue"; +import {RoomStatus} from "../../matrix/room/RoomStatus"; /** Depending on the status of a room (invited, joined, archived, or none), @@ -34,11 +35,11 @@ the now transferred child view model. This is also why there is an explicit initialize method, see comment there. */ export class RoomViewModelObservable extends ObservableValue { - constructor(sessionViewModel, roomId) { + constructor(sessionViewModel, roomIdOrLocalId) { super(null); this._statusSubscription = null; this._sessionViewModel = sessionViewModel; - this.id = roomId; + this.id = roomIdOrLocalId; } /** @@ -59,11 +60,24 @@ export class RoomViewModelObservable extends ObservableValue { } async _statusToViewModel(status) { - if (status.invited) { + console.log("RoomViewModelObservable received status", status); + if (status & RoomStatus.Replaced) { + console.log("replaced!"); + if (status & RoomStatus.BeingCreated) { + const {session} = this._sessionViewModel._client; + const roomBeingCreated = session.roomsBeingCreated.get(this.id); + console.log("new id is", roomBeingCreated.roomId); + this._sessionViewModel.navigation.push("room", roomBeingCreated.roomId); + } else { + throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced)); + } + } else if (status & RoomStatus.BeingCreated) { + return this._sessionViewModel._createRoomBeingCreatedViewModel(this.id); + } else if (status & RoomStatus.Invited) { return this._sessionViewModel._createInviteViewModel(this.id); - } else if (status.joined) { + } else if (status & RoomStatus.Joined) { return this._sessionViewModel._createRoomViewModel(this.id); - } else if (status.archived) { + } else if (status & RoomStatus.Archived) { return await this._sessionViewModel._createArchivedRoomViewModel(this.id); } else { return this._sessionViewModel._createUnknownRoomViewModel(this.id); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 822a4d31..11126ca9 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -19,6 +19,7 @@ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; +import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; @@ -37,10 +38,7 @@ export class SessionViewModel extends ViewModel { reconnector: client.reconnector, session: client.session, }))); - this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ - invites: this._client.session.invites, - rooms: this._client.session.rooms - }))); + this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({session: this._client.session}))); this._settingsViewModel = null; this._roomViewModelObservable = null; this._gridViewModel = null; @@ -200,6 +198,17 @@ export class SessionViewModel extends ViewModel { return null; } + _createRoomBeingCreatedViewModel(localId) { + const roomBeingCreated = this._client.session.roomsBeingCreated.get(localId); + if (roomBeingCreated) { + return new RoomBeingCreatedViewModel(this.childOptions({ + roomBeingCreated, + mediaRepository: this._client.session.mediaRepository, + })); + } + return null; + } + _updateRoom(roomId) { // opening a room and already open? if (this._roomViewModelObservable?.id === roomId) { @@ -263,7 +272,7 @@ export class SessionViewModel extends ViewModel { const enable = !!this.navigation.path.get("right-panel")?.value; if (enable) { const room = this._roomFromNavigation(); - this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room}))); + this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room, session: this._client.session}))); } this.emitChange("rightPanelViewModel"); } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 59b68ca1..d85e5943 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -18,6 +18,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js"; +import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {addPanelIfNeeded} from "../../navigation/index.js"; @@ -25,8 +26,8 @@ import {addPanelIfNeeded} from "../../navigation/index.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { super(options); - const {rooms, invites} = options; - this._tileViewModelsMap = this._mapTileViewModels(rooms, invites); + const {session} = options; + this._tileViewModelsMap = this._mapTileViewModels(session.roomsBeingCreated, session.invites, session.rooms); this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap); this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._currentTileVM = null; @@ -35,16 +36,18 @@ export class LeftPanelViewModel extends ViewModel { this._settingsUrl = this.urlCreator.urlForSegment("settings"); } - _mapTileViewModels(rooms, invites) { + _mapTileViewModels(roomsBeingCreated, invites, rooms) { // join is not commutative, invites will take precedence over rooms - return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + return roomsBeingCreated.join(invites).join(rooms).mapValues((item, emitChange) => { let vm; - if (roomOrInvite.isInvite) { - vm = new InviteTileViewModel(this.childOptions({invite: roomOrInvite, emitChange})); + if (item.isBeingCreated) { + vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange})); + } else if (item.isInvite) { + vm = new InviteTileViewModel(this.childOptions({invite: item, emitChange})); } else { - vm = new RoomTileViewModel(this.childOptions({room: roomOrInvite, emitChange})); + vm = new RoomTileViewModel(this.childOptions({room: item, emitChange})); } - const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; + const isOpen = this.navigation.path.get("room")?.value === item.id; if (isOpen) { vm.open(); this._updateCurrentVM(vm); diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js new file mode 100644 index 00000000..8c629892 --- /dev/null +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -0,0 +1,53 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 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 {BaseTileViewModel} from "./BaseTileViewModel.js"; + +export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { + constructor(options) { + super(options); + const {roomBeingCreated} = options; + this._roomBeingCreated = roomBeingCreated; + this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.localId); + } + + get busy() { return true; } + + get kind() { + return "roomBeingCreated"; + } + + get url() { + return this._url; + } + + compare(other) { + const parentComparison = super.compare(other); + if (parentComparison !== 0) { + return parentComparison; + } + return other._roomBeingCreated.name.localeCompare(this._roomBeingCreated.name); + } + + get name() { + return this._roomBeingCreated.name; + } + + get _avatarSource() { + return this._roomBeingCreated; + } +} diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 0303e05d..6effb90c 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel.js"; +import {RoomType} from "../../../matrix/room/create"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; export class MemberDetailsViewModel extends ViewModel { @@ -25,6 +26,7 @@ export class MemberDetailsViewModel extends ViewModel { this._member = this._observableMember.get(); this._isEncrypted = options.isEncrypted; this._powerLevelsObservable = options.powerLevelsObservable; + this._session = options.session; this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange())); } @@ -79,4 +81,9 @@ export class MemberDetailsViewModel extends ViewModel { get linkToUser() { return `https://matrix.to/#/${this._member.userId}`; } + + async openDirectMessage() { + const localId = await this._session.createRoom(RoomType.DirectMessage, undefined, undefined, undefined, [this.userId]); + this.navigation.push("room", localId); + } } diff --git a/src/domain/session/rightpanel/RightPanelViewModel.js b/src/domain/session/rightpanel/RightPanelViewModel.js index 670df868..3cfe378b 100644 --- a/src/domain/session/rightpanel/RightPanelViewModel.js +++ b/src/domain/session/rightpanel/RightPanelViewModel.js @@ -23,6 +23,7 @@ export class RightPanelViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; + this._session = options.session; this._members = null; this._setupNavigation(); } @@ -48,7 +49,13 @@ export class RightPanelViewModel extends ViewModel { } const isEncrypted = this._room.isEncrypted; const powerLevelsObservable = await this._room.observePowerLevels(); - return {observableMember, isEncrypted, powerLevelsObservable, mediaRepository: this._room.mediaRepository}; + return { + observableMember, + isEncrypted, + powerLevelsObservable, + mediaRepository: this._room.mediaRepository, + session: this._session + }; } _setupNavigation() { diff --git a/src/domain/session/room/README.md b/src/domain/session/room/README.md index adb673eb..dbbe4dca 100644 --- a/src/domain/session/room/README.md +++ b/src/domain/session/room/README.md @@ -1,9 +1,17 @@ # "Room" view models -InviteViewModel and RoomViewModel are interchangebly used as "room view model": - - SessionViewModel.roomViewModel can be an instance of either - - RoomGridViewModel.roomViewModelAt(i) can return an instance of either +InviteViewModel, RoomViewModel and RoomBeingCreatedViewModel are interchangebly used as "room view model": + - SessionViewModel.roomViewModel can be an instance of any + - RoomGridViewModel.roomViewModelAt(i) can return an instance of any This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel. They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method. +Once we convert this folder to typescript, we should use this interface for all the view models: +```ts +interface IGridItemViewModel { + id: string; + kind: string; + focus(); +} +``` diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js new file mode 100644 index 00000000..eebbb668 --- /dev/null +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -0,0 +1,65 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 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 {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +export class RoomBeingCreatedViewModel extends ViewModel { + constructor(options) { + super(options); + const {roomBeingCreated, mediaRepository} = options; + this._roomBeingCreated = roomBeingCreated; + this._mediaRepository = mediaRepository; + this._onRoomChange = this._onRoomChange.bind(this); + this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._roomBeingCreated.on("change", this._onRoomChange); + } + + get kind() { return "roomBeingCreated"; } + get closeUrl() { return this._closeUrl; } + get name() { return this._roomBeingCreated.name; } + get id() { return this._roomBeingCreated.localId; } + get isEncrypted() { return this._roomBeingCreated.isEncrypted; } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository); + } + + get avatarTitle() { + return this.name; + } + + focus() {} + + _onRoomChange() { + this.emitChange(); + } + + dispose() { + super.dispose(); + this._roomBeingCreated.off("change", this._onRoomChange); + } +} + diff --git a/src/matrix/Session.js b/src/matrix/Session.js index f8d42960..49ae0f9c 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -17,7 +17,7 @@ limitations under the License. import {Room} from "./room/Room.js"; import {ArchivedRoom} from "./room/ArchivedRoom.js"; -import {RoomStatus} from "./room/RoomStatus.js"; +import {RoomStatus} from "./room/RoomStatus"; import {RoomBeingCreated} from "./room/create"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher"; @@ -64,6 +64,12 @@ export class Session { this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); + this._roomsBeingCreatedUpdateCallback = (rbc, params) => { + this._roomsBeingCreated.update(rbc.localId, params); + if (rbc.roomId && !!this.rooms.get(rbc.roomId)) { + this._tryReplaceRoomBeingCreated(rbc.roomId); + } + }; this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); @@ -586,14 +592,16 @@ export class Session { 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); + createRoom(type, isEncrypted, explicitName, topic, invites, log = undefined) { + return this._platform.logger.wrapOrRun(log, "create room", log => { + const localId = `room-being-created-${this._platform.random()}`; + const roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, this._mediaRepository, log); + this._roomsBeingCreated.set(localId, roomBeingCreated); + log.wrapDetached("create room network", async log => { + roomBeingCreated.start(this._hsApi, log); + }); + return localId; }); - return localId; } async obtainSyncLock(syncResponse) { @@ -678,18 +686,29 @@ export class Session { } } + _tryReplaceRoomBeingCreated(roomId) { + console.trace("_tryReplaceRoomBeingCreated " + roomId); + for (const [,roomBeingCreated] of this._roomsBeingCreated) { + if (roomBeingCreated.roomId === roomId) { + const observableStatus = this._observedRoomStatus.get(roomBeingCreated.localId); + if (observableStatus) { + console.log("marking room as replaced", observableStatus.get()); + observableStatus.set(observableStatus.get() | RoomStatus.Replaced); + } else { + console.log("no observableStatus"); + } + this._roomsBeingCreated.remove(roomBeingCreated.localId); + return; + } + } + } + applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { // update the collections after sync 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; - } - } + this._tryReplaceRoomBeingCreated(rs.id); } else if (rs.shouldRemove) { this._rooms.remove(rs.id); } @@ -707,12 +726,12 @@ export class Session { if (this._observedRoomStatus.size !== 0) { for (const ars of archivedRoomStates) { if (ars.shouldAdd) { - this._observedRoomStatus.get(ars.id)?.set(RoomStatus.archived); + this._observedRoomStatus.get(ars.id)?.set(RoomStatus.Archived); } } for (const rs of roomStates) { if (rs.shouldAdd) { - this._observedRoomStatus.get(rs.id)?.set(RoomStatus.joined); + this._observedRoomStatus.get(rs.id)?.set(RoomStatus.Joined); } } for (const is of inviteStates) { @@ -731,7 +750,7 @@ export class Session { _forgetArchivedRoom(roomId) { const statusObservable = this._observedRoomStatus.get(roomId); if (statusObservable) { - statusObservable.set(statusObservable.get().withoutArchived()); + statusObservable.set(statusObservable.get() ^ RoomStatus.Archived); } } @@ -820,21 +839,25 @@ export class Session { } async getRoomStatus(roomId) { + const isBeingCreated = !!this._roomsBeingCreated.get(roomId); + if (isBeingCreated) { + return RoomStatus.BeingCreated; + } const isJoined = !!this._rooms.get(roomId); if (isJoined) { - return RoomStatus.joined; + return RoomStatus.Joined; } else { const isInvited = !!this._invites.get(roomId); const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]); const isArchived = await txn.archivedRoomSummary.has(roomId); if (isInvited && isArchived) { - return RoomStatus.invitedAndArchived; + return RoomStatus.Invited | RoomStatus.Archived; } else if (isInvited) { - return RoomStatus.invited; + return RoomStatus.Invited; } else if (isArchived) { - return RoomStatus.archived; + return RoomStatus.Archived; } else { - return RoomStatus.none; + return RoomStatus.None; } } } @@ -843,9 +866,10 @@ export class Session { let observable = this._observedRoomStatus.get(roomId); if (!observable) { const status = await this.getRoomStatus(roomId); - observable = new RetainedObservableValue(status, () => { + observable = new FooRetainedObservableValue(status, () => { this._observedRoomStatus.delete(roomId); }); + this._observedRoomStatus.set(roomId, observable); } return observable; @@ -897,6 +921,13 @@ export class Session { } } +class FooRetainedObservableValue extends RetainedObservableValue { + set(value) { + console.log("setting room status to", value); + super.set(value); + } +} + export function tests() { function createStorageMock(session, pendingEvents = []) { return { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index aaf66be1..46c06af0 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -231,6 +231,7 @@ export class Room extends BaseRoom { } } let emitChange = false; + console.log("Room summaryChanges", this.id, summaryChanges); if (summaryChanges) { this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { diff --git a/src/matrix/room/RoomStatus.js b/src/matrix/room/RoomStatus.js deleted file mode 100644 index 884103e2..00000000 --- a/src/matrix/room/RoomStatus.js +++ /dev/null @@ -1,61 +0,0 @@ -/* -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. -*/ - -export class RoomStatus { - constructor(joined, invited, archived) { - this.joined = joined; - this.invited = invited; - this.archived = archived; - } - - withInvited() { - if (this.invited) { - return this; - } else if (this.archived) { - return RoomStatus.invitedAndArchived; - } else { - return RoomStatus.invited; - } - } - - withoutInvited() { - if (!this.invited) { - return this; - } else if (this.joined) { - return RoomStatus.joined; - } else if (this.archived) { - return RoomStatus.archived; - } else { - return RoomStatus.none; - } - } - - withoutArchived() { - if (!this.archived) { - return this; - } else if (this.invited) { - return RoomStatus.invited; - } else { - return RoomStatus.none; - } - } -} - -RoomStatus.joined = new RoomStatus(true, false, false); -RoomStatus.archived = new RoomStatus(false, false, true); -RoomStatus.invited = new RoomStatus(false, true, false); -RoomStatus.invitedAndArchived = new RoomStatus(false, true, true); -RoomStatus.none = new RoomStatus(false, false, false); diff --git a/src/matrix/room/RoomStatus.ts b/src/matrix/room/RoomStatus.ts new file mode 100644 index 00000000..f66f59d7 --- /dev/null +++ b/src/matrix/room/RoomStatus.ts @@ -0,0 +1,24 @@ +/* +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. +*/ + +export enum RoomStatus { + None = 1 << 0, + BeingCreated = 1 << 1, + Invited = 1 << 2, + Joined = 1 << 3, + Replaced = 1 << 4, + Archived = 1 << 5, +} diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 934650c0..24adfcf1 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -16,6 +16,7 @@ limitations under the License. import {calculateRoomName} from "./members/Heroes"; import {createRoomEncryptionEvent} from "../e2ee/common"; +import {MediaRepository} from "../net/MediaRepository"; import {EventEmitter} from "../../utils/EventEmitter"; import type {StateEvent} from "../storage/types"; @@ -58,32 +59,35 @@ function presetForType(type: RoomType): string { } } -export class RoomBeingCreated extends EventEmitter<{change: never, joined: string}> { +export class RoomBeingCreated extends EventEmitter<{change: never}> { private _roomId?: string; private profiles: Profile[] = []; public readonly isEncrypted: boolean; - public readonly name: string; + private _name: string; constructor( - private readonly localId: string, + public 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, + private readonly updateCallback, + public readonly mediaRepository: MediaRepository, log: ILogItem ) { super(); this.isEncrypted = isEncrypted === undefined ? defaultE2EEStatusForType(this.type) : isEncrypted; if (explicitName) { - this.name = explicitName; + this._name = explicitName; } else { const summaryData = { joinCount: 1, // ourselves inviteCount: (this.inviteUserIds?.length || 0) }; - this.name = calculateRoomName(this.profiles, summaryData, log); + const userIdProfiles = (inviteUserIds || []).map(userId => new UserIdProfile(userId)); + this._name = calculateRoomName(userIdProfiles, summaryData, log); } } @@ -111,31 +115,52 @@ export class RoomBeingCreated extends EventEmitter<{change: never, joined: strin if (this.isEncrypted) { options.initial_state = [createRoomEncryptionEvent()]; } - + console.log("going to create the room now"); const response = await hsApi.createRoom(options, {log}).response(); this._roomId = response["room_id"]; - this.emit("change"); + console.log("done creating the room now", this._roomId); + // TODO: somehow check in Session if we need to replace this with a joined room + // in case the room appears first in sync, and this request returns later + this.emitChange(); } 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"); + console.log("loaded the profiles", this.profiles); + const summaryData = { + joinCount: 1, // ourselves + inviteCount: this.inviteUserIds.length + }; + this._name = calculateRoomName(this.profiles, summaryData, log); + console.log("loaded the profiles and the new name", this.name); + this.emitChange(); } } - notifyJoinedRoom() { - this.emit("joined", this._roomId); + private emitChange() { + this.updateCallback(this); + this.emit("change"); + } + + get avatarColorId(): string { + return this.inviteUserIds?.[0] ?? this._roomId ?? this.localId; } get avatarUrl(): string | undefined { - return this.profiles[0]?.avatarUrl; + const result = this.profiles[0]?.avatarUrl; + console.log("RoomBeingCreated.avatarUrl", this.profiles, result); + return result; } get roomId(): string | undefined { return this._roomId; } + + get name() { return this._name; } + + get isBeingCreated(): boolean { return true; } } export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { @@ -149,8 +174,8 @@ export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: interface IProfile { get userId(): string; - get displayName(): string; - get avatarUrl(): string; + get displayName(): string | undefined; + get avatarUrl(): string | undefined; get name(): string; } @@ -158,8 +183,16 @@ export class Profile implements IProfile { constructor( public readonly userId: string, public readonly displayName: string, - public readonly avatarUrl: string + public readonly avatarUrl: string | undefined ) {} get name() { return this.displayName || this.userId; } } + +class UserIdProfile implements IProfile { + constructor(public readonly userId: string) {} + get displayName() { return undefined; } + get name() { return this.userId; } + get avatarUrl() { return undefined; } + +} diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index fc6497da..79fc3d21 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {RoomView} from "./room/RoomView.js"; +import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js"; import {InviteView} from "./room/InviteView.js"; import {TemplateView} from "../general/TemplateView"; import {StaticView} from "../general/StaticView.js"; @@ -33,7 +34,9 @@ export class RoomGridView extends TemplateView { }, }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { if (roomVM) { - if (roomVM.kind === "invite") { + if (roomVM.kind === "roomBeingCreated") { + return new RoomBeingCreatedView(roomVM); + } else if (roomVM.kind === "invite") { return new InviteView(roomVM); } else { return new RoomView(roomVM); diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index f2ac2971..3740ffca 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -18,6 +18,7 @@ limitations under the License. import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js"; +import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js"; import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; import {TemplateView} from "../general/TemplateView"; @@ -48,6 +49,8 @@ export class SessionView extends TemplateView { return new InviteView(vm.currentRoomViewModel); } else if (vm.currentRoomViewModel.kind === "room") { return new RoomView(vm.currentRoomViewModel); + } else if (vm.currentRoomViewModel.kind === "roomBeingCreated") { + return new RoomBeingCreatedView(vm.currentRoomViewModel); } else { return new UnknownRoomView(vm.currentRoomViewModel); } diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js index b99ab1c6..de064bed 100644 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; -import {renderStaticAvatar} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView"; import {spinner} from "../../common.js"; export class InviteTileView extends TemplateView { @@ -27,9 +27,9 @@ export class InviteTileView extends TemplateView { }; return t.li({"className": classes}, [ t.a({href: vm.url}, [ - renderStaticAvatar(vm, 32), + t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}), t.div({className: "description"}, [ - t.div({className: "name"}, vm.name), + t.div({className: "name"}, vm => vm.name), t.map(vm => vm.busy, busy => { if (busy) { return spinner(t); @@ -41,4 +41,10 @@ export class InviteTileView extends TemplateView { ]) ]); } + + update(value, props) { + super.update(value); + // update the AvatarView as we told it to not subscribe itself with parentProvidesUpdates + this.updateSubViews(value, props); + } } diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 481c5362..bc14fa1e 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -64,7 +64,7 @@ export class LeftPanelView extends TemplateView { list: vm.tileViewModels, }, tileVM => { - if (tileVM.kind === "invite") { + if (tileVM.kind === "invite" || tileVM.kind === "roomBeingCreated") { return new InviteTileView(tileVM); } else { return new RoomTileView(tileVM); diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js index c249515a..5d2f9387 100644 --- a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -46,7 +46,8 @@ export class MemberDetailsView extends TemplateView { t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`), t.div({className: "MemberDetailsView_options"}, [ - t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`) + t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`), + t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`) ]) ]); } diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index f92c4403..9c67fa9f 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -39,7 +39,7 @@ export class MessageComposer extends TemplateView { this._clearHeight(); } }, - placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…", + placeholder: vm => vm.isEncrypted ? "Send an encrypted message…" : "Send a message…", rows: "1" }); this._focusInput = () => this._input.focus(); diff --git a/src/platform/web/ui/session/room/RoomBeingCreatedView.js b/src/platform/web/ui/session/room/RoomBeingCreatedView.js new file mode 100644 index 00000000..df26a43d --- /dev/null +++ b/src/platform/web/ui/session/room/RoomBeingCreatedView.js @@ -0,0 +1,25 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 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 {TemplateView} from "../../general/TemplateView"; +import {renderStaticAvatar} from "../../avatar.js"; + +export class RoomBeingCreatedView extends TemplateView { + render(t, vm) { + return t.h1({className: "middle"}, ["creating room", vm => vm.name]); + } +} From 0b04612d6ce46c0e26512d642884161b4bd8c375 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Feb 2022 11:16:58 +0100 Subject: [PATCH 03/43] WIP2 --- src/domain/session/room/RoomViewModel.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index ace933fb..c81c47ef 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -97,9 +97,8 @@ export class RoomViewModel extends ViewModel { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { - if (this._room.isArchived) { - this._composerVM.emitChange(); - } + // propagate the update to the child view models so it's bindings can update based on room changes + this._composerVM.emitChange(); this.emitChange(); } From 0bb3cfcfadec1e0b223133ce24ad7152fc83b30c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 4 Feb 2022 17:49:10 +0100 Subject: [PATCH 04/43] WIP3 --- .../session/leftpanel/LeftPanelViewModel.js | 7 +- src/logging/LogItem.ts | 3 +- src/logging/types.ts | 2 +- src/matrix/Session.js | 18 ++--- src/matrix/Sync.js | 2 +- src/matrix/room/RoomSummary.js | 5 +- src/matrix/room/create.ts | 6 +- src/observable/map/LogMap.js | 70 +++++++++++++++++++ 8 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 src/observable/map/LogMap.js diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index d85e5943..597c0da6 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -21,6 +21,7 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; +import {LogMap} from "../../../observable/map/LogMap.js"; import {addPanelIfNeeded} from "../../navigation/index.js"; export class LeftPanelViewModel extends ViewModel { @@ -38,7 +39,7 @@ export class LeftPanelViewModel extends ViewModel { _mapTileViewModels(roomsBeingCreated, invites, rooms) { // join is not commutative, invites will take precedence over rooms - return roomsBeingCreated.join(invites).join(rooms).mapValues((item, emitChange) => { + const joined = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => { let vm; if (item.isBeingCreated) { vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange})); @@ -54,6 +55,10 @@ export class LeftPanelViewModel extends ViewModel { } return vm; }); + return joined; + // return new LogMap(joined, (op, key, value) => { + // console.log("room list", op, key, value); + // }); } _updateCurrentVM(vm) { diff --git a/src/logging/LogItem.ts b/src/logging/LogItem.ts index cf3da284..b47b69c1 100644 --- a/src/logging/LogItem.ts +++ b/src/logging/LogItem.ts @@ -108,13 +108,14 @@ export class LogItem implements ILogItem { return item; } - set(key: string | object, value?: unknown): void { + set(key: string | object, value?: unknown): ILogItem { if(typeof key === "object") { const values = key; Object.assign(this._values, values); } else { this._values[key] = value; } + return this; } serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined { diff --git a/src/logging/types.ts b/src/logging/types.ts index f4a20ee0..bf9861a5 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -43,7 +43,7 @@ export interface ILogItem { readonly values: LogItemValues; wrap(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem; - set(key: string | object, value: unknown): void; + set(key: string | object, value: unknown): ILogItem; runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): void; refDetached(logItem: ILogItem, logLevel?: LogLevel): void; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 49ae0f9c..4a9b0bad 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -64,10 +64,10 @@ export class Session { this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); - this._roomsBeingCreatedUpdateCallback = (rbc, params) => { + this._roomsBeingCreatedUpdateCallback = (rbc, params, log) => { this._roomsBeingCreated.update(rbc.localId, params); if (rbc.roomId && !!this.rooms.get(rbc.roomId)) { - this._tryReplaceRoomBeingCreated(rbc.roomId); + this._tryReplaceRoomBeingCreated(rbc.roomId, log); } }; this._roomsBeingCreated = new ObservableMap(); @@ -686,16 +686,16 @@ export class Session { } } - _tryReplaceRoomBeingCreated(roomId) { - console.trace("_tryReplaceRoomBeingCreated " + roomId); + _tryReplaceRoomBeingCreated(roomId, log) { for (const [,roomBeingCreated] of this._roomsBeingCreated) { if (roomBeingCreated.roomId === roomId) { const observableStatus = this._observedRoomStatus.get(roomBeingCreated.localId); if (observableStatus) { - console.log("marking room as replaced", observableStatus.get()); + this._platform.logger.wrapOrRun(log, `replacing room being created`, log => { + log.set("localId", roomBeingCreated.localId) + .set("roomId", roomBeingCreated.roomId); + }); observableStatus.set(observableStatus.get() | RoomStatus.Replaced); - } else { - console.log("no observableStatus"); } this._roomsBeingCreated.remove(roomBeingCreated.localId); return; @@ -703,12 +703,12 @@ export class Session { } } - applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { + applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log) { // update the collections after sync for (const rs of roomStates) { if (rs.shouldAdd) { this._rooms.add(rs.id, rs.room); - this._tryReplaceRoomBeingCreated(rs.id); + this._tryReplaceRoomBeingCreated(rs.id, log); } else if (rs.shouldRemove) { this._rooms.remove(rs.id); } diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index e9faa89b..5eb50b5c 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -316,7 +316,7 @@ export class Sync { for(let is of inviteStates) { log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail); } - this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates); + this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log); } _openSyncTxn() { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index d0c78659..82087200 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -96,7 +96,10 @@ function processRoomAccountData(data, event) { } export function processStateEvent(data, event) { - if (event.type === "m.room.encryption") { + if (event.type === "m.room.create") { + data = data.cloneIfNeeded(); + data.lastMessageTimestamp = event.origin_server_ts; + } else if (event.type === "m.room.encryption") { const algorithm = event.content?.algorithm; if (!data.encryption && algorithm === MEGOLM_ALGORITHM) { data = data.cloneIfNeeded(); diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 24adfcf1..c42da436 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -121,7 +121,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { console.log("done creating the room now", this._roomId); // TODO: somehow check in Session if we need to replace this with a joined room // in case the room appears first in sync, and this request returns later - this.emitChange(); + this.emitChange(undefined, log); } private async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise { @@ -139,8 +139,8 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } } - private emitChange() { - this.updateCallback(this); + private emitChange(params?, log?) { + this.updateCallback(this, params, log); this.emit("change"); } diff --git a/src/observable/map/LogMap.js b/src/observable/map/LogMap.js new file mode 100644 index 00000000..4b8bb686 --- /dev/null +++ b/src/observable/map/LogMap.js @@ -0,0 +1,70 @@ +/* +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 {BaseObservableMap} from "./BaseObservableMap.js"; + +export class LogMap extends BaseObservableMap { + constructor(source, log) { + super(); + this._source = source; + this.log = log; + this._subscription = null; + } + + onAdd(key, value) { + this.log("add", key, value); + this.emitAdd(key, value); + } + + onRemove(key, value) { + this.log("remove", key, value); + this.emitRemove(key, value); + } + + onUpdate(key, value, params) { + this.log("update", key, value, params); + this.emitUpdate(key, value, params); + } + + onSubscribeFirst() { + this.log("subscribeFirst"); + this._subscription = this._source.subscribe(this); + super.onSubscribeFirst(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this._subscription = this._subscription(); + this.log("unsubscribeLast"); + } + + onReset() { + this.log("reset"); + this.emitReset(); + } + + [Symbol.iterator]() { + return this._source[Symbol.iterator](); + } + + get size() { + return this._source.size; + } + + get(key) { + return this._source.get(key); + } +} From e1fbd1242e24c00fca042cb30715620659adfcf1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 7 Feb 2022 16:30:44 +0100 Subject: [PATCH 05/43] WIP 4 --- .../rightpanel/MemberDetailsViewModel.js | 4 +- src/matrix/Session.js | 23 ++++++----- src/matrix/room/create.ts | 41 ++++++++----------- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 6effb90c..b0573bfa 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -83,7 +83,7 @@ export class MemberDetailsViewModel extends ViewModel { } async openDirectMessage() { - const localId = await this._session.createRoom(RoomType.DirectMessage, undefined, undefined, undefined, [this.userId]); - this.navigation.push("room", localId); + const roomBeingCreated = await this._session.createRoom(RoomType.DirectMessage, undefined, undefined, undefined, [this.userId]); + this.navigation.push("room", roomBeingCreated.localId); } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 4a9b0bad..02635855 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -594,13 +594,13 @@ export class Session { createRoom(type, isEncrypted, explicitName, topic, invites, log = undefined) { return this._platform.logger.wrapOrRun(log, "create room", log => { - const localId = `room-being-created-${this._platform.random()}`; + const localId = `local-${Math.round(this._platform.random() * Math.MAX_SAFE_INTEGER)}`; const roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, this._mediaRepository, log); this._roomsBeingCreated.set(localId, roomBeingCreated); - log.wrapDetached("create room network", async log => { - roomBeingCreated.start(this._hsApi, log); + log.wrapDetached("create room network", log => { + return roomBeingCreated.start(this._hsApi, log); }); - return localId; + return roomBeingCreated; }); } @@ -691,10 +691,9 @@ export class Session { if (roomBeingCreated.roomId === roomId) { const observableStatus = this._observedRoomStatus.get(roomBeingCreated.localId); if (observableStatus) { - this._platform.logger.wrapOrRun(log, `replacing room being created`, log => { - log.set("localId", roomBeingCreated.localId) - .set("roomId", roomBeingCreated.roomId); - }); + log.log(`replacing room being created`) + .set("localId", roomBeingCreated.localId) + .set("roomId", roomBeingCreated.roomId); observableStatus.set(observableStatus.get() | RoomStatus.Replaced); } this._roomsBeingCreated.remove(roomBeingCreated.localId); @@ -737,10 +736,12 @@ export class Session { for (const is of inviteStates) { const statusObservable = this._observedRoomStatus.get(is.id); if (statusObservable) { + const withInvited = statusObservable.get() | RoomStatus.Invited; if (is.shouldAdd) { - statusObservable.set(statusObservable.get().withInvited()); + statusObservable.set(withInvited); } else if (is.shouldRemove) { - statusObservable.set(statusObservable.get().withoutInvited()); + const withoutInvited = withInvited ^ RoomStatus.Invited; + statusObservable.set(withoutInvited); } } } @@ -750,7 +751,7 @@ export class Session { _forgetArchivedRoom(roomId) { const statusObservable = this._observedRoomStatus.get(roomId); if (statusObservable) { - statusObservable.set(statusObservable.get() ^ RoomStatus.Archived); + statusObservable.set((statusObservable.get() | RoomStatus.Archived) ^ RoomStatus.Archived); } } diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index c42da436..83b68d3f 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -65,6 +65,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { public readonly isEncrypted: boolean; private _name: string; + private _error?: Error; constructor( public readonly localId: string, @@ -115,12 +116,12 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { if (this.isEncrypted) { options.initial_state = [createRoomEncryptionEvent()]; } - console.log("going to create the room now"); - const response = await hsApi.createRoom(options, {log}).response(); - this._roomId = response["room_id"]; - console.log("done creating the room now", this._roomId); - // TODO: somehow check in Session if we need to replace this with a joined room - // in case the room appears first in sync, and this request returns later + try { + const response = await hsApi.createRoom(options, {log}).response(); + this._roomId = response["room_id"]; + } catch (err) { + this._error = err; + } this.emitChange(undefined, log); } @@ -128,39 +129,30 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { // 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); - console.log("loaded the profiles", this.profiles); const summaryData = { joinCount: 1, // ourselves inviteCount: this.inviteUserIds.length }; this._name = calculateRoomName(this.profiles, summaryData, log); - console.log("loaded the profiles and the new name", this.name); this.emitChange(); } } - private emitChange(params?, log?) { + private emitChange(params?, log?: ILogItem) { this.updateCallback(this, params, log); this.emit("change"); } - get avatarColorId(): string { - return this.inviteUserIds?.[0] ?? this._roomId ?? this.localId; - } - - get avatarUrl(): string | undefined { - const result = this.profiles[0]?.avatarUrl; - console.log("RoomBeingCreated.avatarUrl", this.profiles, result); - return result; - } - - get roomId(): string | undefined { - return this._roomId; - } - + get avatarColorId(): string { return this.inviteUserIds?.[0] ?? this._roomId ?? this.localId; } + get avatarUrl(): string | undefined { return this.profiles[0]?.avatarUrl; } + get roomId(): string | undefined { return this._roomId; } get name() { return this._name; } - get isBeingCreated(): boolean { return true; } + get error(): Error | undefined { return this._error; } + + cancel() { + // remove from collection somehow + } } export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { @@ -194,5 +186,4 @@ class UserIdProfile implements IProfile { get displayName() { return undefined; } get name() { return this.userId; } get avatarUrl() { return undefined; } - } From 26fa2a5d601816963771c18001db5c84c7153c28 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 7 Feb 2022 18:58:43 +0100 Subject: [PATCH 06/43] add option --- src/matrix/Session.js | 8 ++++++-- src/matrix/room/create.ts | 16 +++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 02635855..13901e6c 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -592,13 +592,17 @@ export class Session { return this._roomsBeingCreated; } - createRoom(type, isEncrypted, explicitName, topic, invites, log = undefined) { + createRoom(type, isEncrypted, explicitName, topic, invites, options = undefined, log = undefined) { return this._platform.logger.wrapOrRun(log, "create room", log => { const localId = `local-${Math.round(this._platform.random() * Math.MAX_SAFE_INTEGER)}`; const roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, this._mediaRepository, log); this._roomsBeingCreated.set(localId, roomBeingCreated); log.wrapDetached("create room network", log => { - return roomBeingCreated.start(this._hsApi, log); + const promises = [roomBeingCreated.create(this._hsApi, log)]; + if (options?.loadProfiles) { + promises.push(roomBeingCreated.loadProfiles(this._hsApi, log)); + } + return Promise.all(promises); }); return roomBeingCreated; }); diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 83b68d3f..66714d72 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -92,14 +92,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } } - 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 { + async create(hsApi: HomeServerApi, log: ILogItem): Promise { const options: CreateRoomPayload = { is_direct: this.type === RoomType.DirectMessage, preset: presetForType(this.type) @@ -125,7 +118,12 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { this.emitChange(undefined, log); } - private async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise { + /** requests the profiles of the invitees if needed to give an accurate + * estimated room name in case an explicit room name is not set. + * The room is being created in the background whether this is called + * or not, and this just gives a more accurate name while that request + * is running. */ + 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); From e04463c143c4ed9e266f4b70a5b9d4fdeff63e4a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 7 Feb 2022 18:58:53 +0100 Subject: [PATCH 07/43] WIP for finding DM room --- .../session/rightpanel/MemberDetailsViewModel.js | 10 ++++++++-- src/matrix/Session.js | 13 +++++++++++++ src/matrix/room/BaseRoom.js | 4 ++++ src/matrix/room/Invite.js | 4 ++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index b0573bfa..f56aac9a 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -83,7 +83,13 @@ export class MemberDetailsViewModel extends ViewModel { } async openDirectMessage() { - const roomBeingCreated = await this._session.createRoom(RoomType.DirectMessage, undefined, undefined, undefined, [this.userId]); - this.navigation.push("room", roomBeingCreated.localId); + const room = this._session.findDirectMessageForUserId(this.userId); + let roomId = room?.id; + if (!roomId) { + const roomBeingCreated = await this._session.createRoom( + RoomType.DirectMessage, undefined, undefined, undefined, [this.userId], {loadProfiles: true}); + roomId = roomBeingCreated.localId; + } + this.navigation.push("room", roomId); } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 13901e6c..45cd9b19 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -537,6 +537,19 @@ export class Session { return this._rooms; } + findDirectMessageForUserId(userId) { + for (const [,room] of this._rooms) { + if (room.isDirectMessageForUserId(userId)) { + return room; + } + } + for (const [,invite] of this._invites) { + if (invite.isDirectMessageForUserId(userId)) { + return invite; + } + } + } + /** @internal */ createJoinedRoom(roomId, pendingEvents) { return new Room({ diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index ecd2d860..446c22d1 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -420,6 +420,10 @@ export class BaseRoom extends EventEmitter { return this._summary.data.membership; } + isDirectMessageForUserId(userId) { + return this._summary.data.dmUserId === userId; + } + async _loadPowerLevels() { const txn = await this._storage.readTxn([this._storage.storeNames.roomState]); const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index da721c77..18bb7b8d 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -73,6 +73,10 @@ export class Invite extends EventEmitter { return this._inviter; } + isDirectMessageForUserId(userId) { + return this.isDirectMessage && this._inviter.userId === userId; + } + get isPublic() { return this._inviteData.joinRule === "public"; } From 45c8e3a793cef9b207bc7e834ad45f2073ae632e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Feb 2022 14:34:34 +0100 Subject: [PATCH 08/43] mark room as DM based on synced state events,rather than just inviteData as that does not work for rooms we create ourselves --- src/matrix/Sync.js | 13 +++++----- src/matrix/room/Invite.js | 8 ++---- src/matrix/room/Room.js | 7 ++---- src/matrix/room/RoomSummary.js | 46 ++++++++++++++++++---------------- 4 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 5eb50b5c..3574213e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -249,7 +249,7 @@ export class Sync { if (!isRoomInResponse) { let room = this._session.rooms.get(roomId); if (room) { - roomStates.push(new RoomSyncProcessState(room, false, null, {}, room.membership)); + roomStates.push(new RoomSyncProcessState(room, false, {}, room.membership)); } } } @@ -264,7 +264,7 @@ export class Sync { await rs.room.load(null, prepareTxn, log); } return rs.room.prepareSync( - rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log) + rs.roomResponse, rs.membership, newKeys, prepareTxn, log) }, log.level.Detail); })); @@ -366,7 +366,7 @@ export class Sync { if (invite) { inviteStates.push(new InviteSyncProcessState(invite, false, null, membership)); } - const roomState = this._createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync); + const roomState = this._createRoomSyncState(roomId, roomResponse, membership, isInitialSync); if (roomState) { roomStates.push(roomState); } @@ -381,7 +381,7 @@ export class Sync { return {roomStates, archivedRoomStates}; } - _createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync) { + _createRoomSyncState(roomId, roomResponse, membership, isInitialSync) { let isNewRoom = false; let room = this._session.rooms.get(roomId); // create room only either on new join, @@ -397,7 +397,7 @@ export class Sync { } if (room) { return new RoomSyncProcessState( - room, isNewRoom, invite, roomResponse, membership); + room, isNewRoom, roomResponse, membership); } } @@ -468,10 +468,9 @@ class SessionSyncProcessState { } class RoomSyncProcessState { - constructor(room, isNewRoom, invite, roomResponse, membership) { + constructor(room, isNewRoom, roomResponse, membership) { this.room = room; this.isNewRoom = isNewRoom; - this.invite = invite; this.roomResponse = roomResponse; this.membership = membership; this.preparation = null; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 18bb7b8d..6c5ef121 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -188,7 +188,7 @@ export class Invite extends EventEmitter { return { roomId: this.id, isEncrypted: !!summaryData.encryption, - isDirectMessage: this._isDirectMessage(myInvite), + isDirectMessage: summaryData.isDirectMessage, // type: name, avatarUrl, @@ -200,12 +200,8 @@ export class Invite extends EventEmitter { }; } - _isDirectMessage(myInvite) { - return !!(myInvite?.content?.is_direct); - } - _createSummaryData(inviteState) { - return inviteState.reduce(processStateEvent, new SummaryData(null, this.id)); + return inviteState.reduce((data, event) => processStateEvent(data, event, this._user.id), new SummaryData(null, this.id)); } async _createHeroes(inviteState, log) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 46c06af0..65cfe693 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -54,15 +54,12 @@ export class Room extends BaseRoom { return false; } - async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { + async prepareSync(roomResponse, membership, newKeys, txn, log) { log.set("id", this.id); if (newKeys) { log.set("newKeys", newKeys.length); } - let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); - if (membership === "join" && invite) { - summaryChanges = summaryChanges.applyInvite(invite); - } + let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership, this._user.id); let roomEncryption = this._roomEncryption; // encryption is enabled in this sync if (!roomEncryption && summaryChanges.encryption) { diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 82087200..410352ca 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -46,7 +46,7 @@ export function reduceStateEvents(roomResponse, callback, value) { return value; } -function applySyncResponse(data, roomResponse, membership) { +function applySyncResponse(data, roomResponse, membership, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } @@ -60,7 +60,7 @@ function applySyncResponse(data, roomResponse, membership) { // process state events in state and in timeline. // non-state events are handled by applyTimelineEntries // so decryption is handled properly - data = reduceStateEvents(roomResponse, processStateEvent, data); + data = reduceStateEvents(roomResponse, (data, event) => processStateEvent(data, event, ownUserId), data); const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { data = processNotificationCounts(data, unreadNotifications); @@ -95,7 +95,7 @@ function processRoomAccountData(data, event) { return data; } -export function processStateEvent(data, event) { +export function processStateEvent(data, event, ownUserId) { if (event.type === "m.room.create") { data = data.cloneIfNeeded(); data.lastMessageTimestamp = event.origin_server_ts; @@ -121,6 +121,25 @@ export function processStateEvent(data, event) { const content = event.content; data = data.cloneIfNeeded(); data.canonicalAlias = content.alias; + } else if (event.type === "m.room.member") { + const content = event.content; + if (content.is_direct === true && content.membership === "invite" && !data.isDirectMessage) { + let other; + if (event.sender === ownUserId) { + other = event.state_key; + } else if (event.state_key === ownUserId) { + other = event.sender; + } + if (other) { + data = data.cloneIfNeeded(); + data.isDirectMessage = true; + data.dmUserId = other; + } + } else if (content.membership === "leave" && data.isDirectMessage && data.dmUserId === event.state_key) { + data = data.cloneIfNeeded(); + data.isDirectMessage = false; + data.dmUserId = null; + } } return data; } @@ -161,19 +180,6 @@ function updateSummary(data, summary) { return data; } -function applyInvite(data, invite) { - if (data.isDirectMessage !== invite.isDirectMessage) { - data = data.cloneIfNeeded(); - data.isDirectMessage = invite.isDirectMessage; - if (invite.isDirectMessage) { - data.dmUserId = invite.inviter?.userId; - } else { - data.dmUserId = null; - } - } - return data; -} - export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; @@ -230,12 +236,8 @@ export class SummaryData { return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId); } - applySyncResponse(roomResponse, membership) { - return applySyncResponse(this, roomResponse, membership); - } - - applyInvite(invite) { - return applyInvite(this, invite); + applySyncResponse(roomResponse, membership, ownUserId) { + return applySyncResponse(this, roomResponse, membership, ownUserId); } get needsHeroes() { From d7b024eac11cb3beb577a1e96958ff95888bcdf3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Feb 2022 14:35:14 +0100 Subject: [PATCH 09/43] unrelated fix: encode user name in matrix.to link --- src/domain/session/rightpanel/MemberDetailsViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index f56aac9a..7ea2ead5 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -79,7 +79,7 @@ export class MemberDetailsViewModel extends ViewModel { } get linkToUser() { - return `https://matrix.to/#/${this._member.userId}`; + return `https://matrix.to/#/${encodeURIComponent(this._member.userId)}`; } async openDirectMessage() { From 5325b0b466bdb400b41ad940b9afa2b8c04482e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Feb 2022 14:58:29 +0100 Subject: [PATCH 10/43] cleanup logging --- .../rightpanel/MemberDetailsViewModel.js | 6 ++- src/matrix/Session.js | 38 ++++++++++--------- src/matrix/room/create.ts | 12 +++--- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 7ea2ead5..68045eba 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -86,8 +86,10 @@ export class MemberDetailsViewModel extends ViewModel { const room = this._session.findDirectMessageForUserId(this.userId); let roomId = room?.id; if (!roomId) { - const roomBeingCreated = await this._session.createRoom( - RoomType.DirectMessage, undefined, undefined, undefined, [this.userId], {loadProfiles: true}); + const roomBeingCreated = await this._session.createRoom({ + type: RoomType.DirectMessage, + invites: [this.userId] + }); roomId = roomBeingCreated.localId; } this.navigation.push("room", roomId); diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 45cd9b19..8d1c8613 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -64,12 +64,7 @@ export class Session { this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); - this._roomsBeingCreatedUpdateCallback = (rbc, params, log) => { - this._roomsBeingCreated.update(rbc.localId, params); - if (rbc.roomId && !!this.rooms.get(rbc.roomId)) { - this._tryReplaceRoomBeingCreated(rbc.roomId, log); - } - }; + this._roomsBeingCreatedUpdateCallback = (rbc, params) => this._roomsBeingCreated.update(rbc.localId, params); this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); @@ -605,20 +600,27 @@ export class Session { return this._roomsBeingCreated; } - createRoom(type, isEncrypted, explicitName, topic, invites, options = undefined, log = undefined) { - return this._platform.logger.wrapOrRun(log, "create room", log => { - const localId = `local-${Math.round(this._platform.random() * Math.MAX_SAFE_INTEGER)}`; - const roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, this._mediaRepository, log); + createRoom({type, isEncrypted, explicitName, topic, invites, loadProfiles = true}, log = undefined) { + let roomBeingCreated; + this._platform.logger.runDetached("create room", async log => { + const localId = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; + roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, + explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, + this._mediaRepository, log); this._roomsBeingCreated.set(localId, roomBeingCreated); - log.wrapDetached("create room network", log => { - const promises = [roomBeingCreated.create(this._hsApi, log)]; - if (options?.loadProfiles) { - promises.push(roomBeingCreated.loadProfiles(this._hsApi, log)); - } - return Promise.all(promises); - }); - return roomBeingCreated; + const promises = [roomBeingCreated.create(this._hsApi, log)]; + if (loadProfiles) { + promises.push(roomBeingCreated.loadProfiles(this._hsApi, log)); + } + await Promise.all(promises); + // we should now know the roomId, check if the room was synced before we received + // the room id. Replace the room being created with the synced room. + if (roomBeingCreated.roomId && !!this.rooms.get(roomBeingCreated.roomId)) { + this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); + } + // TODO: if type is DM, then adjust the m.direct account data }); + return roomBeingCreated; } async obtainSyncLock(syncResponse) { diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 66714d72..4bdae474 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -74,7 +74,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { private readonly explicitName: string | undefined, private readonly topic: string | undefined, private readonly inviteUserIds: string[] | undefined, - private readonly updateCallback, + private readonly updateCallback: (self: RoomBeingCreated, params: string | undefined) => void, public readonly mediaRepository: MediaRepository, log: ILogItem ) { @@ -92,6 +92,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } } + /** @internal */ async create(hsApi: HomeServerApi, log: ILogItem): Promise { const options: CreateRoomPayload = { is_direct: this.type === RoomType.DirectMessage, @@ -115,7 +116,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } catch (err) { this._error = err; } - this.emitChange(undefined, log); + this.emitChange(); } /** requests the profiles of the invitees if needed to give an accurate @@ -123,6 +124,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { * The room is being created in the background whether this is called * or not, and this just gives a more accurate name while that request * is running. */ + /** @internal */ 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) { @@ -136,8 +138,8 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } } - private emitChange(params?, log?: ILogItem) { - this.updateCallback(this, params, log); + private emitChange(params?: string) { + this.updateCallback(this, params); this.emit("change"); } @@ -149,7 +151,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { get error(): Error | undefined { return this._error; } cancel() { - // remove from collection somehow + // TODO: remove from collection somehow } } From 743f2270e5c2c07735fd769b345901a7daf70df0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Feb 2022 16:22:44 +0100 Subject: [PATCH 11/43] have a single tile view that supports all 3 view models --- .../session/leftpanel/InviteTileViewModel.js | 27 +++------- .../ui/session/leftpanel/InviteTileView.js | 50 ------------------- .../web/ui/session/leftpanel/LeftPanelView.js | 9 +--- .../web/ui/session/leftpanel/RoomTileView.js | 19 ++++--- 4 files changed, 22 insertions(+), 83 deletions(-) delete mode 100644 src/platform/web/ui/session/leftpanel/InviteTileView.js diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index 10c84628..09c11fb8 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -25,17 +25,14 @@ export class InviteTileViewModel extends BaseTileViewModel { this._url = this.urlCreator.openRoomActionUrl(this._invite.id); } - get busy() { - return this._invite.accepting || this._invite.rejecting; - } - - get kind() { - return "invite"; - } - - get url() { - return this._url; - } + get busy() { return this._invite.accepting || this._invite.rejecting; } + get kind() { return "invite"; } + get url() { return this._url; } + get name() { return this._invite.name; } + get isHighlighted() { return true; } + get isUnread() { return true; } + get badgeCount() { return this.i18n`!`; } + get _avatarSource() { return this._invite; } compare(other) { const parentComparison = super.compare(other); @@ -44,12 +41,4 @@ export class InviteTileViewModel extends BaseTileViewModel { } return other._invite.timestamp - this._invite.timestamp; } - - get name() { - return this._invite.name; - } - - get _avatarSource() { - return this._invite; - } } diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js deleted file mode 100644 index de064bed..00000000 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2020 Bruno Windels -Copyright 2020, 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 {TemplateView} from "../../general/TemplateView"; -import {AvatarView} from "../../AvatarView"; -import {spinner} from "../../common.js"; - -export class InviteTileView extends TemplateView { - render(t, vm) { - const classes = { - "active": vm => vm.isOpen, - "hidden": vm => vm.hidden - }; - return t.li({"className": classes}, [ - t.a({href: vm.url}, [ - t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}), - t.div({className: "description"}, [ - t.div({className: "name"}, vm => vm.name), - t.map(vm => vm.busy, busy => { - if (busy) { - return spinner(t); - } else { - return t.div({className: "badge highlighted"}, "!"); - } - }) - ]) - ]) - ]); - } - - update(value, props) { - super.update(value); - // update the AvatarView as we told it to not subscribe itself with parentProvidesUpdates - this.updateSubViews(value, props); - } -} diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index bc14fa1e..1b3c2ae7 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -17,7 +17,6 @@ limitations under the License. import {ListView} from "../../general/ListView"; import {TemplateView} from "../../general/TemplateView"; import {RoomTileView} from "./RoomTileView.js"; -import {InviteTileView} from "./InviteTileView.js"; class FilterField extends TemplateView { render(t, options) { @@ -63,13 +62,7 @@ export class LeftPanelView extends TemplateView { className: "RoomList", list: vm.tileViewModels, }, - tileVM => { - if (tileVM.kind === "invite" || tileVM.kind === "roomBeingCreated") { - return new InviteTileView(tileVM); - } else { - return new RoomTileView(tileVM); - } - } + tileVM => new RoomTileView(tileVM) )); const utilitiesRow = t.div({className: "utilities"}, [ t.a({className: "button-utility close-session", href: vm.closeUrl, "aria-label": vm.i18n`Back to account list`, title: vm.i18n`Back to account list`}), diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index 28957541..4875c167 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -17,6 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView.js"; +import {spinner} from "../../common.js"; export class RoomTileView extends TemplateView { render(t, vm) { @@ -29,13 +30,19 @@ export class RoomTileView extends TemplateView { t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}), t.div({className: "description"}, [ t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), - t.div({ - className: { - badge: true, - highlighted: vm => vm.isHighlighted, - hidden: vm => !vm.badgeCount + t.map(vm => vm.busy, busy => { + if (busy) { + return spinner(t); + } else { + return t.div({ + className: { + badge: true, + highlighted: vm => vm.isHighlighted, + hidden: vm => !vm.badgeCount + } + }, vm => vm.badgeCount); } - }, vm => vm.badgeCount), + }) ]) ]) ]); From afe8e17a6fc9c506239235bd0009a46b6226a79c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 8 Feb 2022 17:00:06 +0100 Subject: [PATCH 12/43] remove debugging code --- src/matrix/Session.js | 9 +-------- src/matrix/room/Room.js | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 8d1c8613..4bb88f4e 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -886,7 +886,7 @@ export class Session { let observable = this._observedRoomStatus.get(roomId); if (!observable) { const status = await this.getRoomStatus(roomId); - observable = new FooRetainedObservableValue(status, () => { + observable = new RetainedObservableValue(status, () => { this._observedRoomStatus.delete(roomId); }); @@ -941,13 +941,6 @@ export class Session { } } -class FooRetainedObservableValue extends RetainedObservableValue { - set(value) { - console.log("setting room status to", value); - super.set(value); - } -} - export function tests() { function createStorageMock(session, pendingEvents = []) { return { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 65cfe693..a5bce14f 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -228,7 +228,6 @@ export class Room extends BaseRoom { } } let emitChange = false; - console.log("Room summaryChanges", this.id, summaryChanges); if (summaryChanges) { this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { From 83d2b58badaee3218821b215f695f46d0a1d91ea Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Feb 2022 18:58:30 +0100 Subject: [PATCH 13/43] add avatar support to creating room --- .../RoomBeingCreatedTileViewModel.js | 5 + src/matrix/Session.js | 10 +- src/matrix/room/AttachmentUpload.js | 8 +- src/matrix/room/create.ts | 115 +++++++++++++----- src/platform/types/types.ts | 14 +++ 5 files changed, 111 insertions(+), 41 deletions(-) diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js index 8c629892..d7840bfa 100644 --- a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -50,4 +50,9 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { get _avatarSource() { return this._roomBeingCreated; } + + avatarUrl(size) { + // allow blob url which doesn't need mxc => http resolution + return this._roomBeingCreated.avatarBlobUrl ?? super.avatarUrl(size); + } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 4bb88f4e..b03306ed 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -600,15 +600,16 @@ export class Session { return this._roomsBeingCreated; } - createRoom({type, isEncrypted, explicitName, topic, invites, loadProfiles = true}, log = undefined) { + createRoom(options, log = undefined) { let roomBeingCreated; this._platform.logger.runDetached("create room", async log => { const localId = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; - roomBeingCreated = new RoomBeingCreated(localId, type, isEncrypted, - explicitName, topic, invites, this._roomsBeingCreatedUpdateCallback, - this._mediaRepository, log); + roomBeingCreated = new RoomBeingCreated( + localId, options, this._roomsBeingCreatedUpdateCallback, + this._mediaRepository, this._platform, log); this._roomsBeingCreated.set(localId, roomBeingCreated); const promises = [roomBeingCreated.create(this._hsApi, log)]; + const loadProfiles = !(options.loadProfiles === false); // default to true if (loadProfiles) { promises.push(roomBeingCreated.loadProfiles(this._hsApi, log)); } @@ -715,6 +716,7 @@ export class Session { .set("roomId", roomBeingCreated.roomId); observableStatus.set(observableStatus.get() | RoomStatus.Replaced); } + roomBeingCreated.dispose(); this._roomsBeingCreated.remove(roomBeingCreated.localId); return; } diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index e2e7e3bf..6cf4c1db 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -40,17 +40,15 @@ export class AttachmentUpload { return this._sentBytes; } - /** @public */ abort() { this._uploadRequest?.abort(); } - /** @public */ get localPreview() { return this._unencryptedBlob; } - /** @package */ + /** @internal */ async encrypt() { if (this._encryptionInfo) { throw new Error("already encrypted"); @@ -60,7 +58,7 @@ export class AttachmentUpload { this._encryptionInfo = info; } - /** @package */ + /** @internal */ async upload(hsApi, progressCallback, log) { this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, { uploadProgress: sentBytes => { @@ -73,7 +71,7 @@ export class AttachmentUpload { this._mxcUrl = content_uri; } - /** @package */ + /** @internal */ applyToContent(urlPath, content) { if (!this._mxcUrl) { throw new Error("upload has not finished"); diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 4bdae474..e3a1ed4d 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -18,10 +18,12 @@ import {calculateRoomName} from "./members/Heroes"; import {createRoomEncryptionEvent} from "../e2ee/common"; import {MediaRepository} from "../net/MediaRepository"; import {EventEmitter} from "../../utils/EventEmitter"; +import {AttachmentUpload} from "./AttachmentUpload"; -import type {StateEvent} from "../storage/types"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {ILogItem} from "../../logging/types"; +import type {Platform} from "../../platform/web/Platform"; +import type {IBlobHandle} from "../../platform/types/types"; type CreateRoomPayload = { is_direct?: boolean; @@ -29,7 +31,31 @@ type CreateRoomPayload = { name?: string; topic?: string; invite?: string[]; - initial_state?: StateEvent[] + room_alias_name?: string; + initial_state: {type: string; state_key: string; content: Record}[] +} + +type ImageInfo = { + w: number; + h: number; + mimetype: string; + size: number; +} + +type Avatar = { + info: ImageInfo; + blob: IBlobHandle; + name: string; +} + +type Options = { + type: RoomType; + isEncrypted?: boolean; + name?: string; + topic?: string; + invites?: string[]; + avatar?: Avatar; + alias?: string; } export enum RoomType { @@ -64,54 +90,72 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { private profiles: Profile[] = []; public readonly isEncrypted: boolean; - private _name: string; + private _calculatedName: string; private _error?: Error; constructor( public 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, + private readonly options: Options, private readonly updateCallback: (self: RoomBeingCreated, params: string | undefined) => void, public readonly mediaRepository: MediaRepository, + public readonly platform: Platform, log: ILogItem ) { super(); - this.isEncrypted = isEncrypted === undefined ? defaultE2EEStatusForType(this.type) : isEncrypted; - if (explicitName) { - this._name = explicitName; + this.isEncrypted = options.isEncrypted === undefined ? defaultE2EEStatusForType(options.type) : options.isEncrypted; + if (options.name) { + this._calculatedName = options.name; } else { const summaryData = { joinCount: 1, // ourselves - inviteCount: (this.inviteUserIds?.length || 0) + inviteCount: (options.invites?.length || 0) }; - const userIdProfiles = (inviteUserIds || []).map(userId => new UserIdProfile(userId)); - this._name = calculateRoomName(userIdProfiles, summaryData, log); + const userIdProfiles = (options.invites || []).map(userId => new UserIdProfile(userId)); + this._calculatedName = calculateRoomName(userIdProfiles, summaryData, log); } } /** @internal */ async create(hsApi: HomeServerApi, log: ILogItem): Promise { - const options: CreateRoomPayload = { - is_direct: this.type === RoomType.DirectMessage, - preset: presetForType(this.type) + let avatarEventContent; + if (this.options.avatar) { + const {avatar} = this.options; + const attachment = new AttachmentUpload({filename: avatar.name, blob: avatar.blob, platform: this.platform}); + await attachment.upload(hsApi, () => {}, log); + avatarEventContent = { + info: avatar.info + }; + attachment.applyToContent("url", avatarEventContent); + } + const createOptions: CreateRoomPayload = { + is_direct: this.options.type === RoomType.DirectMessage, + preset: presetForType(this.options.type), + initial_state: [] }; - if (this.explicitName) { - options.name = this.explicitName; + if (this.options.name) { + createOptions.name = this.options.name; } - if (this.topic) { - options.topic = this.topic; + if (this.options.topic) { + createOptions.topic = this.options.topic; } - if (this.inviteUserIds) { - options.invite = this.inviteUserIds; + if (this.options.invites) { + createOptions.invite = this.options.invites; + } + if (this.options.alias) { + createOptions.room_alias_name = this.options.alias; } if (this.isEncrypted) { - options.initial_state = [createRoomEncryptionEvent()]; + createOptions.initial_state.push(createRoomEncryptionEvent()); + } + if (avatarEventContent) { + createOptions.initial_state.push({ + type: "m.room.avatar", + state_key: "", + content: avatarEventContent + }); } try { - const response = await hsApi.createRoom(options, {log}).response(); + const response = await hsApi.createRoom(createOptions, {log}).response(); this._roomId = response["room_id"]; } catch (err) { this._error = err; @@ -127,13 +171,13 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { /** @internal */ 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); + if (!this.options.name && this.options.invites) { + this.profiles = await loadProfiles(this.options.invites, hsApi, log); const summaryData = { joinCount: 1, // ourselves - inviteCount: this.inviteUserIds.length + inviteCount: this.options.invites.length }; - this._name = calculateRoomName(this.profiles, summaryData, log); + this._calculatedName = calculateRoomName(this.profiles, summaryData, log); this.emitChange(); } } @@ -143,16 +187,23 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { this.emit("change"); } - get avatarColorId(): string { return this.inviteUserIds?.[0] ?? this._roomId ?? this.localId; } - get avatarUrl(): string | undefined { return this.profiles[0]?.avatarUrl; } + get avatarColorId(): string { return this.options.invites?.[0] ?? this._roomId ?? this.localId; } + get avatarUrl(): string | undefined { return this.profiles?.[0].avatarUrl; } + get avatarBlobUrl(): string | undefined { return this.options.avatar?.blob?.url; } get roomId(): string | undefined { return this._roomId; } - get name() { return this._name; } + get name() { return this._calculatedName; } get isBeingCreated(): boolean { return true; } get error(): Error | undefined { return this._error; } cancel() { // TODO: remove from collection somehow } + + dispose() { + if (this.options.avatar) { + this.options.avatar.blob.dispose(); + } + } } export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 58d2216f..91884117 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -31,3 +31,17 @@ export interface IRequestOptions { } export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; + +export interface IBlobHandle { + nativeBlob: any; + url: string; + size: number; + mimeType: string; + readAsBuffer(): BufferSource; + dispose() +} + +export type File = { + readonly name: string; + readonly blob: IBlobHandle; +} From 8523f6feafa7ecdeb1ea3a19131d34011aab5c0c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Feb 2022 19:00:41 +0100 Subject: [PATCH 14/43] setup navigation for create room form --- src/domain/navigation/index.js | 2 +- src/domain/session/SessionViewModel.js | 27 ++++++++++++++++--- .../session/leftpanel/LeftPanelViewModel.js | 3 +++ src/platform/web/ui/session/SessionView.js | 3 +++ .../web/ui/session/leftpanel/LeftPanelView.js | 1 + 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index e209f2e5..086367ce 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -32,7 +32,7 @@ function allowsChild(parent, child) { // allowed root segments return type === "login" || type === "session" || type === "sso" || type === "logout"; case "session": - return type === "room" || type === "rooms" || type === "settings"; + return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; case "rooms": // downside of the approach: both of these will control which tile is selected return type === "room" || type === "empty-grid-tile"; diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 11126ca9..5a818c4d 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -24,6 +24,7 @@ import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; +import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; import {ViewModel} from "../ViewModel.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; @@ -42,6 +43,7 @@ export class SessionViewModel extends ViewModel { this._settingsViewModel = null; this._roomViewModelObservable = null; this._gridViewModel = null; + this._createRoomViewModel = null; this._setupNavigation(); } @@ -73,6 +75,12 @@ export class SessionViewModel extends ViewModel { })); this._updateSettings(settings.get()); + const createRoom = this.navigation.observe("create-room"); + this.track(createRoom.subscribe(createRoomOpen => { + this._updateCreateRoom(createRoomOpen); + })); + this._updateCreateRoom(createRoom.get()); + const lightbox = this.navigation.observe("lightbox"); this.track(lightbox.subscribe(eventId => { this._updateLightbox(eventId); @@ -94,7 +102,7 @@ export class SessionViewModel extends ViewModel { } get activeMiddleViewModel() { - return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel; + return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel; } get roomGridViewModel() { @@ -117,11 +125,14 @@ export class SessionViewModel extends ViewModel { return this._roomViewModelObservable?.get(); } - get rightPanelViewModel() { return this._rightPanelViewModel; } + get createRoomViewModel() { + return this._createRoomViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -160,7 +171,7 @@ export class SessionViewModel extends ViewModel { } } - _createRoomViewModel(roomId) { + _createRoomViewModelInstance(roomId) { const room = this._client.session.rooms.get(roomId); if (room) { const roomVM = new RoomViewModel(this.childOptions({room})); @@ -246,6 +257,16 @@ export class SessionViewModel extends ViewModel { this.emitChange("activeMiddleViewModel"); } + _updateCreateRoom(createRoomOpen) { + if (this._createRoomViewModel) { + this._createRoomViewModel = this.disposeTracked(this._createRoomViewModel); + } + if (createRoomOpen) { + this._createRoomViewModel = this.track(new CreateRoomViewModel(this.childOptions({session: this._client.session}))); + } + this.emitChange("activeMiddleViewModel"); + } + _updateLightbox(eventId) { if (this._lightboxViewModel) { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 597c0da6..cc3a2ccb 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,6 +35,7 @@ export class LeftPanelViewModel extends ViewModel { this._setupNavigation(); this._closeUrl = this.urlCreator.urlForSegment("session"); this._settingsUrl = this.urlCreator.urlForSegment("settings"); + this._createRoomUrl = this.urlCreator.urlForSegment("create-room"); } _mapTileViewModels(roomsBeingCreated, invites, rooms) { @@ -77,6 +78,8 @@ export class LeftPanelViewModel extends ViewModel { return this._settingsUrl; } + get createRoomUrl() { return this._createRoomUrl; } + _setupNavigation() { const roomObservable = this.navigation.observe("room"); this.track(roomObservable.subscribe(roomId => this._open(roomId))); diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 3740ffca..e7cc406a 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -26,6 +26,7 @@ import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; import {SettingsView} from "./settings/SettingsView.js"; +import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; export class SessionView extends TemplateView { @@ -44,6 +45,8 @@ export class SessionView extends TemplateView { return new RoomGridView(vm.roomGridViewModel); } else if (vm.settingsViewModel) { return new SettingsView(vm.settingsViewModel); + } else if (vm.createRoomViewModel) { + return new CreateRoomView(vm.createRoomViewModel); } else if (vm.currentRoomViewModel) { if (vm.currentRoomViewModel.kind === "invite") { return new InviteView(vm.currentRoomViewModel); diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 1b3c2ae7..c79192be 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -90,6 +90,7 @@ export class LeftPanelView extends TemplateView { "aria-label": gridButtonLabel }), t.a({className: "button-utility settings", href: vm.settingsUrl, "aria-label": vm.i18n`Settings`, title: vm.i18n`Settings`}), + t.a({className: "button-utility create", href: vm.createRoomUrl, "aria-label": vm.i18n`Create room`, title: vm.i18n`Create room`}), ]); return t.div({className: "LeftPanel"}, [ From 4b1be30dc0ef076c11c55520be08b210bf6d2283 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Feb 2022 19:01:35 +0100 Subject: [PATCH 15/43] improve form-row classes so they can work with create room form --- src/platform/web/ui/css/form.css | 2 +- src/platform/web/ui/css/layout.css | 11 +++++++++++ .../web/ui/css/themes/element/theme.css | 18 ++++++++++++++++-- src/platform/web/ui/login/PasswordLoginView.js | 4 ++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/form.css b/src/platform/web/ui/css/form.css index bdb47bec..f5452c65 100644 --- a/src/platform/web/ui/css/form.css +++ b/src/platform/web/ui/css/form.css @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.form input { +.form .form-row.text > input, .form .form-row.text > textarea { display: block; width: 100%; min-width: 0; diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index a85ab109..2b3a04ae 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -216,3 +216,14 @@ the layout viewport up without resizing it when the keyboard shows */ justify-content: center; align-items: center; } + +.vertical-layout { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +} + +.vertical-layout > .stretch { + flex: 1 0 0; +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 5ed8b4c9..eae02ee8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -80,11 +80,19 @@ limitations under the License. flex: 1 0 auto; } +.form textarea { + font-family: "Inter", sans-serif; +} + +.form-group { + margin: 24px 0; +} + .form-row { margin: 12px 0; } -.form-row input { +.form-row.text > input, .form-row.text > textarea { padding: 12px; border: 1px solid rgba(141, 151, 165, 0.15); border-radius: 8px; @@ -92,7 +100,13 @@ limitations under the License. font-size: 1em; } -.form-row label, .form-row input { +.form-row.check { + display: flex; + align-items: baseline; + gap: 4px; +} + +.form-row.text > label, .form-row.text > input { display: block; } diff --git a/src/platform/web/ui/login/PasswordLoginView.js b/src/platform/web/ui/login/PasswordLoginView.js index 4360cc0f..6f47555d 100644 --- a/src/platform/web/ui/login/PasswordLoginView.js +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -41,8 +41,8 @@ export class PasswordLoginView extends TemplateView { } }, [ t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), - t.div({ className: "form-row" }, [t.label({ for: "username" }, vm.i18n`Username`), username]), - t.div({ className: "form-row" }, [t.label({ for: "password" }, vm.i18n`Password`), password]), + t.div({ className: "form-row text" }, [t.label({ for: "username" }, vm.i18n`Username`), username]), + t.div({ className: "form-row text" }, [t.label({ for: "password" }, vm.i18n`Password`), password]), t.div({ className: "button-row" }, [ t.button({ className: "button-action primary", From a1e14c4eeca4b3841ace9e284fc82793de13a836 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Feb 2022 19:02:18 +0100 Subject: [PATCH 16/43] rename to not have conflict between method name and instance of CreateRoomViewModel --- src/domain/session/RoomViewModelObservable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index c16aaf83..02a9a60b 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -76,7 +76,7 @@ export class RoomViewModelObservable extends ObservableValue { } else if (status & RoomStatus.Invited) { return this._sessionViewModel._createInviteViewModel(this.id); } else if (status & RoomStatus.Joined) { - return this._sessionViewModel._createRoomViewModel(this.id); + return this._sessionViewModel._createRoomViewModelInstance(this.id); } else if (status & RoomStatus.Archived) { return await this._sessionViewModel._createArchivedRoomViewModel(this.id); } else { From 5c085efc10fc058efe2bfdf77753820ee62d55c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 9 Feb 2022 19:02:51 +0100 Subject: [PATCH 17/43] create room view and view model --- src/domain/session/CreateRoomViewModel.js | 126 ++++++++++++++++++ src/domain/session/common.js | 24 ++++ src/domain/session/room/RoomViewModel.js | 14 +- src/platform/web/ui/css/avatar.css | 1 + src/platform/web/ui/css/layout.css | 4 +- .../web/ui/css/themes/element/icons/plus.svg | 3 + .../web/ui/css/themes/element/theme.css | 29 ++++ src/platform/web/ui/session/CreateRoomView.js | 105 +++++++++++++++ 8 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 src/domain/session/CreateRoomViewModel.js create mode 100644 src/domain/session/common.js create mode 100644 src/platform/web/ui/css/themes/element/icons/plus.svg create mode 100644 src/platform/web/ui/session/CreateRoomView.js diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js new file mode 100644 index 00000000..47656128 --- /dev/null +++ b/src/domain/session/CreateRoomViewModel.js @@ -0,0 +1,126 @@ +/* +Copyright 2020 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 {ViewModel} from "../ViewModel.js"; +import {imageToInfo} from "./common.js"; +import {RoomType} from "../../matrix/room/create"; + +export class CreateRoomViewModel extends ViewModel { + constructor(options) { + super(options); + const {session} = options; + this._session = session; + this._name = ""; + this._topic = ""; + this._isPublic = false; + this._isEncrypted = true; + this._avatarScaledBlob = undefined; + this._avatarFileName = undefined; + this._avatarInfo = undefined; + } + + setName(name) { + this._name = name; + this.emitChange("name"); + } + + get name() { return this._name; } + + setTopic(topic) { + this._topic = topic; + this.emitChange("topic"); + } + + get topic() { return this._topic; } + + setPublic(isPublic) { + this._isPublic = isPublic; + this.emitChange("isPublic"); + } + + get isPublic() { return this._isPublic; } + + setEncrypted(isEncrypted) { + this._isEncrypted = isEncrypted; + this.emitChange("isEncrypted"); + } + + get isEncrypted() { return this._isEncrypted; } + + get canCreate() { + return !!this.name; + } + + create() { + let avatar; + if (this._avatarScaledBlob) { + avatar = { + info: this._avatarInfo, + name: this._avatarFileName, + blob: this._avatarScaledBlob + } + } + const roomBeingCreated = this._session.createRoom({ + type: this.isPublic ? RoomType.Public : RoomType.Private, + name: this.name ?? undefined, + topic: this.topic ?? undefined, + isEncrypted: !this.isPublic && this._isEncrypted, + alias: this.isPublic ? this.roomAlias : undefined, + avatar, + invites: ["@bwindels:matrix.org"] + }); + this.navigation.push("room", roomBeingCreated.localId); + } + + + avatarUrl() { return this._avatarScaledBlob.url; } + get avatarTitle() { return this.name; } + get avatarLetter() { return ""; } + get avatarColorNumber() { return 0; } + get hasAvatar() { return !!this._avatarScaledBlob; } + get error() { return ""; } + + async selectAvatar() { + if (!this.platform.hasReadPixelPermission()) { + alert("Please allow canvas image data access, so we can scale your images down."); + return; + } + if (this._avatarScaledBlob) { + this._avatarScaledBlob.dispose(); + } + this._avatarScaledBlob = undefined; + this._avatarFileName = undefined; + this._avatarInfo = undefined; + + const file = await this.platform.openFile("image/*"); + if (!file || !file.blob.mimeType.startsWith("image/")) { + // allow to clear the avatar by not selecting an image + this.emitChange("hasAvatar"); + return; + } + let image = await this.platform.loadImage(file.blob); + const limit = 800; + if (image.maxDimension > limit) { + const scaledImage = await image.scale(limit); + image.dispose(); + image = scaledImage; + } + this._avatarScaledBlob = image.blob; + this._avatarInfo = imageToInfo(image); + this._avatarFileName = file.name; + this.emitChange("hasAvatar"); + } +} diff --git a/src/domain/session/common.js b/src/domain/session/common.js new file mode 100644 index 00000000..1ab275bb --- /dev/null +++ b/src/domain/session/common.js @@ -0,0 +1,24 @@ +/* +Copyright 2020 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. +*/ + +export function imageToInfo(image) { + return { + w: image.width, + h: image.height, + mimetype: image.blob.mimeType, + size: image.blob.size + }; +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index c81c47ef..2d10c7ca 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -20,6 +20,7 @@ import {ComposerViewModel} from "./ComposerViewModel.js" import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {tilesCreator} from "./timeline/tilesCreator.js"; import {ViewModel} from "../../ViewModel.js"; +import {imageToInfo} from "../common.js"; export class RoomViewModel extends ViewModel { constructor(options) { @@ -273,7 +274,9 @@ export class RoomViewModel extends ViewModel { let image = await this.platform.loadImage(file.blob); const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); if (limit && image.maxDimension > limit) { - image = await image.scale(limit); + const scaledImage = await image.scale(limit); + image.dispose(); + image = scaledImage; } const content = { body: file.name, @@ -319,15 +322,6 @@ export class RoomViewModel extends ViewModel { } } -function imageToInfo(image) { - return { - w: image.width, - h: image.height, - mimetype: image.blob.mimeType, - size: image.blob.size - }; -} - function videoToInfo(video) { const info = imageToInfo(video); info.duration = video.duration; diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 143ea899..2ee9ca0c 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -34,6 +34,7 @@ limitations under the License. .hydrogen .avatar img { width: 100%; height: 100%; + object-fit: cover; } /* work around postcss-css-variables limitations and repeat variable usage */ diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 2b3a04ae..a0f42b01 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -49,7 +49,7 @@ main { grid-template: "status status" auto "left middle" 1fr / - 300px 1fr; + 320px 1fr; min-height: 0; min-width: 0; } @@ -58,7 +58,7 @@ main { grid-template: "status status status" auto "left middle right" 1fr / - 300px 1fr 300px; + 320px 1fr 300px; } /* resize and reposition session view to account for mobile Safari which shifts diff --git a/src/platform/web/ui/css/themes/element/icons/plus.svg b/src/platform/web/ui/css/themes/element/icons/plus.svg new file mode 100644 index 00000000..ea197223 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index eae02ee8..3a0d34ba 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -171,6 +171,10 @@ a.button-action { background-image: url('icons/settings.svg'); } +.button-utility.create { + background-image: url('icons/plus.svg'); +} + .button-utility.grid.on { background-image: url('icons/disable-grid.svg'); } @@ -1089,3 +1093,28 @@ button.RoomDetailsView_row::after { display: flex; gap: 12px; } + +.CreateRoomView { + padding: 0 12px; + justify-self: center; + max-width: 400px; + width: 100%; + box-sizing: border-box; +} + +.CreateRoomView_selectAvatar { + border: none; + background: none; + cursor: pointer; +} + +.CreateRoomView_selectAvatarPlaceholder { + width: 64px; + height: 64px; + border-radius: 100%; + background-color: #e1e3e6; + background-image: url('icons/plus.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: 36px; +} diff --git a/src/platform/web/ui/session/CreateRoomView.js b/src/platform/web/ui/session/CreateRoomView.js new file mode 100644 index 00000000..6bdc32ca --- /dev/null +++ b/src/platform/web/ui/session/CreateRoomView.js @@ -0,0 +1,105 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 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 {TemplateView} from "../general/TemplateView"; +import {AvatarView} from "../AvatarView"; +import {StaticView} from "../general/StaticView"; + +export class CreateRoomView extends TemplateView { + render(t, vm) { + return t.main({className: "CreateRoomView middle"}, [ + t.h2("Create room"), + //t.div({className: "RoomView_error"}, vm => vm.error), + t.form({className: "CreateRoomView_detailsForm form", onChange: evt => this.onFormChange(evt), onSubmit: evt => this.onSubmit(evt)}, [ + t.div({className: "vertical-layout"}, [ + t.button({type: "button", className: "CreateRoomView_selectAvatar", onClick: () => vm.selectAvatar()}, + t.mapView(vm => vm.hasAvatar, hasAvatar => { + if (hasAvatar) { + return new AvatarView(vm, 64); + } else { + return new StaticView(undefined, t => { + return t.div({className: "CreateRoomView_selectAvatarPlaceholder"}) + }); + } + }) + ), + t.div({className: "stretch form-row text"}, [ + t.label({for: "name"}, vm.i18n`Room name`), + t.input({ + onInput: evt => vm.setName(evt.target.value), + type: "text", name: "name", id: "name", + placeholder: vm.i18n`Enter a room name` + }, vm => vm.name), + ]), + ]), + t.div({className: "form-row text"}, [ + t.label({for: "topic"}, vm.i18n`Topic (optional)`), + t.textarea({ + onInput: evt => vm.setTopic(evt.target.value), + name: "topic", id: "topic", + placeholder: vm.i18n`Topic` + }), + ]), + t.div({className: "form-group"}, [ + t.div({className: "form-row check"}, [ + t.input({type: "radio", name: "isPublic", id: "isPrivate", value: "false", checked: !vm.isPublic}), + t.label({for: "isPrivate"}, vm.i18n`Private room, only upon invitation.`) + ]), + t.div({className: "form-row check"}, [ + t.input({type: "radio", name: "isPublic", id: "isPublic", value: "true", checked: vm.isPublic}), + t.label({for: "isPublic"}, vm.i18n`Public room, anyone can join`) + ]), + ]), + t.div({className: {"form-row check": true, hidden: vm => vm.isPublic}}, [ + t.input({type: "checkbox", name: "isEncrypted", id: "isEncrypted", checked: vm.isEncrypted}), + t.label({for: "isEncrypted"}, vm.i18n`Enable end-to-end encryption`) + ]), + t.div({className: {"form-row text": true, hidden: vm => !vm.isPublic}}, [ + t.label({for: "roomAlias"}, vm.i18n`Room alias`), + t.input({ + onInput: evt => vm.setRoomAlias(evt.target.value), + type: "text", name: "roomAlias", id: "roomAlias", + placeholder: vm.i18n`Room alias + `}), + ]), + t.div({className: "button-row"}, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled: vm => !vm.canCreate + }, vm.i18n`Create room`), + ]), + ]) + ]); + } + + onFormChange(evt) { + switch (evt.target.name) { + case "isEncrypted": + this.value.setEncrypted(evt.target.checked); + break; + case "isPublic": + this.value.setPublic(evt.currentTarget.isPublic.value === "true"); + break; + } + } + + onSubmit(evt) { + evt.preventDefault(); + this.value.create(); + } +} From 74f7879cb6f19f4945508a1021643d5499d481d6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 09:39:27 +0100 Subject: [PATCH 18/43] fix unrelated bug: invite sorting order wasn't stable in left panel as the timestamp is the same when you receive the invite during your first sync --- src/domain/session/leftpanel/InviteTileViewModel.js | 6 +++++- src/matrix/room/create.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index 09c11fb8..bdc0e193 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -39,6 +39,10 @@ export class InviteTileViewModel extends BaseTileViewModel { if (parentComparison !== 0) { return parentComparison; } - return other._invite.timestamp - this._invite.timestamp; + const timeDiff = other._invite.timestamp - this._invite.timestamp; + if (timeDiff !== 0) { + return timeDiff; + } + return this._invite.id < other._invite.id ? -1 : 1; } } diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index e3a1ed4d..6dd9340a 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -194,7 +194,8 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { get name() { return this._calculatedName; } get isBeingCreated(): boolean { return true; } get error(): Error | undefined { return this._error; } - + get id() { return this.localId; } + cancel() { // TODO: remove from collection somehow } From 5f6308e7c440496d6466bc1a6a232a734a8e8c6c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 09:40:19 +0100 Subject: [PATCH 19/43] fix homeserver field style in login view --- src/platform/web/ui/css/form.css | 2 +- src/platform/web/ui/css/themes/element/theme.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/form.css b/src/platform/web/ui/css/form.css index f5452c65..97859f29 100644 --- a/src/platform/web/ui/css/form.css +++ b/src/platform/web/ui/css/form.css @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.form .form-row.text > input, .form .form-row.text > textarea { +.form-row.text > input, .form-row.text > textarea { display: block; width: 100%; min-width: 0; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 3a0d34ba..a6040e7e 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -80,7 +80,7 @@ limitations under the License. flex: 1 0 auto; } -.form textarea { +.form-row.text textarea { font-family: "Inter", sans-serif; } From fed42f13ad7d95d9f0695ac8431509cce9c9b545 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 09:40:30 +0100 Subject: [PATCH 20/43] textarea styling --- src/platform/web/ui/css/themes/element/theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index a6040e7e..449de0c6 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -98,6 +98,7 @@ limitations under the License. border-radius: 8px; margin-top: 5px; font-size: 1em; + resize: vertical; } .form-row.check { From bbb1683dbfe06647f4a89ab091900c2ebb26bf02 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 09:40:42 +0100 Subject: [PATCH 21/43] fixup: login view styling --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index e8e1a0bf..88002625 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -34,7 +34,7 @@ export class LoginView extends TemplateView { t.div({className: "logo"}), t.h1([vm.i18n`Sign In`]), t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null), - t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form form-row" }, + t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form-row text" }, [ t.label({for: "homeserver"}, vm.i18n`Homeserver`), t.input({ From d6d1af13d05a6bbf0715e805d1db0550acc743e5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:03:52 +0100 Subject: [PATCH 22/43] rename RoomBeingCreated.localId to id --- src/domain/session/CreateRoomViewModel.js | 2 +- .../RoomBeingCreatedTileViewModel.js | 2 +- .../rightpanel/MemberDetailsViewModel.js | 2 +- .../session/room/RoomBeingCreatedViewModel.js | 2 +- src/matrix/Session.js | 20 ++++++++++++------- src/matrix/room/create.ts | 6 ++---- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js index 47656128..b398b615 100644 --- a/src/domain/session/CreateRoomViewModel.js +++ b/src/domain/session/CreateRoomViewModel.js @@ -82,7 +82,7 @@ export class CreateRoomViewModel extends ViewModel { avatar, invites: ["@bwindels:matrix.org"] }); - this.navigation.push("room", roomBeingCreated.localId); + this.navigation.push("room", roomBeingCreated.id); } diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js index d7840bfa..c80fba6c 100644 --- a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -22,7 +22,7 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { super(options); const {roomBeingCreated} = options; this._roomBeingCreated = roomBeingCreated; - this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.localId); + this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id); } get busy() { return true; } diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 68045eba..f5169afa 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -90,7 +90,7 @@ export class MemberDetailsViewModel extends ViewModel { type: RoomType.DirectMessage, invites: [this.userId] }); - roomId = roomBeingCreated.localId; + roomId = roomBeingCreated.id; } this.navigation.push("room", roomId); } diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index eebbb668..e9cb3616 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -32,7 +32,7 @@ export class RoomBeingCreatedViewModel extends ViewModel { get kind() { return "roomBeingCreated"; } get closeUrl() { return this._closeUrl; } get name() { return this._roomBeingCreated.name; } - get id() { return this._roomBeingCreated.localId; } + get id() { return this._roomBeingCreated.id; } get isEncrypted() { return this._roomBeingCreated.isEncrypted; } get avatarLetter() { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index b03306ed..2d4516ea 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -64,7 +64,13 @@ export class Session { this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); - this._roomsBeingCreatedUpdateCallback = (rbc, params) => this._roomsBeingCreated.update(rbc.localId, params); + this._roomsBeingCreatedUpdateCallback = (rbc, params) => { + if (rbc.isCancelled) { + this._roomsBeingCreated.remove(rbc.id); + } else { + this._roomsBeingCreated.update(rbc.id, params) + } + }; this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); @@ -603,11 +609,11 @@ export class Session { createRoom(options, log = undefined) { let roomBeingCreated; this._platform.logger.runDetached("create room", async log => { - const localId = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; + const id = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; roomBeingCreated = new RoomBeingCreated( - localId, options, this._roomsBeingCreatedUpdateCallback, + id, options, this._roomsBeingCreatedUpdateCallback, this._mediaRepository, this._platform, log); - this._roomsBeingCreated.set(localId, roomBeingCreated); + this._roomsBeingCreated.set(id, roomBeingCreated); const promises = [roomBeingCreated.create(this._hsApi, log)]; const loadProfiles = !(options.loadProfiles === false); // default to true if (loadProfiles) { @@ -709,15 +715,15 @@ export class Session { _tryReplaceRoomBeingCreated(roomId, log) { for (const [,roomBeingCreated] of this._roomsBeingCreated) { if (roomBeingCreated.roomId === roomId) { - const observableStatus = this._observedRoomStatus.get(roomBeingCreated.localId); + const observableStatus = this._observedRoomStatus.get(roomBeingCreated.id); if (observableStatus) { log.log(`replacing room being created`) - .set("localId", roomBeingCreated.localId) + .set("localId", roomBeingCreated.id) .set("roomId", roomBeingCreated.roomId); observableStatus.set(observableStatus.get() | RoomStatus.Replaced); } roomBeingCreated.dispose(); - this._roomsBeingCreated.remove(roomBeingCreated.localId); + this._roomsBeingCreated.remove(roomBeingCreated.id); return; } } diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 6dd9340a..cc65892e 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -94,7 +94,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { private _error?: Error; constructor( - public readonly localId: string, + public readonly id: string, private readonly options: Options, private readonly updateCallback: (self: RoomBeingCreated, params: string | undefined) => void, public readonly mediaRepository: MediaRepository, @@ -187,15 +187,13 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { this.emit("change"); } - get avatarColorId(): string { return this.options.invites?.[0] ?? this._roomId ?? this.localId; } get avatarUrl(): string | undefined { return this.profiles?.[0].avatarUrl; } + get avatarColorId(): string { return this.options.invites?.[0] ?? this._roomId ?? this.id; } get avatarBlobUrl(): string | undefined { return this.options.avatar?.blob?.url; } get roomId(): string | undefined { return this._roomId; } get name() { return this._calculatedName; } get isBeingCreated(): boolean { return true; } get error(): Error | undefined { return this._error; } - get id() { return this.localId; } - cancel() { // TODO: remove from collection somehow } From f12841b2d37cad9c306d7e71820a2e60fe7905f8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:06:20 +0100 Subject: [PATCH 23/43] better error handling in RoomBeingCreated --- src/matrix/room/create.ts | 96 ++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index cc65892e..1127c7ac 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -117,44 +117,44 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { /** @internal */ async create(hsApi: HomeServerApi, log: ILogItem): Promise { - let avatarEventContent; - if (this.options.avatar) { - const {avatar} = this.options; - const attachment = new AttachmentUpload({filename: avatar.name, blob: avatar.blob, platform: this.platform}); - await attachment.upload(hsApi, () => {}, log); - avatarEventContent = { - info: avatar.info - }; - attachment.applyToContent("url", avatarEventContent); - } - const createOptions: CreateRoomPayload = { - is_direct: this.options.type === RoomType.DirectMessage, - preset: presetForType(this.options.type), - initial_state: [] - }; - if (this.options.name) { - createOptions.name = this.options.name; - } - if (this.options.topic) { - createOptions.topic = this.options.topic; - } - if (this.options.invites) { - createOptions.invite = this.options.invites; - } - if (this.options.alias) { - createOptions.room_alias_name = this.options.alias; - } - if (this.isEncrypted) { - createOptions.initial_state.push(createRoomEncryptionEvent()); - } - if (avatarEventContent) { - createOptions.initial_state.push({ - type: "m.room.avatar", - state_key: "", - content: avatarEventContent - }); - } try { + let avatarEventContent; + if (this.options.avatar) { + const {avatar} = this.options; + const attachment = new AttachmentUpload({filename: avatar.name, blob: avatar.blob, platform: this.platform}); + await attachment.upload(hsApi, () => {}, log); + avatarEventContent = { + info: avatar.info + }; + attachment.applyToContent("url", avatarEventContent); + } + const createOptions: CreateRoomPayload = { + is_direct: this.options.type === RoomType.DirectMessage, + preset: presetForType(this.options.type), + initial_state: [] + }; + if (this.options.name) { + createOptions.name = this.options.name; + } + if (this.options.topic) { + createOptions.topic = this.options.topic; + } + if (this.options.invites) { + createOptions.invite = this.options.invites; + } + if (this.options.alias) { + createOptions.room_alias_name = this.options.alias; + } + if (this.isEncrypted) { + createOptions.initial_state.push(createRoomEncryptionEvent()); + } + if (avatarEventContent) { + createOptions.initial_state.push({ + type: "m.room.avatar", + state_key: "", + content: avatarEventContent + }); + } const response = await hsApi.createRoom(createOptions, {log}).response(); this._roomId = response["room_id"]; } catch (err) { @@ -170,16 +170,18 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { * is running. */ /** @internal */ async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise { - // only load profiles if we need it for the room name and avatar - if (!this.options.name && this.options.invites) { - this.profiles = await loadProfiles(this.options.invites, hsApi, log); - const summaryData = { - joinCount: 1, // ourselves - inviteCount: this.options.invites.length - }; - this._calculatedName = calculateRoomName(this.profiles, summaryData, log); - this.emitChange(); - } + try { + // only load profiles if we need it for the room name and avatar + if (!this.options.name && this.options.invites) { + this.profiles = await loadProfiles(this.options.invites, hsApi, log); + const summaryData = { + joinCount: 1, // ourselves + inviteCount: this.options.invites.length + }; + this._calculatedName = calculateRoomName(this.profiles, summaryData, log); + this.emitChange(); + } + } catch (err) {} // swallow error, loading profiles is not essential } private emitChange(params?: string) { From e8c20c28b28825ba877ed6ab8d571ab8acdb7975 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:06:44 +0100 Subject: [PATCH 24/43] allow passing label into LoadingView also doesn't need to be a template view, as it doesn't have bindings or event handlers --- src/platform/web/ui/general/LoadingView.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/platform/web/ui/general/LoadingView.js b/src/platform/web/ui/general/LoadingView.js index f2ab20cc..436d189c 100644 --- a/src/platform/web/ui/general/LoadingView.js +++ b/src/platform/web/ui/general/LoadingView.js @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "./TemplateView"; +import {StaticView} from "./StaticView"; import {spinner} from "../common.js"; -export class LoadingView extends TemplateView { - render(t) { - return t.div({ className: "LoadingView" }, [spinner(t), "Loading"]); +export class LoadingView extends StaticView { + constructor(label = "Loading") { + super(label, (t, label) => { + return t.div({ className: "LoadingView" }, [spinner(t), label]); + }); } } From 20493f9e87a7dadd9b7105e6e73d82645faaa0a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:07:13 +0100 Subject: [PATCH 25/43] cleanup --- src/matrix/Session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 2d4516ea..a5f48bf3 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -615,7 +615,7 @@ export class Session { this._mediaRepository, this._platform, log); this._roomsBeingCreated.set(id, roomBeingCreated); const promises = [roomBeingCreated.create(this._hsApi, log)]; - const loadProfiles = !(options.loadProfiles === false); // default to true + const loadProfiles = options.loadProfiles !== false; // default to true if (loadProfiles) { promises.push(roomBeingCreated.loadProfiles(this._hsApi, log)); } From b5536830d008a288998f3bf00590a57fe1e4b3fd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:07:29 +0100 Subject: [PATCH 26/43] improve RoomBeingCreatedView, allow removing the roombeingcreated --- .../session/room/RoomBeingCreatedViewModel.js | 26 ++-- src/matrix/room/create.ts | 17 ++- .../web/ui/css/themes/element/theme.css | 13 +- src/platform/web/ui/session/CreateRoomView.js | 122 +++++++++--------- .../ui/session/room/RoomBeingCreatedView.js | 35 ++++- 5 files changed, 132 insertions(+), 81 deletions(-) diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index e9cb3616..f15838df 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -34,21 +34,15 @@ export class RoomBeingCreatedViewModel extends ViewModel { get name() { return this._roomBeingCreated.name; } get id() { return this._roomBeingCreated.id; } get isEncrypted() { return this._roomBeingCreated.isEncrypted; } - - get avatarLetter() { - return avatarInitials(this.name); - } - - get avatarColorNumber() { - return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); - } + get error() { return this._roomBeingCreated.error?.message; } + get avatarLetter() { return avatarInitials(this.name); } + get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); } + get avatarTitle() { return this.name; } avatarUrl(size) { - return getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository); - } - - get avatarTitle() { - return this.name; + // allow blob url which doesn't need mxc => http resolution + return this._roomBeingCreated.avatarBlobUrl ?? + getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository); } focus() {} @@ -57,6 +51,12 @@ export class RoomBeingCreatedViewModel extends ViewModel { this.emitChange(); } + cancel() { + this._roomBeingCreated.cancel(); + // navigate away from the room + this.navigation.applyPath(this.navigation.path.until("session")); + } + dispose() { super.dispose(); this._roomBeingCreated.off("change", this._onRoomChange); diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 1127c7ac..6a1fc947 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -92,6 +92,7 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { public readonly isEncrypted: boolean; private _calculatedName: string; private _error?: Error; + private _isCancelled = false; constructor( public readonly id: string, @@ -189,17 +190,25 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { this.emit("change"); } - get avatarUrl(): string | undefined { return this.profiles?.[0].avatarUrl; } get avatarColorId(): string { return this.options.invites?.[0] ?? this._roomId ?? this.id; } + get avatarUrl(): string | undefined { return this.profiles?.[0]?.avatarUrl; } get avatarBlobUrl(): string | undefined { return this.options.avatar?.blob?.url; } get roomId(): string | undefined { return this._roomId; } get name() { return this._calculatedName; } get isBeingCreated(): boolean { return true; } get error(): Error | undefined { return this._error; } - cancel() { - // TODO: remove from collection somehow - } + cancel() { + if (!this._isCancelled) { + this.dispose(); + this._isCancelled = true; + this.emitChange("isCancelled"); + } + } + // called from Session when updateCallback is invoked to remove it from the collection + get isCancelled() { return this._isCancelled; } + + /** @internal */ dispose() { if (this.options.avatar) { this.options.avatar.blob.dispose(); diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 449de0c6..87d82485 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1095,10 +1095,17 @@ button.RoomDetailsView_row::after { gap: 12px; } -.CreateRoomView { - padding: 0 12px; - justify-self: center; +.CreateRoomView, .RoomBeingCreated_error { max-width: 400px; +} + +.RoomBeingCreated_error { + margin-top: 48px; +} + +.centered-column { + padding: 0 12px; + align-self: center; width: 100%; box-sizing: border-box; } diff --git a/src/platform/web/ui/session/CreateRoomView.js b/src/platform/web/ui/session/CreateRoomView.js index 6bdc32ca..c447710f 100644 --- a/src/platform/web/ui/session/CreateRoomView.js +++ b/src/platform/web/ui/session/CreateRoomView.js @@ -21,70 +21,72 @@ import {StaticView} from "../general/StaticView"; export class CreateRoomView extends TemplateView { render(t, vm) { - return t.main({className: "CreateRoomView middle"}, [ - t.h2("Create room"), - //t.div({className: "RoomView_error"}, vm => vm.error), - t.form({className: "CreateRoomView_detailsForm form", onChange: evt => this.onFormChange(evt), onSubmit: evt => this.onSubmit(evt)}, [ - t.div({className: "vertical-layout"}, [ - t.button({type: "button", className: "CreateRoomView_selectAvatar", onClick: () => vm.selectAvatar()}, - t.mapView(vm => vm.hasAvatar, hasAvatar => { - if (hasAvatar) { - return new AvatarView(vm, 64); - } else { - return new StaticView(undefined, t => { - return t.div({className: "CreateRoomView_selectAvatarPlaceholder"}) - }); - } - }) - ), - t.div({className: "stretch form-row text"}, [ - t.label({for: "name"}, vm.i18n`Room name`), + return t.main({className: "middle"}, + t.div({className: "CreateRoomView centered-column"}, [ + t.h2("Create room"), + //t.div({className: "RoomView_error"}, vm => vm.error), + t.form({className: "CreateRoomView_detailsForm form", onChange: evt => this.onFormChange(evt), onSubmit: evt => this.onSubmit(evt)}, [ + t.div({className: "vertical-layout"}, [ + t.button({type: "button", className: "CreateRoomView_selectAvatar", onClick: () => vm.selectAvatar()}, + t.mapView(vm => vm.hasAvatar, hasAvatar => { + if (hasAvatar) { + return new AvatarView(vm, 64); + } else { + return new StaticView(undefined, t => { + return t.div({className: "CreateRoomView_selectAvatarPlaceholder"}) + }); + } + }) + ), + t.div({className: "stretch form-row text"}, [ + t.label({for: "name"}, vm.i18n`Room name`), + t.input({ + onInput: evt => vm.setName(evt.target.value), + type: "text", name: "name", id: "name", + placeholder: vm.i18n`Enter a room name` + }, vm => vm.name), + ]), + ]), + t.div({className: "form-row text"}, [ + t.label({for: "topic"}, vm.i18n`Topic (optional)`), + t.textarea({ + onInput: evt => vm.setTopic(evt.target.value), + name: "topic", id: "topic", + placeholder: vm.i18n`Topic` + }), + ]), + t.div({className: "form-group"}, [ + t.div({className: "form-row check"}, [ + t.input({type: "radio", name: "isPublic", id: "isPrivate", value: "false", checked: !vm.isPublic}), + t.label({for: "isPrivate"}, vm.i18n`Private room, only upon invitation.`) + ]), + t.div({className: "form-row check"}, [ + t.input({type: "radio", name: "isPublic", id: "isPublic", value: "true", checked: vm.isPublic}), + t.label({for: "isPublic"}, vm.i18n`Public room, anyone can join`) + ]), + ]), + t.div({className: {"form-row check": true, hidden: vm => vm.isPublic}}, [ + t.input({type: "checkbox", name: "isEncrypted", id: "isEncrypted", checked: vm.isEncrypted}), + t.label({for: "isEncrypted"}, vm.i18n`Enable end-to-end encryption`) + ]), + t.div({className: {"form-row text": true, hidden: vm => !vm.isPublic}}, [ + t.label({for: "roomAlias"}, vm.i18n`Room alias`), t.input({ - onInput: evt => vm.setName(evt.target.value), - type: "text", name: "name", id: "name", - placeholder: vm.i18n`Enter a room name` - }, vm => vm.name), + onInput: evt => vm.setRoomAlias(evt.target.value), + type: "text", name: "roomAlias", id: "roomAlias", + placeholder: vm.i18n`Room alias + `}), ]), - ]), - t.div({className: "form-row text"}, [ - t.label({for: "topic"}, vm.i18n`Topic (optional)`), - t.textarea({ - onInput: evt => vm.setTopic(evt.target.value), - name: "topic", id: "topic", - placeholder: vm.i18n`Topic` - }), - ]), - t.div({className: "form-group"}, [ - t.div({className: "form-row check"}, [ - t.input({type: "radio", name: "isPublic", id: "isPrivate", value: "false", checked: !vm.isPublic}), - t.label({for: "isPrivate"}, vm.i18n`Private room, only upon invitation.`) + t.div({className: "button-row"}, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled: vm => !vm.canCreate + }, vm.i18n`Create room`), ]), - t.div({className: "form-row check"}, [ - t.input({type: "radio", name: "isPublic", id: "isPublic", value: "true", checked: vm.isPublic}), - t.label({for: "isPublic"}, vm.i18n`Public room, anyone can join`) - ]), - ]), - t.div({className: {"form-row check": true, hidden: vm => vm.isPublic}}, [ - t.input({type: "checkbox", name: "isEncrypted", id: "isEncrypted", checked: vm.isEncrypted}), - t.label({for: "isEncrypted"}, vm.i18n`Enable end-to-end encryption`) - ]), - t.div({className: {"form-row text": true, hidden: vm => !vm.isPublic}}, [ - t.label({for: "roomAlias"}, vm.i18n`Room alias`), - t.input({ - onInput: evt => vm.setRoomAlias(evt.target.value), - type: "text", name: "roomAlias", id: "roomAlias", - placeholder: vm.i18n`Room alias - `}), - ]), - t.div({className: "button-row"}, [ - t.button({ - className: "button-action primary", - type: "submit", - disabled: vm => !vm.canCreate - }, vm.i18n`Create room`), - ]), + ]) ]) - ]); + ); } onFormChange(evt) { diff --git a/src/platform/web/ui/session/room/RoomBeingCreatedView.js b/src/platform/web/ui/session/room/RoomBeingCreatedView.js index df26a43d..1da44d34 100644 --- a/src/platform/web/ui/session/room/RoomBeingCreatedView.js +++ b/src/platform/web/ui/session/room/RoomBeingCreatedView.js @@ -16,10 +16,43 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; +import {LoadingView} from "../../general/LoadingView"; +import {AvatarView} from "../../AvatarView"; import {renderStaticAvatar} from "../../avatar.js"; export class RoomBeingCreatedView extends TemplateView { render(t, vm) { - return t.h1({className: "middle"}, ["creating room", vm => vm.name]); + return t.main({className: "RoomView middle"}, [ + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), + t.view(new AvatarView(vm, 32)), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), + ]) + ]), + t.div({className: "RoomView_body"}, [ + t.mapView(vm => vm.error, error => { + if (error) { + return new ErrorView(vm); + } else { + return new LoadingView(vm.i18n`Setting up the room…`); + } + }) + ]) + ]); + } +} + +class ErrorView extends TemplateView { + render(t,vm) { + return t.div({className: "RoomBeingCreated_error centered-column"}, [ + t.h3(vm.i18n`Could not create the room, something went wrong:`), + t.div({className: "RoomView_error form-group"}, vm.error), + t.div({className: "button-row"}, + t.button({ + className: "button-action primary destructive", + onClick: () => vm.cancel() + }, vm.i18n`Cancel`)) + ]); } } From 024a6c06aa364dfb817ede8805adc05b4c53af00 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:11:15 +0100 Subject: [PATCH 27/43] handle offline error nicer --- src/domain/session/room/RoomBeingCreatedViewModel.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index f15838df..0ed82be9 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -34,7 +34,16 @@ export class RoomBeingCreatedViewModel extends ViewModel { get name() { return this._roomBeingCreated.name; } get id() { return this._roomBeingCreated.id; } get isEncrypted() { return this._roomBeingCreated.isEncrypted; } - get error() { return this._roomBeingCreated.error?.message; } + get error() { + const {error} = this._roomBeingCreated; + if (error) { + if (error.name === "ConnectionError") { + return this.i18n`You seem to be offline`; + } else { + return error.message; + } + } + } get avatarLetter() { return avatarInitials(this.name); } get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); } get avatarTitle() { return this.name; } From 4c0167ed74cfc00f67ca8db6409d19470a396ffd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 11:19:43 +0100 Subject: [PATCH 28/43] don't show spinner in left panel when room creation fails --- .../RoomBeingCreatedTileViewModel.js | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js index c80fba6c..e6fc4cee 100644 --- a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -25,15 +25,13 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id); } - get busy() { return true; } - - get kind() { - return "roomBeingCreated"; - } - - get url() { - return this._url; - } + get busy() { return !this._roomBeingCreated.error; } + get kind() { return "roomBeingCreated"; } + get isHighlighted() { return !this.busy; } + get badgeCount() { return !this.busy && this.i18n`Failed`; } + get url() { return this._url; } + get name() { return this._roomBeingCreated.name; } + get _avatarSource() { return this._roomBeingCreated; } compare(other) { const parentComparison = super.compare(other); @@ -43,14 +41,6 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { return other._roomBeingCreated.name.localeCompare(this._roomBeingCreated.name); } - get name() { - return this._roomBeingCreated.name; - } - - get _avatarSource() { - return this._roomBeingCreated; - } - avatarUrl(size) { // allow blob url which doesn't need mxc => http resolution return this._roomBeingCreated.avatarBlobUrl ?? super.avatarUrl(size); From 147810864f530a113a7a37b82d935c7f239eaf13 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 14:09:18 +0100 Subject: [PATCH 29/43] add support to set alias and federation flag in create room --- src/domain/session/CreateRoomViewModel.js | 64 ++++++++++++------- src/matrix/room/create.ts | 7 ++ .../web/ui/css/themes/element/theme.css | 6 ++ src/platform/web/ui/session/CreateRoomView.js | 19 +++++- 4 files changed, 70 insertions(+), 26 deletions(-) diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js index b398b615..6e207e31 100644 --- a/src/domain/session/CreateRoomViewModel.js +++ b/src/domain/session/CreateRoomViewModel.js @@ -23,45 +23,60 @@ export class CreateRoomViewModel extends ViewModel { super(options); const {session} = options; this._session = session; - this._name = ""; - this._topic = ""; + this._name = undefined; + this._topic = undefined; + this._roomAlias = undefined; this._isPublic = false; this._isEncrypted = true; + this._isAdvancedShown = false; + this._isFederationDisabled = false; this._avatarScaledBlob = undefined; this._avatarFileName = undefined; this._avatarInfo = undefined; } + get isPublic() { return this._isPublic; } + get isEncrypted() { return this._isEncrypted; } + get canCreate() { return !!this._name; } + avatarUrl() { return this._avatarScaledBlob.url; } + get avatarTitle() { return this._name; } + get avatarLetter() { return ""; } + get avatarColorNumber() { return 0; } + get hasAvatar() { return !!this._avatarScaledBlob; } + get isFederationDisabled() { return this._isFederationDisabled; } + get isAdvancedShown() { return this._isAdvancedShown; } + setName(name) { this._name = name; - this.emitChange("name"); + this.emitChange("canCreate"); } - get name() { return this._name; } + setRoomAlias(roomAlias) { + this._roomAlias = roomAlias; + } setTopic(topic) { this._topic = topic; - this.emitChange("topic"); } - get topic() { return this._topic; } - setPublic(isPublic) { this._isPublic = isPublic; this.emitChange("isPublic"); } - get isPublic() { return this._isPublic; } - setEncrypted(isEncrypted) { this._isEncrypted = isEncrypted; this.emitChange("isEncrypted"); } - get isEncrypted() { return this._isEncrypted; } + setFederationDisabled(disable) { + this._isFederationDisabled = disable; + this.emitChange("isFederationDisabled"); + } - get canCreate() { - return !!this.name; + toggleAdvancedShown() { + this._isAdvancedShown = !this._isAdvancedShown; + this.emitChange("isAdvancedShown"); } create() { @@ -75,24 +90,16 @@ export class CreateRoomViewModel extends ViewModel { } const roomBeingCreated = this._session.createRoom({ type: this.isPublic ? RoomType.Public : RoomType.Private, - name: this.name ?? undefined, + name: this._name ?? undefined, topic: this.topic ?? undefined, isEncrypted: !this.isPublic && this._isEncrypted, - alias: this.isPublic ? this.roomAlias : undefined, + isFederationDisabled: this._isFederationDisabled, + alias: this.isPublic ? ensureAliasIsLocalPart(this._roomAlias) : undefined, avatar, - invites: ["@bwindels:matrix.org"] }); this.navigation.push("room", roomBeingCreated.id); } - - avatarUrl() { return this._avatarScaledBlob.url; } - get avatarTitle() { return this.name; } - get avatarLetter() { return ""; } - get avatarColorNumber() { return 0; } - get hasAvatar() { return !!this._avatarScaledBlob; } - get error() { return ""; } - async selectAvatar() { if (!this.platform.hasReadPixelPermission()) { alert("Please allow canvas image data access, so we can scale your images down."); @@ -124,3 +131,14 @@ export class CreateRoomViewModel extends ViewModel { this.emitChange("hasAvatar"); } } + +function ensureAliasIsLocalPart(roomAliasLocalPart) { + if (roomAliasLocalPart.startsWith("#")) { + roomAliasLocalPart = roomAliasLocalPart.substr(1); + } + const colonIdx = roomAliasLocalPart.indexOf(":"); + if (colonIdx !== -1) { + roomAliasLocalPart = roomAliasLocalPart.substr(0, colonIdx); + } + return roomAliasLocalPart; +} diff --git a/src/matrix/room/create.ts b/src/matrix/room/create.ts index 6a1fc947..c50aba6b 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/create.ts @@ -32,6 +32,7 @@ type CreateRoomPayload = { topic?: string; invite?: string[]; room_alias_name?: string; + creation_content?: {"m.federate": boolean}; initial_state: {type: string; state_key: string; content: Record}[] } @@ -51,6 +52,7 @@ type Avatar = { type Options = { type: RoomType; isEncrypted?: boolean; + isFederationDisabled?: boolean; name?: string; topic?: string; invites?: string[]; @@ -146,6 +148,11 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { if (this.options.alias) { createOptions.room_alias_name = this.options.alias; } + if (this.options.isFederationDisabled === true) { + createOptions.creation_content = { + "m.federate": false + }; + } if (this.isEncrypted) { createOptions.initial_state.push(createRoomEncryptionEvent()); } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 87d82485..a3fb12ee 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -111,6 +111,12 @@ limitations under the License. display: block; } +.form-row .form-row-description { + font-size: 1rem; + color: #777; + margin: 8px 0 0 0; +} + .button-action { cursor: pointer; } diff --git a/src/platform/web/ui/session/CreateRoomView.js b/src/platform/web/ui/session/CreateRoomView.js index c447710f..9d6c6bbc 100644 --- a/src/platform/web/ui/session/CreateRoomView.js +++ b/src/platform/web/ui/session/CreateRoomView.js @@ -44,7 +44,7 @@ export class CreateRoomView extends TemplateView { onInput: evt => vm.setName(evt.target.value), type: "text", name: "name", id: "name", placeholder: vm.i18n`Enter a room name` - }, vm => vm.name), + }), ]), ]), t.div({className: "form-row text"}, [ @@ -74,8 +74,18 @@ export class CreateRoomView extends TemplateView { t.input({ onInput: evt => vm.setRoomAlias(evt.target.value), type: "text", name: "roomAlias", id: "roomAlias", - placeholder: vm.i18n`Room alias - `}), + placeholder: vm.i18n`Room alias (, or # or #:hs.tld`}), + ]), + t.div({className: "form-group"}, [ + t.div(t.button({className: "link", type: "button", onClick: () => vm.toggleAdvancedShown()}, + vm => vm.isAdvancedShown ? vm.i18n`Hide advanced settings` : vm.i18n`Show advanced settings`)), + t.div({className: {"form-row check": true, hidden: vm => !vm.isAdvancedShown}}, [ + t.input({type: "checkbox", name: "isFederationDisabled", id: "isFederationDisabled", checked: vm.isFederationDisabled}), + t.label({for: "isFederationDisabled"}, [ + vm.i18n`Disable federation`, + t.p({className: "form-row-description"}, vm.i18n`Can't be changed later. This will prevent people on other homeservers from joining the room. This is typically used when only people from your own organisation (if applicable) should be allowed in the room, and is otherwise not needed.`) + ]), + ]), ]), t.div({className: "button-row"}, [ t.button({ @@ -97,6 +107,9 @@ export class CreateRoomView extends TemplateView { case "isPublic": this.value.setPublic(evt.currentTarget.isPublic.value === "true"); break; + case "isFederationDisabled": + this.value.setFederationDisabled(evt.target.checked); + break; } } From 955a6bd6f928468edf1ffec431203183c84816ca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 14:38:12 +0100 Subject: [PATCH 30/43] styling for button in member details to open DM --- src/platform/web/ui/css/themes/element/theme.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index a3fb12ee..383feeae 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1074,10 +1074,14 @@ button.RoomDetailsView_row::after { flex-direction: column; } -.MemberDetailsView_options a{ +.MemberDetailsView_options a, .MemberDetailsView_options button { color: #0dbd8b; text-decoration: none; - margin-bottom: 3px; + margin: 0 0 3px 0; + padding: 0; + border: none; + background: none; + cursor: pointer; } .LazyListParent { From 75bbde598da083de04b136b5299bea2aa8529a96 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 14:39:18 +0100 Subject: [PATCH 31/43] also consider rooms without a name and just you and the other a DM as we don't process m.direct account data yet --- src/matrix/room/BaseRoom.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 446c22d1..dda3e2e5 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -421,7 +421,18 @@ export class BaseRoom extends EventEmitter { } isDirectMessageForUserId(userId) { - return this._summary.data.dmUserId === userId; + if (this._summary.data.dmUserId === userId) { + return true; + } else { + // fall back to considering any room a DM containing heroes (e.g. no name) and 2 members, + // on of which the userId we're looking for. + // We need this because we're not yet processing m.direct account data correctly. + const {heroes, joinCount, inviteCount} = this._summary.data; + if (heroes && heroes.includes(userId) && (joinCount + inviteCount) === 2) { + return true; + } + } + return false; } async _loadPowerLevels() { From 2c1b29e637bd6a387bf0965187670dd0ebffcfb6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 14:39:41 +0100 Subject: [PATCH 32/43] remove logging --- src/domain/session/RoomViewModelObservable.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 02a9a60b..cec2e221 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -60,13 +60,10 @@ export class RoomViewModelObservable extends ObservableValue { } async _statusToViewModel(status) { - console.log("RoomViewModelObservable received status", status); if (status & RoomStatus.Replaced) { - console.log("replaced!"); if (status & RoomStatus.BeingCreated) { const {session} = this._sessionViewModel._client; const roomBeingCreated = session.roomsBeingCreated.get(this.id); - console.log("new id is", roomBeingCreated.roomId); this._sessionViewModel.navigation.push("room", roomBeingCreated.roomId); } else { throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced)); From b0d790543a03f432084c79fa287800bfda43303b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 14:57:48 +0100 Subject: [PATCH 33/43] push to navigation in SessionViewModel rather than RVO --- src/domain/session/RoomViewModelObservable.js | 2 +- src/domain/session/SessionViewModel.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index cec2e221..8fd0daf3 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -64,7 +64,7 @@ export class RoomViewModelObservable extends ObservableValue { if (status & RoomStatus.BeingCreated) { const {session} = this._sessionViewModel._client; const roomBeingCreated = session.roomsBeingCreated.get(this.id); - this._sessionViewModel.navigation.push("room", roomBeingCreated.roomId); + this._sessionViewModel.notifyRoomReplaced(roomBeingCreated.id, roomBeingCreated.roomId); } else { throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced)); } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 5a818c4d..24276f42 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -298,4 +298,7 @@ export class SessionViewModel extends ViewModel { this.emitChange("rightPanelViewModel"); } + notifyRoomReplaced(oldId, newId) { + this.navigation.push("room", newId); + } } From 30c8ea29b2a4ab619641c697c494114a69bc34ea Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 16:27:32 +0100 Subject: [PATCH 34/43] fix bug where the wrong left panel tile is removed when accepting invite because when comparing a tile to itself it wasn't returned 0 --- .../session/leftpanel/BaseTileViewModel.js | 2 +- .../session/leftpanel/InviteTileViewModel.js | 20 +++++++++++-- .../RoomBeingCreatedTileViewModel.js | 30 ++++++++++++++++--- .../session/leftpanel/RoomTileViewModel.js | 3 ++ src/domain/session/leftpanel/common.js | 23 ++++++++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 src/domain/session/leftpanel/common.js diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 95e91458..b360b1d4 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -18,7 +18,7 @@ limitations under the License. import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; -const KIND_ORDER = ["invite", "room"]; +const KIND_ORDER = ["roomBeingCreated", "invite", "room"]; export class BaseTileViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index bdc0e193..cdd955b1 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -1,5 +1,4 @@ /* -Copyright 2020 Bruno Windels Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,6 +15,7 @@ limitations under the License. */ import {BaseTileViewModel} from "./BaseTileViewModel.js"; +import {comparePrimitive} from "./common"; export class InviteTileViewModel extends BaseTileViewModel { constructor(options) { @@ -34,6 +34,9 @@ export class InviteTileViewModel extends BaseTileViewModel { get badgeCount() { return this.i18n`!`; } get _avatarSource() { return this._invite; } + /** very important that sorting order is stable and that comparing + * to itself always returns 0, otherwise SortedMapList will + * remove the wrong children, etc ... */ compare(other) { const parentComparison = super.compare(other); if (parentComparison !== 0) { @@ -43,6 +46,19 @@ export class InviteTileViewModel extends BaseTileViewModel { if (timeDiff !== 0) { return timeDiff; } - return this._invite.id < other._invite.id ? -1 : 1; + return comparePrimitive(this._invite.id, other._invite.id); + } +} + +export function tests() { + return { + "test compare with timestamp": assert => { + const urlCreator = {openRoomActionUrl() { return "";}} + const vm1 = new InviteTileViewModel({invite: {timestamp: 500, id: "1"}, urlCreator}); + const vm2 = new InviteTileViewModel({invite: {timestamp: 250, id: "2"}, urlCreator}); + assert(vm1.compare(vm2) < 0); + assert(vm2.compare(vm1) > 0); + assert.equal(vm1.compare(vm1), 0); + }, } } diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js index e6fc4cee..81785bd9 100644 --- a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -16,6 +16,7 @@ limitations under the License. */ import {BaseTileViewModel} from "./BaseTileViewModel.js"; +import {comparePrimitive} from "./common"; export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { constructor(options) { @@ -33,12 +34,20 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { get name() { return this._roomBeingCreated.name; } get _avatarSource() { return this._roomBeingCreated; } + /** very important that sorting order is stable and that comparing + * to itself always returns 0, otherwise SortedMapList will + * remove the wrong children, etc ... */ compare(other) { - const parentComparison = super.compare(other); - if (parentComparison !== 0) { - return parentComparison; + const parentCmp = super.compare(other); + if (parentCmp !== 0) { + return parentCmp; + } + const nameCmp = comparePrimitive(this.name, other.name); + if (nameCmp === 0) { + return comparePrimitive(this._roomBeingCreated.id, other._roomBeingCreated.id); + } else { + return nameCmp; } - return other._roomBeingCreated.name.localeCompare(this._roomBeingCreated.name); } avatarUrl(size) { @@ -46,3 +55,16 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { return this._roomBeingCreated.avatarBlobUrl ?? super.avatarUrl(size); } } + +export function tests() { + return { + "test compare with names": assert => { + const urlCreator = {openRoomActionUrl() { return "";}} + const vm1 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "A", id: "1"}, urlCreator}); + const vm2 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "B", id: "2"}, urlCreator}); + assert(vm1.compare(vm2) < 0); + assert(vm2.compare(vm1) > 0); + assert.equal(vm1.compare(vm1), 0); + }, + } +} diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index eebea618..8c38bb3e 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -33,6 +33,9 @@ export class RoomTileViewModel extends BaseTileViewModel { return this._url; } + /** very important that sorting order is stable and that comparing + * to itself always returns 0, otherwise SortedMapList will + * remove the wrong children, etc ... */ compare(other) { const parentComparison = super.compare(other); if (parentComparison !== 0) { diff --git a/src/domain/session/leftpanel/common.js b/src/domain/session/leftpanel/common.js new file mode 100644 index 00000000..3af95eba --- /dev/null +++ b/src/domain/session/leftpanel/common.js @@ -0,0 +1,23 @@ +/* +Copyright 2020, 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. +*/ + +export function comparePrimitive(a, b) { + if (a === b) { + return 0; + } else { + return a < b ? -1 : 1; + } +} From 15eecbb46358a1fb7e625dba0485a6ad34ad0d74 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 16:28:44 +0100 Subject: [PATCH 35/43] cleanup --- src/domain/session/leftpanel/LeftPanelViewModel.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index cc3a2ccb..08584d7f 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -40,7 +40,7 @@ export class LeftPanelViewModel extends ViewModel { _mapTileViewModels(roomsBeingCreated, invites, rooms) { // join is not commutative, invites will take precedence over rooms - const joined = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => { + const allTiles = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => { let vm; if (item.isBeingCreated) { vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange})); @@ -56,10 +56,7 @@ export class LeftPanelViewModel extends ViewModel { } return vm; }); - return joined; - // return new LogMap(joined, (op, key, value) => { - // console.log("room list", op, key, value); - // }); + return allTiles; } _updateCurrentVM(vm) { From 8526461d3c28abab2b55177e5aa98012eaa0493d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 16:39:54 +0100 Subject: [PATCH 36/43] split up create code into separate files --- src/domain/session/CreateRoomViewModel.js | 2 +- src/domain/session/RoomViewModelObservable.js | 2 +- .../rightpanel/MemberDetailsViewModel.js | 2 +- src/matrix/Session.js | 4 +- src/matrix/common.js | 2 +- src/matrix/profile.ts | 51 +++++++++++++++++++ .../room/{create.ts => RoomBeingCreated.ts} | 41 +-------------- src/matrix/room/RoomStatus.ts | 24 --------- src/matrix/room/{common.js => common.ts} | 15 ++++++ src/matrix/room/members/RoomMember.js | 2 +- src/matrix/room/sending/PendingEvent.js | 2 +- src/matrix/room/sending/SendQueue.js | 2 +- src/matrix/room/timeline/Timeline.js | 2 +- .../room/timeline/entries/BaseEventEntry.js | 2 +- .../room/timeline/entries/EventEntry.js | 2 +- .../timeline/persistence/RelationWriter.js | 2 +- src/matrix/room/timeline/relations.js | 2 +- 17 files changed, 82 insertions(+), 77 deletions(-) create mode 100644 src/matrix/profile.ts rename src/matrix/room/{create.ts => RoomBeingCreated.ts} (86%) delete mode 100644 src/matrix/room/RoomStatus.ts rename src/matrix/room/{common.js => common.ts} (81%) diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js index 6e207e31..9d2835c2 100644 --- a/src/domain/session/CreateRoomViewModel.js +++ b/src/domain/session/CreateRoomViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {ViewModel} from "../ViewModel.js"; import {imageToInfo} from "./common.js"; -import {RoomType} from "../../matrix/room/create"; +import {RoomType} from "../../matrix/room/common"; export class CreateRoomViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 8fd0daf3..52833332 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ObservableValue} from "../../observable/ObservableValue"; -import {RoomStatus} from "../../matrix/room/RoomStatus"; +import {RoomStatus} from "../../matrix/room/common"; /** Depending on the status of a room (invited, joined, archived, or none), diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index f5169afa..f6cbd747 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel.js"; -import {RoomType} from "../../../matrix/room/create"; +import {RoomType} from "../../../matrix/room/common"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; export class MemberDetailsViewModel extends ViewModel { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index a5f48bf3..c5b5ad0d 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -17,8 +17,8 @@ limitations under the License. import {Room} from "./room/Room.js"; import {ArchivedRoom} from "./room/ArchivedRoom.js"; -import {RoomStatus} from "./room/RoomStatus"; -import {RoomBeingCreated} from "./room/create"; +import {RoomStatus} from "./room/common"; +import {RoomBeingCreated} from "./room/RoomBeingCreated"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher"; import { ObservableMap } from "../observable/index.js"; diff --git a/src/matrix/common.js b/src/matrix/common.js index 67a95205..ba7876ed 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -34,4 +34,4 @@ export function tests() { assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm")); }, } -} \ No newline at end of file +} diff --git a/src/matrix/profile.ts b/src/matrix/profile.ts new file mode 100644 index 00000000..a95b2139 --- /dev/null +++ b/src/matrix/profile.ts @@ -0,0 +1,51 @@ +/* +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 type {HomeServerApi} from "./net/HomeServerApi"; +import type {ILogItem} from "../logging/types"; + +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; +} + +export interface IProfile { + get userId(): string; + get displayName(): string | undefined; + get avatarUrl(): string | undefined; + get name(): string; +} + +export class Profile implements IProfile { + constructor( + public readonly userId: string, + public readonly displayName: string, + public readonly avatarUrl: string | undefined + ) {} + + get name() { return this.displayName || this.userId; } +} + +export class UserIdProfile implements IProfile { + constructor(public readonly userId: string) {} + get displayName() { return undefined; } + get name() { return this.userId; } + get avatarUrl() { return undefined; } +} diff --git a/src/matrix/room/create.ts b/src/matrix/room/RoomBeingCreated.ts similarity index 86% rename from src/matrix/room/create.ts rename to src/matrix/room/RoomBeingCreated.ts index c50aba6b..1ad84a47 100644 --- a/src/matrix/room/create.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -19,6 +19,8 @@ import {createRoomEncryptionEvent} from "../e2ee/common"; import {MediaRepository} from "../net/MediaRepository"; import {EventEmitter} from "../../utils/EventEmitter"; import {AttachmentUpload} from "./AttachmentUpload"; +import {loadProfiles, Profile, UserIdProfile} from "../profile"; +import {RoomType} from "./common"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {ILogItem} from "../../logging/types"; @@ -60,12 +62,6 @@ type Options = { alias?: string; } -export enum RoomType { - DirectMessage, - Private, - Public -} - function defaultE2EEStatusForType(type: RoomType): boolean { switch (type) { case RoomType.DirectMessage: @@ -222,36 +218,3 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { } } } - -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 | undefined; - get avatarUrl(): string | undefined; - get name(): string; -} - -export class Profile implements IProfile { - constructor( - public readonly userId: string, - public readonly displayName: string, - public readonly avatarUrl: string | undefined - ) {} - - get name() { return this.displayName || this.userId; } -} - -class UserIdProfile implements IProfile { - constructor(public readonly userId: string) {} - get displayName() { return undefined; } - get name() { return this.userId; } - get avatarUrl() { return undefined; } -} diff --git a/src/matrix/room/RoomStatus.ts b/src/matrix/room/RoomStatus.ts deleted file mode 100644 index f66f59d7..00000000 --- a/src/matrix/room/RoomStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* -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. -*/ - -export enum RoomStatus { - None = 1 << 0, - BeingCreated = 1 << 1, - Invited = 1 << 2, - Joined = 1 << 3, - Replaced = 1 << 4, - Archived = 1 << 5, -} diff --git a/src/matrix/room/common.js b/src/matrix/room/common.ts similarity index 81% rename from src/matrix/room/common.js rename to src/matrix/room/common.ts index b009a89c..57ab7023 100644 --- a/src/matrix/room/common.js +++ b/src/matrix/room/common.ts @@ -25,3 +25,18 @@ export const REDACTION_TYPE = "m.room.redaction"; export function isRedacted(event) { return !!event?.unsigned?.redacted_because; } + +export enum RoomStatus { + None = 1 << 0, + BeingCreated = 1 << 1, + Invited = 1 << 2, + Joined = 1 << 3, + Replaced = 1 << 4, + Archived = 1 << 5, +} + +export enum RoomType { + DirectMessage, + Private, + Public +} diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index 78096060..dabff972 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getPrevContentFromStateEvent} from "../common.js"; +import {getPrevContentFromStateEvent} from "../common"; export const EVENT_TYPE = "m.room.member"; diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index d4c10704..1b3567a7 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,7 +15,7 @@ limitations under the License. */ import {createEnum} from "../../../utils/enum"; import {AbortError} from "../../../utils/error"; -import {REDACTION_TYPE} from "../common.js"; +import {REDACTION_TYPE} from "../common"; import {getRelationFromContent, getRelationTarget, setRelationTarget} from "../timeline/relations.js"; export const SendStatus = createEnum( diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 791ed854..f9950c01 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -18,7 +18,7 @@ import {SortedArray} from "../../../observable/list/SortedArray"; import {ConnectionError} from "../../error.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {makeTxnId, isTxnId} from "../../common.js"; -import {REDACTION_TYPE} from "../common.js"; +import {REDACTION_TYPE} from "../common"; import {getRelationFromContent, getRelationTarget, setRelationTarget, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js"; export class SendQueue { diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 90ec29eb..19e9e647 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; -import {REDACTION_TYPE} from "../common.js"; +import {REDACTION_TYPE} from "../common"; import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js"; import {DecryptionSource} from "../../e2ee/common.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js"; diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index ec20f48a..44fdcaec 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseEntry} from "./BaseEntry"; -import {REDACTION_TYPE} from "../../common.js"; +import {REDACTION_TYPE} from "../../common"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {PendingAnnotation} from "../PendingAnnotation.js"; import {createReplyContent} from "./reply.js" diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index 7b957e01..d218a598 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseEventEntry} from "./BaseEventEntry.js"; -import {getPrevContentFromStateEvent, isRedacted} from "../../common.js"; +import {getPrevContentFromStateEvent, isRedacted} from "../../common"; import {getRelationFromContent, getRelatedEventId} from "../relations.js"; export class EventEntry extends BaseEventEntry { diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 92f97671..ae078bfc 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -15,7 +15,7 @@ limitations under the License. */ import {EventEntry} from "../entries/EventEntry.js"; -import {REDACTION_TYPE, isRedacted} from "../../common.js"; +import {REDACTION_TYPE, isRedacted} from "../../common"; import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js"; import {redactEvent} from "../common.js"; diff --git a/src/matrix/room/timeline/relations.js b/src/matrix/room/timeline/relations.js index 4009d8c4..2183a6c5 100644 --- a/src/matrix/room/timeline/relations.js +++ b/src/matrix/room/timeline/relations.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {REDACTION_TYPE} from "../common.js"; +import {REDACTION_TYPE} from "../common"; export const REACTION_TYPE = "m.reaction"; export const ANNOTATION_RELATION_TYPE = "m.annotation"; From 3adb2c32545d435769f72d087473a48113df2051 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 16:44:40 +0100 Subject: [PATCH 37/43] fix ts errors --- src/logging/NullLogger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logging/NullLogger.ts b/src/logging/NullLogger.ts index 076bb313..21c3d349 100644 --- a/src/logging/NullLogger.ts +++ b/src/logging/NullLogger.ts @@ -67,7 +67,7 @@ export class NullLogItem implements ILogItem { log(): ILogItem { return this; } - set(): void {} + set(): ILogItem { return this; } runDetached(_: LabelOrValues, callback: LogCallback): ILogItem { new Promise(r => r(callback(this))).then(noop, noop); From ff46d382acc476d06a8522c981686cee38f869ef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 19:54:15 +0100 Subject: [PATCH 38/43] adjust m.direct when creating a DM --- src/matrix/Session.js | 2 +- src/matrix/net/HomeServerApi.ts | 3 +++ src/matrix/room/RoomBeingCreated.ts | 35 +++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c5b5ad0d..be634754 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -624,8 +624,8 @@ export class Session { // the room id. Replace the room being created with the synced room. if (roomBeingCreated.roomId && !!this.rooms.get(roomBeingCreated.roomId)) { this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); + await roomBeingCreated.adjustDirectMessageMapIfNeeded(this._user, this._storage, this._hsApi, log); } - // TODO: if type is DM, then adjust the m.direct account data }); return roomBeingCreated; } diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index bf2c2c21..a6749cc4 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -286,6 +286,9 @@ export class HomeServerApi { return this._post(`/createRoom`, {}, payload, options); } + setAccountData(ownUserId: string, type: string, content: Record, options?: IRequestOptions): IHomeServerRequest { + return this._put(`/user/${encodeURIComponent(ownUserId)}/account_data/${encodeURIComponent(type)}`, {}, content, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 1ad84a47..9e564a87 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -26,6 +26,8 @@ import type {HomeServerApi} from "../net/HomeServerApi"; import type {ILogItem} from "../../logging/types"; import type {Platform} from "../../platform/web/Platform"; import type {IBlobHandle} from "../../platform/types/types"; +import type {User} from "../User"; +import type {Storage} from "../storage/idb/Storage"; type CreateRoomPayload = { is_direct?: boolean; @@ -217,4 +219,37 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { this.options.avatar.blob.dispose(); } } + + async adjustDirectMessageMapIfNeeded(user: User, storage: Storage, hsApi: HomeServerApi, log: ILogItem): Promise { + if (!this.options.invites || this.options.type !== RoomType.DirectMessage) { + return; + } + const userId = this.options.invites[0]; + const DM_MAP_TYPE = "m.direct"; + await log.wrap("set " + DM_MAP_TYPE, async log => { + try { + const txn = await storage.readWriteTxn([storage.storeNames.accountData]); + let mapEntry; + try { + mapEntry = await txn.accountData.get(DM_MAP_TYPE); + if (!mapEntry) { + mapEntry = {type: DM_MAP_TYPE, content: {}}; + } + const map = mapEntry.content; + const userRooms = map[userId]; + // this is a new room id so no need to check if it's already there + userRooms.push(this._roomId); + txn.accountData.set(mapEntry); + await txn.complete(); + } catch (err) { + txn.abort(); + throw err; + } + await hsApi.setAccountData(user.id, DM_MAP_TYPE, mapEntry.content, {log}).response(); + } catch (err) { + // we can't really do anything else but logging here + log.catch(err); + } + }); + } } From d2008a336b4e4ecc4d9bd074a3af23bd308e1049 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 19:54:47 +0100 Subject: [PATCH 39/43] fix lint errors --- src/domain/session/leftpanel/LeftPanelViewModel.js | 1 - src/domain/session/room/RoomBeingCreatedViewModel.js | 1 + src/matrix/Session.js | 2 +- src/platform/web/ui/session/room/RoomBeingCreatedView.js | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 08584d7f..843ed1ca 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -21,7 +21,6 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; -import {LogMap} from "../../../observable/map/LogMap.js"; import {addPanelIfNeeded} from "../../navigation/index.js"; export class LeftPanelViewModel extends ViewModel { diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index 0ed82be9..f98c86f9 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -43,6 +43,7 @@ export class RoomBeingCreatedViewModel extends ViewModel { return error.message; } } + return ""; } get avatarLetter() { return avatarInitials(this.name); } get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index be634754..287c48ea 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -606,7 +606,7 @@ export class Session { return this._roomsBeingCreated; } - createRoom(options, log = undefined) { + createRoom(options) { let roomBeingCreated; this._platform.logger.runDetached("create room", async log => { const id = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; diff --git a/src/platform/web/ui/session/room/RoomBeingCreatedView.js b/src/platform/web/ui/session/room/RoomBeingCreatedView.js index 1da44d34..3ecfde69 100644 --- a/src/platform/web/ui/session/room/RoomBeingCreatedView.js +++ b/src/platform/web/ui/session/room/RoomBeingCreatedView.js @@ -18,7 +18,6 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {LoadingView} from "../../general/LoadingView"; import {AvatarView} from "../../AvatarView"; -import {renderStaticAvatar} from "../../avatar.js"; export class RoomBeingCreatedView extends TemplateView { render(t, vm) { From 2765f48a640f5a5474cb9f71463f3ad33a640d41 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 19:59:44 +0100 Subject: [PATCH 40/43] create user id array in m.direct if it doesn't exist already --- src/matrix/room/RoomBeingCreated.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 9e564a87..78202203 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -236,7 +236,10 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { mapEntry = {type: DM_MAP_TYPE, content: {}}; } const map = mapEntry.content; - const userRooms = map[userId]; + let userRooms = map[userId]; + if (!userRooms) { + map[userId] = userRooms = []; + } // this is a new room id so no need to check if it's already there userRooms.push(this._roomId); txn.accountData.set(mapEntry); From d65b25f08440c88008b8751b1edac7957fdf7ee9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 20:00:01 +0100 Subject: [PATCH 41/43] also adjust m.direct if the room has already been replaced --- src/matrix/Session.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 287c48ea..778d2866 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -622,8 +622,10 @@ export class Session { await Promise.all(promises); // we should now know the roomId, check if the room was synced before we received // the room id. Replace the room being created with the synced room. - if (roomBeingCreated.roomId && !!this.rooms.get(roomBeingCreated.roomId)) { - this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); + if (roomBeingCreated.roomId) { + if (!!this.rooms.get(roomBeingCreated.roomId)) { + this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); + } await roomBeingCreated.adjustDirectMessageMapIfNeeded(this._user, this._storage, this._hsApi, log); } }); From 175f869c836f26fed2c090eb50ab34b3a8b45141 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 10 Feb 2022 20:07:27 +0100 Subject: [PATCH 42/43] fix lint --- src/matrix/Session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 778d2866..83a2df02 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -623,7 +623,7 @@ export class Session { // we should now know the roomId, check if the room was synced before we received // the room id. Replace the room being created with the synced room. if (roomBeingCreated.roomId) { - if (!!this.rooms.get(roomBeingCreated.roomId)) { + if (this.rooms.get(roomBeingCreated.roomId)) { this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); } await roomBeingCreated.adjustDirectMessageMapIfNeeded(this._user, this._storage, this._hsApi, log); From 57b15426882545d65a6567fa2f7f598437b807cf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 11 Feb 2022 09:37:56 +0100 Subject: [PATCH 43/43] use private topic field as public one got removed as not needed in view --- src/domain/session/CreateRoomViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js index 9d2835c2..51a9b7a4 100644 --- a/src/domain/session/CreateRoomViewModel.js +++ b/src/domain/session/CreateRoomViewModel.js @@ -91,7 +91,7 @@ export class CreateRoomViewModel extends ViewModel { const roomBeingCreated = this._session.createRoom({ type: this.isPublic ? RoomType.Public : RoomType.Private, name: this._name ?? undefined, - topic: this.topic ?? undefined, + topic: this._topic ?? undefined, isEncrypted: !this.isPublic && this._isEncrypted, isFederationDisabled: this._isFederationDisabled, alias: this.isPublic ? ensureAliasIsLocalPart(this._roomAlias) : undefined,