From 1c2e05e925529afe71910fa43cc6257c15f6cd03 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 31 Aug 2020 14:40:16 -0400 Subject: [PATCH 01/51] initial version of device rehydration support --- src/CrossSigningManager.js | 2 +- src/Login.js | 60 +++++++++++++++++++++++++++++++++++++- src/MatrixClientPeg.ts | 28 ++++++++++++++++-- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 676c41d7d7..43d089010c 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -40,7 +40,7 @@ export class AccessCancelledError extends Error { } } -async function confirmToDismiss() { +export async function confirmToDismiss() { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const [sure] = await Modal.createDialog(QuestionDialog, { title: _t("Cancel entering passphrase?"), diff --git a/src/Login.js b/src/Login.js index 04805b4af9..4e46fc3665 100644 --- a/src/Login.js +++ b/src/Login.js @@ -18,7 +18,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import Modal from './Modal'; +import * as sdk from './index'; +import { AccessCancelledError, confirmToDismiss } from "./CrossSigningManager"; import Matrix from "matrix-js-sdk"; +import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; export default class Login { constructor(hsUrl, isUrl, fallbackHsUrl, opts) { @@ -159,12 +164,18 @@ export default class Login { * @returns {MatrixClientCreds} */ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { + let rehydrationKeyInfo; + let rehydrationKey; + const client = Matrix.createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, + cryptoCallbacks: { + getDehydrationKey + } }); - const data = await client.login(loginType, loginParams); + const data = await client.loginWithRehydration(null, loginType, loginParams); const wellknown = data.well_known; if (wellknown) { @@ -185,5 +196,52 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { userId: data.user_id, deviceId: data.device_id, accessToken: data.access_token, + rehydrationKeyInfo, + rehydrationKey, + olmAccount: data._olm_account, }; } + +async function getDehydrationKey(keyInfo) { + const inputToKey = async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + // FIXME: + return true; + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + return key; +} diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index be16f5fe10..61b7a04069 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -42,6 +42,9 @@ export interface IMatrixClientCreds { accessToken: string; guest: boolean; pickleKey?: string; + rehydrationKey?: Uint8Array; + rehydrationKeyInfo?: {[props: string]: any}; + olmAccount?: any; } // TODO: Move this to the js-sdk @@ -248,12 +251,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { private createClient(creds: IMatrixClientCreds): void { // TODO: Make these opts typesafe with the js-sdk - const opts = { + const opts: any = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, - userId: creds.userId, - deviceId: creds.deviceId, pickleKey: creds.pickleKey, timelineSupport: true, forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'), @@ -268,6 +269,23 @@ class _MatrixClientPeg implements IMatrixClientPeg { cryptoCallbacks: {}, }; + if (creds.olmAccount) { + opts.deviceToImport = { + olmDevice: { + pickledAccount: creds.olmAccount.pickle("DEFAULT_KEY"), + sessions: [], + pickleKey: "DEFAULT_KEY", + }, + userId: creds.userId, + deviceId: creds.deviceId, + }; + } else { + opts.userId = creds.userId; + opts.deviceId = creds.deviceId; + } + + // FIXME: modify crossSigningCallbacks.getSecretStorageKey so that it tries using rehydrationkey and/or saves the passphrase info + // These are always installed regardless of the labs flag so that // cross-signing features can toggle on without reloading and also be // accessed immediately after login. @@ -275,6 +293,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { this.matrixClient = createMatrixClient(opts); + if (creds.rehydrationKey) { + this.matrixClient.cacheDehydrationKey(creds.rehydrationKey, creds.rehydrationKeyInfo || {}); + } + // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. this.matrixClient.setMaxListeners(500); From 4398f1eb949c8f4b5b3e61c51c756bec3c9c97d8 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Thu, 3 Sep 2020 16:28:42 -0400 Subject: [PATCH 02/51] support setting up dehydration from blank account, SSO support, and other fixes --- src/CrossSigningManager.js | 26 +++++++++++ src/Lifecycle.js | 44 ++++++++++++++++++- src/Login.js | 18 +++++--- src/MatrixClientPeg.ts | 24 +++++++--- .../CreateSecretStorageDialog.js | 5 +++ 5 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 43d089010c..111fc26889 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -30,6 +30,16 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; let secretStorageKeys = {}; let secretStorageBeingAccessed = false; +let dehydrationInfo = {}; + +export function cacheDehydrationKey(key, keyInfo = {}) { + dehydrationInfo = {key, keyInfo}; +} + +export function getDehydrationKeyCache() { + return dehydrationInfo; +} + function isCachingAllowed() { return secretStorageBeingAccessed; } @@ -64,6 +74,22 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { return [name, secretStorageKeys[name]]; } + // if we dehydrated a device, see if that key works for SSSS + if (dehydrationInfo.key) { + try { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationInfo.key, info)) { + const key = dehydrationInfo.key; + // Save to cache to avoid future prompts in the current session + if (isCachingAllowed()) { + secretStorageKeys[name] = key; + } + dehydrationInfo = {}; + return [name, key]; + } + } catch {} + dehydrationInfo = {}; + } + const inputToKey = async ({ passphrase, recoveryKey }) => { if (passphrase) { return deriveKey( diff --git a/src/Lifecycle.js b/src/Lifecycle.js index d2de31eb80..9a84d4e1f4 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -42,6 +42,7 @@ import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; +import {decodeBase64, encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -311,6 +312,25 @@ async function _restoreFromLocalStorage(opts) { console.log("No pickle key available"); } + const rehydrationKeyInfoJSON = sessionStorage.getItem("mx_rehydration_key_info"); + const rehydrationKeyInfo = rehydrationKeyInfoJSON && JSON.parse(rehydrationKeyInfoJSON); + const rehydrationKeyB64 = sessionStorage.getItem("mx_rehydration_key"); + const rehydrationKey = rehydrationKeyB64 && decodeBase64(rehydrationKeyB64); + const rehydrationOlmPickle = sessionStorage.getItem("mx_rehydration_account"); + let olmAccount; + if (rehydrationOlmPickle) { + olmAccount = new global.Olm.Account(); + try { + olmAccount.unpickle("DEFAULT_KEY", rehydrationOlmPickle); + } catch { + olmAccount.free(); + olmAccount = undefined; + } + } + sessionStorage.removeItem("mx_rehydration_key_info"); + sessionStorage.removeItem("mx_rehydration_key"); + sessionStorage.removeItem("mx_rehydration_account"); + console.log(`Restoring session for ${userId}`); await _doSetLoggedIn({ userId: userId, @@ -320,6 +340,9 @@ async function _restoreFromLocalStorage(opts) { identityServerUrl: isUrl, guest: isGuest, pickleKey: pickleKey, + rehydrationKey: rehydrationKey, + rehydrationKeyInfo: rehydrationKeyInfo, + olmAccount: olmAccount, }, false); return true; } else { @@ -463,7 +486,13 @@ async function _doSetLoggedIn(credentials, clearStorage) { if (localStorage) { try { - _persistCredentialsToLocalStorage(credentials); + // drop dehydration key and olm account before persisting. (Those + // get persisted for token login, but aren't needed at this point.) + const strippedCredentials = Object.assign({}, credentials); + delete strippedCredentials.rehydrationKeyInfo; + delete strippedCredentials.rehydrationKey; + delete strippedCredentials.olmAcconut; + _persistCredentialsToLocalStorage(strippedCredentials); // The user registered as a PWLU (PassWord-Less User), the generated password // is cached here such that the user can change it at a later time. @@ -528,6 +557,19 @@ function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_device_id", credentials.deviceId); } + // Temporarily save dehydration information if it's provided. This is + // needed for token logins, because the page reloads after the login, so we + // can't keep it in memory. + if (credentials.rehydrationKeyInfo) { + sessionStorage.setItem("mx_rehydration_key_info", JSON.stringify(credentials.rehydrationKeyInfo)); + } + if (credentials.rehydrationKey) { + sessionStorage.setItem("mx_rehydration_key", encodeBase64(credentials.rehydrationKey)); + } + if (credentials.olmAccount) { + sessionStorage.setItem("mx_rehydration_account", credentials.olmAccount.pickle("DEFAULT_KEY")); + } + console.log(`Session persisted for ${credentials.userId}`); } diff --git a/src/Login.js b/src/Login.js index 4e46fc3665..0563952c5d 100644 --- a/src/Login.js +++ b/src/Login.js @@ -20,7 +20,12 @@ limitations under the License. import Modal from './Modal'; import * as sdk from './index'; -import { AccessCancelledError, confirmToDismiss } from "./CrossSigningManager"; +import { + AccessCancelledError, + cacheDehydrationKey, + confirmToDismiss, + getDehydrationKeyCache, +} from "./CrossSigningManager"; import Matrix from "matrix-js-sdk"; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; @@ -164,9 +169,6 @@ export default class Login { * @returns {MatrixClientCreds} */ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { - let rehydrationKeyInfo; - let rehydrationKey; - const client = Matrix.createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -190,14 +192,16 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { } } + const dehydrationKeyCache = getDehydrationKeyCache(); + return { homeserverUrl: hsUrl, identityServerUrl: isUrl, userId: data.user_id, deviceId: data.device_id, accessToken: data.access_token, - rehydrationKeyInfo, - rehydrationKey, + rehydrationKeyInfo: dehydrationKeyCache.keyInfo, + rehydrationKey: dehydrationKeyCache.key, olmAccount: data._olm_account, }; } @@ -243,5 +247,7 @@ async function getDehydrationKey(keyInfo) { throw new AccessCancelledError(); } const key = await inputToKey(input); + // need to copy the key because rehydration (unpickling) will clobber it + cacheDehydrationKey(new Uint8Array(key), keyInfo); return key; } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 61b7a04069..18af378fac 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { crossSigningCallbacks } from './CrossSigningManager'; +import { cacheDehydrationKey, crossSigningCallbacks } from './CrossSigningManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { @@ -270,33 +270,43 @@ class _MatrixClientPeg implements IMatrixClientPeg { }; if (creds.olmAccount) { + console.log("got a dehydrated account"); opts.deviceToImport = { olmDevice: { - pickledAccount: creds.olmAccount.pickle("DEFAULT_KEY"), + pickledAccount: creds.olmAccount.pickle(creds.pickleKey || "DEFAULT_KEY"), sessions: [], - pickleKey: "DEFAULT_KEY", + pickleKey: creds.pickleKey || "DEFAULT_KEY", }, userId: creds.userId, deviceId: creds.deviceId, }; + creds.olmAccount.free(); } else { opts.userId = creds.userId; opts.deviceId = creds.deviceId; } - // FIXME: modify crossSigningCallbacks.getSecretStorageKey so that it tries using rehydrationkey and/or saves the passphrase info - // These are always installed regardless of the labs flag so that // cross-signing features can toggle on without reloading and also be // accessed immediately after login. Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); - this.matrixClient = createMatrixClient(opts); + // set dehydration key after cross-signing gets set up -- we wait until + // cross-signing is set up because we want to cross-sign the dehydrated + // key + const origGetSecretStorageKey = opts.cryptoCallbacks.getSecretStorageKey + opts.cryptoCallbacks.getSecretStorageKey = async (keyinfo, ssssItemName) => { + const [name, key] = await origGetSecretStorageKey(keyinfo, ssssItemName); + this.matrixClient.setDehydrationKey(key, {passphrase: keyinfo.keys[name].passphrase}); + return [name, key]; + } if (creds.rehydrationKey) { - this.matrixClient.cacheDehydrationKey(creds.rehydrationKey, creds.rehydrationKeyInfo || {}); + cacheDehydrationKey(creds.rehydrationKey, creds.rehydrationKeyInfo); } + this.matrixClient = createMatrixClient(opts); + // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. this.matrixClient.setMaxListeners(500); diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index 53b3033330..b1c9dc5a60 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -304,6 +304,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } + const dehydrationKeyInfo = + this._recoveryKey.keyInfo && this._recoveryKey.keyInfo.passphrase + ? {passphrase: this._recoveryKey.keyInfo.passphrase} + : {}; + await cli.setDehydrationKey(this._recoveryKey.privateKey, dehydrationKeyInfo); this.props.onFinished(true); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { From 999b5afa0ad9c40a810a39dd2d77a92ed8ab6b09 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 4 Sep 2020 21:41:14 -0600 Subject: [PATCH 03/51] Acknowledge the visibility request --- src/FromWidgetPostMessageApi.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index d5d7c08d50..bbccc47d28 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -218,6 +218,9 @@ export default class FromWidgetPostMessageApi { if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { ActiveWidgetStore.setWidgetPersistence(widgetId, val); } + + // acknowledge + this.sendResponse(event, {}); } else if (action === 'get_openid') { // Handled by caller } else { From b1b7215532596acac7fe17fd83f65ca317dd8e7d Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 18 Sep 2020 18:08:17 -0400 Subject: [PATCH 04/51] fix lint and merge issues --- src/Login.js | 6 +++--- src/SecurityManager.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Login.js b/src/Login.js index 0563952c5d..c04b086afa 100644 --- a/src/Login.js +++ b/src/Login.js @@ -25,7 +25,7 @@ import { cacheDehydrationKey, confirmToDismiss, getDehydrationKeyCache, -} from "./CrossSigningManager"; +} from "./SecurityManager"; import Matrix from "matrix-js-sdk"; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; @@ -173,8 +173,8 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { baseUrl: hsUrl, idBaseUrl: isUrl, cryptoCallbacks: { - getDehydrationKey - } + getDehydrationKey, + }, }); const data = await client.loginWithRehydration(null, loginType, loginParams); diff --git a/src/SecurityManager.js b/src/SecurityManager.js index e8bd63d2ff..967c0cc266 100644 --- a/src/SecurityManager.js +++ b/src/SecurityManager.js @@ -91,7 +91,7 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { // if we dehydrated a device, see if that key works for SSSS if (dehydrationInfo.key) { try { - if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationInfo.key, info)) { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationInfo.key, keyInfo)) { const key = dehydrationInfo.key; // Save to cache to avoid future prompts in the current session if (isCachingAllowed()) { From 4e2397a79db8242f6ff04f9b5c5e61693d21533c Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 18 Sep 2020 20:53:39 -0400 Subject: [PATCH 05/51] doc fixes and minor code improvement --- src/MatrixClientPeg.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index a5fa0fb3cf..84bc610896 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -271,11 +271,12 @@ class _MatrixClientPeg implements IMatrixClientPeg { if (creds.olmAccount) { console.log("got a dehydrated account"); + const pickleKey = creds.pickleKey || "DEFAULT_KEY"; opts.deviceToImport = { olmDevice: { - pickledAccount: creds.olmAccount.pickle(creds.pickleKey || "DEFAULT_KEY"), + pickledAccount: creds.olmAccount.pickle(pickleKey), sessions: [], - pickleKey: creds.pickleKey || "DEFAULT_KEY", + pickleKey: pickleKey, }, userId: creds.userId, deviceId: creds.deviceId, @@ -293,7 +294,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { // set dehydration key after cross-signing gets set up -- we wait until // cross-signing is set up because we want to cross-sign the dehydrated - // key + // device const origGetSecretStorageKey = opts.cryptoCallbacks.getSecretStorageKey opts.cryptoCallbacks.getSecretStorageKey = async (keyinfo, ssssItemName) => { const [name, key] = await origGetSecretStorageKey(keyinfo, ssssItemName); @@ -302,6 +303,8 @@ class _MatrixClientPeg implements IMatrixClientPeg { } if (creds.rehydrationKey) { + // cache the key so that the SSSS prompt tries using it without + // prompting the user cacheDehydrationKey(creds.rehydrationKey, creds.rehydrationKeyInfo); } From 634ffb0140d2e13e6b9af32400e024ead6b5c577 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 25 Sep 2020 09:39:21 -0600 Subject: [PATCH 06/51] Add structure for widget messaging layer --- src/stores/widgets/SdkWidgetDriver.ts | 34 ++++++ src/stores/widgets/WidgetMessagingStore.ts | 117 +++++++++++++++++++++ src/stores/widgets/WidgetSurrogate.ts | 25 +++++ src/utils/iterables.ts | 21 ++++ src/utils/maps.ts | 17 +++ 5 files changed, 214 insertions(+) create mode 100644 src/stores/widgets/SdkWidgetDriver.ts create mode 100644 src/stores/widgets/WidgetMessagingStore.ts create mode 100644 src/stores/widgets/WidgetSurrogate.ts create mode 100644 src/utils/iterables.ts diff --git a/src/stores/widgets/SdkWidgetDriver.ts b/src/stores/widgets/SdkWidgetDriver.ts new file mode 100644 index 0000000000..1462303fa3 --- /dev/null +++ b/src/stores/widgets/SdkWidgetDriver.ts @@ -0,0 +1,34 @@ +/* + * 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 { Capability, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; +import { iterableUnion } from "../../utils/iterables"; + +export class SdkWidgetDriver extends WidgetDriver { + public constructor( + private widget: Widget, + private widgetKind: WidgetKind, + private locationEntityId: string, + private preapprovedCapabilities: Set = new Set(), + ) { + super(); + } + + public async validateCapabilities(requested: Set): Promise> { + // TODO: Prompt the user to accept capabilities + return iterableUnion(requested, this.preapprovedCapabilities); + } +} diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts new file mode 100644 index 0000000000..6d05cae8c6 --- /dev/null +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -0,0 +1,117 @@ +/* + * 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 { ClientWidgetApi, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { WidgetSurrogate } from "./WidgetSurrogate"; +import { SdkWidgetDriver } from "./SdkWidgetDriver"; +import { EnhancedMap } from "../../utils/maps"; + +/** + * Temporary holding store for widget messaging instances. This is eventually + * going to be merged with a more complete WidgetStore, but for now it's + * easiest to split this into a single place. + */ +export class WidgetMessagingStore extends AsyncStoreWithClient { + private static internalInstance = new WidgetMessagingStore(); + + // > + private widgetMap = new EnhancedMap>(); + + public constructor() { + super(defaultDispatcher); + } + + public static get instance(): WidgetMessagingStore { + return WidgetMessagingStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise { + // nothing to do + } + + protected async onReady(): Promise { + // just in case + this.widgetMap.clear(); + } + + /** + * Gets the messaging instance for the widget. Returns a falsey value if none + * is present. + * @param {Room} room The room for which the widget lives within. + * @param {Widget} widget The widget to get messaging for. + * @returns {ClientWidgetApi} The messaging, or a falsey value. + */ + public messagingForRoomWidget(room: Room, widget: Widget): ClientWidgetApi { + return this.widgetMap.get(room.roomId)?.get(widget.id)?.messaging; + } + + /** + * Gets the messaging instance for the widget. Returns a falsey value if none + * is present. + * @param {Widget} widget The widget to get messaging for. + * @returns {ClientWidgetApi} The messaging, or a falsey value. + */ + public messagingForAccountWidget(widget: Widget): ClientWidgetApi { + return this.widgetMap.get(this.matrixClient?.getUserId())?.get(widget.id)?.messaging; + } + + private generateMessaging(locationId: string, widget: Widget, iframe: HTMLIFrameElement, driver: WidgetDriver) { + const messaging = new ClientWidgetApi(widget, iframe, driver); + this.widgetMap.getOrCreate(locationId, new EnhancedMap()) + .getOrCreate(widget.id, new WidgetSurrogate(widget, messaging)); + return messaging; + } + + /** + * Generates a messaging instance for the widget. If an instance already exists, it + * will be returned instead. + * @param {Room} room The room in which the widget lives. + * @param {Widget} widget The widget to generate/get messaging for. + * @param {HTMLIFrameElement} iframe The widget's iframe. + * @returns {ClientWidgetApi} The generated/cached messaging. + */ + public generateMessagingForRoomWidget(room: Room, widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { + const existing = this.messagingForRoomWidget(room, widget); + if (existing) return existing; + + const driver = new SdkWidgetDriver(widget, WidgetKind.Room, room.roomId); + return this.generateMessaging(room.roomId, widget, iframe, driver); + } + + /** + * Generates a messaging instance for the widget. If an instance already exists, it + * will be returned instead. + * @param {Widget} widget The widget to generate/get messaging for. + * @param {HTMLIFrameElement} iframe The widget's iframe. + * @returns {ClientWidgetApi} The generated/cached messaging. + */ + public generateMessagingForAccountWidget(widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { + if (!this.matrixClient) { + throw new Error("No matrix client to create account widgets with"); + } + + const existing = this.messagingForAccountWidget(widget); + if (existing) return existing; + + const userId = this.matrixClient.getUserId(); + const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId); + return this.generateMessaging(userId, widget, iframe, driver); + } +} diff --git a/src/stores/widgets/WidgetSurrogate.ts b/src/stores/widgets/WidgetSurrogate.ts new file mode 100644 index 0000000000..4d482124a6 --- /dev/null +++ b/src/stores/widgets/WidgetSurrogate.ts @@ -0,0 +1,25 @@ +/* + * 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 { ClientWidgetApi, Widget } from "matrix-widget-api"; + +export class WidgetSurrogate { + public constructor( + public readonly definition: Widget, + public readonly messaging: ClientWidgetApi, + ) { + } +} diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts new file mode 100644 index 0000000000..3d2585906d --- /dev/null +++ b/src/utils/iterables.ts @@ -0,0 +1,21 @@ +/* + * 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 { arrayUnion } from "./arrays"; + +export function iterableUnion, T>(a: C, b: C): Set { + return new Set(arrayUnion(Array.from(a), Array.from(b))); +} diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 96832094f0..630e0af286 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -44,3 +44,20 @@ export function mapKeyChanges(a: Map, b: Map): K[] { const diff = mapDiff(a, b); return arrayMerge(diff.removed, diff.added, diff.changed); } + +/** + * A Map with added utility. + */ +export class EnhancedMap extends Map { + public constructor(entries?: Iterable<[K, V]>) { + super(entries); + } + + public getOrCreate(key: K, def: V): V { + if (this.has(key)) { + return this.get(key); + } + this.set(key, def); + return def; + } +} From 96fa34eecfc251507b9e4788a3cdcb1214694d40 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 26 Sep 2020 18:40:26 -0600 Subject: [PATCH 07/51] Add stop functions --- src/stores/widgets/WidgetMessagingStore.ts | 20 ++++++++++++++++++++ src/utils/maps.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 6d05cae8c6..dfa8eed943 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -114,4 +114,24 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId); return this.generateMessaging(userId, widget, iframe, driver); } + + /** + * Stops the messaging instance for the widget, unregistering it. + * @param {Room} room The room where the widget resides. + * @param {Widget} widget The widget + */ + public stopMessagingForRoomWidget(room: Room, widget: Widget) { + const api = this.widgetMap.getOrCreate(room.roomId, new EnhancedMap()).remove(widget.id); + if (api) api.messaging.stop(); + } + + /** + * Stops the messaging instance for the widget, unregistering it. + * @param {Widget} widget The widget + */ + public stopMessagingForAccountWidget(widget: Widget) { + if (!this.matrixClient) return; + const api = this.widgetMap.getOrCreate(this.matrixClient.getUserId(), new EnhancedMap()).remove(widget.id); + if (api) api.messaging.stop(); + } } diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 630e0af286..57d84bd33f 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -60,4 +60,10 @@ export class EnhancedMap extends Map { this.set(key, def); return def; } + + public remove(key: K): V { + const v = this.get(key); + this.delete(key); + return v; + } } From 68734026667afb708f80ff8d765f33ad94f19e81 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 28 Sep 2020 15:47:03 +0100 Subject: [PATCH 08/51] Convert emojipicker to typescript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../emojipicker/{Category.js => Category.tsx} | 61 ++++++--- .../views/emojipicker/{Emoji.js => Emoji.tsx} | 20 +-- .../{EmojiPicker.js => EmojiPicker.tsx} | 118 ++++++++++-------- .../emojipicker/{Header.js => Header.tsx} | 28 +++-- .../emojipicker/{Preview.js => Preview.tsx} | 13 +- .../{QuickReactions.js => QuickReactions.tsx} | 46 ++++--- .../{ReactionPicker.js => ReactionPicker.tsx} | 49 ++++---- .../emojipicker/{Search.js => Search.tsx} | 36 +++--- 8 files changed, 211 insertions(+), 160 deletions(-) rename src/components/views/emojipicker/{Category.js => Category.tsx} (68%) rename src/components/views/emojipicker/{Emoji.js => Emoji.tsx} (81%) rename src/components/views/emojipicker/{EmojiPicker.js => EmojiPicker.tsx} (70%) rename src/components/views/emojipicker/{Header.js => Header.tsx} (83%) rename src/components/views/emojipicker/{Preview.js => Preview.tsx} (88%) rename src/components/views/emojipicker/{QuickReactions.js => QuickReactions.tsx} (69%) rename src/components/views/emojipicker/{ReactionPicker.js => ReactionPicker.tsx} (77%) rename src/components/views/emojipicker/{Search.js => Search.tsx} (64%) diff --git a/src/components/views/emojipicker/Category.js b/src/components/views/emojipicker/Category.tsx similarity index 68% rename from src/components/views/emojipicker/Category.js rename to src/components/views/emojipicker/Category.tsx index eb3f83dcdf..c4feaac8ae 100644 --- a/src/components/views/emojipicker/Category.js +++ b/src/components/views/emojipicker/Category.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan +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. @@ -14,32 +15,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {RefObject} from 'react'; + import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker"; -import * as sdk from '../../../index'; +import LazyRenderList from "../elements/LazyRenderList"; +import {DATA_BY_CATEGORY, IEmoji} from "../../../emoji"; +import Emoji from './Emoji'; const OVERFLOW_ROWS = 3; -class Category extends React.PureComponent { - static propTypes = { - emojis: PropTypes.arrayOf(PropTypes.object).isRequired, - name: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, - onMouseEnter: PropTypes.func.isRequired, - onMouseLeave: PropTypes.func.isRequired, - onClick: PropTypes.func.isRequired, - selectedEmojis: PropTypes.instanceOf(Set), - }; +export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent"; - _renderEmojiRow = (rowIndex) => { +export interface ICategory { + id: CategoryKey; + name: string; + enabled: boolean; + visible: boolean; + ref: RefObject; +} + +interface IProps { + id: string; + name: string; + emojis: IEmoji[]; + selectedEmojis: Set; + heightBefore: number; + viewportHeight: number; + scrollTop: number; + onClick(emoji: IEmoji): void; + onMouseEnter(emoji: IEmoji): void; + onMouseLeave(emoji: IEmoji): void; +} + +class Category extends React.PureComponent { + private renderEmojiRow = (rowIndex: number) => { const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); - const Emoji = sdk.getComponent("emojipicker.Emoji"); return (
{ - emojisForRow.map(emoji => - ) + emojisForRow.map(emoji => (( + + ))) }
); }; @@ -52,7 +74,6 @@ class Category extends React.PureComponent { for (let counter = 0; counter < rows.length; ++counter) { rows[counter] = counter; } - const LazyRenderList = sdk.getComponent('elements.LazyRenderList'); const viewportTop = scrollTop; const viewportBottom = viewportTop + viewportHeight; @@ -84,7 +105,7 @@ class Category extends React.PureComponent { height={localHeight} overflowItems={OVERFLOW_ROWS} overflowMargin={0} - renderItem={this._renderEmojiRow}> + renderItem={this.renderEmojiRow}> ); diff --git a/src/components/views/emojipicker/Emoji.js b/src/components/views/emojipicker/Emoji.tsx similarity index 81% rename from src/components/views/emojipicker/Emoji.js rename to src/components/views/emojipicker/Emoji.tsx index 36aa4ff782..5d715fb935 100644 --- a/src/components/views/emojipicker/Emoji.js +++ b/src/components/views/emojipicker/Emoji.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan +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. @@ -15,18 +16,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import {MenuItem} from "../../structures/ContextMenu"; +import {IEmoji} from "../../../emoji"; -class Emoji extends React.PureComponent { - static propTypes = { - onClick: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - emoji: PropTypes.object.isRequired, - selectedEmojis: PropTypes.instanceOf(Set), - }; +interface IProps { + emoji: IEmoji; + selectedEmojis?: Set; + onClick(emoji: IEmoji): void; + onMouseEnter(emoji: IEmoji): void; + onMouseLeave(emoji: IEmoji): void; +} +class Emoji extends React.PureComponent { render() { const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); diff --git a/src/components/views/emojipicker/EmojiPicker.js b/src/components/views/emojipicker/EmojiPicker.tsx similarity index 70% rename from src/components/views/emojipicker/EmojiPicker.js rename to src/components/views/emojipicker/EmojiPicker.tsx index 16a0fc67e7..3aa6b109b2 100644 --- a/src/components/views/emojipicker/EmojiPicker.js +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan +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. @@ -15,25 +16,43 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; - import * as recent from '../../../emojipicker/recent'; -import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji"; +import {DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji} from "../../../emoji"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import Header from "./Header"; +import Search from "./Search"; +import Preview from "./Preview"; +import QuickReactions from "./QuickReactions"; +import Category, {ICategory, CategoryKey} from "./Category"; export const CATEGORY_HEADER_HEIGHT = 22; export const EMOJI_HEIGHT = 37; export const EMOJIS_PER_ROW = 8; -class EmojiPicker extends React.Component { - static propTypes = { - onChoose: PropTypes.func.isRequired, - selectedEmojis: PropTypes.instanceOf(Set), - showQuickReactions: PropTypes.bool, - }; +interface IProps { + selectedEmojis: Set; + showQuickReactions?: boolean; + onChoose(unicode: string): boolean; +} + +interface IState { + filter: string; + previewEmoji?: IEmoji; + scrollTop: number; + // initial estimation of height, dialog is hardcoded to 450px height. + // should be enough to never have blank rows of emojis as + // 3 rows of overflow are also rendered. The actual value is updated on scroll. + viewportHeight: number; +} + +class EmojiPicker extends React.Component { + private readonly recentlyUsed: IEmoji[]; + private readonly memoizedDataByCategory: Record; + private readonly categories: ICategory[]; + + private bodyRef = React.createRef(); constructor(props) { super(props); @@ -42,9 +61,6 @@ class EmojiPicker extends React.Component { filter: "", previewEmoji: null, scrollTop: 0, - // initial estimation of height, dialog is hardcoded to 450px height. - // should be enough to never have blank rows of emojis as - // 3 rows of overflow are also rendered. The actual value is updated on scroll. viewportHeight: 280, }; @@ -110,18 +126,9 @@ class EmojiPicker extends React.Component { visible: false, ref: React.createRef(), }]; - - this.bodyRef = React.createRef(); - - this.onChangeFilter = this.onChangeFilter.bind(this); - this.onHoverEmoji = this.onHoverEmoji.bind(this); - this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this); - this.onClickEmoji = this.onClickEmoji.bind(this); - this.scrollToCategory = this.scrollToCategory.bind(this); - this.updateVisibility = this.updateVisibility.bind(this); } - onScroll = () => { + private onScroll = () => { const body = this.bodyRef.current; this.setState({ scrollTop: body.scrollTop, @@ -130,7 +137,7 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; - updateVisibility() { + private updateVisibility = () => { const body = this.bodyRef.current; const rect = body.getBoundingClientRect(); for (const cat of this.categories) { @@ -147,21 +154,21 @@ class EmojiPicker extends React.Component { // We update this here instead of through React to avoid re-render on scroll. if (cat.visible) { cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible"); - cat.ref.current.setAttribute("aria-selected", true); - cat.ref.current.setAttribute("tabindex", 0); + cat.ref.current.setAttribute("aria-selected", "true"); + cat.ref.current.setAttribute("tabindex", "0"); } else { cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible"); - cat.ref.current.setAttribute("aria-selected", false); - cat.ref.current.setAttribute("tabindex", -1); + cat.ref.current.setAttribute("aria-selected", "false"); + cat.ref.current.setAttribute("tabindex", "-1"); } } - } + }; - scrollToCategory(category) { + private scrollToCategory = (category: string) => { this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView(); - } + }; - onChangeFilter(filter) { + private onChangeFilter = (filter: string) => { filter = filter.toLowerCase(); // filter is case insensitive stored lower-case for (const cat of this.categories) { let emojis; @@ -181,27 +188,27 @@ class EmojiPicker extends React.Component { // Header underlines need to be updated, but updating requires knowing // where the categories are, so we wait for a tick. setTimeout(this.updateVisibility, 0); - } + }; - onHoverEmoji(emoji) { + private onHoverEmoji = (emoji: IEmoji) => { this.setState({ previewEmoji: emoji, }); - } + }; - onHoverEmojiEnd(emoji) { + private onHoverEmojiEnd = (emoji: IEmoji) => { this.setState({ previewEmoji: null, }); - } + }; - onClickEmoji(emoji) { + private onClickEmoji = (emoji: IEmoji) => { if (this.props.onChoose(emoji.unicode) !== false) { recent.add(emoji.unicode); } - } + }; - _categoryHeightForEmojiCount(count) { + private static categoryHeightForEmojiCount(count: number) { if (count === 0) { return 0; } @@ -209,25 +216,30 @@ class EmojiPicker extends React.Component { } render() { - const Header = sdk.getComponent("emojipicker.Header"); - const Search = sdk.getComponent("emojipicker.Search"); - const Category = sdk.getComponent("emojipicker.Category"); - const Preview = sdk.getComponent("emojipicker.Preview"); - const QuickReactions = sdk.getComponent("emojipicker.QuickReactions"); let heightBefore = 0; return (
-
+
- this.bodyRef.current = e} onScroll={this.onScroll}> + {this.categories.map(category => { const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = (); - const height = this._categoryHeightForEmojiCount(emojis.length); + const categoryElement = (( + + )); + const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); heightBefore += height; return categoryElement; })} diff --git a/src/components/views/emojipicker/Header.js b/src/components/views/emojipicker/Header.tsx similarity index 83% rename from src/components/views/emojipicker/Header.js rename to src/components/views/emojipicker/Header.tsx index c53437e02d..9a93722483 100644 --- a/src/components/views/emojipicker/Header.js +++ b/src/components/views/emojipicker/Header.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan +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. @@ -15,19 +16,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from "classnames"; import {_t} from "../../../languageHandler"; import {Key} from "../../../Keyboard"; +import {CategoryKey, ICategory} from "./Category"; -class Header extends React.PureComponent { - static propTypes = { - categories: PropTypes.arrayOf(PropTypes.object).isRequired, - onAnchorClick: PropTypes.func.isRequired, - }; +interface IProps { + categories: ICategory[]; + onAnchorClick(id: CategoryKey): void +} - findNearestEnabled(index, delta) { +class Header extends React.PureComponent { + private findNearestEnabled(index: number, delta: number) { index += this.props.categories.length; const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories]; @@ -37,12 +38,12 @@ class Header extends React.PureComponent { } } - changeCategoryRelative(delta) { + private changeCategoryRelative(delta: number) { const current = this.props.categories.findIndex(c => c.visible); this.changeCategoryAbsolute(current + delta, delta); } - changeCategoryAbsolute(index, delta=1) { + private changeCategoryAbsolute(index: number, delta=1) { const category = this.props.categories[this.findNearestEnabled(index, delta)]; if (category) { this.props.onAnchorClick(category.id); @@ -52,7 +53,7 @@ class Header extends React.PureComponent { // Implements ARIA Tabs with Automatic Activation pattern // https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html - onKeyDown = (ev) => { + private onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; switch (ev.key) { case Key.ARROW_LEFT: @@ -80,7 +81,12 @@ class Header extends React.PureComponent { render() { return ( -