diff --git a/package.json b/package.json index 21a731b5..8fa27f47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.23", + "version": "0.2.26", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "directories": { "doc": "doc" diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index f7b9beeb..9d380b30 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.4", + "version": "0.0.5", "main": "./lib-build/hydrogen.es.js", "type": "module" } diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index b2ce808a..4ad0d8d5 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {ViewModel} from "./ViewModel.js"; import {KeyType} from "../matrix/ssss/index"; -import {Status} from "./session/settings/SessionBackupViewModel.js"; +import {Status} from "./session/settings/KeyBackupViewModel.js"; export class AccountSetupViewModel extends ViewModel { constructor(accountSetup) { @@ -50,7 +50,7 @@ export class AccountSetupViewModel extends ViewModel { } } -// this vm adopts the same shape as SessionBackupViewModel so the same view can be reused. +// this vm adopts the same shape as KeyBackupViewModel so the same view can be reused. class DecryptDehydratedDeviceViewModel extends ViewModel { constructor(accountSetupViewModel, decryptedCallback) { super(); 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/SessionStatusViewModel.js b/src/domain/session/SessionStatusViewModel.js index fcedb371..3f2263ac 100644 --- a/src/domain/session/SessionStatusViewModel.js +++ b/src/domain/session/SessionStatusViewModel.js @@ -36,7 +36,7 @@ export class SessionStatusViewModel extends ViewModel { this._reconnector = reconnector; this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get()); this._session = session; - this._setupSessionBackupUrl = this.urlCreator.urlForSegment("settings"); + this._setupKeyBackupUrl = this.urlCreator.urlForSegment("settings"); this._dismissSecretStorage = false; } @@ -44,17 +44,17 @@ export class SessionStatusViewModel extends ViewModel { const update = () => this._updateStatus(); this.track(this._sync.status.subscribe(update)); this.track(this._reconnector.connectionStatus.subscribe(update)); - this.track(this._session.needsSessionBackup.subscribe(() => { + this.track(this._session.needsKeyBackup.subscribe(() => { this.emitChange(); })); } - get setupSessionBackupUrl () { - return this._setupSessionBackupUrl; + get setupKeyBackupUrl () { + return this._setupKeyBackupUrl; } get isShown() { - return (this._session.needsSessionBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing; + return (this._session.needsKeyBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing; } get statusLabel() { @@ -70,7 +70,7 @@ export class SessionStatusViewModel extends ViewModel { case SessionStatus.SyncError: return this.i18n`Sync failed because of ${this._sync.error}`; } - if (this._session.needsSessionBackup.get()) { + if (this._session.needsKeyBackup.get()) { return this.i18n`Set up session backup to decrypt older messages.`; } return ""; @@ -135,7 +135,7 @@ export class SessionStatusViewModel extends ViewModel { get isSecretStorageShown() { // TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other. - return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get() && !this._dismissSecretStorage; + return this._status === SessionStatus.Syncing && this._session.needsKeyBackup.get() && !this._dismissSecretStorage; } get canDismiss() { 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 45799f39..90464870 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) { @@ -98,9 +99,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(); } @@ -275,7 +275,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, @@ -321,15 +323,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/domain/session/settings/SessionBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js similarity index 60% rename from src/domain/session/settings/SessionBackupViewModel.js rename to src/domain/session/settings/KeyBackupViewModel.js index 5a127904..b44de7e5 100644 --- a/src/domain/session/settings/SessionBackupViewModel.js +++ b/src/domain/session/settings/KeyBackupViewModel.js @@ -18,9 +18,10 @@ import {ViewModel} from "../../ViewModel.js"; import {KeyType} from "../../../matrix/ssss/index"; import {createEnum} from "../../../utils/enum"; -export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending"); +export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable"); +export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending"); -export class SessionBackupViewModel extends ViewModel { +export class KeyBackupViewModel extends ViewModel { constructor(options) { super(options); this._session = options.session; @@ -28,8 +29,16 @@ export class SessionBackupViewModel extends ViewModel { this._isBusy = false; this._dehydratedDeviceId = undefined; this._status = undefined; + this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress); + this._progress = this._backupOperation.flatMap(op => op.progress); + this.track(this._backupOperation.subscribe(() => { + // see if needsNewKey might be set + this._reevaluateStatus(); + this.emitChange("isBackingUp"); + })); + this.track(this._progress.subscribe(() => this.emitChange("backupPercentage"))); this._reevaluateStatus(); - this.track(this._session.hasSecretStorageKey.subscribe(() => { + this.track(this._session.keyBackup.subscribe(() => { if (this._reevaluateStatus()) { this.emitChange("status"); } @@ -41,11 +50,11 @@ export class SessionBackupViewModel extends ViewModel { return false; } let status; - const hasSecretStorageKey = this._session.hasSecretStorageKey.get(); - if (hasSecretStorageKey === true) { - status = this._session.sessionBackup ? Status.Enabled : Status.SetupKey; - } else if (hasSecretStorageKey === false) { - status = Status.SetupKey; + const keyBackup = this._session.keyBackup.get(); + if (keyBackup) { + status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; + } else if (keyBackup === null) { + status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey; } else { status = Status.Pending; } @@ -59,7 +68,7 @@ export class SessionBackupViewModel extends ViewModel { } get purpose() { - return this.i18n`set up session backup`; + return this.i18n`set up key backup`; } offerDehydratedDeviceSetup() { @@ -75,7 +84,28 @@ export class SessionBackupViewModel extends ViewModel { } get backupVersion() { - return this._session.sessionBackup?.version; + return this._session.keyBackup.get()?.version; + } + + get backupWriteStatus() { + const keyBackup = this._session.keyBackup.get(); + if (!keyBackup) { + return BackupWriteStatus.Pending; + } else if (keyBackup.hasStopped) { + return BackupWriteStatus.Stopped; + } + const operation = keyBackup.operationInProgress.get(); + if (operation) { + return BackupWriteStatus.Writing; + } else if (keyBackup.hasBackedUpAllKeys) { + return BackupWriteStatus.Done; + } else { + return BackupWriteStatus.Pending; + } + } + + get backupError() { + return this._session.keyBackup.get()?.error?.message; } get status() { @@ -144,4 +174,33 @@ export class SessionBackupViewModel extends ViewModel { this.emitChange(""); } } + + get isBackingUp() { + return !!this._backupOperation.get(); + } + + get backupPercentage() { + const progress = this._progress.get(); + if (progress) { + return Math.round((progress.finished / progress.total) * 100); + } + return 0; + } + + get backupInProgressLabel() { + const progress = this._progress.get(); + if (progress) { + return this.i18n`${progress.finished} of ${progress.total}`; + } + return this.i18n`…`; + } + + cancelBackup() { + this._backupOperation.get()?.abort(); + } + + startBackup() { + this._session.keyBackup.get()?.flush(); + } } + diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 70e507b8..0b68f168 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel.js"; -import {SessionBackupViewModel} from "./SessionBackupViewModel.js"; +import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; class PushNotificationStatus { constructor() { @@ -43,7 +43,7 @@ export class SettingsViewModel extends ViewModel { this._updateService = options.updateService; const {client} = options; this._client = client; - this._sessionBackupViewModel = this.track(new SessionBackupViewModel(this.childOptions({session: this._session}))); + this._keyBackupViewModel = this.track(new KeyBackupViewModel(this.childOptions({session: this._session}))); this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._estimate = null; this.sentImageSizeLimit = null; @@ -115,8 +115,8 @@ export class SettingsViewModel extends ViewModel { return !!this.platform.updateService; } - get sessionBackupViewModel() { - return this._sessionBackupViewModel; + get keyBackupViewModel() { + return this._keyBackupViewModel; } get storageQuota() { diff --git a/src/lib.ts b/src/lib.ts index b776fce8..6c113eeb 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -35,3 +35,10 @@ export {encodeKey, decodeKey, encodeEventIdKey, decodeEventIdKey} from "./matrix export {Timeline} from "./matrix/room/timeline/Timeline.js"; export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js"; export {TimelineView} from "./platform/web/ui/session/room/TimelineView"; +export {Navigation} from "./domain/navigation/Navigation.js"; +export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js"; +export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; +export {TemplateView} from "./platform/web/ui/general/TemplateView"; +export {ViewModel} from "./domain/ViewModel.js"; +export {LoadingView} from "./platform/web/ui/general/LoadingView.js"; +export {AvatarView} from "./platform/web/ui/AvatarView.js"; 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/Client.js b/src/matrix/Client.js index f070d24c..b24c1ec9 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -30,6 +30,7 @@ import {PasswordLoginMethod} from "./login/PasswordLoginMethod"; import {TokenLoginMethod} from "./login/TokenLoginMethod"; import {SSOLoginHelper} from "./login/SSOLoginHelper"; import {getDehydratedDevice} from "./e2ee/Dehydration.js"; +import {Registration} from "./registration/Registration"; export const LoadStatus = createEnum( "NotLoading", @@ -131,6 +132,17 @@ export class Client { }); } + async startRegistration(homeserver, username, password, initialDeviceDisplayName) { + const request = this._platform.request; + const hsApi = new HomeServerApi({homeserver, request}); + const registration = new Registration(hsApi, { + username, + password, + initialDeviceDisplayName, + }); + return registration; + } + async startWithLogin(loginMethod, {inspectAccountSetup} = {}) { const currentStatus = this._status.get(); if (currentStatus !== LoadStatus.LoginFailed && diff --git a/src/matrix/DeviceMessageHandler.js b/src/matrix/DeviceMessageHandler.js index 0a606841..6ac5ac07 100644 --- a/src/matrix/DeviceMessageHandler.js +++ b/src/matrix/DeviceMessageHandler.js @@ -57,7 +57,8 @@ export class DeviceMessageHandler { async writeSync(prep, txn) { // write olm changes prep.olmDecryptChanges.write(txn); - await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn))); + const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn))); + return didWriteValues.some(didWrite => !!didWrite); } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 8ccc71cc..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"; @@ -29,7 +30,7 @@ import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; -import {SessionBackup} from "./e2ee/megolm/SessionBackup.js"; +import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup"; import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js"; import {MEGOLM_ALGORITHM} from "./e2ee/common.js"; import {RoomEncryption} from "./e2ee/RoomEncryption.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; @@ -70,12 +79,12 @@ export class Session { this._e2eeAccount = null; this._deviceTracker = null; this._olmEncryption = null; + this._keyLoader = null; this._megolmEncryption = null; this._megolmDecryption = null; this._getSyncToken = () => this.syncToken; this._olmWorker = olmWorker; - this._sessionBackup = null; - this._hasSecretStorageKey = new ObservableValue(null); + this._keyBackup = new ObservableValue(undefined); this._observedRoomStatus = new Map(); if (olm) { @@ -90,7 +99,7 @@ export class Session { } this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); - this.needsSessionBackup = new ObservableValue(false); + this.needsKeyBackup = new ObservableValue(false); } get fingerprintKey() { @@ -133,16 +142,17 @@ export class Session { olmUtil: this._olmUtil, senderKeyLock }); + this._keyLoader = new MegOlmKeyLoader(this._olm, PICKLE_KEY, 20); this._megolmEncryption = new MegOlmEncryption({ account: this._e2eeAccount, pickleKey: PICKLE_KEY, olm: this._olm, storage: this._storage, + keyLoader: this._keyLoader, now: this._platform.clock.now, ownDeviceId: this._sessionInfo.deviceId, }); - const keyLoader = new MegOlmKeyLoader(this._olm, PICKLE_KEY, 20); - this._megolmDecryption = new MegOlmDecryption(keyLoader, this._olmWorker); + this._megolmDecryption = new MegOlmDecryption(this._keyLoader, this._olmWorker); this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption}); } @@ -169,11 +179,11 @@ export class Session { megolmEncryption: this._megolmEncryption, megolmDecryption: this._megolmDecryption, storage: this._storage, - sessionBackup: this._sessionBackup, + keyBackup: this._keyBackup?.get(), encryptionParams, notifyMissingMegolmSession: () => { - if (!this._sessionBackup) { - this.needsSessionBackup.set(true) + if (!this._keyBackup.get()) { + this.needsKeyBackup.set(true) } }, clock: this._platform.clock @@ -182,38 +192,59 @@ export class Session { /** * Enable secret storage by providing the secret storage credential. - * This will also see if there is a megolm session backup and try to enable that if so. + * This will also see if there is a megolm key backup and try to enable that if so. * * @param {string} type either "passphrase" or "recoverykey" * @param {string} credential either the passphrase or the recovery key, depending on the type * @return {Promise} resolves or rejects after having tried to enable secret storage */ - async enableSecretStorage(type, credential) { - if (!this._olm) { - throw new Error("olm required"); - } - if (this._sessionBackup) { - return false; - } - const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); - // and create session backup, which needs to read from accountData - const readTxn = await this._storage.readTxn([ - this._storage.storeNames.accountData, - ]); - await this._createSessionBackup(key, readTxn); - await this._writeSSSSKey(key); - this._hasSecretStorageKey.set(true); - return key; + enableSecretStorage(type, credential, log = undefined) { + return this._platform.logger.wrapOrRun(log, "enable secret storage", async log => { + if (!this._olm) { + throw new Error("olm required"); + } + if (this._keyBackup.get()) { + this._keyBackup.get().dispose(); + this._keyBackup.set(null); + } + const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); + // and create key backup, which needs to read from accountData + const readTxn = await this._storage.readTxn([ + this._storage.storeNames.accountData, + ]); + if (await this._createKeyBackup(key, readTxn, log)) { + // only after having read a secret, write the key + // as we only find out if it was good if the MAC verification succeeds + await this._writeSSSSKey(key, log); + this._keyBackup.get().flush(log); + return key; + } else { + throw new Error("Could not read key backup with the given key"); + } + }); } - async _writeSSSSKey(key) { - // only after having read a secret, write the key - // as we only find out if it was good if the MAC verification succeeds + async _writeSSSSKey(key, log) { + // we're going to write the 4S key, and also the backup version. + // this way, we can detect when we enter a key for a new backup version + // and mark all inbound sessions to be backed up again + const keyBackup = this._keyBackup.get(); + if (!keyBackup) { + return; + } + const backupVersion = keyBackup.version; const writeTxn = await this._storage.readWriteTxn([ this._storage.storeNames.session, + this._storage.storeNames.inboundGroupSessions, ]); try { - ssssWriteKey(key, writeTxn); + const previousBackupVersion = await ssssWriteKey(key, backupVersion, writeTxn); + log.set("previousBackupVersion", previousBackupVersion); + log.set("backupVersion", backupVersion); + if (!!previousBackupVersion && previousBackupVersion !== backupVersion) { + const amountMarked = await keyBackup.markAllForBackup(writeTxn); + log.set("amountMarkedForBackup", amountMarked); + } } catch (err) { writeTxn.abort(); throw err; @@ -232,38 +263,53 @@ export class Session { throw err; } await writeTxn.complete(); - if (this._sessionBackup) { + if (this._keyBackup.get()) { for (const room of this._rooms.values()) { if (room.isEncrypted) { - room.enableSessionBackup(undefined); + room.enableKeyBackup(undefined); } } - this._sessionBackup?.dispose(); - this._sessionBackup = undefined; + this._keyBackup.get().dispose(); + this._keyBackup.set(null); } - this._hasSecretStorageKey.set(false); } - async _createSessionBackup(ssssKey, txn) { - const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform}); - this._sessionBackup = await SessionBackup.fromSecretStorage({ - platform: this._platform, - olm: this._olm, secretStorage, - hsApi: this._hsApi, - txn + _createKeyBackup(ssssKey, txn, log) { + return log.wrap("enable key backup", async log => { + try { + const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform}); + const keyBackup = await KeyBackup.fromSecretStorage( + this._platform, + this._olm, + secretStorage, + this._hsApi, + this._keyLoader, + this._storage, + txn + ); + if (keyBackup) { + for (const room of this._rooms.values()) { + if (room.isEncrypted) { + room.enableKeyBackup(keyBackup); + } + } + this._keyBackup.set(keyBackup); + return true; + } + } catch (err) { + log.catch(err); + } + return false; }); - if (this._sessionBackup) { - for (const room of this._rooms.values()) { - if (room.isEncrypted) { - room.enableSessionBackup(this._sessionBackup); - } - } - } - this.needsSessionBackup.set(false); } - get sessionBackup() { - return this._sessionBackup; + /** + * @type {ObservableValue { - 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); })); @@ -401,8 +447,8 @@ export class Session { dispose() { this._olmWorker?.dispose(); this._olmWorker = undefined; - this._sessionBackup?.dispose(); - this._sessionBackup = undefined; + this._keyBackup.get()?.dispose(); + this._keyBackup.set(undefined); this._megolmDecryption?.dispose(); this._megolmDecryption = undefined; this._e2eeAccount?.dispose(); @@ -430,7 +476,7 @@ export class Session { await txn.complete(); } // enable session backup, this requests the latest backup version - if (!this._sessionBackup) { + if (!this._keyBackup.get()) { if (dehydratedDevice) { await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); @@ -438,7 +484,7 @@ export class Session { log.set("success", true); await this._writeSSSSKey(ssssKey); } - }) + }); } const txn = await this._storage.readTxn([ this._storage.storeNames.session, @@ -448,9 +494,15 @@ export class Session { const ssssKey = await ssssReadKey(txn); if (ssssKey) { // txn will end here as this does a network request - await this._createSessionBackup(ssssKey, txn); + if (await this._createKeyBackup(ssssKey, txn, log)) { + this._keyBackup.get()?.flush(log); + } + } + if (!this._keyBackup.get()) { + // null means key backup isn't configured yet + // as opposed to undefined, which means we're still checking + this._keyBackup.set(null); } - this._hasSecretStorageKey.set(!!ssssKey); } // restore unfinished operations, like sending out room keys const opsTxn = await this._storage.readWriteTxn([ @@ -486,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, @@ -537,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) { @@ -555,7 +650,7 @@ export class Session { async writeSync(syncResponse, syncFilterId, preparation, txn, log) { const changes = { syncInfo: null, - e2eeAccountChanges: null, + e2eeAccountChanges: null }; const syncToken = syncResponse.next_batch; if (syncToken !== this.syncToken) { @@ -576,7 +671,7 @@ export class Session { } if (preparation) { - await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log)); + changes.hasNewRoomKeys = await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log)); } // store account data @@ -614,13 +709,34 @@ export class Session { await log.wrap("uploadKeys", log => this._e2eeAccount.uploadKeys(this._storage, false, log)); } } + if (changes.hasNewRoomKeys) { + this._keyBackup.get()?.flush(log); + } } - 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); } @@ -638,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); } } } @@ -662,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); } } @@ -751,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; } } } @@ -777,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/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index d9151a85..80f57507 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -28,7 +28,7 @@ const MIN_PRESHARE_INTERVAL = 60 * 1000; // 1min // TODO: this class is a good candidate for splitting up into encryption and decryption, there doesn't seem to be much overlap export class RoomEncryption { - constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, sessionBackup, notifyMissingMegolmSession, clock}) { + constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage, keyBackup, notifyMissingMegolmSession, clock}) { this._room = room; this._deviceTracker = deviceTracker; this._olmEncryption = olmEncryption; @@ -39,7 +39,7 @@ export class RoomEncryption { // caches devices to verify events this._senderDeviceCache = new Map(); this._storage = storage; - this._sessionBackup = sessionBackup; + this._keyBackup = keyBackup; this._notifyMissingMegolmSession = notifyMissingMegolmSession; this._clock = clock; this._isFlushingRoomKeyShares = false; @@ -48,11 +48,11 @@ export class RoomEncryption { this._disposed = false; } - enableSessionBackup(sessionBackup) { - if (this._sessionBackup && !!sessionBackup) { + enableKeyBackup(keyBackup) { + if (this._keyBackup && !!keyBackup) { return; } - this._sessionBackup = sessionBackup; + this._keyBackup = keyBackup; } async restoreMissingSessionsFromBackup(entries, log) { @@ -130,7 +130,7 @@ export class RoomEncryption { })); } - if (!this._sessionBackup) { + if (!this._keyBackup) { return; } @@ -174,7 +174,7 @@ export class RoomEncryption { async _requestMissingSessionFromBackup(senderKey, sessionId, log) { // show prompt to enable secret storage - if (!this._sessionBackup) { + if (!this._keyBackup) { log.set("enabled", false); this._notifyMissingMegolmSession(); return; @@ -182,35 +182,30 @@ export class RoomEncryption { log.set("id", sessionId); log.set("senderKey", senderKey); try { - const session = await this._sessionBackup.getSession(this._room.id, sessionId, log); - if (session?.algorithm === MEGOLM_ALGORITHM) { - let roomKey = this._megolmDecryption.roomKeyFromBackup(this._room.id, sessionId, session); - if (roomKey) { - if (roomKey.senderKey !== senderKey) { - log.set("wrong_sender_key", roomKey.senderKey); - log.logLevel = log.level.Warn; - return; - } - let keyIsBestOne = false; - let retryEventIds; - const txn = await this._storage.readWriteTxn([this._storage.storeNames.inboundGroupSessions]); - try { - keyIsBestOne = await this._megolmDecryption.writeRoomKey(roomKey, txn); - log.set("isBetter", keyIsBestOne); - if (keyIsBestOne) { - retryEventIds = roomKey.eventIds; - } - } catch (err) { - txn.abort(); - throw err; - } - await txn.complete(); - if (keyIsBestOne) { - await log.wrap("retryDecryption", log => this._room.notifyRoomKey(roomKey, retryEventIds || [], log)); - } + const roomKey = await this._keyBackup.getRoomKey(this._room.id, sessionId, log); + if (roomKey) { + if (roomKey.senderKey !== senderKey) { + log.set("wrong_sender_key", roomKey.senderKey); + log.logLevel = log.level.Warn; + return; + } + let keyIsBestOne = false; + let retryEventIds; + const txn = await this._storage.readWriteTxn([this._storage.storeNames.inboundGroupSessions]); + try { + keyIsBestOne = await this._megolmDecryption.writeRoomKey(roomKey, txn); + log.set("isBetter", keyIsBestOne); + if (keyIsBestOne) { + retryEventIds = roomKey.eventIds; + } + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + if (keyIsBestOne) { + await log.wrap("retryDecryption", log => this._room.notifyRoomKey(roomKey, retryEventIds || [], log)); } - } else if (session?.algorithm) { - log.set("unknown algorithm", session.algorithm); } } catch (err) { if (!(err.name === "HomeServerError" && err.errcode === "M_NOT_FOUND")) { @@ -241,6 +236,7 @@ export class RoomEncryption { this._keySharePromise = (async () => { const roomKeyMessage = await this._megolmEncryption.ensureOutboundSession(this._room.id, this._encryptionParams); if (roomKeyMessage) { + this._keyBackup?.flush(log); await log.wrap("share key", log => this._shareNewRoomKey(roomKeyMessage, hsApi, log)); } })(); @@ -259,6 +255,7 @@ export class RoomEncryption { } const megolmResult = await log.wrap("megolm encrypt", () => this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams)); if (megolmResult.roomKeyMessage) { + this._keyBackup?.flush(log); await log.wrap("share key", log => this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi, log)); } return { 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/e2ee/megolm/Encryption.js b/src/matrix/e2ee/megolm/Encryption.js index bd2311d7..eb5f68d3 100644 --- a/src/matrix/e2ee/megolm/Encryption.js +++ b/src/matrix/e2ee/megolm/Encryption.js @@ -15,12 +15,14 @@ limitations under the License. */ import {MEGOLM_ALGORITHM} from "../common.js"; +import {OutboundRoomKey} from "./decryption/RoomKey"; export class Encryption { - constructor({pickleKey, olm, account, storage, now, ownDeviceId}) { + constructor({pickleKey, olm, account, keyLoader, storage, now, ownDeviceId}) { this._pickleKey = pickleKey; this._olm = olm; this._account = account; + this._keyLoader = keyLoader; this._storage = storage; this._now = now; this._ownDeviceId = ownDeviceId; @@ -64,7 +66,7 @@ export class Encryption { let roomKeyMessage; try { let sessionEntry = await txn.outboundGroupSessions.get(roomId); - roomKeyMessage = this._readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn); + roomKeyMessage = await this._readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn); if (roomKeyMessage) { this._writeSession(this._now(), session, roomId, txn); } @@ -79,7 +81,7 @@ export class Encryption { } } - _readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn) { + async _readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn) { if (sessionEntry) { session.unpickle(this._pickleKey, sessionEntry.session); } @@ -91,7 +93,8 @@ export class Encryption { } session.create(); const roomKeyMessage = this._createRoomKeyMessage(session, roomId); - this._storeAsInboundSession(session, roomId, txn); + const roomKey = new OutboundRoomKey(roomId, session, this._account.identityKeys); + await roomKey.write(this._keyLoader, txn); return roomKeyMessage; } } @@ -123,7 +126,7 @@ export class Encryption { let encryptedContent; try { let sessionEntry = await txn.outboundGroupSessions.get(roomId); - roomKeyMessage = this._readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn); + roomKeyMessage = await this._readOrCreateSession(session, sessionEntry, roomId, encryptionParams, txn); encryptedContent = this._encryptContent(roomId, session, type, content); // update timestamp when a new session is created const createdAt = roomKeyMessage ? this._now() : sessionEntry.createdAt; @@ -190,26 +193,6 @@ export class Encryption { chain_index: session.message_index() } } - - _storeAsInboundSession(outboundSession, roomId, txn) { - const {identityKeys} = this._account; - const claimedKeys = {ed25519: identityKeys.ed25519}; - const session = new this._olm.InboundGroupSession(); - try { - session.create(outboundSession.session_key()); - const sessionEntry = { - roomId, - senderKey: identityKeys.curve25519, - sessionId: session.session_id(), - session: session.pickle(this._pickleKey), - claimedKeys, - }; - txn.inboundGroupSessions.set(sessionEntry); - return sessionEntry; - } finally { - session.free(); - } - } } /** diff --git a/src/matrix/e2ee/megolm/SessionBackup.js b/src/matrix/e2ee/megolm/SessionBackup.js deleted file mode 100644 index daba9961..00000000 --- a/src/matrix/e2ee/megolm/SessionBackup.js +++ /dev/null @@ -1,62 +0,0 @@ -/* -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 class SessionBackup { - constructor({backupInfo, decryption, hsApi}) { - this._backupInfo = backupInfo; - this._decryption = decryption; - this._hsApi = hsApi; - } - - async getSession(roomId, sessionId, log) { - const sessionResponse = await this._hsApi.roomKeyForRoomAndSession(this._backupInfo.version, roomId, sessionId, {log}).response(); - const sessionInfo = this._decryption.decrypt( - sessionResponse.session_data.ephemeral, - sessionResponse.session_data.mac, - sessionResponse.session_data.ciphertext, - ); - return JSON.parse(sessionInfo); - } - - get version() { - return this._backupInfo.version; - } - - dispose() { - this._decryption.free(); - } - - static async fromSecretStorage({platform, olm, secretStorage, hsApi, txn}) { - const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); - if (base64PrivateKey) { - const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey)); - const backupInfo = await hsApi.roomKeysVersion().response(); - const expectedPubKey = backupInfo.auth_data.public_key; - const decryption = new olm.PkDecryption(); - try { - const pubKey = decryption.init_with_private_key(privateKey); - if (pubKey !== expectedPubKey) { - throw new Error(`Bad backup key, public key does not match. Calculated ${pubKey} but expected ${expectedPubKey}`); - } - } catch(err) { - decryption.free(); - throw err; - } - return new SessionBackup({backupInfo, decryption, hsApi}); - } - } -} - diff --git a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts index 3aca957d..884203a3 100644 --- a/src/matrix/e2ee/megolm/decryption/KeyLoader.ts +++ b/src/matrix/e2ee/megolm/decryption/KeyLoader.ts @@ -17,25 +17,14 @@ limitations under the License. import {isBetterThan, IncomingRoomKey} from "./RoomKey"; import {BaseLRUCache} from "../../../../utils/LRUCache"; import type {RoomKey} from "./RoomKey"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; export declare class OlmDecryptionResult { readonly plaintext: string; readonly message_index: number; } -export declare class OlmInboundGroupSession { - constructor(); - free(): void; - pickle(key: string | Uint8Array): string; - unpickle(key: string | Uint8Array, pickle: string); - create(session_key: string): string; - import_session(session_key: string): string; - decrypt(message: string): OlmDecryptionResult; - session_id(): string; - first_known_index(): number; - export_session(message_index: number): string; -} - /* Because Olm only has very limited memory available when compiled to wasm, we limit the amount of sessions held in memory. @@ -43,11 +32,11 @@ we limit the amount of sessions held in memory. export class KeyLoader extends BaseLRUCache { private pickleKey: string; - private olm: any; + private olm: Olm; private resolveUnusedOperation?: () => void; private operationBecomesUnusedPromise?: Promise; - constructor(olm: any, pickleKey: string, limit: number) { + constructor(olm: Olm, pickleKey: string, limit: number) { super(limit); this.pickleKey = pickleKey; this.olm = olm; @@ -60,7 +49,7 @@ export class KeyLoader extends BaseLRUCache { } } - async useKey(key: RoomKey, callback: (session: OlmInboundGroupSession, pickleKey: string) => Promise | T): Promise { + async useKey(key: RoomKey, callback: (session: Olm.InboundGroupSession, pickleKey: string) => Promise | T): Promise { const keyOp = await this.allocateOperation(key); try { return await callback(keyOp.session, this.pickleKey); @@ -186,11 +175,11 @@ export class KeyLoader extends BaseLRUCache { } class KeyOperation { - session: OlmInboundGroupSession; + session: Olm.InboundGroupSession; key: RoomKey; refCount: number; - constructor(key: RoomKey, session: OlmInboundGroupSession) { + constructor(key: RoomKey, session: Olm.InboundGroupSession) { this.key = key; this.session = session; this.refCount = 1; @@ -224,6 +213,9 @@ class KeyOperation { } } +import {KeySource} from "../../../storage/idb/stores/InboundGroupSessionStore"; + + export function tests() { let instances = 0; @@ -248,7 +240,9 @@ export function tests() { get serializationKey(): string { return `key-${this.sessionId}-${this._firstKnownIndex}`; } get serializationType(): string { return "type"; } get eventIds(): string[] | undefined { return undefined; } - loadInto(session: OlmInboundGroupSession) { + get keySource(): KeySource { return KeySource.DeviceMessage; } + + loadInto(session: Olm.InboundGroupSession) { const mockSession = session as MockInboundSession; mockSession.sessionId = this.sessionId; mockSession.firstKnownIndex = this._firstKnownIndex; @@ -284,7 +278,7 @@ export function tests() { return { "load key gives correct session": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); let callback1Called = false; let callback2Called = false; const p1 = loader.useKey(new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1), async session => { @@ -305,7 +299,7 @@ export function tests() { assert(callback2Called); }, "keys with different first index are kept separate": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); let callback1Called = false; let callback2Called = false; const p1 = loader.useKey(new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1), async session => { @@ -326,7 +320,7 @@ export function tests() { assert(callback2Called); }, "useKey blocks as long as no free sessions are available": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 1); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 1); let resolve; let callbackCalled = false; loader.useKey(new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1), async session => { @@ -343,7 +337,7 @@ export function tests() { assert.equal(callbackCalled, true); }, "cache hit while key in use, then replace (check refCount works properly)": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 1); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 1); let resolve1, resolve2; const key1 = new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1); const p1 = loader.useKey(key1, async session => { @@ -371,7 +365,7 @@ export function tests() { assert.equal(callbackCalled, true); }, "cache hit while key not in use": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); let resolve1, resolve2, invocations = 0; const key1 = new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1); await loader.useKey(key1, async session => { invocations += 1; }); @@ -385,7 +379,7 @@ export function tests() { }, "dispose calls free on all sessions": async assert => { instances = 0; - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); await loader.useKey(new MockRoomKey(roomId, aliceSenderKey, sessionId1, 1), async session => {}); await loader.useKey(new MockRoomKey(roomId, aliceSenderKey, sessionId2, 1), async session => {}); assert.equal(instances, 2); @@ -395,7 +389,7 @@ export function tests() { assert.strictEqual(loader.size, 0, "loader.size"); }, "checkBetterThanKeyInStorage false with cache": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); const key1 = new MockRoomKey(roomId, aliceSenderKey, sessionId1, 2); await loader.useKey(key1, async session => {}); // fake we've checked with storage that this is the best key, @@ -409,7 +403,7 @@ export function tests() { assert.strictEqual(key2.isBetter, false); }, "checkBetterThanKeyInStorage true with cache": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); const key1 = new MockRoomKey(roomId, aliceSenderKey, sessionId1, 2); key1.isBetter = true; // fake we've check with storage so far (not including key2) this is the best key await loader.useKey(key1, async session => {}); @@ -420,7 +414,7 @@ export function tests() { assert.strictEqual(key2.isBetter, true); }, "prefer to remove worst key for a session from cache": async assert => { - const loader = new KeyLoader(olm, PICKLE_KEY, 2); + const loader = new KeyLoader(olm as any as Olm, PICKLE_KEY, 2); const key1 = new MockRoomKey(roomId, aliceSenderKey, sessionId1, 2); await loader.useKey(key1, async session => {}); key1.isBetter = true; // set to true just so it gets returned from getCachedKey diff --git a/src/matrix/e2ee/megolm/decryption/RoomKey.ts b/src/matrix/e2ee/megolm/decryption/RoomKey.ts index 81f1a9be..b5f75224 100644 --- a/src/matrix/e2ee/megolm/decryption/RoomKey.ts +++ b/src/matrix/e2ee/megolm/decryption/RoomKey.ts @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {BackupStatus, KeySource} from "../../../storage/idb/stores/InboundGroupSessionStore"; import type {InboundGroupSessionEntry} from "../../../storage/idb/stores/InboundGroupSessionStore"; import type {Transaction} from "../../../storage/idb/Transaction"; import type {DecryptionResult} from "../../DecryptionResult"; -import type {KeyLoader, OlmInboundGroupSession} from "./KeyLoader"; +import type {KeyLoader} from "./KeyLoader"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; export abstract class RoomKey { private _isBetter: boolean | undefined; @@ -33,7 +36,7 @@ export abstract class RoomKey { abstract get serializationKey(): string; abstract get serializationType(): string; abstract get eventIds(): string[] | undefined; - abstract loadInto(session: OlmInboundGroupSession, pickleKey: string): void; + abstract loadInto(session: Olm.InboundGroupSession, pickleKey: string): void; /* Whether the key has been checked against storage (or is from storage) * to be the better key for a given session. Given that all keys are checked to be better * as part of writing, we can trust that when this returns true, it really is the best key @@ -44,7 +47,7 @@ export abstract class RoomKey { set isBetter(value: boolean | undefined) { this._isBetter = value; } } -export function isBetterThan(newSession: OlmInboundGroupSession, existingSession: OlmInboundGroupSession) { +export function isBetterThan(newSession: Olm.InboundGroupSession, existingSession: Olm.InboundGroupSession) { return newSession.first_known_index() < existingSession.first_known_index(); } @@ -57,7 +60,7 @@ export abstract class IncomingRoomKey extends RoomKey { async write(loader: KeyLoader, txn: Transaction): Promise { // we checked already and we had a better session in storage, so don't write - let pickledSession; + let pickledSession: string | undefined; if (this.isBetter === undefined) { // if this key wasn't used to decrypt any messages in the same sync, // we haven't checked if this is the best key yet, @@ -79,6 +82,8 @@ export abstract class IncomingRoomKey extends RoomKey { senderKey: this.senderKey, sessionId: this.sessionId, session: pickledSession, + backup: this.backupStatus, + source: this.keySource, claimedKeys: {"ed25519": this.claimedEd25519Key}, }; txn.inboundGroupSessions.set(sessionEntry); @@ -87,7 +92,7 @@ export abstract class IncomingRoomKey extends RoomKey { get eventIds() { return this._eventIds; } - private async _checkBetterThanKeyInStorage(loader: KeyLoader, callback: (((session: OlmInboundGroupSession, pickleKey: string) => void) | undefined), txn: Transaction): Promise { + private async _checkBetterThanKeyInStorage(loader: KeyLoader, callback: (((session: Olm.InboundGroupSession, pickleKey: string) => void) | undefined), txn: Transaction): Promise { if (this.isBetter !== undefined) { return this.isBetter; } @@ -123,6 +128,12 @@ export abstract class IncomingRoomKey extends RoomKey { } return this.isBetter!; } + + protected get backupStatus(): BackupStatus { + return BackupStatus.NotBackedUp; + } + + protected abstract get keySource(): KeySource; } class DeviceMessageRoomKey extends IncomingRoomKey { @@ -139,22 +150,48 @@ class DeviceMessageRoomKey extends IncomingRoomKey { get claimedEd25519Key() { return this._decryptionResult.claimedEd25519Key; } get serializationKey(): string { return this._decryptionResult.event.content?.["session_key"]; } get serializationType(): string { return "create"; } + protected get keySource(): KeySource { return KeySource.DeviceMessage; } loadInto(session) { session.create(this.serializationKey); } } -class BackupRoomKey extends IncomingRoomKey { - private _roomId: string; - private _sessionId: string; - private _backupInfo: string; +// a room key we send out ourselves, +// here adapted to write it as an incoming key +// as we don't send it to ourself with a to_device msg +export class OutboundRoomKey extends IncomingRoomKey { + private _sessionKey: string; - constructor(roomId, sessionId, backupInfo) { + constructor( + private readonly _roomId: string, + private readonly outboundSession: Olm.OutboundGroupSession, + private readonly identityKeys: {[algo: string]: string} + ) { + super(); + // this is a new key, so always better than what might be in storage, no need to check + this.isBetter = true; + // cache this, as it is used by key loader to find a matching key and + // this calls into WASM so is not just reading a prop + this._sessionKey = this.outboundSession.session_key(); + } + + get roomId(): string { return this._roomId; } + get senderKey(): string { return this.identityKeys.curve25519; } + get sessionId(): string { return this.outboundSession.session_id(); } + get claimedEd25519Key(): string { return this.identityKeys.ed25519; } + get serializationKey(): string { return this._sessionKey; } + get serializationType(): string { return "create"; } + protected get keySource(): KeySource { return KeySource.Outbound; } + + loadInto(session: Olm.InboundGroupSession) { + session.create(this.serializationKey); + } +} + +class BackupRoomKey extends IncomingRoomKey { + constructor(private _roomId: string, private _sessionId: string, private _backupInfo: object) { super(); - this._roomId = roomId; - this._sessionId = sessionId; - this._backupInfo = backupInfo; } get roomId() { return this._roomId; } @@ -163,13 +200,18 @@ class BackupRoomKey extends IncomingRoomKey { get claimedEd25519Key() { return this._backupInfo["sender_claimed_keys"]?.["ed25519"]; } get serializationKey(): string { return this._backupInfo["session_key"]; } get serializationType(): string { return "import_session"; } - + protected get keySource(): KeySource { return KeySource.Backup; } + loadInto(session) { session.import_session(this.serializationKey); } + + protected get backupStatus(): BackupStatus { + return BackupStatus.BackedUp; + } } -class StoredRoomKey extends RoomKey { +export class StoredRoomKey extends RoomKey { private storageEntry: InboundGroupSessionEntry; constructor(storageEntry: InboundGroupSessionEntry) { diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts index 7e466806..f56feb47 100644 --- a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts +++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts @@ -17,7 +17,7 @@ limitations under the License. import {DecryptionResult} from "../../DecryptionResult.js"; import {DecryptionError} from "../../common.js"; import {ReplayDetectionEntry} from "./ReplayDetectionEntry"; -import type {RoomKey} from "./RoomKey.js"; +import type {RoomKey} from "./RoomKey"; import type {KeyLoader, OlmDecryptionResult} from "./KeyLoader"; import type {OlmWorker} from "../../OlmWorker"; import type {TimelineEvent} from "../../../storage/types"; @@ -61,7 +61,7 @@ export class SessionDecryption { this.decryptionRequests!.push(request); decryptionResult = await request.response(); } else { - decryptionResult = session.decrypt(ciphertext); + decryptionResult = session.decrypt(ciphertext) as OlmDecryptionResult; } const {plaintext} = decryptionResult!; let payload; diff --git a/src/matrix/e2ee/megolm/keybackup/Curve25519.ts b/src/matrix/e2ee/megolm/keybackup/Curve25519.ts new file mode 100644 index 00000000..7d2ebac7 --- /dev/null +++ b/src/matrix/e2ee/megolm/keybackup/Curve25519.ts @@ -0,0 +1,91 @@ +/* +Copyright 2022 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 {MEGOLM_ALGORITHM} from "../../common"; +import type {RoomKey} from "../decryption/RoomKey"; + +import type {BaseBackupInfo, SignatureMap, SessionKeyInfo} from "./types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +export const Algorithm = "m.megolm_backup.v1.curve25519-aes-sha2"; + +export type BackupInfo = BaseBackupInfo & { + algorithm: typeof Algorithm, + auth_data: AuthData, +} + +type AuthData = { + public_key: string, + signatures: SignatureMap +} + +export type SessionData = { + ciphertext: string, + mac: string, + ephemeral: string, +} + +export class BackupEncryption { + constructor( + private encryption?: Olm.PkEncryption, + private decryption?: Olm.PkDecryption + ) {} + + static fromAuthData(authData: AuthData, privateKey: Uint8Array, olm: Olm): BackupEncryption { + const expectedPubKey = authData.public_key; + const decryption = new olm.PkDecryption(); + const encryption = new olm.PkEncryption(); + try { + const pubKey = decryption.init_with_private_key(privateKey); + if (pubKey !== expectedPubKey) { + throw new Error(`Bad backup key, public key does not match. Calculated ${pubKey} but expected ${expectedPubKey}`); + } + encryption.set_recipient_key(pubKey); + } catch(err) { + decryption.free(); + throw err; + } + return new BackupEncryption(encryption, decryption); + } + + decryptRoomKey(sessionData: SessionData): SessionKeyInfo { + const sessionInfo = this.decryption!.decrypt( + sessionData.ephemeral, + sessionData.mac, + sessionData.ciphertext, + ); + return JSON.parse(sessionInfo) as SessionKeyInfo; + } + + encryptRoomKey(key: RoomKey, sessionKey: string): SessionData { + const sessionInfo: SessionKeyInfo = { + algorithm: MEGOLM_ALGORITHM, + sender_key: key.senderKey, + sender_claimed_keys: {ed25519: key.claimedEd25519Key}, + forwarding_curve25519_key_chain: [], + session_key: sessionKey + }; + return this.encryption!.encrypt(JSON.stringify(sessionInfo)) as SessionData; + } + + dispose() { + this.decryption?.free(); + this.decryption = undefined; + this.encryption?.free(); + this.encryption = undefined; + } +} diff --git a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts new file mode 100644 index 00000000..43631552 --- /dev/null +++ b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts @@ -0,0 +1,210 @@ +/* +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 {StoreNames} from "../../../storage/common"; +import {StoredRoomKey, keyFromBackup} from "../decryption/RoomKey"; +import {MEGOLM_ALGORITHM} from "../../common"; +import * as Curve25519 from "./Curve25519"; +import {AbortableOperation} from "../../../../utils/AbortableOperation"; +import {ObservableValue} from "../../../../observable/ObservableValue"; + +import {SetAbortableFn} from "../../../../utils/AbortableOperation"; +import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; +import type {HomeServerApi} from "../../../net/HomeServerApi"; +import type {IncomingRoomKey, RoomKey} from "../decryption/RoomKey"; +import type {KeyLoader} from "../decryption/KeyLoader"; +import type {SecretStorage} from "../../../ssss/SecretStorage"; +import type {Storage} from "../../../storage/idb/Storage"; +import type {ILogItem} from "../../../../logging/types"; +import type {Platform} from "../../../../platform/web/Platform"; +import type {Transaction} from "../../../storage/idb/Transaction"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +const KEYS_PER_REQUEST = 200; + +export class KeyBackup { + public readonly operationInProgress = new ObservableValue, Progress> | undefined>(undefined); + + private _stopped = false; + private _needsNewKey = false; + private _hasBackedUpAllKeys = false; + private _error?: Error; + + constructor( + private readonly backupInfo: BackupInfo, + private readonly crypto: Curve25519.BackupEncryption, + private readonly hsApi: HomeServerApi, + private readonly keyLoader: KeyLoader, + private readonly storage: Storage, + private readonly platform: Platform, + private readonly maxDelay: number = 10000 + ) {} + + get hasStopped(): boolean { return this._stopped; } + get error(): Error | undefined { return this._error; } + get version(): string { return this.backupInfo.version; } + get needsNewKey(): boolean { return this._needsNewKey; } + get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; } + + async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise { + const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response(); + if (!sessionResponse.session_data) { + return; + } + const sessionKeyInfo = this.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); + if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) { + return keyFromBackup(roomId, sessionId, sessionKeyInfo); + } else if (sessionKeyInfo?.algorithm) { + log.set("unknown algorithm", sessionKeyInfo.algorithm); + } + } + + markAllForBackup(txn: Transaction): Promise { + return txn.inboundGroupSessions.markAllAsNotBackedUp(); + } + + flush(log: ILogItem): void { + if (!this.operationInProgress.get()) { + log.wrapDetached("flush key backup", async log => { + if (this._needsNewKey) { + log.set("needsNewKey", this._needsNewKey); + return; + } + this._stopped = false; + this._error = undefined; + this._hasBackedUpAllKeys = false; + const operation = this._runFlushOperation(log); + this.operationInProgress.set(operation); + try { + await operation.result; + this._hasBackedUpAllKeys = true; + } catch (err) { + this._stopped = true; + if (err.name === "HomeServerError" && (err.errcode === "M_WRONG_ROOM_KEYS_VERSION" || err.errcode === "M_NOT_FOUND")) { + log.set("wrong_version", true); + this._needsNewKey = true; + } else { + // TODO should really also use AbortError in storage + if (err.name !== "AbortError" || (err.name === "StorageError" && err.errcode === "AbortError")) { + this._error = err; + } + } + log.catch(err); + } + this.operationInProgress.set(undefined); + }); + } + } + + private _runFlushOperation(log: ILogItem): AbortableOperation, Progress> { + return new AbortableOperation(async (setAbortable, setProgress) => { + let total = 0; + let amountFinished = 0; + while (true) { + const waitMs = this.platform.random() * this.maxDelay; + const timeout = this.platform.clock.createTimeout(waitMs); + setAbortable(timeout); + await timeout.elapsed(); + const txn = await this.storage.readTxn([StoreNames.inboundGroupSessions]); + setAbortable(txn); + // fetch total again on each iteration as while we are flushing, sync might be adding keys + total = amountFinished + await txn.inboundGroupSessions.countNonBackedUpSessions(); + setProgress(new Progress(total, amountFinished)); + const keysNeedingBackup = (await txn.inboundGroupSessions.getFirstNonBackedUpSessions(KEYS_PER_REQUEST)) + .map(entry => new StoredRoomKey(entry)); + if (keysNeedingBackup.length === 0) { + log.set("total", total); + return; + } + const payload = await this.encodeKeysForBackup(keysNeedingBackup); + const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log}); + setAbortable(uploadRequest); + await uploadRequest.response(); + await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable); + amountFinished += keysNeedingBackup.length; + setProgress(new Progress(total, amountFinished)); + } + }); + } + + private async encodeKeysForBackup(roomKeys: RoomKey[]): Promise { + const payload: KeyBackupPayload = { rooms: {} }; + const payloadRooms = payload.rooms; + for (const key of roomKeys) { + let roomPayload = payloadRooms[key.roomId]; + if (!roomPayload) { + roomPayload = payloadRooms[key.roomId] = { sessions: {} }; + } + roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key); + } + return payload; + } + + private async markKeysAsBackedUp(roomKeys: RoomKey[], setAbortable: SetAbortableFn) { + const txn = await this.storage.readWriteTxn([ + StoreNames.inboundGroupSessions, + ]); + setAbortable(txn); + try { + await Promise.all(roomKeys.map(key => { + return txn.inboundGroupSessions.markAsBackedUp(key.roomId, key.senderKey, key.sessionId); + })); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } + + private async encodeRoomKey(roomKey: RoomKey): Promise { + return await this.keyLoader.useKey(roomKey, session => { + const firstMessageIndex = session.first_known_index(); + const sessionKey = session.export_session(firstMessageIndex); + return { + first_message_index: firstMessageIndex, + forwarded_count: 0, + is_verified: false, + session_data: this.crypto.encryptRoomKey(roomKey, sessionKey) + }; + }); + } + + dispose() { + this.crypto.dispose(); + } + + static async fromSecretStorage(platform: Platform, olm: Olm, secretStorage: SecretStorage, hsApi: HomeServerApi, keyLoader: KeyLoader, storage: Storage, txn: Transaction): Promise { + const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); + if (base64PrivateKey) { + const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey)); + const backupInfo = await hsApi.roomKeysVersion().response() as BackupInfo; + if (backupInfo.algorithm === Curve25519.Algorithm) { + const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, privateKey, olm); + return new KeyBackup(backupInfo, crypto, hsApi, keyLoader, storage, platform); + } else { + throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`); + } + } + } +} + +export class Progress { + constructor( + public readonly total: number, + public readonly finished: number + ) {} +} diff --git a/src/matrix/e2ee/megolm/keybackup/types.ts b/src/matrix/e2ee/megolm/keybackup/types.ts new file mode 100644 index 00000000..ce56cca7 --- /dev/null +++ b/src/matrix/e2ee/megolm/keybackup/types.ts @@ -0,0 +1,61 @@ +/* +Copyright 2022 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 type * as Curve25519 from "./Curve25519"; +import type {MEGOLM_ALGORITHM} from "../../common"; + +export type SignatureMap = { + [userId: string]: {[deviceIdAndAlgorithm: string]: string} +} + +export type BaseBackupInfo = { + version: string, + etag: string, + count: number, +} + +export type OtherBackupInfo = BaseBackupInfo & { + algorithm: "other" +}; + +export type BackupInfo = Curve25519.BackupInfo | OtherBackupInfo; +export type SessionData = Curve25519.SessionData; + +export type SessionInfo = { + first_message_index: number, + forwarded_count: number, + is_verified: boolean, + session_data: SessionData +} + +export type MegOlmSessionKeyInfo = { + algorithm: MEGOLM_ALGORITHM, + sender_key: string, + sender_claimed_keys: {[algorithm: string]: string}, + forwarding_curve25519_key_chain: string[], + session_key: string +} + +// the type that session_data decrypts from / encrypts to +export type SessionKeyInfo = MegOlmSessionKeyInfo | {algorithm: string}; + +export type KeyBackupPayload = { + rooms: { + [roomId: string]: { + sessions: {[sessionId: string]: SessionInfo} + } + } +} diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index 3a6517cd..e9902ef8 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -20,12 +20,13 @@ import {HomeServerRequest} from "./HomeServerRequest"; import type {IHomeServerRequest} from "./HomeServerRequest"; import type {Reconnector} from "./Reconnector"; import type {EncodedBody} from "./common"; -import type {IRequestOptions, RequestFunction} from "../../platform/types/types"; +import type {RequestFunction} from "../../platform/types/types"; import type {ILogItem} from "../../logging/types"; type RequestMethod = "POST" | "GET" | "PUT"; const CS_R0_PREFIX = "/_matrix/client/r0"; +const CS_V3_PREFIX = "/_matrix/client/v3"; const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2"; type Options = { @@ -35,6 +36,14 @@ type Options = { reconnector: Reconnector; }; +type BaseRequestOptions = { + log?: ILogItem; + allowedStatusCodes?: number[]; + uploadProgress?: (loadedBytes: number) => void; + timeout?: number; + prefix?: string; +}; + export class HomeServerApi { private readonly _homeserver: string; private readonly _accessToken: string; @@ -54,18 +63,9 @@ export class HomeServerApi { return this._homeserver + prefix + csPath; } - private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: IRequestOptions, accessToken?: string): IHomeServerRequest { + private _baseRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions, accessToken?: string): IHomeServerRequest { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; - let log: ILogItem | undefined; - if (options?.log) { - const parent = options?.log; - log = parent.child({ - t: "network", - url, - method, - }, parent.level.Info); - } let encodedBody: EncodedBody["body"]; const headers: Map = new Map(); if (accessToken) { @@ -84,10 +84,11 @@ export class HomeServerApi { body: encodedBody, timeout: options?.timeout, uploadProgress: options?.uploadProgress, - format: "json" // response format + format: "json", // response format + cache: method !== "GET", }); - const hsRequest = new HomeServerRequest(method, url, requestResult, log); + const hsRequest = new HomeServerRequest(method, url, requestResult, options); if (this._reconnector) { hsRequest.response().catch(err => { @@ -104,27 +105,27 @@ export class HomeServerApi { return hsRequest; } - private _unauthedRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: IRequestOptions): IHomeServerRequest { + private _unauthedRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._baseRequest(method, url, queryParams, body, options); } - private _authedRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: IRequestOptions): IHomeServerRequest { + private _authedRequest(method: RequestMethod, url: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._baseRequest(method, url, queryParams, body, options, this._accessToken); } - private _post(csPath: string, queryParams: Record, body: Record, options?: IRequestOptions): IHomeServerRequest { + private _post(csPath: string, queryParams: Record, body: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._authedRequest("POST", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } - private _put(csPath: string, queryParams: Record, body?: Record, options?: IRequestOptions): IHomeServerRequest { + private _put(csPath: string, queryParams: Record, body?: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._authedRequest("PUT", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } - private _get(csPath: string, queryParams?: Record, body?: Record, options?: IRequestOptions): IHomeServerRequest { + private _get(csPath: string, queryParams?: Record, body?: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._authedRequest("GET", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options); } - sync(since: string, filter: string, timeout: number, options?: IRequestOptions): IHomeServerRequest { + sync(since: string, filter: string, timeout: number, options?: BaseRequestOptions): IHomeServerRequest { return this._get("/sync", {since, timeout, filter}, undefined, options); } @@ -133,29 +134,29 @@ export class HomeServerApi { } // params is from, dir and optionally to, limit, filter. - messages(roomId: string, params: Record, options?: IRequestOptions): IHomeServerRequest { + messages(roomId: string, params: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params, undefined, options); } // params is at, membership and not_membership - members(roomId: string, params: Record, options?: IRequestOptions): IHomeServerRequest { + members(roomId: string, params: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._get(`/rooms/${encodeURIComponent(roomId)}/members`, params, undefined, options); } - send(roomId: string, eventType: string, txnId: string, content: Record, options?: IRequestOptions): IHomeServerRequest { + send(roomId: string, eventType: string, txnId: string, content: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); } - redact(roomId: string, eventId: string, txnId: string, content: Record, options?: IRequestOptions): IHomeServerRequest { + redact(roomId: string, eventId: string, txnId: string, content: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._put(`/rooms/${encodeURIComponent(roomId)}/redact/${encodeURIComponent(eventId)}/${encodeURIComponent(txnId)}`, {}, content, options); } - receipt(roomId: string, receiptType: string, eventId: string, options?: IRequestOptions): IHomeServerRequest { + receipt(roomId: string, receiptType: string, eventId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/rooms/${encodeURIComponent(roomId)}/receipt/${encodeURIComponent(receiptType)}/${encodeURIComponent(eventId)}`, {}, {}, options); } - state(roomId: string, eventType: string, stateKey: string, options?: IRequestOptions): IHomeServerRequest { + state(roomId: string, eventType: string, stateKey: string, options?: BaseRequestOptions): IHomeServerRequest { return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options); } @@ -163,7 +164,22 @@ export class HomeServerApi { return this._unauthedRequest("GET", this._url("/login")); } - passwordLogin(username: string, password: string, initialDeviceDisplayName: string, options?: IRequestOptions): IHomeServerRequest { + register(username: string | null, password: string, initialDeviceDisplayName: string, auth?: Record, inhibitLogin: boolean = true , options: BaseRequestOptions = {}): IHomeServerRequest { + options.allowedStatusCodes = [401]; + const body: any = { + auth, + password, + initial_device_displayname: initialDeviceDisplayName, + inhibit_login: inhibitLogin, + }; + if (username) { + // username is optional for registration + body.username = username; + } + return this._unauthedRequest( "POST", this._url("/register", CS_V3_PREFIX), undefined, body, options); + } + + passwordLogin(username: string, password: string, initialDeviceDisplayName: string, options?: BaseRequestOptions): IHomeServerRequest { return this._unauthedRequest("POST", this._url("/login"), undefined, { "type": "m.login.password", "identifier": { @@ -175,7 +191,7 @@ export class HomeServerApi { }, options); } - tokenLogin(loginToken: string, txnId: string, initialDeviceDisplayName: string, options?: IRequestOptions): IHomeServerRequest { + tokenLogin(loginToken: string, txnId: string, initialDeviceDisplayName: string, options?: BaseRequestOptions): IHomeServerRequest { return this._unauthedRequest("POST", this._url("/login"), undefined, { "type": "m.login.token", "identifier": { @@ -187,15 +203,15 @@ export class HomeServerApi { }, options); } - createFilter(userId: string, filter: Record, options?: IRequestOptions): IHomeServerRequest { + createFilter(userId: string, filter: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/user/${encodeURIComponent(userId)}/filter`, {}, filter, options); } - versions(options?: IRequestOptions): IHomeServerRequest { + versions(options?: BaseRequestOptions): IHomeServerRequest { return this._unauthedRequest("GET", `${this._homeserver}/_matrix/client/versions`, undefined, undefined, options); } - uploadKeys(dehydratedDeviceId: string, payload: Record, options?: IRequestOptions): IHomeServerRequest { + uploadKeys(dehydratedDeviceId: string, payload: Record, options?: BaseRequestOptions): IHomeServerRequest { let path = "/keys/upload"; if (dehydratedDeviceId) { path = path + `/${encodeURIComponent(dehydratedDeviceId)}`; @@ -203,19 +219,19 @@ export class HomeServerApi { return this._post(path, {}, payload, options); } - queryKeys(queryRequest: Record, options?: IRequestOptions): IHomeServerRequest { + queryKeys(queryRequest: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post("/keys/query", {}, queryRequest, options); } - claimKeys(payload: Record, options?: IRequestOptions): IHomeServerRequest { + claimKeys(payload: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post("/keys/claim", {}, payload, options); } - sendToDevice(type: string, payload: Record, txnId: string, options?: IRequestOptions): IHomeServerRequest { + sendToDevice(type: string, payload: Record, txnId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, {}, payload, options); } - roomKeysVersion(version?: string, options?: IRequestOptions): IHomeServerRequest { + roomKeysVersion(version?: string, options?: BaseRequestOptions): IHomeServerRequest { let versionPart = ""; if (version) { versionPart = `/${encodeURIComponent(version)}`; @@ -223,56 +239,72 @@ export class HomeServerApi { return this._get(`/room_keys/version${versionPart}`, undefined, undefined, options); } - roomKeyForRoomAndSession(version: string, roomId: string, sessionId: string, options?: IRequestOptions): IHomeServerRequest { + roomKeyForRoomAndSession(version: string, roomId: string, sessionId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, undefined, options); } - uploadAttachment(blob: Blob, filename: string, options?: IRequestOptions): IHomeServerRequest { + uploadRoomKeysToBackup(version: string, payload: Record, options?: BaseRequestOptions): IHomeServerRequest { + return this._put(`/room_keys/keys`, {version}, payload, options); + } + + uploadAttachment(blob: Blob, filename: string, options?: BaseRequestOptions): IHomeServerRequest { return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); } - setPusher(pusher: Record, options?: IRequestOptions): IHomeServerRequest { + setPusher(pusher: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post("/pushers/set", {}, pusher, options); } - getPushers(options?: IRequestOptions): IHomeServerRequest { + getPushers(options?: BaseRequestOptions): IHomeServerRequest { return this._get("/pushers", undefined, undefined, options); } - join(roomId: string, options?: IRequestOptions): IHomeServerRequest { + join(roomId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/rooms/${encodeURIComponent(roomId)}/join`, {}, {}, options); } - joinIdOrAlias(roomIdOrAlias: string, options?: IRequestOptions): IHomeServerRequest { + joinIdOrAlias(roomIdOrAlias: string, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/join/${encodeURIComponent(roomIdOrAlias)}`, {}, {}, options); } - leave(roomId: string, options?: IRequestOptions): IHomeServerRequest { + leave(roomId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, {}, {}, options); } - forget(roomId: string, options?: IRequestOptions): IHomeServerRequest { + forget(roomId: string, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/rooms/${encodeURIComponent(roomId)}/forget`, {}, {}, options); } - logout(options?: IRequestOptions): IHomeServerRequest { + logout(options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/logout`, {}, {}, options); } - getDehydratedDevice(options: IRequestOptions): IHomeServerRequest { + getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._get(`/dehydrated_device`, undefined, undefined, options); } - createDehydratedDevice(payload: Record, options: IRequestOptions): IHomeServerRequest { + createDehydratedDevice(payload: Record, options: BaseRequestOptions = {}): IHomeServerRequest { options.prefix = DEHYDRATION_PREFIX; return this._put(`/dehydrated_device`, {}, payload, options); } - claimDehydratedDevice(deviceId: string, options: IRequestOptions): 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/net/HomeServerRequest.ts b/src/matrix/net/HomeServerRequest.ts index ea5d2e40..d6745d03 100644 --- a/src/matrix/net/HomeServerRequest.ts +++ b/src/matrix/net/HomeServerRequest.ts @@ -22,21 +22,32 @@ import type {ILogItem} from "../../logging/types"; export interface IHomeServerRequest { abort(): void; response(): Promise; + responseCode(): Promise; } +type HomeServerRequestOptions = { + log?: ILogItem; + allowedStatusCodes?: number[]; +}; + export class HomeServerRequest implements IHomeServerRequest { private readonly _log?: ILogItem; private _sourceRequest?: RequestResult; // as we add types for expected responses from hs, this could be a generic class instead private readonly _promise: Promise; - constructor(method: string, url: string, sourceRequest: RequestResult, log?: ILogItem) { + constructor(method: string, url: string, sourceRequest: RequestResult, options?: HomeServerRequestOptions) { + let log: ILogItem | undefined; + if (options?.log) { + const parent = options?.log; + log = parent.child({ t: "network", url, method, }, parent.level.Info); + } this._log = log; this._sourceRequest = sourceRequest; this._promise = sourceRequest.response().then(response => { log?.set("status", response.status); // ok? - if (response.status >= 200 && response.status < 300) { + if (response.status >= 200 && response.status < 300 || options?.allowedStatusCodes?.includes(response.status)) { log?.finish(); return response.body; } else { @@ -104,6 +115,11 @@ export class HomeServerRequest implements IHomeServerRequest { response(): Promise { return this._promise; } + + async responseCode(): Promise { + const response = await this._sourceRequest.response(); + return response.status; + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/net/RequestScheduler.ts b/src/matrix/net/RequestScheduler.ts index 45405da3..dc5c501b 100644 --- a/src/matrix/net/RequestScheduler.ts +++ b/src/matrix/net/RequestScheduler.ts @@ -25,31 +25,60 @@ import type {IHomeServerRequest} from "./HomeServerRequest.js"; class Request implements IHomeServerRequest { public readonly methodName: string; public readonly args: any[]; - public resolve: (result: any) => void; - public reject: (error: Error) => void; - public requestResult?: IHomeServerRequest; + private responseResolve: (result: any) => void; + public responseReject: (error: Error) => void; + private responseCodeResolve: (result: any) => void; + private responseCodeReject: (result: any) => void; + private _requestResult?: IHomeServerRequest; private readonly _responsePromise: Promise; + private _responseCodePromise: Promise; constructor(methodName: string, args: any[]) { this.methodName = methodName; this.args = args; this._responsePromise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; + this.responseResolve = resolve; + this.responseReject = reject; }); } abort(): void { - if (this.requestResult) { - this.requestResult.abort(); + if (this._requestResult) { + this._requestResult.abort(); } else { - this.reject(new AbortError()); + this.responseReject(new AbortError()); + this.responseCodeReject?.(new AbortError()); } } response(): Promise { return this._responsePromise; } + + responseCode(): Promise { + if (this.requestResult) { + return this.requestResult.responseCode(); + } + if (!this._responseCodePromise) { + this._responseCodePromise = new Promise((resolve, reject) => { + this.responseCodeResolve = resolve; + this.responseCodeReject = reject; + }); + } + return this._responseCodePromise; + } + + async setRequestResult(result) { + this._requestResult = result; + const response = await this._requestResult?.response(); + this.responseResolve(response); + const responseCode = await this._requestResult?.responseCode(); + this.responseCodeResolve(responseCode); + } + + get requestResult() { + return this._requestResult; + } } class HomeServerApiWrapper { @@ -113,9 +142,7 @@ export class RequestScheduler { request.methodName ].apply(this._hsApi, request.args); // so the request can be aborted - request.requestResult = requestResult; - const response = await requestResult.response(); - request.resolve(response); + await request.setRequestResult(requestResult); return; } catch (err) { if ( @@ -135,7 +162,7 @@ export class RequestScheduler { await retryDelay.waitForRetry(); } } else { - request.reject(err); + request.responseReject(err); return; } } 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/registration/Registration.ts b/src/matrix/registration/Registration.ts new file mode 100644 index 00000000..c9c9af87 --- /dev/null +++ b/src/matrix/registration/Registration.ts @@ -0,0 +1,119 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {BaseRegistrationStage} from "./stages/BaseRegistrationStage"; +import {DummyAuth} from "./stages/DummyAuth"; +import {TermsAuth} from "./stages/TermsAuth"; +import type { + AccountDetails, + RegistrationFlow, + RegistrationResponseMoreDataNeeded, + RegistrationResponse, + RegistrationResponseSuccess, + RegistrationParams, +} from "./types"; + +type FlowSelector = (flows: RegistrationFlow[]) => RegistrationFlow | void; + +export class Registration { + private readonly _hsApi: HomeServerApi; + private readonly _accountDetails: AccountDetails; + private readonly _flowSelector: FlowSelector; + private _sessionInfo?: RegistrationResponseSuccess + + constructor(hsApi: HomeServerApi, accountDetails: AccountDetails, flowSelector?: FlowSelector) { + this._hsApi = hsApi; + this._accountDetails = accountDetails; + this._flowSelector = flowSelector ?? (flows => flows[0]); + } + + async start(): Promise { + const response = await this._hsApi.register( + this._accountDetails.username, + this._accountDetails.password, + this._accountDetails.initialDeviceDisplayName, + undefined, + this._accountDetails.inhibitLogin).response(); + return this.parseStagesFromResponse(response); + } + + /** + * Finish a registration stage, return value is: + * - the next stage if this stage was completed successfully + * - undefined if registration is completed + */ + async submitStage(stage: BaseRegistrationStage): Promise { + const auth = stage.generateAuthenticationData(); + const { username, password, initialDeviceDisplayName, inhibitLogin } = this._accountDetails; + const request = this._hsApi.register(username, password, initialDeviceDisplayName, auth, inhibitLogin); + const response = await request.response(); + const status = await request.responseCode(); + const registrationResponse: RegistrationResponse = { ...response, status }; + return this.parseRegistrationResponse(registrationResponse, stage); + } + + private parseStagesFromResponse(response: RegistrationResponseMoreDataNeeded): BaseRegistrationStage { + const { session, params } = response; + const flow = this._flowSelector(response.flows); + if (!flow) { + throw new Error("flowSelector did not return any flow!"); + } + let firstStage: BaseRegistrationStage | undefined; + let lastStage: BaseRegistrationStage | undefined; + for (const stage of flow.stages) { + const registrationStage = this._createRegistrationStage(stage, session, params); + if (!firstStage) { + firstStage = registrationStage; + lastStage = registrationStage; + } else { + lastStage!.setNextStage(registrationStage); + lastStage = registrationStage; + } + } + return firstStage!; + } + + private async parseRegistrationResponse(response: RegistrationResponse, currentStage: BaseRegistrationStage) { + switch (response.status) { + case 200: + this._sessionInfo = response; + return undefined; + case 401: + if (response.completed?.includes(currentStage.type)) { + return currentStage.nextStage; + } + else { + throw new Error("This stage could not be completed!"); + } + } + } + + private _createRegistrationStage(type: string, session: string, params?: RegistrationParams) { + switch (type) { + case "m.login.dummy": + return new DummyAuth(session, params?.[type]); + case "m.login.terms": + return new TermsAuth(session, params?.[type]); + default: + throw new Error(`Unknown stage: ${type}`); + } + } + + get sessionInfo(): RegistrationResponseSuccess | undefined { + return this._sessionInfo; + } +} diff --git a/src/matrix/registration/stages/BaseRegistrationStage.ts b/src/matrix/registration/stages/BaseRegistrationStage.ts new file mode 100644 index 00000000..cc5f46c1 --- /dev/null +++ b/src/matrix/registration/stages/BaseRegistrationStage.ts @@ -0,0 +1,48 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {AuthenticationData, RegistrationParams} from "../types"; + +export abstract class BaseRegistrationStage { + protected _session: string; + protected _nextStage: BaseRegistrationStage; + protected readonly _params?: Record + + constructor(session: string, params?: RegistrationParams) { + this._session = session; + this._params = params; + } + + /** + * eg: m.login.recaptcha or m.login.dummy + */ + abstract get type(): string; + + /** + * This method should return auth part that must be provided to + * /register endpoint to successfully complete this stage + */ + /** @internal */ + abstract generateAuthenticationData(): AuthenticationData; + + setNextStage(stage: BaseRegistrationStage) { + this._nextStage = stage; + } + + get nextStage(): BaseRegistrationStage { + return this._nextStage; + } +} diff --git a/src/matrix/registration/stages/DummyAuth.ts b/src/matrix/registration/stages/DummyAuth.ts new file mode 100644 index 00000000..b7f0a6ff --- /dev/null +++ b/src/matrix/registration/stages/DummyAuth.ts @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AuthenticationData} from "../types"; +import {BaseRegistrationStage} from "./BaseRegistrationStage"; + +export class DummyAuth extends BaseRegistrationStage { + generateAuthenticationData(): AuthenticationData { + return { + session: this._session, + type: this.type, + }; + } + + get type(): string { + return "m.login.dummy"; + } +} diff --git a/src/matrix/registration/stages/TermsAuth.ts b/src/matrix/registration/stages/TermsAuth.ts new file mode 100644 index 00000000..bf54dd4d --- /dev/null +++ b/src/matrix/registration/stages/TermsAuth.ts @@ -0,0 +1,40 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AuthenticationData} from "../types"; +import {BaseRegistrationStage} from "./BaseRegistrationStage"; + +export class TermsAuth extends BaseRegistrationStage { + generateAuthenticationData(): AuthenticationData { + return { + session: this._session, + type: this.type, + // No other auth data needed for m.login.terms + }; + } + + get type(): string { + return "m.login.terms"; + } + + get privacyPolicy() { + return this._params?.policies["privacy_policy"]; + } + + get termsOfService() { + return this._params?.policies["terms_of_service"]; + } +} diff --git a/src/matrix/registration/types.ts b/src/matrix/registration/types.ts new file mode 100644 index 00000000..f1ddbe98 --- /dev/null +++ b/src/matrix/registration/types.ts @@ -0,0 +1,55 @@ +/* +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 type AccountDetails = { + username: string | null; + password: string; + initialDeviceDisplayName: string; + inhibitLogin: boolean; +} + +export type RegistrationResponse = RegistrationResponseMoreDataNeeded | RegistrationResponseSuccess; + +export type RegistrationResponseMoreDataNeeded = { + completed?: string[]; + flows: RegistrationFlow[]; + params: Record; + session: string; + status: 401; +} + +export type RegistrationResponseSuccess = { + user_id: string; + device_id: string; + access_token?: string; + status: 200; +} + +export type RegistrationFlow = { + stages: string[]; +} + +/* Types for Registration Stage */ +export type AuthenticationData = { + type: string; + session: string; + [key: string]: any; +} + +// contains additional data needed to complete a stage, eg: link to privacy policy +export type RegistrationParams = { + [key: string]: any; +} 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 39232ae5..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", ""); @@ -461,11 +476,11 @@ export class BaseRoom extends EventEmitter { return observable; } - enableSessionBackup(sessionBackup) { - this._roomEncryption?.enableSessionBackup(sessionBackup); + enableKeyBackup(keyBackup) { + this._roomEncryption?.enableKeyBackup(keyBackup); // TODO: do we really want to do this every time you open the app? - if (this._timeline && sessionBackup) { - this._platform.logger.run("enableSessionBackup", log => { + if (this._timeline && keyBackup) { + this._platform.logger.run("enableKeyBackup", log => { return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); }); } 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..12c17580 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) { @@ -161,7 +158,7 @@ export class Room extends BaseRoom { summaryChanges = this._summary.writeData(summaryChanges, txn); } if (summaryChanges) { - log.set("summaryChanges", summaryChanges.diff(this._summary.data)); + log.set("summaryChanges", summaryChanges.changedKeys(this._summary.data)); } // fetch new members while we have txn open, // but don't make any in-memory changes yet 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..a3dec467 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; @@ -194,16 +203,11 @@ export class SummaryData { this.cloned = copy ? true : false; } - diff(other) { + changedKeys(other) { const props = Object.getOwnPropertyNames(this); - return props.reduce((diff, prop) => { - if (prop !== "cloned") { - if (this[prop] !== other[prop]) { - diff[prop] = this[prop]; - } - } - return diff; - }, {}); + return props.filter(prop => { + return prop !== "cloned" && this[prop] !== other[prop] + }); } cloneIfNeeded() { @@ -227,12 +231,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 12997a21..44f767f7 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"; @@ -385,7 +385,7 @@ export class Timeline { }; const eventEntry = new NonPersistedEventEntry(entry, this._fragmentIdComparer); if (this._decryptEntries) { - const request = this._decryptEntries(DecryptionSource.Timeline, [eventEntry]); + const request = this._decryptEntries([eventEntry]); await request.complete(); } return eventEntry; 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/matrix/ssss/index.ts b/src/matrix/ssss/index.ts index 37f47963..fd4c2245 100644 --- a/src/matrix/ssss/index.ts +++ b/src/matrix/ssss/index.ts @@ -27,6 +27,7 @@ import type * as OlmNamespace from "@matrix-org/olm" type Olm = typeof OlmNamespace; const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`; +const BACKUPVERSION_KEY = `${SESSION_E2EE_KEY_PREFIX}keyBackupVersion`; export enum KeyType { "RecoveryKey", @@ -49,8 +50,11 @@ async function readDefaultKeyDescription(storage: Storage): Promise { +export async function writeKey(key: Key, keyBackupVersion: number, txn: Transaction): Promise { + const existingVersion: number | undefined = await txn.session.get(BACKUPVERSION_KEY); + txn.session.set(BACKUPVERSION_KEY, keyBackupVersion); txn.session.set(SSSS_KEY, {id: key.id, binaryKey: key.binaryKey}); + return existingVersion; } export async function readKey(txn: Transaction): Promise { diff --git a/src/matrix/storage/idb/QueryTarget.ts b/src/matrix/storage/idb/QueryTarget.ts index 26821e0f..f27123d5 100644 --- a/src/matrix/storage/idb/QueryTarget.ts +++ b/src/matrix/storage/idb/QueryTarget.ts @@ -37,7 +37,8 @@ interface QueryTargetInterface { openKeyCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest; supports(method: string): boolean; keyPath: string | string[]; - get(key: IDBValidKey | IDBKeyRange): IDBRequest; + count(keyRange?: IDBKeyRange): IDBRequest; + get(key: IDBValidKey | IDBKeyRange): IDBRequest; getKey(key: IDBValidKey | IDBKeyRange): IDBRequest; } @@ -78,7 +79,11 @@ export class QueryTarget { return this._target.supports(methodName); } - get(key: IDBValidKey | IDBKeyRange): Promise { + count(keyRange?: IDBKeyRange): Promise { + return reqAsPromise(this._target.count(keyRange)); + } + + get(key: IDBValidKey | IDBKeyRange): Promise { return reqAsPromise(this._target.get(key)); } diff --git a/src/matrix/storage/idb/Store.ts b/src/matrix/storage/idb/Store.ts index 07cc90b0..c9df33b2 100644 --- a/src/matrix/storage/idb/Store.ts +++ b/src/matrix/storage/idb/Store.ts @@ -91,7 +91,7 @@ export class QueryTargetWrapper { } } - get(key: IDBValidKey | IDBKeyRange): IDBRequest { + get(key: IDBValidKey | IDBKeyRange): IDBRequest { try { LOG_REQUESTS && logRequest("get", [key], this._qt); return this._qt.get(key); @@ -118,6 +118,14 @@ export class QueryTargetWrapper { } } + count(keyRange?: IDBKeyRange): IDBRequest { + try { + return this._qt.count(keyRange); + } catch(err) { + throw new IDBRequestAttemptError("count", this._qt, err, [keyRange]); + } + } + index(name: string): IDBIndex { try { return this._qtStore.index(name); diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index ad3e5896..7819130e 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -6,6 +6,7 @@ import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js"; import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js"; import {SummaryData} from "../../room/RoomSummary"; import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore"; +import {InboundGroupSessionStore, InboundGroupSessionEntry, BackupStatus, KeySource} from "./stores/InboundGroupSessionStore"; import {RoomStateEntry} from "./stores/RoomStateStore"; import {SessionStore} from "./stores/SessionStore"; import {Store} from "./Store"; @@ -31,13 +32,30 @@ export const schema: MigrationFunc[] = [ fixMissingRoomsInUserIdentities, changeSSSSKeyPrefix, backupAndRestoreE2EEAccountToLocalStorage, - clearAllStores + clearAllStores, + addInboundSessionBackupIndex, + migrateBackupStatus ]; // TODO: how to deal with git merge conflicts of this array? // TypeScript note: for now, do not bother introducing interfaces / alias // for old schemas. Just take them as `any`. +function createDatabaseNameHelper(db: IDBDatabase): ITransaction { + // the Store object gets passed in several things through the Transaction class (a wrapper around IDBTransaction), + // the only thing we should need here is the databaseName though, so we mock it out. + // ideally we should have an easier way to go from the idb primitive layer to the specific store classes where + // we implement logic, but for now we need this. + const databaseNameHelper: ITransaction = { + databaseName: db.name, + get idbFactory(): IDBFactory { throw new Error("unused");}, + get IDBKeyRange(): typeof IDBKeyRange { throw new Error("unused");}, + addWriteError() {}, + }; + return databaseNameHelper; +} + + // how do we deal with schema updates vs existing data migration in a way that //v1 function createInitialStores(db: IDBDatabase): void { @@ -222,17 +240,7 @@ async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) { // v13 async function backupAndRestoreE2EEAccountToLocalStorage(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) { const session = txn.objectStore("session"); - // the Store object gets passed in several things through the Transaction class (a wrapper around IDBTransaction), - // the only thing we should need here is the databaseName though, so we mock it out. - // ideally we should have an easier way to go from the idb primitive layer to the specific store classes where - // we implement logic, but for now we need this. - const databaseNameHelper: ITransaction = { - databaseName: db.name, - get idbFactory(): IDBFactory { throw new Error("unused");}, - get IDBKeyRange(): typeof IDBKeyRange { throw new Error("unused");}, - addWriteError() {}, - }; - const sessionStore = new SessionStore(new Store(session, databaseNameHelper), localStorage); + const sessionStore = new SessionStore(new Store(session, createDatabaseNameHelper(db)), localStorage); // if we already have an e2ee identity, write a backup to local storage. // further updates to e2ee keys in the session store will also write to local storage from 0.2.15 on, // but here we make sure a backup is immediately created after installing the update and we don't wait until @@ -270,3 +278,34 @@ async function clearAllStores(db: IDBDatabase, txn: IDBTransaction) { } } } + +// v15 add backup index to inboundGroupSessions +async function addInboundSessionBackupIndex(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem): Promise { + const inboundGroupSessions = txn.objectStore("inboundGroupSessions"); + inboundGroupSessions.createIndex("byBackup", "backup", {unique: false}); +} + + +// v16 migrates the backup and source fields of inbound group sessions +async function migrateBackupStatus(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem): Promise { + const inboundGroupSessions = txn.objectStore("inboundGroupSessions"); + let countWithSession = 0; + let countWithoutSession = 0; + await iterateCursor(inboundGroupSessions.openCursor(), (value, key, cursor) => { + if (value.session) { + value.backup = BackupStatus.NotBackedUp; + // we'll also have backup keys in here, we can't tell, + // but the worst thing that can happen is that we try + // to backup keys that were already in backup, which + // the server will ignore + value.source = KeySource.DeviceMessage; + cursor.update(value); + countWithSession += 1; + } else { + countWithoutSession += 1; + } + return NOT_DONE; + }); + log.set("countWithoutSession", countWithoutSession); + log.set("countWithSession", countWithSession); +} diff --git a/src/matrix/storage/idb/stores/AccountDataStore.ts b/src/matrix/storage/idb/stores/AccountDataStore.ts index 32aec513..2081ad8f 100644 --- a/src/matrix/storage/idb/stores/AccountDataStore.ts +++ b/src/matrix/storage/idb/stores/AccountDataStore.ts @@ -28,7 +28,7 @@ export class AccountDataStore { this._store = store; } - async get(type: string): Promise { + async get(type: string): Promise { return await this._store.get(type); } diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts index 6b9f5332..2936f079 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts +++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.ts @@ -17,7 +17,7 @@ limitations under the License. import {MAX_UNICODE, MIN_UNICODE} from "./common"; import {Store} from "../Store"; -interface DeviceIdentity { +export interface DeviceIdentity { userId: string; deviceId: string; ed25519Key: string; @@ -65,7 +65,7 @@ export class DeviceIdentityStore { return deviceIds; } - get(userId: string, deviceId: string): Promise { + get(userId: string, deviceId: string): Promise { return this._store.get(encodeKey(userId, deviceId)); } @@ -74,7 +74,7 @@ export class DeviceIdentityStore { this._store.put(deviceIdentity); } - getByCurve25519Key(curve25519Key: string): Promise { + getByCurve25519Key(curve25519Key: string): Promise { return this._store.index("byCurve25519Key").get(curve25519Key); } diff --git a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts index 1627e44a..a73c882d 100644 --- a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts +++ b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.ts @@ -35,7 +35,7 @@ export class GroupSessionDecryptionStore { this._store = store; } - get(roomId: string, sessionId: string, messageIndex: number): Promise { + get(roomId: string, sessionId: string, messageIndex: number): Promise { return this._store.get(encodeKey(roomId, sessionId, messageIndex)); } diff --git a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts index 22093884..b78c817e 100644 --- a/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/InboundGroupSessionStore.ts @@ -17,6 +17,17 @@ limitations under the License. import {MIN_UNICODE, MAX_UNICODE} from "./common"; import {Store} from "../Store"; +export enum BackupStatus { + NotBackedUp = 0, + BackedUp = 1 +} + +export enum KeySource { + DeviceMessage = 1, + Backup, + Outbound +} + export interface InboundGroupSessionEntry { roomId: string; senderKey: string; @@ -24,6 +35,8 @@ export interface InboundGroupSessionEntry { session?: string; claimedKeys?: { [algorithm : string] : string }; eventIds?: string[]; + backup: BackupStatus, + source: KeySource } type InboundGroupSessionStorageEntry = InboundGroupSessionEntry & { key: string }; @@ -46,7 +59,7 @@ export class InboundGroupSessionStore { return key === fetchedKey; } - get(roomId: string, senderKey: string, sessionId: string): Promise { + get(roomId: string, senderKey: string, sessionId: string): Promise { return this._store.get(encodeKey(roomId, senderKey, sessionId)); } @@ -63,4 +76,31 @@ export class InboundGroupSessionStore { ); this._store.delete(range); } + countNonBackedUpSessions(): Promise { + return this._store.index("byBackup").count(this._store.IDBKeyRange.only(BackupStatus.NotBackedUp)); + } + + getFirstNonBackedUpSessions(amount: number): Promise { + return this._store.index("byBackup").selectLimit(this._store.IDBKeyRange.only(BackupStatus.NotBackedUp), amount); + } + + async markAsBackedUp(roomId: string, senderKey: string, sessionId: string): Promise { + const entry = await this._store.get(encodeKey(roomId, senderKey, sessionId)); + if (entry) { + entry.backup = BackupStatus.BackedUp; + this._store.put(entry); + } + } + + async markAllAsNotBackedUp(): Promise { + const backedUpKey = this._store.IDBKeyRange.only(BackupStatus.BackedUp); + let count = 0; + await this._store.index("byBackup").iterateValues(backedUpKey, (val: InboundGroupSessionEntry, key: IDBValidKey, cur: IDBCursorWithValue) => { + val.backup = BackupStatus.NotBackedUp; + cur.update(val); + count += 1; + return false; + }); + return count; + } } diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.ts b/src/matrix/storage/idb/stores/OlmSessionStore.ts index 3310215e..d5a79de2 100644 --- a/src/matrix/storage/idb/stores/OlmSessionStore.ts +++ b/src/matrix/storage/idb/stores/OlmSessionStore.ts @@ -62,7 +62,7 @@ export class OlmSessionStore { }); } - get(senderKey: string, sessionId: string): Promise { + get(senderKey: string, sessionId: string): Promise { return this._store.get(encodeKey(senderKey, sessionId)); } diff --git a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts index 9712c717..28ef23b0 100644 --- a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts +++ b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.ts @@ -32,7 +32,7 @@ export class OutboundGroupSessionStore { this._store.delete(roomId); } - get(roomId: string): Promise { + get(roomId: string): Promise { return this._store.get(roomId); } diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.ts b/src/matrix/storage/idb/stores/RoomMemberStore.ts index 00cf607f..78024e7d 100644 --- a/src/matrix/storage/idb/stores/RoomMemberStore.ts +++ b/src/matrix/storage/idb/stores/RoomMemberStore.ts @@ -46,7 +46,7 @@ export class RoomMemberStore { this._roomMembersStore = roomMembersStore; } - get(roomId: string, userId: string): Promise { + get(roomId: string, userId: string): Promise { return this._roomMembersStore.get(encodeKey(roomId, userId)); } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.ts b/src/matrix/storage/idb/stores/RoomStateStore.ts index b7ece0f7..d2bf811d 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.ts +++ b/src/matrix/storage/idb/stores/RoomStateStore.ts @@ -36,7 +36,7 @@ export class RoomStateStore { this._roomStateStore = idbStore; } - get(roomId: string, type: string, stateKey: string): Promise { + get(roomId: string, type: string, stateKey: string): Promise { const key = encodeKey(roomId, type, stateKey); return this._roomStateStore.get(key); } diff --git a/src/matrix/storage/idb/stores/TimelineEventStore.ts b/src/matrix/storage/idb/stores/TimelineEventStore.ts index 91cda071..4d95ecd1 100644 --- a/src/matrix/storage/idb/stores/TimelineEventStore.ts +++ b/src/matrix/storage/idb/stores/TimelineEventStore.ts @@ -301,11 +301,11 @@ export class TimelineEventStore { this._timelineStore.put(entry as TimelineEventStorageEntry); } - get(roomId: string, eventKey: EventKey): Promise { + get(roomId: string, eventKey: EventKey): Promise { return this._timelineStore.get(encodeKey(roomId, eventKey.fragmentId, eventKey.eventIndex)); } - getByEventId(roomId: string, eventId: string): Promise { + getByEventId(roomId: string, eventId: string): Promise { return this._timelineStore.index("byEventId").get(encodeEventIdKey(roomId, eventId)); } diff --git a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts index 5753a93e..4e15589f 100644 --- a/src/matrix/storage/idb/stores/TimelineFragmentStore.ts +++ b/src/matrix/storage/idb/stores/TimelineFragmentStore.ts @@ -83,7 +83,7 @@ export class TimelineFragmentStore { this._store.put(fragment); } - get(roomId: string, fragmentId: number): Promise { + get(roomId: string, fragmentId: number): Promise { return this._store.get(encodeKey(roomId, fragmentId)); } diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.ts b/src/matrix/storage/idb/stores/UserIdentityStore.ts index 692d8384..1c55baf0 100644 --- a/src/matrix/storage/idb/stores/UserIdentityStore.ts +++ b/src/matrix/storage/idb/stores/UserIdentityStore.ts @@ -28,7 +28,7 @@ export class UserIdentityStore { this._store = store; } - get(userId: string): Promise { + get(userId: string): Promise { return this._store.get(userId); } diff --git a/src/observable/ObservableValue.ts b/src/observable/ObservableValue.ts index b3ffa6ee..ad0a226d 100644 --- a/src/observable/ObservableValue.ts +++ b/src/observable/ObservableValue.ts @@ -16,6 +16,7 @@ limitations under the License. import {AbortError} from "../utils/error"; import {BaseObservable} from "./BaseObservable"; +import type {SubscriptionHandle} from "./BaseObservable"; // like an EventEmitter, but doesn't have an event type export abstract class BaseObservableValue extends BaseObservable<(value: T) => void> { @@ -34,6 +35,10 @@ export abstract class BaseObservableValue extends BaseObservable<(value: T) = return new WaitForHandle(this, predicate); } } + + flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { + return new FlatMapObservableValue(this, mapper); + } } interface IWaitHandle { @@ -114,6 +119,61 @@ export class RetainedObservableValue extends ObservableValue { } } +export class FlatMapObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + private targetSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => (BaseObservableValue | undefined) + ) { + super(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + onSubscribeFirst() { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.updateTargetSubscription(); + this.emit(this.get()); + }); + this.updateTargetSubscription(); + } + + private updateTargetSubscription() { + const sourceValue = this.source.get(); + if (sourceValue) { + const target = this.mapper(sourceValue); + if (target) { + if (!this.targetSubscription) { + this.targetSubscription = target.subscribe(() => this.emit(this.get())); + } + return; + } + } + // if no sourceValue or target + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + get(): C | undefined { + const sourceValue = this.source.get(); + if (!sourceValue) { + return undefined; + } + const mapped = this.mapper(sourceValue); + return mapped?.get(); + } +} + export function tests() { return { "set emits an update": assert => { @@ -155,5 +215,34 @@ export function tests() { }); await assert.rejects(handle.promise, AbortError); }, + "flatMap.get": assert => { + const a = new ObservableValue}>(undefined); + const countProxy = a.flatMap(a => a!.count); + assert.strictEqual(countProxy.get(), undefined); + const count = new ObservableValue(0); + a.set({count}); + assert.strictEqual(countProxy.get(), 0); + }, + "flatMap update from source": assert => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + assert.deepEqual(updates, [0]); + }, + "flatMap update from target": assert => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + count.set(5); + assert.deepEqual(updates, [0, 5]); + } } } 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 58d2216f..da8ec8e7 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -24,10 +24,22 @@ export interface IRequestOptions { body?: EncodedBody; headers?: Map; cache?: boolean; - log?: ILogItem; - prefix?: string; method?: string; format?: string; } 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/parsehtml.js b/src/platform/web/parsehtml.js index 1e646d10..e80ec995 100644 --- a/src/platform/web/parsehtml.js +++ b/src/platform/web/parsehtml.js @@ -65,6 +65,5 @@ export function parseHTML(html) { // instead of re-parsing? const sanitized = DOMPurify.sanitize(html, sanitizeConfig); const bodyNode = new DOMParser().parseFromString(`${sanitized}`, "text/html").body; - return new HTMLParseResult(bodyNode); } 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/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index 32a4afb5..e0d41693 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -15,13 +15,13 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView"; -import {SessionBackupSettingsView} from "../session/settings/SessionBackupSettingsView.js"; +import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js"; export class AccountSetupView extends TemplateView { render(t, vm) { return t.div({className: "Settings" /* hack for now to get the layout right*/}, [ t.h3(vm.i18n`Restore your encrypted history?`), - t.ifView(vm => vm.decryptDehydratedDeviceViewModel, vm => new SessionBackupSettingsView(vm.decryptDehydratedDeviceViewModel)), + t.ifView(vm => vm.decryptDehydratedDeviceViewModel, vm => new KeyBackupSettingsView(vm.decryptDehydratedDeviceViewModel)), t.map(vm => vm.deviceDecrypted, (decrypted, t) => { if (decrypted) { return t.p(vm.i18n`That worked out, you're good to go!`); 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/SessionStatusView.js b/src/platform/web/ui/session/SessionStatusView.js index bd8c6dbb..a629b2a1 100644 --- a/src/platform/web/ui/session/SessionStatusView.js +++ b/src/platform/web/ui/session/SessionStatusView.js @@ -26,7 +26,7 @@ export class SessionStatusView extends TemplateView { spinner(t, {hidden: vm => !vm.isWaiting}), t.p(vm => vm.statusLabel), t.if(vm => vm.isConnectNowShown, t => t.button({className: "link", onClick: () => vm.connectNow()}, "Retry now")), - t.if(vm => vm.isSecretStorageShown, t => t.a({href: vm.setupSessionBackupUrl}, "Go to settings")), + t.if(vm => vm.isSecretStorageShown, t => t.a({href: vm.setupKeyBackupUrl}, "Go to settings")), t.if(vm => vm.canDismiss, t => t.div({className: "end"}, t.button({className: "dismiss", onClick: () => vm.dismiss()}))), ]); } 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`)) + ]); + } +} diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 510a676f..c0c0cfb0 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -42,7 +42,8 @@ export class TextMessageView extends BaseMessageView { } })); - const shouldRemove = (element) => element?.nodeType === Node.ELEMENT_NODE && element.className !== "ReplyPreviewView"; + // exclude comment nodes as they are used by t.map and friends for placeholders + const shouldRemove = (element) => element?.nodeType !== Node.COMMENT_NODE && element.className !== "ReplyPreviewView"; t.mapSideEffect(vm => vm.body, body => { while (shouldRemove(container.lastChild)) { diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/KeyBackupSettingsView.js similarity index 62% rename from src/platform/web/ui/session/settings/SessionBackupSettingsView.js rename to src/platform/web/ui/session/settings/KeyBackupSettingsView.js index b8206c55..3f8812c9 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.js @@ -14,25 +14,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView, InlineTemplateView} from "../../general/TemplateView"; -import {StaticView} from "../../general/StaticView.js"; +import {TemplateView} from "../../general/TemplateView"; -export class SessionBackupSettingsView extends TemplateView { - render(t, vm) { - return t.mapView(vm => vm.status, status => { - switch (status) { - case "Enabled": return new InlineTemplateView(vm, renderEnabled) - case "SetupKey": return new InlineTemplateView(vm, renderEnableFromKey) - case "SetupPhrase": return new InlineTemplateView(vm, renderEnableFromPhrase) - case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) - } - }); +export class KeyBackupSettingsView extends TemplateView { + render(t) { + return t.div([ + t.map(vm => vm.status, (status, t, vm) => { + switch (status) { + case "Enabled": return renderEnabled(t, vm); + case "NewVersionAvailable": return renderNewVersionAvailable(t, vm); + case "SetupKey": return renderEnableFromKey(t, vm); + case "SetupPhrase": return renderEnableFromPhrase(t, vm); + case "Pending": return t.p(vm.i18n`Waiting to go online…`); + } + }), + t.map(vm => vm.backupWriteStatus, (status, t, vm) => { + switch (status) { + case "Writing": { + const progress = t.progress({ + min: 0, + max: 100, + value: vm => vm.backupPercentage, + }); + return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]); + } + case "Stopped": { + let label; + const error = vm.backupError; + if (error) { + label = `Backup has stopped because of an error: ${vm.backupError}`; + } else { + label = `Backup has stopped`; + } + return t.p(label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)); + } + case "Done": + return t.p(`All keys are backed up.`); + default: + return null; + } + }) + ]); } } function renderEnabled(t, vm) { const items = [ - t.p([vm.i18n`Session backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) + t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; if (vm.dehydratedDeviceId) { items.push(t.p(vm.i18n`A dehydrated device id was set up with id ${vm.dehydratedDeviceId} which you can use during your next login with your secret storage key.`)); @@ -40,6 +68,13 @@ function renderEnabled(t, vm) { return t.div(items); } +function renderNewVersionAvailable(t, vm) { + const items = [ + t.p([vm.i18n`A new backup version has been created from another device. Disable key backup and enable it again with the new key.`, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) + ]; + return t.div(items); +} + function renderEnableFromKey(t, vm) { const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`); return t.div([ @@ -87,7 +122,7 @@ function renderEnableFieldRow(t, vm, label, callback) { function renderError(t) { return t.if(vm => vm.error, (t, vm) => { return t.div([ - t.p({className: "error"}, vm => vm.i18n`Could not enable session backup: ${vm.error}.`), + t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`), t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`) ]) }); diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 78ca2007..93e44307 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; -import {SessionBackupSettingsView} from "./SessionBackupSettingsView.js" +import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" export class SettingsView extends TemplateView { render(t, vm) { @@ -47,8 +47,8 @@ export class SettingsView extends TemplateView { }, vm.i18n`Log out`)), ); settingNodes.push( - t.h3("Session Backup"), - t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)) + t.h3("Key backup"), + t.view(new KeyBackupSettingsView(vm.keyBackupViewModel)) ); settingNodes.push( diff --git a/src/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts index d03f820a..fba71a8c 100644 --- a/src/utils/AbortableOperation.ts +++ b/src/utils/AbortableOperation.ts @@ -14,27 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {BaseObservableValue, ObservableValue} from "../observable/ObservableValue"; + export interface IAbortable { abort(); } -type RunFn = (setAbortable: (a: IAbortable) => typeof a) => T; +export type SetAbortableFn = (a: IAbortable) => typeof a; +export type SetProgressFn

= (progress: P) => void; +type RunFn = (setAbortable: SetAbortableFn, setProgress: SetProgressFn

) => T; -export class AbortableOperation { +export class AbortableOperation implements IAbortable { public readonly result: T; - private _abortable: IAbortable | null; + private _abortable?: IAbortable; + private _progress: ObservableValue

; - constructor(run: RunFn) { - this._abortable = null; - const setAbortable = abortable => { + constructor(run: RunFn) { + this._abortable = undefined; + const setAbortable: SetAbortableFn = abortable => { this._abortable = abortable; return abortable; }; - this.result = run(setAbortable); + this._progress = new ObservableValue

(undefined); + const setProgress: SetProgressFn

= (progress: P) => { + this._progress.set(progress); + }; + this.result = run(setAbortable, setProgress); + } + + get progress(): BaseObservableValue

{ + return this._progress; } abort() { this._abortable?.abort(); - this._abortable = null; + this._abortable = undefined; } }