Merge branch 'master' into madlittlemods/matrix-public-archive-scratch-changes

Conflicts:
	scripts/sdk/base-manifest.json
	src/platform/web/parsehtml.js
This commit is contained in:
Eric Eastwood 2022-02-14 15:03:07 -06:00
commit 1032f4dbc6
109 changed files with 2794 additions and 664 deletions

View File

@ -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"

View File

@ -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"
}

View File

@ -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();

View File

@ -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";

View File

@ -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;
}

View File

@ -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);

View File

@ -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() {

View File

@ -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);
}
}

View File

@ -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
};
}

View File

@ -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) {

View File

@ -1,5 +1,4 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
},
}
}

View File

@ -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)));

View File

@ -0,0 +1,70 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
},
}
}

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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() {

View File

@ -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();
}
```

View File

@ -0,0 +1,75 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
}
}

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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() {

View File

@ -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";

View File

@ -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 {

View File

@ -67,7 +67,7 @@ export class NullLogItem implements ILogItem {
log(): ILogItem {
return this;
}
set(): void {}
set(): ILogItem { return this; }
runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem {
new Promise(r => r(callback(this))).then(noop, noop);

View File

@ -43,7 +43,7 @@ export interface ILogItem {
readonly values: LogItemValues;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, 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<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): void;
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;

View File

@ -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 &&

View File

@ -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);
}
}

View File

@ -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<KeyBackup | undefined | null}
* - `undefined` means, we're not done with catchup sync yet and haven't checked yet if key backup is configured
* - `null` means we've checked and key backup hasn't been configured correctly or at all.
*/
get keyBackup() {
return this._keyBackup;
}
get hasIdentity() {
@ -384,7 +430,7 @@ export class Session {
// load rooms
const rooms = await txn.roomSummary.getAll();
const roomLoadPromise = Promise.all(rooms.map(async summary => {
const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId));
const room = this.createJoinedRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId));
await log.wrap("room", log => room.load(summary, txn, log));
this._rooms.add(room.id, room);
}));
@ -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;

View File

@ -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;

View File

@ -34,4 +34,4 @@ export function tests() {
assert(!isTxnId("$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_rm"));
},
}
}
}

View File

@ -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 {

View File

@ -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
}
}
}

View File

@ -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();
}
}
}
/**

View File

@ -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});
}
}
}

View File

@ -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<KeyOperation> {
private pickleKey: string;
private olm: any;
private olm: Olm;
private resolveUnusedOperation?: () => void;
private operationBecomesUnusedPromise?: Promise<void>;
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<KeyOperation> {
}
}
async useKey<T>(key: RoomKey, callback: (session: OlmInboundGroupSession, pickleKey: string) => Promise<T> | T): Promise<T> {
async useKey<T>(key: RoomKey, callback: (session: Olm.InboundGroupSession, pickleKey: string) => Promise<T> | T): Promise<T> {
const keyOp = await this.allocateOperation(key);
try {
return await callback(keyOp.session, this.pickleKey);
@ -186,11 +175,11 @@ export class KeyLoader extends BaseLRUCache<KeyOperation> {
}
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

View File

@ -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<boolean> {
// 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<boolean> {
private async _checkBetterThanKeyInStorage(loader: KeyLoader, callback: (((session: Olm.InboundGroupSession, pickleKey: string) => void) | undefined), txn: Transaction): Promise<boolean> {
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) {

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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<AbortableOperation<Promise<void>, 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<IncomingRoomKey | undefined> {
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<number> {
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<Promise<void>, 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<KeyBackupPayload> {
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<SessionInfo> {
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<KeyBackup | undefined> {
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
) {}
}

View File

@ -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}
}
}
}

View File

@ -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<string, any>, body?: Record<string, any>, options?: IRequestOptions, accessToken?: string): IHomeServerRequest {
private _baseRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, 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<string, string | number> = 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<string, any>, body?: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
private _unauthedRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._baseRequest(method, url, queryParams, body, options);
}
private _authedRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
private _authedRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._baseRequest(method, url, queryParams, body, options, this._accessToken);
}
private _post(csPath: string, queryParams: Record<string, any>, body: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
private _post(csPath: string, queryParams: Record<string, any>, body: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._authedRequest("POST", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options);
}
private _put(csPath: string, queryParams: Record<string, any>, body?: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
private _put(csPath: string, queryParams: Record<string, any>, body?: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._authedRequest("PUT", this._url(csPath, options?.prefix || CS_R0_PREFIX), queryParams, body, options);
}
private _get(csPath: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
private _get(csPath: string, queryParams?: Record<string, any>, body?: Record<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
messages(roomId: string, params: Record<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
members(roomId: string, params: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._get(`/rooms/${encodeURIComponent(roomId)}/members`, params, undefined, options);
}
send(roomId: string, eventType: string, txnId: string, content: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
send(roomId: string, eventType: string, txnId: string, content: Record<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
redact(roomId: string, eventId: string, txnId: string, content: Record<string, any>, 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<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
createFilter(userId: string, filter: Record<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
uploadKeys(dehydratedDeviceId: string, payload: Record<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
queryKeys(queryRequest: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._post("/keys/query", {}, queryRequest, options);
}
claimKeys(payload: Record<string, any>, options?: IRequestOptions): IHomeServerRequest {
claimKeys(payload: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._post("/keys/claim", {}, payload, options);
}
sendToDevice(type: string, payload: Record<string, any>, txnId: string, options?: IRequestOptions): IHomeServerRequest {
sendToDevice(type: string, payload: Record<string, any>, 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<string, any>, 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<string, any>, options?: IRequestOptions): IHomeServerRequest {
setPusher(pusher: Record<string, any>, 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<string, any>, options: IRequestOptions): IHomeServerRequest {
createDehydratedDevice(payload: Record<string, any>, 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<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._post(`/createRoom`, {}, payload, options);
}
setAccountData(ownUserId: string, type: string, content: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._put(`/user/${encodeURIComponent(ownUserId)}/account_data/${encodeURIComponent(type)}`, {}, content, options);
}
}
import {Request as MockRequest} from "../../mocks/Request.js";

View File

@ -22,21 +22,32 @@ import type {ILogItem} from "../../logging/types";
export interface IHomeServerRequest {
abort(): void;
response(): Promise<any>;
responseCode(): Promise<number>;
}
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<any>;
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<any> {
return this._promise;
}
async responseCode(): Promise<number> {
const response = await this._sourceRequest.response();
return response.status;
}
}
import {Request as MockRequest} from "../../mocks/Request.js";

View File

@ -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<any>;
private _responseCodePromise: Promise<any>;
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<any> {
return this._responsePromise;
}
responseCode(): Promise<number> {
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;
}
}

51
src/matrix/profile.ts Normal file
View File

@ -0,0 +1,51 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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<Profile[]> {
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; }
}

View File

@ -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<BaseRegistrationStage> {
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<BaseRegistrationStage | undefined> {
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;
}
}

View File

@ -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<string, any>
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;
}
}

View File

@ -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";
}
}

View File

@ -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"];
}
}

View File

@ -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<string, any>;
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;
}

View File

@ -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");

View File

@ -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);
});
}

View File

@ -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) {

View File

@ -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

View File

@ -0,0 +1,258 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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<string, any>}[]
}
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<void> {
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<void> {
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<void> {
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);
}
});
}
}

View File

@ -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);

View File

@ -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() {

View File

@ -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
}

View File

@ -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) {

View File

@ -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";

View File

@ -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(

View File

@ -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 {

View File

@ -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;

View File

@ -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"

View File

@ -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 {

View File

@ -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";

View File

@ -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";

View File

@ -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<KeyDescripti
return new KeyDescription(id, keyAccountData.content as KeyDescriptionData);
}
export async function writeKey(key: Key, txn: Transaction): Promise<void> {
export async function writeKey(key: Key, keyBackupVersion: number, txn: Transaction): Promise<number | undefined> {
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<Key | undefined> {

View File

@ -37,7 +37,8 @@ interface QueryTargetInterface<T> {
openKeyCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest<IDBCursor | null>;
supports(method: string): boolean;
keyPath: string | string[];
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | null>;
count(keyRange?: IDBKeyRange): IDBRequest<number>;
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | undefined>;
getKey(key: IDBValidKey | IDBKeyRange): IDBRequest<IDBValidKey | undefined>;
}
@ -78,7 +79,11 @@ export class QueryTarget<T> {
return this._target.supports(methodName);
}
get(key: IDBValidKey | IDBKeyRange): Promise<T | null> {
count(keyRange?: IDBKeyRange): Promise<number> {
return reqAsPromise(this._target.count(keyRange));
}
get(key: IDBValidKey | IDBKeyRange): Promise<T | undefined> {
return reqAsPromise(this._target.get(key));
}

View File

@ -91,7 +91,7 @@ export class QueryTargetWrapper<T> {
}
}
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | null> {
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | undefined> {
try {
LOG_REQUESTS && logRequest("get", [key], this._qt);
return this._qt.get(key);
@ -118,6 +118,14 @@ export class QueryTargetWrapper<T> {
}
}
count(keyRange?: IDBKeyRange): IDBRequest<number> {
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);

View File

@ -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<void> {
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<void> {
const inboundGroupSessions = txn.objectStore("inboundGroupSessions");
let countWithSession = 0;
let countWithoutSession = 0;
await iterateCursor<InboundGroupSessionEntry>(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);
}

View File

@ -28,7 +28,7 @@ export class AccountDataStore {
this._store = store;
}
async get(type: string): Promise<AccountDataEntry | null> {
async get(type: string): Promise<AccountDataEntry | undefined> {
return await this._store.get(type);
}

View File

@ -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<DeviceIdentity | null> {
get(userId: string, deviceId: string): Promise<DeviceIdentity | undefined> {
return this._store.get(encodeKey(userId, deviceId));
}
@ -74,7 +74,7 @@ export class DeviceIdentityStore {
this._store.put(deviceIdentity);
}
getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | null> {
getByCurve25519Key(curve25519Key: string): Promise<DeviceIdentity | undefined> {
return this._store.index("byCurve25519Key").get(curve25519Key);
}

View File

@ -35,7 +35,7 @@ export class GroupSessionDecryptionStore {
this._store = store;
}
get(roomId: string, sessionId: string, messageIndex: number): Promise<GroupSessionDecryption | null> {
get(roomId: string, sessionId: string, messageIndex: number): Promise<GroupSessionDecryption | undefined> {
return this._store.get(encodeKey(roomId, sessionId, messageIndex));
}

View File

@ -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<InboundGroupSessionEntry | null> {
get(roomId: string, senderKey: string, sessionId: string): Promise<InboundGroupSessionEntry | undefined> {
return this._store.get(encodeKey(roomId, senderKey, sessionId));
}
@ -63,4 +76,31 @@ export class InboundGroupSessionStore {
);
this._store.delete(range);
}
countNonBackedUpSessions(): Promise<number> {
return this._store.index("byBackup").count(this._store.IDBKeyRange.only(BackupStatus.NotBackedUp));
}
getFirstNonBackedUpSessions(amount: number): Promise<InboundGroupSessionEntry[]> {
return this._store.index("byBackup").selectLimit(this._store.IDBKeyRange.only(BackupStatus.NotBackedUp), amount);
}
async markAsBackedUp(roomId: string, senderKey: string, sessionId: string): Promise<void> {
const entry = await this._store.get(encodeKey(roomId, senderKey, sessionId));
if (entry) {
entry.backup = BackupStatus.BackedUp;
this._store.put(entry);
}
}
async markAllAsNotBackedUp(): Promise<number> {
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;
}
}

View File

@ -62,7 +62,7 @@ export class OlmSessionStore {
});
}
get(senderKey: string, sessionId: string): Promise<OlmSession | null> {
get(senderKey: string, sessionId: string): Promise<OlmSession | undefined> {
return this._store.get(encodeKey(senderKey, sessionId));
}

View File

@ -32,7 +32,7 @@ export class OutboundGroupSessionStore {
this._store.delete(roomId);
}
get(roomId: string): Promise<OutboundSession | null> {
get(roomId: string): Promise<OutboundSession | undefined> {
return this._store.get(roomId);
}

View File

@ -46,7 +46,7 @@ export class RoomMemberStore {
this._roomMembersStore = roomMembersStore;
}
get(roomId: string, userId: string): Promise<MemberStorageEntry | null> {
get(roomId: string, userId: string): Promise<MemberStorageEntry | undefined> {
return this._roomMembersStore.get(encodeKey(roomId, userId));
}

View File

@ -36,7 +36,7 @@ export class RoomStateStore {
this._roomStateStore = idbStore;
}
get(roomId: string, type: string, stateKey: string): Promise<RoomStateEntry | null> {
get(roomId: string, type: string, stateKey: string): Promise<RoomStateEntry | undefined> {
const key = encodeKey(roomId, type, stateKey);
return this._roomStateStore.get(key);
}

View File

@ -301,11 +301,11 @@ export class TimelineEventStore {
this._timelineStore.put(entry as TimelineEventStorageEntry);
}
get(roomId: string, eventKey: EventKey): Promise<TimelineEventEntry | null> {
get(roomId: string, eventKey: EventKey): Promise<TimelineEventEntry | undefined> {
return this._timelineStore.get(encodeKey(roomId, eventKey.fragmentId, eventKey.eventIndex));
}
getByEventId(roomId: string, eventId: string): Promise<TimelineEventEntry | null> {
getByEventId(roomId: string, eventId: string): Promise<TimelineEventEntry | undefined> {
return this._timelineStore.index("byEventId").get(encodeEventIdKey(roomId, eventId));
}

View File

@ -83,7 +83,7 @@ export class TimelineFragmentStore {
this._store.put(fragment);
}
get(roomId: string, fragmentId: number): Promise<FragmentEntry | null> {
get(roomId: string, fragmentId: number): Promise<FragmentEntry | undefined> {
return this._store.get(encodeKey(roomId, fragmentId));
}

View File

@ -28,7 +28,7 @@ export class UserIdentityStore {
this._store = store;
}
get(userId: string): Promise<UserIdentity | null> {
get(userId: string): Promise<UserIdentity | undefined> {
return this._store.get(userId);
}

View File

@ -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<T> extends BaseObservable<(value: T) => void> {
@ -34,6 +35,10 @@ export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) =
return new WaitForHandle(this, predicate);
}
}
flatMap<C>(mapper: (value: T) => (BaseObservableValue<C> | undefined)): BaseObservableValue<C | undefined> {
return new FlatMapObservableValue<T, C>(this, mapper);
}
}
interface IWaitHandle<T> {
@ -114,6 +119,61 @@ export class RetainedObservableValue<T> extends ObservableValue<T> {
}
}
export class FlatMapObservableValue<P, C> extends BaseObservableValue<C | undefined> {
private sourceSubscription?: SubscriptionHandle;
private targetSubscription?: SubscriptionHandle;
constructor(
private readonly source: BaseObservableValue<P>,
private readonly mapper: (value: P) => (BaseObservableValue<C> | 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 | {count: ObservableValue<number>}>(undefined);
const countProxy = a.flatMap(a => a!.count);
assert.strictEqual(countProxy.get(), undefined);
const count = new ObservableValue<number>(0);
a.set({count});
assert.strictEqual(countProxy.get(), 0);
},
"flatMap update from source": assert => {
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
const updates: (number | undefined)[] = [];
a.flatMap(a => a!.count).subscribe(count => {
updates.push(count);
});
const count = new ObservableValue<number>(0);
a.set({count});
assert.deepEqual(updates, [0]);
},
"flatMap update from target": assert => {
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
const updates: (number | undefined)[] = [];
a.flatMap(a => a!.count).subscribe(count => {
updates.push(count);
});
const count = new ObservableValue<number>(0);
a.set({count});
count.set(5);
assert.deepEqual(updates, [0, 5]);
}
}
}

View File

@ -0,0 +1,70 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
}
}

View File

@ -24,10 +24,22 @@ export interface IRequestOptions {
body?: EncodedBody;
headers?: Map<string, string|number>;
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;
}

View File

@ -65,6 +65,5 @@ export function parseHTML(html) {
// instead of re-parsing?
const sanitized = DOMPurify.sanitize(html, sanitizeConfig);
const bodyNode = new DOMParser().parseFromString(`<!DOCTYPE html><html><body>${sanitized}</body></html>`, "text/html").body;
return new HTMLParseResult(bodyNode);
}

View File

@ -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 */

View File

@ -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;

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.74986 3.55554C8.74986 3.14133 8.41408 2.80554 7.99986 2.80554C7.58565 2.80554 7.24986 3.14133 7.24986 3.55554V7.24999L3.55542 7.24999C3.14121 7.24999 2.80542 7.58577 2.80542 7.99999C2.80542 8.4142 3.14121 8.74999 3.55542 8.74999L7.24987 8.74999V12.4444C7.24987 12.8586 7.58565 13.1944 7.99987 13.1944C8.41408 13.1944 8.74987 12.8586 8.74987 12.4444V8.74999L12.4443 8.74999C12.8585 8.74999 13.1943 8.4142 13.1943 7.99999C13.1943 7.58577 12.8585 7.24999 12.4443 7.24999L8.74986 7.24999V3.55554Z" fill="#8E99A4"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@ -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;
}

View File

@ -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]);
});
}
}

View File

@ -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!`);

View File

@ -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({

View File

@ -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",

View File

@ -0,0 +1,120 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 (<alias>, or #<alias> or #<alias>: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();
}
}

View File

@ -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);

View File

@ -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()}))),
]);
}

View File

@ -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);
}

View File

@ -1,44 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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"}, "!");
}
})
])
])
]);
}
}

Some files were not shown because too many files have changed in this diff Show More