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/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js new file mode 100644 index 00000000..51a9b7a4 --- /dev/null +++ b/src/domain/session/CreateRoomViewModel.js @@ -0,0 +1,144 @@ +/* +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/common"; + +export class CreateRoomViewModel extends ViewModel { + constructor(options) { + super(options); + const {session} = options; + this._session = session; + 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("canCreate"); + } + + setRoomAlias(roomAlias) { + this._roomAlias = roomAlias; + } + + setTopic(topic) { + this._topic = topic; + } + + setPublic(isPublic) { + this._isPublic = isPublic; + this.emitChange("isPublic"); + } + + setEncrypted(isEncrypted) { + this._isEncrypted = isEncrypted; + this.emitChange("isEncrypted"); + } + + setFederationDisabled(disable) { + this._isFederationDisabled = disable; + this.emitChange("isFederationDisabled"); + } + + toggleAdvancedShown() { + this._isAdvancedShown = !this._isAdvancedShown; + this.emitChange("isAdvancedShown"); + } + + 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, + isFederationDisabled: this._isFederationDisabled, + alias: this.isPublic ? ensureAliasIsLocalPart(this._roomAlias) : undefined, + avatar, + }); + this.navigation.push("room", roomBeingCreated.id); + } + + 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"); + } +} + +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/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 8d576a98..52833332 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/common"; /** 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,21 @@ export class RoomViewModelObservable extends ObservableValue { } async _statusToViewModel(status) { - if (status.invited) { + if (status & RoomStatus.Replaced) { + if (status & RoomStatus.BeingCreated) { + const {session} = this._sessionViewModel._client; + const roomBeingCreated = session.roomsBeingCreated.get(this.id); + 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)); + } + } 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) { - return this._sessionViewModel._createRoomViewModel(this.id); - } else if (status.archived) { + } else if (status & RoomStatus.Joined) { + return this._sessionViewModel._createRoomViewModelInstance(this.id); + } 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..24276f42 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -19,10 +19,12 @@ 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"; 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"; @@ -37,13 +39,11 @@ 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; + this._createRoomViewModel = null; this._setupNavigation(); } @@ -75,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); @@ -96,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() { @@ -119,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"); @@ -162,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})); @@ -200,6 +209,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) { @@ -237,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); @@ -263,9 +293,12 @@ 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"); } + notifyRoomReplaced(oldId, newId) { + this.navigation.push("room", newId); + } } 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/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 10c84628..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) { @@ -25,31 +25,40 @@ 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; } + /** 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; } - return other._invite.timestamp - this._invite.timestamp; - } - - get name() { - return this._invite.name; - } - - get _avatarSource() { - return this._invite; + const timeDiff = other._invite.timestamp - this._invite.timestamp; + if (timeDiff !== 0) { + return timeDiff; + } + 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/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 59b68ca1..843ed1ca 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,32 +26,36 @@ 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; this._setupNavigation(); this._closeUrl = this.urlCreator.urlForSegment("session"); this._settingsUrl = this.urlCreator.urlForSegment("settings"); + this._createRoomUrl = this.urlCreator.urlForSegment("create-room"); } - _mapTileViewModels(rooms, invites) { + _mapTileViewModels(roomsBeingCreated, invites, rooms) { // join is not commutative, invites will take precedence over rooms - return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + const allTiles = invites.join(roomsBeingCreated, 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); } return vm; }); + return allTiles; } _updateCurrentVM(vm) { @@ -69,6 +74,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/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js new file mode 100644 index 00000000..81785bd9 --- /dev/null +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -0,0 +1,70 @@ +/* +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"; +import {comparePrimitive} from "./common"; + +export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { + constructor(options) { + super(options); + const {roomBeingCreated} = options; + this._roomBeingCreated = roomBeingCreated; + this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id); + } + + 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; } + + /** 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 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; + } + } + + avatarUrl(size) { + // allow blob url which doesn't need mxc => http resolution + 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; + } +} diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index 0303e05d..f6cbd747 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/common"; 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())); } @@ -77,6 +79,19 @@ export class MemberDetailsViewModel extends ViewModel { } get linkToUser() { - return `https://matrix.to/#/${this._member.userId}`; + return `https://matrix.to/#/${encodeURIComponent(this._member.userId)}`; + } + + async openDirectMessage() { + const room = this._session.findDirectMessageForUserId(this.userId); + let roomId = room?.id; + if (!roomId) { + const roomBeingCreated = await this._session.createRoom({ + type: RoomType.DirectMessage, + invites: [this.userId] + }); + roomId = roomBeingCreated.id; + } + this.navigation.push("room", roomId); } } 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..f98c86f9 --- /dev/null +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -0,0 +1,75 @@ +/* +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.id; } + get isEncrypted() { return this._roomBeingCreated.isEncrypted; } + get error() { + const {error} = this._roomBeingCreated; + if (error) { + if (error.name === "ConnectionError") { + return this.i18n`You seem to be offline`; + } else { + return error.message; + } + } + return ""; + } + get avatarLetter() { return avatarInitials(this.name); } + get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); } + get avatarTitle() { return this.name; } + + avatarUrl(size) { + // allow blob url which doesn't need mxc => http resolution + return this._roomBeingCreated.avatarBlobUrl ?? + getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository); + } + + focus() {} + + _onRoomChange() { + 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/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index ace933fb..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) { @@ -97,9 +98,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(); } @@ -274,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, @@ -320,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/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/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); 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 a2725bd4..83a2df02 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -17,7 +17,8 @@ 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/common"; +import {RoomBeingCreated} from "./room/RoomBeingCreated"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher"; import { ObservableMap } from "../observable/index.js"; @@ -63,6 +64,14 @@ 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) => { + 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}); this._olm = olm; @@ -421,7 +430,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); })); @@ -529,8 +538,21 @@ 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 */ - createRoom(roomId, pendingEvents) { + createJoinedRoom(roomId, pendingEvents) { return new Room({ roomId, getSyncToken: this._getSyncToken, @@ -580,6 +602,36 @@ export class Session { }); } + get roomsBeingCreated() { + return this._roomsBeingCreated; + } + + createRoom(options) { + let roomBeingCreated; + this._platform.logger.runDetached("create room", async log => { + const id = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`; + roomBeingCreated = new RoomBeingCreated( + id, options, this._roomsBeingCreatedUpdateCallback, + 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 + 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) { + if (this.rooms.get(roomBeingCreated.roomId)) { + this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log); + } + await roomBeingCreated.adjustDirectMessageMapIfNeeded(this._user, this._storage, this._hsApi, log); + } + }); + return roomBeingCreated; + } + async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { @@ -662,11 +714,29 @@ export class Session { } } - applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { + _tryReplaceRoomBeingCreated(roomId, log) { + for (const [,roomBeingCreated] of this._roomsBeingCreated) { + if (roomBeingCreated.roomId === roomId) { + const observableStatus = this._observedRoomStatus.get(roomBeingCreated.id); + if (observableStatus) { + log.log(`replacing room being created`) + .set("localId", roomBeingCreated.id) + .set("roomId", roomBeingCreated.roomId); + observableStatus.set(observableStatus.get() | RoomStatus.Replaced); + } + roomBeingCreated.dispose(); + this._roomsBeingCreated.remove(roomBeingCreated.id); + return; + } + } + } + + 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, log); } else if (rs.shouldRemove) { this._rooms.remove(rs.id); } @@ -684,21 +754,23 @@ 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) { 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); } } } @@ -708,7 +780,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) ^ RoomStatus.Archived); } } @@ -797,21 +869,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; } } } @@ -823,6 +899,7 @@ export class Session { observable = new RetainedObservableValue(status, () => { this._observedRoomStatus.delete(roomId); }); + this._observedRoomStatus.set(roomId, observable); } return observable; diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index de09a96d..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); })); @@ -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() { @@ -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, @@ -392,12 +392,12 @@ 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) { 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/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/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 605607b6..e9902ef8 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -279,20 +279,32 @@ export class HomeServerApi { return this._post(`/logout`, {}, {}, options); } - getDehydratedDevice(options: BaseRequestOptions): IHomeServerRequest { + getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); } - createDehydratedDevice(payload: Record, options: BaseRequestOptions): IHomeServerRequest { + createDehydratedDevice(payload: Record, options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._put(`/dehydrated_device`, {}, payload, options); } - claimDehydratedDevice(deviceId: string, options: BaseRequestOptions): IHomeServerRequest { + claimDehydratedDevice(deviceId: string, options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._post(`/dehydrated_device/claim`, {}, {device_id: deviceId}, options); } + + profile(userId: string, options?: BaseRequestOptions): IHomeServerRequest { + return this._get(`/profile/${encodeURIComponent(userId)}`); + } + + createRoom(payload: Record, options?: BaseRequestOptions): IHomeServerRequest { + return this._post(`/createRoom`, {}, payload, options); + } + + setAccountData(ownUserId: string, type: string, content: Record, options?: BaseRequestOptions): 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/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/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/BaseRoom.js b/src/matrix/room/BaseRoom.js index ecd2d860..dda3e2e5 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -420,6 +420,21 @@ export class BaseRoom extends EventEmitter { return this._summary.data.membership; } + isDirectMessageForUserId(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() { 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..6c5ef121 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"; } @@ -184,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, @@ -196,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 aaf66be1..a5bce14f 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/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts new file mode 100644 index 00000000..78202203 --- /dev/null +++ b/src/matrix/room/RoomBeingCreated.ts @@ -0,0 +1,258 @@ +/* +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 {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"; +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; + preset?: string; + name?: string; + topic?: string; + invite?: string[]; + room_alias_name?: string; + creation_content?: {"m.federate": boolean}; + 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; + isFederationDisabled?: boolean; + name?: string; + topic?: string; + invites?: string[]; + avatar?: Avatar; + alias?: string; +} + +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}> { + private _roomId?: string; + private profiles: Profile[] = []; + + public readonly isEncrypted: boolean; + private _calculatedName: string; + private _error?: Error; + private _isCancelled = false; + + constructor( + public readonly id: string, + 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 = options.isEncrypted === undefined ? defaultE2EEStatusForType(options.type) : options.isEncrypted; + if (options.name) { + this._calculatedName = options.name; + } else { + const summaryData = { + joinCount: 1, // ourselves + inviteCount: (options.invites?.length || 0) + }; + const userIdProfiles = (options.invites || []).map(userId => new UserIdProfile(userId)); + this._calculatedName = calculateRoomName(userIdProfiles, summaryData, log); + } + } + + /** @internal */ + async create(hsApi: HomeServerApi, log: ILogItem): Promise { + 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.options.isFederationDisabled === true) { + createOptions.creation_content = { + "m.federate": false + }; + } + 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) { + this._error = err; + } + this.emitChange(); + } + + /** 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. */ + /** @internal */ + async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise { + 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) { + this.updateCallback(this, params); + this.emit("change"); + } + + 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() { + 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(); + } + } + + 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; + 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); + 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); + } + }); + } +} 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/RoomSummary.js b/src/matrix/room/RoomSummary.js index d0c78659..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,8 +95,11 @@ function processRoomAccountData(data, event) { return data; } -export function processStateEvent(data, event) { - if (event.type === "m.room.encryption") { +export function processStateEvent(data, event, ownUserId) { + 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(); @@ -118,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; } @@ -158,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; @@ -227,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() { 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/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) { 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"; 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); + } +} diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index cc57d56f..da8ec8e7 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -29,3 +29,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; +} 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/form.css b/src/platform/web/ui/css/form.css index bdb47bec..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 input { +.form-row.text > input, .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..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 @@ -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/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 5ed8b4c9..383feeae 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -80,22 +80,43 @@ limitations under the License. flex: 1 0 auto; } +.form-row.text 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; margin-top: 5px; font-size: 1em; + resize: vertical; } -.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; } +.form-row .form-row-description { + font-size: 1rem; + color: #777; + margin: 8px 0 0 0; +} + .button-action { cursor: pointer; } @@ -157,6 +178,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'); } @@ -1049,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 { @@ -1075,3 +1104,35 @@ button.RoomDetailsView_row::after { display: flex; gap: 12px; } + +.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; +} + +.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/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]); + }); } } 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({ 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", diff --git a/src/platform/web/ui/session/CreateRoomView.js b/src/platform/web/ui/session/CreateRoomView.js new file mode 100644 index 00000000..9d6c6bbc --- /dev/null +++ b/src/platform/web/ui/session/CreateRoomView.js @@ -0,0 +1,120 @@ +/* +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: "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` + }), + ]), + ]), + 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 (, 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({ + 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; + case "isFederationDisabled": + this.value.setFederationDisabled(evt.target.checked); + break; + } + } + + onSubmit(evt) { + evt.preventDefault(); + this.value.create(); + } +} 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..e7cc406a 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"; @@ -25,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 { @@ -43,11 +45,15 @@ 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); } 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 deleted file mode 100644 index b99ab1c6..00000000 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ /dev/null @@ -1,44 +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 {renderStaticAvatar} from "../../avatar.js"; -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}, [ - renderStaticAvatar(vm, 32), - t.div({className: "description"}, [ - t.div({className: "name"}, vm.name), - t.map(vm => vm.busy, busy => { - if (busy) { - return spinner(t); - } else { - return t.div({className: "badge highlighted"}, "!"); - } - }) - ]) - ]) - ]); - } -} diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 481c5362..c79192be 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") { - 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`}), @@ -97,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"}, [ 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), + }) ]) ]) ]); 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..3ecfde69 --- /dev/null +++ b/src/platform/web/ui/session/room/RoomBeingCreatedView.js @@ -0,0 +1,57 @@ +/* +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 {LoadingView} from "../../general/LoadingView"; +import {AvatarView} from "../../AvatarView"; + +export class RoomBeingCreatedView extends TemplateView { + render(t, vm) { + 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`)) + ]); + } +}