diff --git a/package.json b/package.json index 3ab523ee9a..e66d0aabcf 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.2", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/release.sh b/release.sh index 23b8822041..e2cefcbe74 100755 --- a/release.sh +++ b/release.sh @@ -9,6 +9,9 @@ set -e cd `dirname $0` +# This link seems to get eaten by the release process, so ensure it exists. +yarn link matrix-js-sdk + for i in matrix-js-sdk do echo "Checking version of $i..." diff --git a/res/css/_components.scss b/res/css/_components.scss index 4c253c7d9a..261b35690e 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -141,6 +141,7 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index e0814182f5..29e6fecd34 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -133,6 +133,10 @@ limitations under the License. .mx_RoomDirectory_topic { cursor: initial; color: $light-fg-color; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; } .mx_RoomDirectory_alias { diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss new file mode 100644 index 0000000000..3e51e89744 --- /dev/null +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -0,0 +1,55 @@ +/* +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. +*/ + +.mx_MJitsiWidgetEvent { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before { + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: $composer-e2e-icon-color; // XXX: Variable abuse + margin-top: 4px; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + + .mx_MJitsiWidgetEvent_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_MJitsiWidgetEvent_subtitle { + grid-column: 2; + grid-row: 2; + } + + .mx_MJitsiWidgetEvent_title, + .mx_MJitsiWidgetEvent_subtitle { + overflow-wrap: break-word; + } +} diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index fee3d61153..451704bd88 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -$MiniAppTileHeight: 114px; +$MiniAppTileHeight: 200px; .mx_AppsDrawer { margin: 5px 5px 5px 18px; @@ -78,10 +78,6 @@ $MiniAppTileHeight: 114px; font-size: $font-12px; } -.mx_AddWidget_button_full_width { - max-width: 960px; -} - .mx_SetAppURLDialog_input { border-radius: 3px; border: 1px solid $input-border-color; @@ -92,7 +88,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTile { - max-width: 960px; width: 50%; border: 5px solid $widget-menu-bar-bg-color; border-radius: 4px; @@ -105,7 +100,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTileFullWidth { - max-width: 960px; width: 100%; margin: 0; padding: 0; @@ -116,7 +110,6 @@ $MiniAppTileHeight: 114px; } .mx_AppTile_mini { - max-width: 960px; width: 100%; margin: 0; padding: 0; @@ -220,9 +213,10 @@ $MiniAppTileHeight: 114px; } .mx_AppTileBody_mini { - height: 112px; + height: $MiniAppTileHeight; width: 100%; overflow: hidden; + border-radius: 8px; } .mx_AppTile .mx_AppTileBody, diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index a403a8dc4c..71c0db947e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -217,7 +217,7 @@ limitations under the License. } } - &.mx_MessageComposer_hangup::before { + &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before { background-color: $warning-color; } } diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss index 52a0ee95d7..4a1f57a00e 100644 --- a/res/css/views/settings/_AvatarSetting.scss +++ b/res/css/views/settings/_AvatarSetting.scss @@ -16,6 +16,7 @@ limitations under the License. .mx_AvatarSetting_avatar { width: 90px; + min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words height: 90px; margin-top: 8px; position: relative; diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 4d26d8a312..759797ae7b 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -23,9 +23,16 @@ limitations under the License. z-index: 100; box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); - cursor: pointer; + // Disable pointer events for Jitsi widgets to function. Direct + // calls have their own cursor and behaviour, but we need to make + // sure the cursor hits the iframe for Jitsi which will be at a + // different level. + pointer-events: none; .mx_CallPreview { + pointer-events: initial; // restore pointer events so the user can leave/interact + cursor: pointer; + .mx_VideoView { width: 350px; } @@ -37,7 +44,7 @@ limitations under the License. } .mx_AppTile_persistedWrapper div { - min-width: 300px; + min-width: 350px; } .mx_IncomingCallBox { @@ -45,11 +52,14 @@ limitations under the License. background-color: $primary-bg-color; padding: 8px; + pointer-events: initial; // restore pointer events so the user can accept/decline + cursor: pointer; + .mx_IncomingCallBox_CallerInfo { display: flex; direction: row; - img { + img, .mx_BaseAvatar_initial { margin: 8px; } diff --git a/src/Avatar.js b/src/Avatar.js index d76ea6f2c4..1c1182b98d 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -82,6 +82,7 @@ function urlForColor(color) { const colorToDataURLCache = new Map(); export function defaultAvatarUrlForString(s) { + if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; let total = 0; for (let i = 0; i < s.length; ++i) { diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 62b91f938b..5b368016b6 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -74,6 +74,9 @@ import {base32} from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import WidgetStore from "./stores/WidgetStore"; +import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; // until we ts-ify the js-sdk voip code type Call = any; @@ -110,11 +113,9 @@ export default class CallHandler { } getAnyActiveCall() { - const roomsWithCalls = Object.keys(this.calls); - for (let i = 0; i < roomsWithCalls.length; i++) { - if (this.calls.get(roomsWithCalls[i]) && - this.calls.get(roomsWithCalls[i]).call_state !== "ended") { - return this.calls.get(roomsWithCalls[i]); + for (const call of this.calls.values()) { + if (call.state !== "ended") { + return call; } } return null; @@ -180,7 +181,7 @@ export default class CallHandler { }); }); call.on("hangup", () => { - this.setCallState(undefined, call.roomId, "ended"); + this.removeCallForRoom(call.roomId); }); // map web rtc states to dummy UI state // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing @@ -192,7 +193,7 @@ export default class CallHandler { this.setCallState(call, call.roomId, "ringback"); this.play("ringbackAudio"); } else if (newState === "ended" && oldState === "connected") { - this.setCallState(undefined, call.roomId, "ended"); + this.removeCallForRoom(call.roomId); this.pause("ringbackAudio"); this.play("callendAudio"); } else if (newState === "ended" && oldState === "invite_sent" && @@ -223,7 +224,11 @@ export default class CallHandler { console.log( `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, ); - this.calls.set(roomId, call); + if (call) { + this.calls.set(roomId, call); + } else { + this.calls.delete(roomId); + } if (status === "ringing") { this.play("ringAudio"); @@ -241,6 +246,10 @@ export default class CallHandler { }); } + private removeCallForRoom(roomId: string) { + this.setCallState(null, roomId, null); + } + private showICEFallbackPrompt() { const cli = MatrixClientPeg.get(); const code = sub => <code>{sub}</code>; @@ -283,7 +292,7 @@ export default class CallHandler { } else if (payload.type === 'screensharing') { const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); if (screenCapErrorString) { - this.setCallState(undefined, newCall.roomId, "ended"); + this.removeCallForRoom(newCall.roomId); console.log("Can't capture screen: " + screenCapErrorString); Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { title: _t('Unable to capture screen'), @@ -351,6 +360,14 @@ export default class CallHandler { console.info("Place conference call in %s", payload.room_id); this.startCallApp(payload.room_id, payload.type); break; + case 'end_conference': + console.info("Terminating conference call in %s", payload.room_id); + this.terminateCallApp(payload.room_id); + break; + case 'hangup_conference': + console.info("Leaving conference call in %s", payload.room_id); + this.hangupCallApp(payload.room_id); + break; case 'incoming_call': { if (this.getAnyActiveCall()) { @@ -376,7 +393,7 @@ export default class CallHandler { return; // no call to hangup } this.calls.get(payload.room_id).hangup(); - this.setCallState(null, payload.room_id, "ended"); + this.removeCallForRoom(payload.room_id); break; case 'answer': if (!this.calls.get(payload.room_id)) { @@ -398,10 +415,12 @@ export default class CallHandler { show: true, }); + // prevent double clicking the call button const room = MatrixClientPeg.get().getRoom(roomId); const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); - - if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { + const hasJitsi = currentJitsiWidgets.length > 0 + || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); + if (hasJitsi) { Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { title: _t('Call in Progress'), description: _t('A call is currently being placed!'), @@ -409,33 +428,6 @@ export default class CallHandler { return; } - if (currentJitsiWidgets.length > 0) { - console.warn( - "Refusing to start conference call widget in " + roomId + - " a conference call widget is already present", - ); - - if (WidgetUtils.canUserModifyWidgets(roomId)) { - Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, { - title: _t('End Call'), - description: _t('Remove the group call from the room?'), - button: _t('End Call'), - cancelButton: _t('Cancel'), - onFinished: (endCall) => { - if (endCall) { - WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']); - } - }, - }); - } else { - Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t("You don't have permission to remove the call from the room"), - }); - } - return; - } - const jitsiDomain = Jitsi.getInstance().preferredDomain; const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); let confId; @@ -484,4 +476,38 @@ export default class CallHandler { console.error(e); }); } + + private terminateCallApp(roomId: string) { + Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, { + hasCancelButton: true, + title: _t("End conference"), + description: _t("This will end the conference for everyone. Continue?"), + button: _t("End conference"), + onFinished: (proceed) => { + if (!proceed) return; + + // We'll just obliterate them all. There should only ever be one, but might as well + // be safe. + const roomInfo = WidgetStore.instance.getRoom(roomId); + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + // setting invalid content removes it + WidgetUtils.setRoomWidget(roomId, w.id); + }); + }, + }); + } + + private hangupCallApp(roomId: string) { + const roomInfo = WidgetStore.instance.getRoom(roomId); + if (!roomInfo) return; // "should never happen" clauses go here + + const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + jitsiWidgets.forEach(w => { + const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id); + if (!messaging) return; // more "should never happen" words + + messaging.transport.send(ElementWidgetActions.HangupCall, {}); + }); + } } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js deleted file mode 100644 index d5d7c08d50..0000000000 --- a/src/FromWidgetPostMessageApi.js +++ /dev/null @@ -1,275 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 Travis Ralston -Copyright 2019 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 URL from 'url'; -import dis from './dispatcher/dispatcher'; -import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; -import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import RoomViewStore from "./stores/RoomViewStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import SettingsStore from "./settings/SettingsStore"; -import {Capability} from "./widgets/WidgetApi"; -import {objectClone} from "./utils/objects"; - -const WIDGET_API_VERSION = '0.0.2'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', - '0.0.2', -]; -const INBOUND_API_NAME = 'fromWidget'; - -// Listen for and handle incoming requests using the 'fromWidget' postMessage -// API and initiate responses -export default class FromWidgetPostMessageApi { - constructor() { - this.widgetMessagingEndpoints = []; - this.widgetListeners = {}; // {action: func[]} - - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - /** - * Adds a listener for a given action - * @param {string} action The action to listen for. - * @param {Function} callbackFn A callback function to be called when the action is - * encountered. Called with two parameters: the interesting request information and - * the raw event received from the postMessage API. The raw event is meant to be used - * for sendResponse and similar functions. - */ - addListener(action, callbackFn) { - if (!this.widgetListeners[action]) this.widgetListeners[action] = []; - this.widgetListeners[action].push(callbackFn); - } - - /** - * Removes a listener for a given action. - * @param {string} action The action that was subscribed to. - * @param {Function} callbackFn The original callback function that was used to subscribe - * to updates. - */ - removeListener(action, callbackFn) { - if (!this.widgetListeners[action]) return; - - const idx = this.widgetListeners[action].indexOf(callbackFn); - if (idx !== -1) this.widgetListeners[action].splice(idx, 1); - } - - /** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ - addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessagingEndpoint(widgetId, origin); - if (this.widgetMessagingEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); - return; - } else { - console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); - this.widgetMessagingEndpoints.push(endpoint); - } - } - - /** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ - removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Remove widget messaging endpoint - Invalid origin'); - return; - } - - const origin = u.protocol + '//' + u.host; - if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { - const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints - .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); - return (length > this.widgetMessagingEndpoints.length); - } - return false; - } - - /** - * Handle widget postMessage events - * Messages are only handled where a valid, registered messaging endpoints - * @param {Event} event Event to handle - * @return {undefined} - */ - onPostMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !this.trustedEndpoint(event.origin) || - event.data.api !== INBOUND_API_NAME || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - // Call any listeners we have registered - if (this.widgetListeners[event.data.action]) { - for (const fn of this.widgetListeners[event.data.action]) { - fn(event.data, event); - } - } - - // Although the requestId is required, we don't use it. We'll be nice and process the message - // if the property is missing, but with a warning for widget developers. - if (!event.data.requestId) { - console.warn("fromWidget action '" + event.data.action + "' does not have a requestId"); - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - console.log('Widget reported content loaded for', widgetId); - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - this.sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - version: WIDGET_API_VERSION, - }); - } else if (action === 'm.sticker') { - // console.warn('Got sticker message from widget', widgetId); - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId}); - } else if (action === 'integration_manager_open') { - // Close the stickerpicker - dis.dispatch({action: 'stickerpicker_close'}); - // Open the integration manager - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - const integType = (data && data.integType) ? data.integType : null; - const integId = (data && data.integId) ? data.integId : null; - - // TODO: Open the right integration manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } - } else if (action === 'set_always_on_screen') { - // This is a new message: there is no reason to support the deprecated widgetData here - const data = event.data.data; - const val = data.value; - - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { - ActiveWidgetStore.setWidgetPersistence(widgetId, val); - } - } else if (action === 'get_openid') { - // Handled by caller - } else { - console.warn('Widget postMessage event unhandled'); - this.sendError(event, {message: 'The postMessage was unhandled'}); - } - } - - /** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ - trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return this.widgetMessagingEndpoints.some((endpoint) => { - // TODO / FIXME -- Should this also check the widgetId? - return endpoint.endpointUrl === origin; - }); - } - - /** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ - sendResponse(event, res) { - const data = objectClone(event.data); - data.response = res; - event.source.postMessage(data, event.origin); - } - - /** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ - sendError(event, msg, nestedError) { - console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = objectClone(event.data); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); - } -} diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index f991d2df5d..c503247bf7 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -53,7 +53,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; /* * Return true if the given string contains emoji diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 3a48de5eef..dc04e47535 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -186,6 +186,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { console.log("Logged in with token"); return _clearStorage().then(() => { _persistCredentialsToLocalStorage(creds); + // remember that we just logged in + sessionStorage.setItem("mx_fresh_login", true); return true; }); }).catch((err) => { @@ -312,6 +314,9 @@ async function _restoreFromLocalStorage(opts) { console.log("No pickle key available"); } + const freshLogin = sessionStorage.getItem("mx_fresh_login"); + sessionStorage.removeItem("mx_fresh_login"); + console.log(`Restoring session for ${userId}`); await _doSetLoggedIn({ userId: userId, @@ -321,6 +326,7 @@ async function _restoreFromLocalStorage(opts) { identityServerUrl: isUrl, guest: isGuest, pickleKey: pickleKey, + freshLogin: freshLogin, }, false); return true; } else { @@ -364,6 +370,7 @@ async function _handleLoadSessionFailure(e) { * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ export async function setLoggedIn(credentials) { + credentials.freshLogin = true; stopMatrixClient(); const pickleKey = credentials.userId && credentials.deviceId ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) @@ -429,6 +436,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { " guest: " + credentials.guest + " hs: " + credentials.homeserverUrl + " softLogout: " + softLogout, + " freshLogin: " + credentials.freshLogin, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -462,10 +470,28 @@ async function _doSetLoggedIn(credentials, clearStorage) { Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); + MatrixClientPeg.replaceUsingCreds(credentials); + const client = MatrixClientPeg.get(); + + if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { + // If we just logged in, try to rehydrate a device instead of using a + // new device. If it succeeds, we'll get a new device ID, so make sure + // we persist that ID to localStorage + const newDeviceId = await client.rehydrateDevice(); + if (newDeviceId) { + credentials.deviceId = newDeviceId; + } + + delete credentials.freshLogin; + } + if (localStorage) { try { _persistCredentialsToLocalStorage(credentials); + // make sure we don't think that it's a fresh login any more + sessionStorage.removeItem("mx_fresh_login"); + // 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. if (credentials.password) { @@ -482,12 +508,10 @@ async function _doSetLoggedIn(credentials, clearStorage) { console.warn("No local storage available: can't persist session!"); } - MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({ action: 'on_logged_in' }); await startMatrixClient(/*startSyncing=*/!softLogout); - return MatrixClientPeg.get(); + return client; } function _showStorageEvictedDialog() { diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 9589130e7f..69e586c58d 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 './SecurityManager'; +import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { @@ -42,6 +42,7 @@ export interface IMatrixClientCreds { accessToken: string; guest: boolean; pickleKey?: string; + freshLogin?: boolean; } // TODO: Move this to the js-sdk @@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { this.matrixClient.setCryptoTrustCrossSignedDevices( !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'), ); + await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient); StorageManager.setCryptoInitialised(true); } } catch (e) { diff --git a/src/SecurityManager.js b/src/SecurityManager.js index f6b9c993d0..3272c0f015 100644 --- a/src/SecurityManager.js +++ b/src/SecurityManager.js @@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; import { isSecureBackupRequired } from './utils/WellKnownUtils'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; +import SettingsStore from "./settings/SettingsStore"; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -31,8 +32,13 @@ import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreK // single secret storage operation, as it will clear the cached keys once the // operation ends. let secretStorageKeys = {}; +let secretStorageKeyInfo = {}; let secretStorageBeingAccessed = false; +let nonInteractive = false; + +let dehydrationCache = {}; + function isCachingAllowed() { return secretStorageBeingAccessed; } @@ -66,6 +72,20 @@ async function confirmToDismiss() { return !sure; } +function makeInputToKey(keyInfo) { + return async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; +} + async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const keyInfoEntries = Object.entries(keyInfos); if (keyInfoEntries.length > 1) { @@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { return [keyId, secretStorageKeys[keyId]]; } - const inputToKey = async ({ passphrase, recoveryKey }) => { - if (passphrase) { - return deriveKey( - passphrase, - keyInfo.passphrase.salt, - keyInfo.passphrase.iterations, - ); - } else { - return decodeRecoveryKey(recoveryKey); + if (dehydrationCache.key) { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { + cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo); + return [keyId, dehydrationCache.key]; } - }; + } + + if (nonInteractive) { + throw new Error("Could not unlock non-interactively"); + } + + const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, /* props= */ @@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); // Save to cache to avoid future prompts in the current session - cacheSecretStorageKey(keyId, key); + cacheSecretStorageKey(keyId, key, keyInfo); return [keyId, key]; } -function cacheSecretStorageKey(keyId, key) { +export async function getDehydrationKey(keyInfo, checkFunc) { + const inputToKey = makeInputToKey(keyInfo); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + try { + checkFunc(key); + return true; + } catch (e) { + return false; + } + }, + }, + /* 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); + + // need to copy the key because rehydration (unpickling) will clobber it + dehydrationCache = {key: new Uint8Array(key), keyInfo}; + + return key; +} + +function cacheSecretStorageKey(keyId, key, keyInfo) { if (isCachingAllowed()) { secretStorageKeys[keyId] = key; + secretStorageKeyInfo[keyId] = keyInfo; } } @@ -176,6 +239,7 @@ export const crossSigningCallbacks = { getSecretStorageKey, cacheSecretStorageKey, onSecretRequested, + getDehydrationKey, }; export async function promptForBackupPassphrase() { @@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f await cli.bootstrapSecretStorage({ getKeyBackupPassphrase: promptForBackupPassphrase, }); + + const keyId = Object.keys(secretStorageKeys)[0]; + if (keyId && SettingsStore.getValue("feature_dehydration")) { + const dehydrationKeyInfo = + secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase + ? {passphrase: secretStorageKeyInfo[keyId].passphrase} + : {}; + console.log("Setting dehydration key"); + await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); + } else { + console.log("Not setting dehydration key: no SSSS key found"); + } } // `return await` needed here to ensure `finally` block runs after the @@ -272,6 +348,57 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f secretStorageBeingAccessed = false; if (!isCachingAllowed()) { secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } +} + +// FIXME: this function name is a bit of a mouthful +export async function tryToUnlockSecretStorageWithDehydrationKey(client) { + const key = dehydrationCache.key; + let restoringBackup = false; + if (key && await client.isSecretStorageReady()) { + console.log("Trying to set up cross-signing using dehydration key"); + secretStorageBeingAccessed = true; + nonInteractive = true; + try { + await client.checkOwnCrossSigningTrust(); + + // we also need to set a new dehydrated device to replace the + // device we rehydrated + const dehydrationKeyInfo = + dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase + ? {passphrase: dehydrationCache.keyInfo.passphrase} + : {}; + await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); + + // and restore from backup + const backupInfo = await client.getKeyBackupVersion(); + if (backupInfo) { + restoringBackup = true; + // don't await, because this can take a long time + client.restoreKeyBackupWithSecretStorage(backupInfo) + .finally(() => { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + }); + } + } finally { + dehydrationCache = {}; + // the secret storage cache is needed for restoring from backup, so + // don't clear it yet if we're restoring from backup + if (!restoringBackup) { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } } } } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index f9cda23650..34d40bf1fd 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -18,7 +18,6 @@ import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; -import {WidgetType} from "./widgets/WidgetType"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; function textForMemberEvent(ev) { @@ -464,10 +463,6 @@ function textForWidgetEvent(event) { const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name, type, url} = event.getContent() || {}; - if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) { - return textForJitsiWidgetEvent(event, senderName, url, prevUrl); - } - let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name if (widgetName && widgetName.length > 0) { @@ -493,24 +488,6 @@ function textForWidgetEvent(event) { } } -function textForJitsiWidgetEvent(event, senderName, url, prevUrl) { - if (url) { - if (prevUrl) { - return _t('Group call modified by %(senderName)s', { - senderName, - }); - } else { - return _t('Group call started by %(senderName)s', { - senderName, - }); - } - } else { - return _t('Group call ended by %(senderName)s', { - senderName, - }); - } -} - function textForMjolnirEvent(event) { const senderName = event.getSender(); const {entity: prevEntity} = event.getPrevContent(); diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js deleted file mode 100644 index 00309d252c..0000000000 --- a/src/ToWidgetPostMessageApi.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - -// const OUTBOUND_API_NAME = 'toWidget'; - -// Initiate requests using the "toWidget" postMessage API and handle responses -// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a -// response field -export default class ToWidgetPostMessageApi { - constructor(timeoutMs) { - this._timeoutMs = timeoutMs || 5000; // default to 5s timer - this._counter = 0; - this._requestMap = { - // $ID: {resolve, reject} - }; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - onPostMessage(ev) { - // THIS IS ALL UNSAFE EXECUTION. - // We do not verify who the sender of `ev` is! - const payload = ev.data; - // NOTE: Workaround for running in a mobile WebView where a - // postMessage immediately triggers this callback even though it is - // not the response. - if (payload.response === undefined) { - return; - } - const promise = this._requestMap[payload.requestId]; - if (!promise) { - return; - } - delete this._requestMap[payload.requestId]; - promise.resolve(payload); - } - - // Initiate outbound requests (toWidget) - exec(action, targetWindow, targetOrigin) { - targetWindow = targetWindow || window.parent; // default to parent window - targetOrigin = targetOrigin || "*"; - this._counter += 1; - action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; - - return new Promise((resolve, reject) => { - this._requestMap[action.requestId] = {resolve, reject}; - targetWindow.postMessage(action, targetOrigin); - - if (this._timeoutMs > 0) { - setTimeout(() => { - if (!this._requestMap[action.requestId]) { - return; - } - console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), - this._requestMap); - this._requestMap[action.requestId].reject(new Error("Timed out")); - delete this._requestMap[action.requestId]; - }, this._timeoutMs); - } - }); - } -} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js deleted file mode 100644 index c68e926ac1..0000000000 --- a/src/WidgetMessaging.js +++ /dev/null @@ -1,212 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 Travis Ralston - -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. -*/ - -/* -* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for -* spec. details / documentation. -*/ - -import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; -import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -import Modal from "./Modal"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import SettingsStore from "./settings/SettingsStore"; -import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetUtils from "./utils/WidgetUtils"; -import {KnownWidgetActions} from "./widgets/WidgetApi"; - -if (!global.mxFromWidgetMessaging) { - global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); - global.mxFromWidgetMessaging.start(); -} -if (!global.mxToWidgetMessaging) { - global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); - global.mxToWidgetMessaging.start(); -} - -const OUTBOUND_API_NAME = 'toWidget'; - -export default class WidgetMessaging { - /** - * @param {string} widgetId The widget's ID - * @param {string} wurl The raw URL of the widget as in the event (the 'wURL') - * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL - * or a different URL of the clients choosing if it is using its own impl). - * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget - * @param {object} target Where widget messages should be sent (eg. the iframe object) - */ - constructor(widgetId, wurl, renderedUrl, isUserWidget, target) { - this.widgetId = widgetId; - this.wurl = wurl; - this.renderedUrl = renderedUrl; - this.isUserWidget = isUserWidget; - this.target = target; - this.fromWidget = global.mxFromWidgetMessaging; - this.toWidget = global.mxToWidgetMessaging; - this._onOpenIdRequest = this._onOpenIdRequest.bind(this); - this.start(); - } - - messageToWidget(action) { - action.widgetId = this.widgetId; // Required to be sent for all outbound requests - - return this.toWidget.exec(action, this.target).then((data) => { - // Check for errors and reject if found - if (data.response === undefined) { // null is valid - throw new Error("Missing 'response' field"); - } - if (data.response && data.response.error) { - const err = data.response.error; - const msg = String(err.message ? err.message : "An error was returned"); - if (err._error) { - console.error(err._error); - } - // Potential XSS attack if 'msg' is not appropriately sanitized, - // as it is untrusted input by our parent window (which we assume is Element). - // We can't aggressively sanitize [A-z0-9] since it might be a translation. - throw new Error(msg); - } - // Return the response field for the request - return data.response; - }); - } - - /** - * Tells the widget that the client is ready to handle further widget requests. - * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message. - */ - flagReadyToContinue() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.ClientReady, - }); - } - - /** - * Tells the widget that it should terminate now. - * @returns {Promise<*>} Resolves when widget has acknowledged the message. - */ - terminate() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Terminate, - }); - } - - /** - * Request a screenshot from a widget - * @return {Promise} To be resolved with screenshot data when it has been generated - */ - getScreenshot() { - console.log('Requesting screenshot for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "screenshot", - }) - .catch((error) => new Error("Failed to get screenshot: " + error.message)) - .then((response) => response.screenshot); - } - - /** - * Request capabilities required by the widget - * @return {Promise} To be resolved with an array of requested widget capabilities - */ - getCapabilities() { - console.log('Requesting capabilities for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "capabilities", - }).then((response) => { - console.log('Got capabilities for', this.widgetId, response.capabilities); - return response.capabilities; - }); - } - - sendVisibility(visible) { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "visibility", - visible, - }) - .catch((error) => { - console.error("Failed to send visibility: ", error); - }); - } - - start() { - this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.addListener("get_openid", this._onOpenIdRequest); - } - - stop() { - this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); - } - - async _onOpenIdRequest(ev, rawEv) { - if (ev.widgetId !== this.widgetId) return; // not interesting - - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.fromWidget.sendResponse(rawEv, {state: "blocked"}); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const responseBody = {state: "allowed"}; - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - this.fromWidget.sendResponse(rawEv, responseBody); - return; - } - - // Confirm that we received the request - this.fromWidget.sendResponse(rawEv, {state: "request"}); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', - WidgetOpenIDPermissionsDialog, { - widgetUrl: this.wurl, - widgetId: this.widgetId, - isUserWidget: this.isUserWidget, - - onFinished: async (confirm) => { - const responseBody = { - // Legacy (early draft) fields - success: confirm, - - // New style MSC1960 fields - state: confirm ? "allowed" : "blocked", - original_request_id: ev.requestId, // eslint-disable-line camelcase - }; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "openid_credentials", - data: responseBody, - }).catch((error) => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }, - ); - } -} diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js deleted file mode 100644 index 9114e12137..0000000000 --- a/src/WidgetMessagingEndpoint.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -export default class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } - this.widgetId = widgetId; - this.endpointUrl = endpointUrl; - } -} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a638ad6de1..19418df414 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -1019,7 +1019,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); if (communityId) { // double check the user will have permission to associate this room with the community - if (CommunityPrototypeStore.instance.isAdminOf(communityId)) { + if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) { Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, { title: _t("Cannot create rooms in this community"), description: _t("You do not have permission to create rooms in this community."), diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 6c6d8700a5..021cdb438d 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -162,7 +159,7 @@ export default class RightPanel extends React.Component { } onRoomStateMember(ev, state, member) { - if (member.roomId !== this.props.room.roomId) { + if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list @@ -202,13 +199,19 @@ export default class RightPanel extends React.Component { dis.dispatch({ action: "view_home_page", }); + } else if (this.state.phase === RightPanelPhases.EncryptionPanel && + this.state.verificationRequest && this.state.verificationRequest.pending + ) { + // When the user clicks close on the encryption panel cancel the pending request first if any + this.state.verificationRequest.cancel(); } else { // Otherwise we have got our user from RoomViewStore which means we're being shown // within a room/group, so go back to the member panel if we were in the encryption panel, // or the member list if we were in the member panel... phew. + const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel; dis.dispatch({ action: Action.ViewUser, - member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null, + member: isEncryptionPhase ? this.state.member : null, }); } }; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 55c6527f06..df580e8de0 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -35,7 +35,7 @@ import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; const MAX_NAME_LENGTH = 80; -const MAX_TOPIC_LENGTH = 160; +const MAX_TOPIC_LENGTH = 800; function track(action) { Analytics.trackEvent('RoomDirectory', action); @@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component { } let topic = room.topic || ''; + // Additional truncation based on line numbers is done via CSS, + // but to ensure that the DOM is not polluted with a huge string + // we give it a hard limit before rendering. if (topic.length > MAX_TOPIC_LENGTH) { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 4927c6b712..3aedaa5219 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1820,7 +1820,6 @@ export default class RoomView extends React.Component<IProps, IState> { let aux = null; let previewBar; let hideCancel = false; - let forceHideRightPanel = false; if (this.state.forwardingEvent) { aux = <ForwardMessage onCancelClick={this.onCancelClick} />; } else if (this.state.searching) { @@ -1865,8 +1864,6 @@ export default class RoomView extends React.Component<IProps, IState> { { previewBar } </div> ); - } else { - forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( @@ -2069,7 +2066,7 @@ export default class RoomView extends React.Component<IProps, IState> { "mx_fadable_faded": this.props.disabled, }); - const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel; + const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel ? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} /> : null; diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 8fd51d3715..60b043016b 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; -interface IProps { +interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js index 3649190ac9..f2d5a96b4c 100644 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ b/src/components/views/dialogs/SetPasswordDialog.js @@ -117,7 +117,9 @@ export default class SetPasswordDialog extends React.Component { autoFocusNewPasswordInput={true} shouldAskForEmail={true} onError={this._onPasswordChangeError} - onFinished={this._onPasswordChanged} /> + onFinished={this._onPasswordChanged} + buttonLabel={_t("Set Password")} + /> <div className="error"> { this.state.error } </div> diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 6aaeab060f..3405d4ff16 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,11 +18,9 @@ limitations under the License. */ import url from 'url'; -import qs from 'qs'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import WidgetMessaging from '../../../WidgetMessaging'; import AccessibleButton from './AccessibleButton'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; @@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; -import {Capability} from "../../../widgets/WidgetApi"; -import {sleep} from "../../../utils/promise"; import {SettingLevel} from "../../../settings/SettingLevel"; import WidgetStore from "../../../stores/WidgetStore"; import {Action} from "../../../dispatcher/actions"; - -const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; -const ENABLE_REACT_PERF = false; - -/** - * Does template substitution on a URL (or any string). Variables will be - * passed through encodeURIComponent. - * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. - * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { '$bar': 'baz' }. - * @return {string} The result of replacing all template variables e.g. '/foo/baz'. - */ -function uriFromTemplate(uriTemplate, variables) { - let out = uriTemplate; - for (const [key, val] of Object.entries(variables)) { - out = out.replace( - '$' + key, encodeURIComponent(val), - ); - } - return out; -} +import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; +import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class AppTile extends React.Component { constructor(props) { @@ -72,11 +49,13 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this._persistKey = 'widget_' + this.props.app.id; + this._sgWidget = new StopGapWidget(this.props); + this._sgWidget.on("ready", this._onWidgetReady); + this.iframe = null; // ref to the iframe (callback style) this.state = this._getNewState(props); this._onAction = this._onAction.bind(this); - this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); this._onRevokeClicked = this._onRevokeClicked.bind(this); @@ -89,7 +68,6 @@ export default class AppTile extends React.Component { this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._contextMenuButton = createRef(); - this._appFrame = createRef(); this._menu_bar = createRef(); } @@ -108,12 +86,10 @@ export default class AppTile extends React.Component { return !!currentlyAllowedWidgets[newProps.app.eventId]; }; - const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.app.url), // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -124,43 +100,6 @@ export default class AppTile extends React.Component { }; } - /** - * Does the widget support a given capability - * @param {string} capability Capability to check for - * @return {Boolean} True if capability supported - */ - _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); - } - - /** - * Add widget instance specific parameters to pass in wUrl - * Properties passed to widget instance: - * - widgetId - * - origin / parent URL - * @param {string} urlString Url string to modify - * @return {string} - * Url string with parameters appended. - * If url can not be parsed, it is returned unmodified. - */ - _addWurlParams(urlString) { - try { - const parsed = new URL(urlString); - - // TODO: Replace these with proper widget params - // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 - parsed.searchParams.set('widgetId', this.props.app.id); - parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); - - // Replace the encoded dollar signs back to dollar signs. They have no special meaning - // in HTTP, but URL parsers encode them anyways. - return parsed.toString().replace(/%24/g, '$'); - } catch (e) { - console.error("Failed to add widget URL params:", e); - return urlString; - } - } - isMixedContent() { const parentContentProtocol = window.location.protocol; const u = url.parse(this.props.app.url); @@ -176,7 +115,7 @@ export default class AppTile extends React.Component { componentDidMount() { // Only fetch IM token on mount if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } // Widget action listeners @@ -190,93 +129,44 @@ export default class AppTile extends React.Component { // if it's not remaining on screen, get rid of the PersistedElement container if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } + + if (this._sgWidget) { + this._sgWidget.stop(); + } } - // TODO: Generify the name of this function. It's not just scalar tokens. - /** - * Adds a scalar token to the widget URL, if required - * Component initialisation is only complete when this function has resolved - */ - setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.app.url)) { - console.warn('Widget does not match integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; + _resetWidget(newProps) { + if (this._sgWidget) { + this._sgWidget.stop(); } + this._sgWidget = new StopGapWidget(newProps); + this._sgWidget.on("ready", this._onWidgetReady); + this._startWidget(); + } - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - console.warn("No integration manager - not setting scalar token", url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // TODO: Pick the right manager for the widget - - const defaultManager = managers.getPrimaryManager(); - if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { - console.warn('Unknown integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // Fetch the token before loading the iframe as we need it to mangle the URL - if (!this._scalarClient) { - this._scalarClient = defaultManager.getScalarClient(); - } - this._scalarClient.getScalarToken().then((token) => { - // Append scalar_token as a query param if not already present - this._scalarClient.scalarToken = token; - const u = url.parse(this._addWurlParams(this.props.app.url)); - const params = qs.parse(u.query); - if (!params.scalar_token) { - params.scalar_token = encodeURIComponent(token); - // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options - u.search = undefined; - u.query = params; - } - - this.setState({ - error: null, - widgetUrl: u.format(), - initialising: false, - }); - - // Fetch page title from remote content if not already set - if (!this.state.widgetPageTitle && params.url) { - this._fetchWidgetTitle(params.url); - } - }, (err) => { - console.error("Failed to get scalar_token", err); - this.setState({ - error: err.message, - initialising: false, - }); + _startWidget() { + this._sgWidget.prepare().then(() => { + this.setState({initialising: false}); }); } + _iframeRefChange = (ref) => { + this.iframe = ref; + if (ref) { + this._sgWidget.start(ref); + } else { + this._resetWidget(this.props); + } + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); - // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._resetWidget(nextProps); } } @@ -287,9 +177,9 @@ export default class AppTile extends React.Component { loading: true, }); } - // Fetch IM token now that we're showing if we already have permission to load + // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } } @@ -319,7 +209,14 @@ export default class AppTile extends React.Component { } _onSnapshotClick() { - WidgetUtils.snapshotWidget(this.props.app); + this._sgWidget.widgetApi.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); } /** @@ -327,35 +224,24 @@ export default class AppTile extends React.Component { * @private * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - _endWidgetActions() { - let terminationPromise; - - if (this._hasCapability(Capability.ReceiveTerminate)) { - // Wait for widget to terminate within a timeout - const timeout = 2000; - const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); - terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); - } else { - terminationPromise = Promise.resolve(); + async _endWidgetActions() { // widget migration dev note: async to maintain signature + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 + if (this.iframe) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Element instance is located. + this.iframe.src = 'about:blank'; } - return terminationPromise.finally(() => { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this._appFrame.current.src = 'about:blank'; - } + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); - }); + this._sgWidget.stop(); } /* If user has permission to modify widgets, delete the widget, @@ -409,73 +295,18 @@ export default class AppTile extends React.Component { this._revokeWidgetPermission(); } - /** - * Called when widget iframe has finished loading - */ - _onLoaded() { - // Destroy the old widget messaging before starting it back up again. Some widgets - // have startup routines that run when they are loaded, so we just need to reinitialize - // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.app.id); - this._setupWidgetMessaging(); - - ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); + _onWidgetReady = () => { this.setState({loading: false}); - } - - _setupWidgetMessaging() { - // FIXME: There's probably no reason to do this here: it should probably be done entirely - // in ActiveWidgetStore. - const widgetMessaging = new WidgetMessaging( - this.props.app.id, - this.props.app.url, - this._getRenderedUrl(), - this.props.userWidget, - this._appFrame.current.contentWindow, - ); - ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); - widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); - requestedCapabilities = requestedCapabilities || []; - - // Allow whitelisted capabilities - let requestedWhitelistCapabilies = []; - - if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { - requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { - return this.indexOf(e)>=0; - }, this.props.whitelistCapabilities); - - if (requestedWhitelistCapabilies.length > 0 ) { - console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + - requestedWhitelistCapabilies, - ); - } - } - - // TODO -- Add UI to warn about and optionally allow requested capabilities - - ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - - if (this.props.onCapabilityRequest) { - this.props.onCapabilityRequest(requestedCapabilities); - } - - // We only tell Jitsi widgets that we're ready because they're realistically the only ones - // using this custom extension to the widget API. - if (WidgetType.JITSI.matches(this.props.app.type)) { - widgetMessaging.flagReadyToContinue(); - } - }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); - }); - } + if (WidgetType.JITSI.matches(this.props.app.type)) { + this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); + } + }; _onAction(payload) { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { + if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { dis.dispatch({action: 'post_sticker_message', data: payload.data}); } else { console.warn('Ignoring sticker message. Invalid capability'); @@ -493,20 +324,6 @@ export default class AppTile extends React.Component { } } - /** - * Set remote content title on AppTile - * @param {string} url Url to check for title - */ - _fetchWidgetTitle(url) { - this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { - if (widgetPageTitle) { - this.setState({widgetPageTitle: widgetPageTitle}); - } - }, (err) =>{ - console.error("Failed to get page title", err); - }); - } - _grantWidgetPermission() { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); @@ -516,7 +333,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: true}); // Fetch a token for the integration manager, now that we're allowed to - this.setScalarToken(); + this._startWidget(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -535,6 +352,7 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -572,40 +390,6 @@ export default class AppTile extends React.Component { } } - /** - * Replace the widget template variables in a url with their values - * - * @param {string} u The URL with template variables - * @param {string} widgetType The widget's type - * - * @returns {string} url with temlate variables replaced - */ - _templatedUrl(u, widgetType: string) { - const targetData = {}; - if (WidgetType.JITSI.matches(widgetType)) { - targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded - } - const myUserId = MatrixClientPeg.get().credentials.userId; - const myUser = MatrixClientPeg.get().getUser(myUserId); - const vars = Object.assign(targetData, this.props.app.data, { - 'matrix_user_id': myUserId, - 'matrix_room_id': this.props.room.roomId, - 'matrix_display_name': myUser ? myUser.displayName : myUserId, - 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', - - // TODO: Namespace themes through some standard - 'theme': SettingsStore.getValue("theme"), - }); - - if (vars.conferenceId === undefined) { - // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets - const parsedUrl = new URL(this.props.app.url); - vars.conferenceId = parsedUrl.searchParams.get("confId"); - } - - return uriFromTemplate(u, vars); - } - /** * Whether we're using a local version of the widget rather than loading the * actual widget URL @@ -615,67 +399,11 @@ export default class AppTile extends React.Component { return WidgetType.JITSI.matches(this.props.app.type); } - /** - * Get the URL used in the iframe - * In cases where we supply our own UI for a widget, this is an internal - * URL different to the one used if the widget is popped out to a separate - * tab / browser - * - * @returns {string} url - */ - _getRenderedUrl() { - let url; - - if (WidgetType.JITSI.matches(this.props.app.type)) { - console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: true, - auth: this.props.app.data ? this.props.app.data.auth : null, - }); - url = this._addWurlParams(url); - } else { - url = this._getSafeUrl(this.state.widgetUrl); - } - return this._templatedUrl(url, this.props.app.type); - } - - _getPopoutUrl() { - if (WidgetType.JITSI.matches(this.props.app.type)) { - return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: false, - auth: this.props.app.data ? this.props.app.data.auth : null, - }), - this.props.app.type, - ); - } else { - // use app.url, not state.widgetUrl, because we want the one without - // the wURL params for the popped-out version. - return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type); - } - } - - _getSafeUrl(u) { - const parsedWidgetUrl = url.parse(u, true); - if (ENABLE_REACT_PERF) { - parsedWidgetUrl.search = null; - parsedWidgetUrl.query.react_perf = true; - } - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } - - // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all. - // We also need the dollar signs in-tact for variable substitution. - return safeWidgetUrl.replace(/%24/g, '$'); - } - _getTileTitle() { const name = this.formatAppTileName(); const titleSpacer = <span> - </span>; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { title = this.state.widgetPageTitle; } @@ -698,9 +426,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this._appFrame.current) { + if (this.iframe) { // Reload iframe - this._appFrame.current.src = this._getRenderedUrl(); + this.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -708,13 +436,13 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this._appFrame.current.src = this._appFrame.current.src; + this.iframe.src = this.iframe.src; } _onContextMenuClick = () => { @@ -760,7 +488,7 @@ export default class AppTile extends React.Component { <AppPermission roomId={this.props.room.roomId} creatorUserId={this.props.creatorUserId} - url={this.state.widgetUrl} + url={this._sgWidget.embedUrl} isRoomEncrypted={isEncrypted} onPermissionGranted={this._grantWidgetPermission} /> @@ -785,11 +513,11 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement } <iframe allow={iframeFeatures} - ref={this._appFrame} - src={this._getRenderedUrl()} + ref={this._iframeRefChange} + src={this._sgWidget.embedUrl} allowFullScreen={true} sandbox={sandboxFlags} - onLoad={this._onLoaded} /> + /> </div> ); // if the widget would be allowed to remain on screen, we must put it in @@ -833,9 +561,10 @@ export default class AppTile extends React.Component { const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const canUserModify = this._canUserModify(); - const showEditButton = Boolean(this._scalarClient && canUserModify); + const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify); const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; - const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; + const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi && + this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots); const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); contextMenu = ( @@ -943,9 +672,6 @@ AppTile.propTypes = { // NOTE -- Use with caution. This is intended to aid better integration / UX // basic widget capabilities, e.g. injecting sticker message events. whitelistCapabilities: PropTypes.array, - // Optional function to be called on widget capability request - // Called with an array of the requested capabilities - onCapabilityRequest: PropTypes.func, // Is this an instance of a user widget userWidget: PropTypes.bool, }; diff --git a/src/components/views/elements/EventListSummary.js b/src/components/views/elements/EventListSummary.tsx similarity index 81% rename from src/components/views/elements/EventListSummary.js rename to src/components/views/elements/EventListSummary.tsx index 5a4a6e4f5a..1d3b6e8764 100644 --- a/src/components/views/elements/EventListSummary.js +++ b/src/components/views/elements/EventListSummary.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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,15 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useEffect} from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactChildren, useEffect} from 'react'; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import MemberAvatar from '../avatars/MemberAvatar'; import { _t } from '../../../languageHandler'; -import {MatrixEvent, RoomMember} from "matrix-js-sdk"; import {useStateToggle} from "../../../hooks/useStateToggle"; import AccessibleButton from "./AccessibleButton"; -const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => { +interface IProps { + // An array of member events to summarise + events: MatrixEvent[]; + // The minimum number of events needed to trigger summarisation + threshold?: number; + // Whether or not to begin with state.expanded=true + startExpanded?: boolean, + // The list of room members for which to show avatars next to the summary + summaryMembers?: RoomMember[], + // The text to show as the summary of this event list + summaryText?: string, + // An array of EventTiles to render when expanded + children: ReactChildren, + // Called when the event list expansion is toggled + onToggle?(): void; +} + +const EventListSummary: React.FC<IProps> = ({ + events, + children, + threshold = 3, + onToggle, + startExpanded, + summaryMembers = [], + summaryText, +}) => { const [expanded, toggleExpanded] = useStateToggle(startExpanded); // Whenever expanded changes call onToggle @@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande ); }; -EventListSummary.propTypes = { - // An array of member events to summarise - events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, - // An array of EventTiles to render when expanded - children: PropTypes.arrayOf(PropTypes.element).isRequired, - // The minimum number of events needed to trigger summarisation - threshold: PropTypes.number, - // Called when the event list expansion is toggled - onToggle: PropTypes.func, - // Whether or not to begin with state.expanded=true - startExpanded: PropTypes.bool, - - // The list of room members for which to show avatars next to the summary - summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)), - // The text to show as the summary of this event list - summaryText: PropTypes.string, -}; - export default EventListSummary; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.tsx similarity index 70% rename from src/components/views/elements/MemberEventListSummary.js rename to src/components/views/elements/MemberEventListSummary.tsx index e16b52c8a2..073bedf207 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -16,32 +16,60 @@ 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, { ReactChildren } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; -import * as sdk from "../../../index"; -import {MatrixEvent} from "matrix-js-sdk"; -import {isValid3pidInvite} from "../../../RoomInvite"; +import { isValid3pidInvite } from "../../../RoomInvite"; +import EventListSummary from "./EventListSummary"; -export default class MemberEventListSummary extends React.Component { - static propTypes = { - // An array of member events to summarise - events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, - // An array of EventTiles to render when expanded - children: PropTypes.array.isRequired, - // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" - summaryLength: PropTypes.number, - // The maximum number of avatars to display in the summary - avatarsMaxLength: PropTypes.number, - // The minimum number of events needed to trigger summarisation - threshold: PropTypes.number, - // Called when the MELS expansion is toggled - onToggle: PropTypes.func, - // Whether or not to begin with state.expanded=true - startExpanded: PropTypes.bool, - }; +interface IProps { + // An array of member events to summarise + events: MatrixEvent[]; + // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" + summaryLength?: number; + // The maximum number of avatars to display in the summary + avatarsMaxLength?: number; + // The minimum number of events needed to trigger summarisation + threshold?: number, + // Whether or not to begin with state.expanded=true + startExpanded?: boolean, + // An array of EventTiles to render when expanded + children: ReactChildren; + // Called when the MELS expansion is toggled + onToggle?(): void, +} +interface IUserEvents { + // The original event + mxEvent: MatrixEvent; + // The display name of the user (if not, then user ID) + displayName: string; + // The original index of the event in this.props.events + index: number; +} + +enum TransitionType { + Joined = "joined", + Left = "left", + JoinedAndLeft = "joined_and_left", + LeftAndJoined = "left_and_joined", + InviteReject = "invite_reject", + InviteWithdrawal = "invite_withdrawal", + Invited = "invited", + Banned = "banned", + Unbanned = "unbanned", + Kicked = "kicked", + ChangedName = "changed_name", + ChangedAvatar = "changed_avatar", + NoChange = "no_change", +} + +const SEP = ","; + +export default class MemberEventListSummary extends React.Component<IProps> { static defaultProps = { summaryLength: 1, threshold: 3, @@ -62,30 +90,28 @@ export default class MemberEventListSummary extends React.Component { /** * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where * the sequences are ordered by `orderedTransitionSequences`. - * @param {object[]} eventAggregates a map of transition sequence to array of user display names + * @param {object} eventAggregates a map of transition sequence to array of user display names * or user IDs. * @param {string[]} orderedTransitionSequences an array which is some ordering of * `Object.keys(eventAggregates)`. * @returns {string} the textual summary of the aggregated events that occurred. */ - _generateSummary(eventAggregates, orderedTransitionSequences) { + private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; - const nameList = this._renderNameList(userNames); + const nameList = this.renderNameList(userNames); - const splitTransitions = transitions.split(','); + const splitTransitions = transitions.split(SEP) as TransitionType[]; // Some neighbouring transitions are common, so canonicalise some into "pair" // transitions - const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); + const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions); // Transform into consecutive repetitions of the same transition (like 5 // consecutive 'joined_and_left's) - const coalescedTransitions = this._coalesceRepeatedTransitions( - canonicalTransitions, - ); + const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions); const descs = coalescedTransitions.map((t) => { - return this._getDescriptionForTransition( + return MemberEventListSummary.getDescriptionForTransition( t.transitionType, userNames.length, t.repeats, ); }); @@ -108,7 +134,7 @@ export default class MemberEventListSummary extends React.Component { * more items in `users` than `this.props.summaryLength`, which is the number of names * included before "and [n] others". */ - _renderNameList(users) { + private renderNameList(users: string[]) { return formatCommaSeparatedList(users, this.props.summaryLength); } @@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component { * @param {string[]} transitions an array of transitions. * @returns {string[]} an array of transitions. */ - _getCanonicalTransitions(transitions) { + private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] { const modMap = { - 'joined': { - 'after': 'left', - 'newTransition': 'joined_and_left', + [TransitionType.Joined]: { + after: TransitionType.Left, + newTransition: TransitionType.JoinedAndLeft, }, - 'left': { - 'after': 'joined', - 'newTransition': 'left_and_joined', + [TransitionType.Left]: { + after: TransitionType.Joined, + newTransition: TransitionType.LeftAndJoined, }, // $currentTransition : { // 'after' : $nextTransition, // 'newTransition' : 'new_transition_type', // }, }; - const res = []; + const res: TransitionType[] = []; for (let i = 0; i < transitions.length; i++) { const t = transitions[i]; @@ -166,8 +192,12 @@ export default class MemberEventListSummary extends React.Component { * @param {string[]} transitions the array of transitions to transform. * @returns {object[]} an array of coalesced transitions. */ - _coalesceRepeatedTransitions(transitions) { - const res = []; + private static coalesceRepeatedTransitions(transitions: TransitionType[]) { + const res: { + transitionType: TransitionType; + repeats: number; + }[] = []; + for (let i = 0; i < transitions.length; i++) { if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { res[res.length - 1].repeats += 1; @@ -189,7 +219,7 @@ export default class MemberEventListSummary extends React.Component { * @param {number} repeats the number of times the transition was repeated in a row. * @returns {string} the written Human Readable equivalent of the transition. */ - _getDescriptionForTransition(t, userCount, repeats) { + private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) { // The empty interpolations 'severalUsers' and 'oneUser' // are there only to show translators to non-English languages // that the verb is conjugated to plural or singular Subject. @@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component { break; case "invite_reject": res = (userCount > 1) - ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats }) + ? _t("%(severalUsers)srejected their invitations %(count)s times", { + severalUsers: "", + count: repeats, + }) : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats }); break; case "invite_withdrawal": res = (userCount > 1) - ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats }) + ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { + severalUsers: "", + count: repeats, + }) : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats }); break; case "invited": @@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component { return res; } - _getTransitionSequence(events) { - return events.map(this._getTransition); + private static getTransitionSequence(events: MatrixEvent[]) { + return events.map(MemberEventListSummary.getTransition); } /** @@ -277,60 +313,60 @@ export default class MemberEventListSummary extends React.Component { * @returns {string?} the transition type given to this event. This defaults to `null` * if a transition is not recognised. */ - _getTransition(e) { + private static getTransition(e: MatrixEvent): TransitionType { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { - return 'invite_withdrawal'; + return TransitionType.InviteWithdrawal; } - return 'invited'; + return TransitionType.Invited; } switch (e.mxEvent.getContent().membership) { - case 'invite': return 'invited'; - case 'ban': return 'banned'; + case 'invite': return TransitionType.Invited; + case 'ban': return TransitionType.Banned; case 'join': if (e.mxEvent.getPrevContent().membership === 'join') { if (e.mxEvent.getContent().displayname !== e.mxEvent.getPrevContent().displayname) { - return 'changed_name'; + return TransitionType.ChangedName; } else if (e.mxEvent.getContent().avatar_url !== e.mxEvent.getPrevContent().avatar_url) { - return 'changed_avatar'; + return TransitionType.ChangedAvatar; } // console.log("MELS ignoring duplicate membership join event"); - return 'no_change'; + return TransitionType.NoChange; } else { - return 'joined'; + return TransitionType.Joined; } case 'leave': if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return 'invite_reject'; - default: return 'left'; + case 'invite': return TransitionType.InviteReject; + default: return TransitionType.Left; } } switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return 'invite_withdrawal'; - case 'ban': return 'unbanned'; + case 'invite': return TransitionType.InviteWithdrawal; + case 'ban': return TransitionType.Unbanned; // sender is not target and made the target leave, if not from invite/ban then this is a kick - default: return 'kicked'; + default: return TransitionType.Kicked; } default: return null; } } - _getAggregate(userEvents) { + getAggregate(userEvents: Record<string, IUserEvents[]>) { // A map of aggregate type to arrays of display names. Each aggregate type // is a comma-delimited string of transitions, e.g. "joined,left,kicked". // The array of display names is the array of users who went through that // sequence during eventsToRender. - const aggregate = { + const aggregate: Record<string, string[]> = { // $aggregateType : []:string }; // A map of aggregate types to the indices that order them (the index of // the first event for a given transition sequence) - const aggregateIndices = { + const aggregateIndices: Record<string, number> = { // $aggregateType : int }; @@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component { const firstEvent = userEvents[userId][0]; const displayName = firstEvent.displayName; - const seq = this._getTransitionSequence(userEvents[userId]); + const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP); if (!aggregate[seq]) { aggregate[seq] = []; aggregateIndices[seq] = -1; @@ -349,8 +385,9 @@ export default class MemberEventListSummary extends React.Component { aggregate[seq].push(displayName); if (aggregateIndices[seq] === -1 || - firstEvent.index < aggregateIndices[seq]) { - aggregateIndices[seq] = firstEvent.index; + firstEvent.index < aggregateIndices[seq] + ) { + aggregateIndices[seq] = firstEvent.index; } }, ); @@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component { render() { const eventsToRender = this.props.events; - // Map user IDs to an array of objects: - const userEvents = { - // $userId : [{ - // // The original event - // mxEvent: e, - // // The display name of the user (if not, then user ID) - // displayName: e.target.name || userId, - // // The original index of the event in this.props.events - // index: index, - // }] - }; + // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created, + // so this works perfectly for us to match event order whilst storing the latest Avatar Member + const latestUserAvatarMember = new Map<string, RoomMember>(); - const avatarMembers = []; + // Object mapping user IDs to an array of IUserEvents + const userEvents: Record<string, IUserEvents[]> = {}; eventsToRender.forEach((e, index) => { const userId = e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { userEvents[userId] = []; - if (e.target) avatarMembers.push(e.target); + } + + if (e.target) { + latestUserAvatarMember.set(userId, e.target); } let displayName = userId; @@ -399,21 +432,20 @@ export default class MemberEventListSummary extends React.Component { }); }); - const aggregate = this._getAggregate(userEvents); + const aggregate = this.getAggregate(userEvents); // Sort types by order of lowest event index within sequence const orderedTransitionSequences = Object.keys(aggregate.names).sort( - (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2], + (seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2], ); - const EventListSummary = sdk.getComponent("views.elements.EventListSummary"); return <EventListSummary events={this.props.events} threshold={this.props.threshold} onToggle={this.props.onToggle} startExpanded={this.props.startExpanded} children={this.props.children} - summaryMembers={avatarMembers} - summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />; + summaryMembers={[...latestUserAvatarMember.values()]} + summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />; } } diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index 686739a9f7..a3e413151a 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -82,6 +82,7 @@ export default class PersistentApp extends React.Component { showDelete={false} showMinimise={false} miniMode={true} + showMenubar={false} />; } } diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 2d17c858a2..24b49f2b13 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -29,6 +29,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; import sanitizeHtml from "sanitize-html"; import {UIFeature} from "../../../settings/UIFeature"; +import {PERMITTED_URL_SCHEMES} from "../../../HtmlUtils"; // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would @@ -62,6 +63,12 @@ export default class ReplyThread extends React.Component { err: false, }; + this.unmounted = false; + this.context.on("Event.replaced", this.onEventReplaced); + this.room = this.context.getRoom(this.props.parentEv.getRoomId()); + this.room.on("Room.redaction", this.onRoomRedaction); + this.room.on("Room.redactionCancelled", this.onRoomRedaction); + this.onQuoteClick = this.onQuoteClick.bind(this); this.canCollapse = this.canCollapse.bind(this); this.collapse = this.collapse.bind(this); @@ -106,6 +113,9 @@ export default class ReplyThread extends React.Component { { allowedTags: false, // false means allow everything allowedAttributes: false, + // we somehow can't allow all schemes, so we allow all that we + // know of and mxc (for img tags) + allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'], exclusiveFilter: (frame) => frame.tag === "mx-reply", }, ); @@ -213,11 +223,6 @@ export default class ReplyThread extends React.Component { } componentDidMount() { - this.unmounted = false; - this.room = this.context.getRoom(this.props.parentEv.getRoomId()); - this.room.on("Room.redaction", this.onRoomRedaction); - // same event handler as Room.redaction as for both we just do forceUpdate - this.room.on("Room.redactionCancelled", this.onRoomRedaction); this.initialize(); } @@ -227,21 +232,36 @@ export default class ReplyThread extends React.Component { componentWillUnmount() { this.unmounted = true; + this.context.removeListener("Event.replaced", this.onEventReplaced); if (this.room) { this.room.removeListener("Room.redaction", this.onRoomRedaction); this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction); } } - onRoomRedaction = (ev, room) => { - if (this.unmounted) return; - - // If one of the events we are rendering gets redacted, force a re-render - if (this.state.events.some(event => event.getId() === ev.getId())) { + updateForEventId = (eventId) => { + if (this.state.events.some(event => event.getId() === eventId)) { this.forceUpdate(); } }; + onEventReplaced = (ev) => { + if (this.unmounted) return; + + // If one of the events we are rendering gets replaced, force a re-render + this.updateForEventId(ev.getId()); + }; + + onRoomRedaction = (ev) => { + if (this.unmounted) return; + + const eventId = ev.getAssociatedId(); + if (!eventId) return; + + // If one of the events we are rendering gets redacted, force a re-render + this.updateForEventId(eventId); + }; + async initialize() { const {parentEv} = this.props; // at time of making this component we checked that props.parentEv has a parentEventId @@ -368,6 +388,7 @@ export default class ReplyThread extends React.Component { isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} useIRCLayout={this.props.useIRCLayout} enableFlair={SettingsStore.getValue(UIFeature.Flair)} + replacingEventId={ev.replacingEventId()} /> </blockquote>; }); 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 <tulir@maunium.net> +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<HTMLButtonElement>; +} + +interface IProps { + id: string; + name: string; + emojis: IEmoji[]; + selectedEmojis: Set<string>; + heightBefore: number; + viewportHeight: number; + scrollTop: number; + onClick(emoji: IEmoji): void; + onMouseEnter(emoji: IEmoji): void; + onMouseLeave(emoji: IEmoji): void; +} + +class Category extends React.PureComponent<IProps> { + 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 (<div key={rowIndex}>{ - emojisForRow.map(emoji => - <Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis} - onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />) + emojisForRow.map(emoji => (( + <Emoji + key={emoji.hexcode} + emoji={emoji} + selectedEmojis={selectedEmojis} + onClick={onClick} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + /> + ))) }</div>); }; @@ -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}> </LazyRenderList> </section> ); 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 <tulir@maunium.net> +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<string>; + onClick(emoji: IEmoji): void; + onMouseEnter(emoji: IEmoji): void; + onMouseLeave(emoji: IEmoji): void; +} +class Emoji extends React.PureComponent<IProps> { 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 65% rename from src/components/views/emojipicker/EmojiPicker.js rename to src/components/views/emojipicker/EmojiPicker.tsx index 16a0fc67e7..bf0481c51c 100644 --- a/src/components/views/emojipicker/EmojiPicker.js +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan <tulir@maunium.net> +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<string>; + 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<IProps, IState> { + private readonly recentlyUsed: IEmoji[]; + private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>; + private readonly categories: ICategory[]; + + private bodyRef = React.createRef<HTMLDivElement>(); 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,34 @@ 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 onEnterFilter = () => { + const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item"); + if (btn) { + btn.click(); + } + }; + + 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 +223,37 @@ 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 ( <div className="mx_EmojiPicker"> - <Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} /> - <Search query={this.state.filter} onChange={this.onChangeFilter} /> - <AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}> + <Header categories={this.categories} onAnchorClick={this.scrollToCategory} /> + <Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} /> + <AutoHideScrollbar + className="mx_EmojiPicker_body" + wrappedRef={ref => { + // @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead + this.bodyRef.current = ref + }} + onScroll={this.onScroll} + > {this.categories.map(category => { const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = (<Category key={category.id} id={category.id} name={category.name} - heightBefore={heightBefore} viewportHeight={this.state.viewportHeight} - scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji} - onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd} - selectedEmojis={this.props.selectedEmojis} />); - const height = this._categoryHeightForEmojiCount(emojis.length); + const categoryElement = (( + <Category + key={category.id} + id={category.id} + name={category.name} + heightBefore={heightBefore} + viewportHeight={this.state.viewportHeight} + scrollTop={this.state.scrollTop} + emojis={emojis} + onClick={this.onClickEmoji} + onMouseEnter={this.onHoverEmoji} + onMouseLeave={this.onHoverEmojiEnd} + selectedEmojis={this.props.selectedEmojis} + /> + )); + 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 <tulir@maunium.net> +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<IProps> { + 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 ( - <nav className="mx_EmojiPicker_header" role="tablist" aria-label={_t("Categories")} onKeyDown={this.onKeyDown}> + <nav + className="mx_EmojiPicker_header" + role="tablist" + aria-label={_t("Categories")} + onKeyDown={this.onKeyDown} + > {this.props.categories.map(category => { const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, { mx_EmojiPicker_anchor_visible: category.visible, diff --git a/src/components/views/emojipicker/Preview.js b/src/components/views/emojipicker/Preview.tsx similarity index 88% rename from src/components/views/emojipicker/Preview.js rename to src/components/views/emojipicker/Preview.tsx index bbe2bcb22c..69bfdf4d1c 100644 --- a/src/components/views/emojipicker/Preview.js +++ b/src/components/views/emojipicker/Preview.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan <tulir@maunium.net> +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,21 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -class Preview extends React.PureComponent { - static propTypes = { - emoji: PropTypes.object, - }; +import {IEmoji} from "../../../emoji"; +interface IProps { + emoji: IEmoji; +} + +class Preview extends React.PureComponent<IProps> { render() { const { unicode = "", annotation = "", shortcodes: [shortcode = ""], } = this.props.emoji || {}; + return ( <div className="mx_EmojiPicker_footer mx_EmojiPicker_preview"> <div className="mx_EmojiPicker_preview_emoji"> diff --git a/src/components/views/emojipicker/QuickReactions.js b/src/components/views/emojipicker/QuickReactions.tsx similarity index 69% rename from src/components/views/emojipicker/QuickReactions.js rename to src/components/views/emojipicker/QuickReactions.tsx index 2f30ae767e..0477ecfb93 100644 --- a/src/components/views/emojipicker/QuickReactions.js +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan <tulir@maunium.net> +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,11 +16,10 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import {getEmojiFromUnicode} from "../../../emoji"; +import {getEmojiFromUnicode, IEmoji} from "../../../emoji"; +import Emoji from "./Emoji"; // We use the variation-selector Heart in Quick Reactions for some reason const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => { @@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀 return data; }); -class QuickReactions extends React.Component { - static propTypes = { - onClick: PropTypes.func.isRequired, - selectedEmojis: PropTypes.instanceOf(Set), - }; +interface IProps { + selectedEmojis?: Set<string>; + onClick(emoji: IEmoji): void; +} +interface IState { + hover?: IEmoji; +} + +class QuickReactions extends React.Component<IProps, IState> { constructor(props) { super(props); this.state = { hover: null, }; - this.onMouseEnter = this.onMouseEnter.bind(this); - this.onMouseLeave = this.onMouseLeave.bind(this); } - onMouseEnter(emoji) { + private onMouseEnter = (emoji: IEmoji) => { this.setState({ hover: emoji, }); - } + }; - onMouseLeave() { + private onMouseLeave = () => { this.setState({ hover: null, }); - } + }; render() { - const Emoji = sdk.getComponent("emojipicker.Emoji"); - return ( <section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category"> <h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label"> @@ -72,10 +72,16 @@ class QuickReactions extends React.Component { } </h2> <ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}> - {QUICK_REACTIONS.map(emoji => <Emoji - key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick} - onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave} - selectedEmojis={this.props.selectedEmojis} />)} + {QUICK_REACTIONS.map(emoji => (( + <Emoji + key={emoji.hexcode} + emoji={emoji} + onClick={this.props.onClick} + onMouseEnter={this.onMouseEnter} + onMouseLeave={this.onMouseLeave} + selectedEmojis={this.props.selectedEmojis} + /> + )))} </ul> </section> ); diff --git a/src/components/views/emojipicker/ReactionPicker.js b/src/components/views/emojipicker/ReactionPicker.tsx similarity index 77% rename from src/components/views/emojipicker/ReactionPicker.js rename to src/components/views/emojipicker/ReactionPicker.tsx index 6f8cc46c40..dbef0eadbe 100644 --- a/src/components/views/emojipicker/ReactionPicker.js +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan <tulir@maunium.net> +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,26 +16,29 @@ limitations under the License. */ import React from 'react'; -import PropTypes from "prop-types"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + import EmojiPicker from "./EmojiPicker"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; -class ReactionPicker extends React.Component { - static propTypes = { - mxEvent: PropTypes.object.isRequired, - onFinished: PropTypes.func.isRequired, - reactions: PropTypes.object, - }; +interface IProps { + mxEvent: MatrixEvent; + reactions: any; // TODO type this once js-sdk is more typescripted + onFinished(): void; +} +interface IState { + selectedEmojis: Set<string>; +} + +class ReactionPicker extends React.Component<IProps, IState> { constructor(props) { super(props); this.state = { selectedEmojis: new Set(Object.keys(this.getReactions())), }; - this.onChoose = this.onChoose.bind(this); - this.onReactionsChange = this.onReactionsChange.bind(this); this.addListeners(); } @@ -45,7 +49,7 @@ class ReactionPicker extends React.Component { } } - addListeners() { + private addListeners() { if (this.props.reactions) { this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.remove", this.onReactionsChange); @@ -55,22 +59,13 @@ class ReactionPicker extends React.Component { componentWillUnmount() { if (this.props.reactions) { - this.props.reactions.removeListener( - "Relations.add", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.remove", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.redaction", - this.onReactionsChange, - ); + this.props.reactions.removeListener("Relations.add", this.onReactionsChange); + this.props.reactions.removeListener("Relations.remove", this.onReactionsChange); + this.props.reactions.removeListener("Relations.redaction", this.onReactionsChange); } } - getReactions() { + private getReactions() { if (!this.props.reactions) { return {}; } @@ -81,13 +76,13 @@ class ReactionPicker extends React.Component { .map(event => [event.getRelation().key, event.getId()])); } - onReactionsChange() { + private onReactionsChange = () => { this.setState({ selectedEmojis: new Set(Object.keys(this.getReactions())), }); - } + }; - onChoose(reaction) { + onChoose = (reaction: string) => { this.componentWillUnmount(); this.props.onFinished(); const myReactions = this.getReactions(); @@ -109,7 +104,7 @@ class ReactionPicker extends React.Component { dis.dispatch({action: "message_sent"}); return true; } - } + }; render() { return <EmojiPicker diff --git a/src/components/views/emojipicker/Search.js b/src/components/views/emojipicker/Search.tsx similarity index 56% rename from src/components/views/emojipicker/Search.js rename to src/components/views/emojipicker/Search.tsx index 3432dadea8..fe1fecec7b 100644 --- a/src/components/views/emojipicker/Search.js +++ b/src/components/views/emojipicker/Search.tsx @@ -1,5 +1,6 @@ /* Copyright 2019 Tulir Asokan <tulir@maunium.net> +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,32 +16,41 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import { _t } from '../../../languageHandler'; +import {Key} from "../../../Keyboard"; -class Search extends React.PureComponent { - static propTypes = { - query: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - }; +interface IProps { + query: string; + onChange(value: string): void; + onEnter(): void; +} - constructor(props) { - super(props); - this.inputRef = React.createRef(); - } +class Search extends React.PureComponent<IProps> { + private inputRef = React.createRef<HTMLInputElement>(); componentDidMount() { // For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout setTimeout(() => this.inputRef.current.focus(), 0); } + private onKeyDown = (ev: React.KeyboardEvent) => { + if (ev.key === Key.ENTER) { + this.props.onEnter(); + ev.stopPropagation(); + ev.preventDefault(); + } + }; + render() { let rightButton; if (this.props.query) { rightButton = ( - <button onClick={() => this.props.onChange("")} - className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear" - title={_t("Cancel search")} /> + <button + onClick={() => this.props.onChange("")} + className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear" + title={_t("Cancel search")} + /> ); } else { rightButton = <span className="mx_EmojiPicker_search_icon" />; @@ -48,8 +58,15 @@ class Search extends React.PureComponent { return ( <div className="mx_EmojiPicker_search"> - <input autoFocus type="text" placeholder="Search" value={this.props.query} - onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} /> + <input + autoFocus + type="text" + placeholder="Search" + value={this.props.query} + onChange={ev => this.props.onChange(ev.target.value)} + onKeyDown={this.onKeyDown} + ref={this.inputRef} + /> {rightButton} </div> ); diff --git a/src/components/views/messages/EncryptionEvent.js b/src/components/views/messages/EncryptionEvent.js index ab0f3fde2e..a9ce10d202 100644 --- a/src/components/views/messages/EncryptionEvent.js +++ b/src/components/views/messages/EncryptionEvent.js @@ -25,10 +25,8 @@ export default class EncryptionEvent extends React.Component { let body; let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon"; - if ( - mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && - MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId()) - ) { + const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId()); + if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) { body = <div> <div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div> <div className="mx_cryptoEvent_subtitle"> @@ -38,6 +36,13 @@ export default class EncryptionEvent extends React.Component { )} </div> </div>; + } else if (isRoomEncrypted) { + body = <div> + <div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div> + <div className="mx_cryptoEvent_subtitle"> + {_t("Ignored attempt to disable encryption")} + </div> + </div>; } else { body = <div> <div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div> diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx new file mode 100644 index 0000000000..3d191209f9 --- /dev/null +++ b/src/components/views/messages/MJitsiWidgetEvent.tsx @@ -0,0 +1,76 @@ +/* +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 React from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { _t } from "../../../languageHandler"; +import WidgetStore from "../../../stores/WidgetStore"; + +interface IProps { + mxEvent: MatrixEvent; +} + +export default class MJitsiWidgetEvent extends React.PureComponent<IProps> { + constructor(props) { + super(props); + } + + render() { + const url = this.props.mxEvent.getContent()['url']; + const prevUrl = this.props.mxEvent.getPrevContent()['url']; + const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender(); + + let joinCopy = _t('Join the conference at the top of this room'); + if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) { + joinCopy = _t('Join the conference from the room information card on the right'); + } + + if (!url) { + // removed + return ( + <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'> + <div className='mx_MJitsiWidgetEvent_title'> + {_t('Video conference ended by %(senderName)s', {senderName})} + </div> + </div> + ); + } else if (prevUrl) { + // modified + return ( + <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'> + <div className='mx_MJitsiWidgetEvent_title'> + {_t('Video conference updated by %(senderName)s', {senderName})} + </div> + <div className='mx_MJitsiWidgetEvent_subtitle'> + {joinCopy} + </div> + </div> + ); + } else { + // assume added + return ( + <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'> + <div className='mx_MJitsiWidgetEvent_title'> + {_t("Video conference started by %(senderName)s", {senderName})} + </div> + <div className='mx_MJitsiWidgetEvent_subtitle'> + {joinCopy} + </div> + </div> + ); + } + } +} diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index 3e95da1bc1..5927c7c3cc 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -31,6 +31,7 @@ interface IProps { className?: string; withoutScrollContainer?: boolean; previousPhase?: RightPanelPhases; + closeLabel?: string; onClose?(): void; } @@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) => }; const BaseCard: React.FC<IProps> = ({ + closeLabel, onClose, className, header, @@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({ let closeButton; if (onClose) { - closeButton = <AccessibleButton className="mx_BaseCard_close" onClick={onClose} title={_t("Close")} />; + closeButton = <AccessibleButton + className="mx_BaseCard_close" + onClick={onClose} + title={closeLabel || _t("Close")} + />; } if (!withoutScrollContainer) { diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index df52e5cabd..c237a4ade6 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -27,6 +27,9 @@ import * as sdk from "../../../index"; import {_t} from "../../../languageHandler"; import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; +import dis from "../../../dispatcher/dispatcher"; +import {Action} from "../../../dispatcher/actions"; +import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; // cancellation codes which constitute a key mismatch const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"]; @@ -42,7 +45,14 @@ interface IProps { } const EncryptionPanel: React.FC<IProps> = (props: IProps) => { - const {verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted} = props; + const { + verificationRequest, + verificationRequestPromise, + member, + onClose, + layout, + isRoomEncrypted, + } = props; const [request, setRequest] = useState(verificationRequest); // state to show a spinner immediately after clicking "start verification", // before we have a request @@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => { }, [onClose, request]); useEventEmitter(request, "change", changeHandler); - const onCancel = useCallback(function() { - if (request) { - request.cancel(); - } - }, [request]); - - let cancelButton: JSX.Element; - if (layout !== "dialog" && request && request.pending) { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - cancelButton = (<AccessibleButton - className="mx_EncryptionPanel_cancel" - onClick={onCancel} - title={_t('Cancel')} - ></AccessibleButton>); - } - const onStartVerification = useCallback(async () => { setRequesting(true); const cli = MatrixClientPeg.get(); @@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => { const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId); setRequest(verificationRequest_); setPhase(verificationRequest_.phase); - }, [member.userId]); + // Notify the RightPanelStore about this + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.EncryptionPanel, + refireParams: { member, verificationRequest: verificationRequest_ }, + }); + }, [member]); const requested = (!request && isRequesting) || @@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => { member.userId === MatrixClientPeg.get().getUserId(); if (!request || requested) { const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe); - return (<React.Fragment> - {cancelButton} + return ( <EncryptionInfo isRoomEncrypted={isRoomEncrypted} onStartVerification={onStartVerification} @@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => { waitingForOtherParty={requested && initiatedByMe} waitingForNetwork={requested && !initiatedByMe} inDialog={layout === "dialog"} /> - </React.Fragment>); + ); } else { - return (<React.Fragment> - {cancelButton} + return ( <VerificationPanel isRoomEncrypted={isRoomEncrypted} layout={layout} @@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => { inDialog={layout === "dialog"} phase={phase} /> - </React.Fragment>); + ); } }; diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.tsx similarity index 85% rename from src/components/views/right_panel/UserInfo.js rename to src/components/views/right_panel/UserInfo.tsx index 8440532b9d..807bd27796 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.tsx @@ -17,20 +17,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react'; -import PropTypes from 'prop-types'; +import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import classNames from 'classnames'; -import {Group, RoomMember, User, Room} from 'matrix-js-sdk'; +import {MatrixClient} from 'matrix-js-sdk/src/client'; +import {RoomMember} from 'matrix-js-sdk/src/models/room-member'; +import {User} from 'matrix-js-sdk/src/models/user'; +import {Room} from 'matrix-js-sdk/src/models/room'; +import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; + import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; +import {_t} from '../../../languageHandler'; import createRoom, {privateShouldBeEncrypted} from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; import SettingsStore from "../../../settings/SettingsStore"; -import {EventTimeline} from "matrix-js-sdk"; import RoomViewStore from "../../../stores/RoomViewStore"; import MultiInviter from "../../../utils/MultiInviter"; import GroupStore from "../../../stores/GroupStore"; @@ -41,13 +43,31 @@ import {textualPowerLevel} from '../../../Roles'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import EncryptionPanel from "./EncryptionPanel"; -import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; -import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification'; +import {useAsyncMemo} from '../../../hooks/useAsyncMemo'; +import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification'; import {Action} from "../../../dispatcher/actions"; import {useIsEncrypted} from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; +import {E2EStatus} from "../../../utils/ShieldUtils"; +import ImageView from "../elements/ImageView"; +import Spinner from "../elements/Spinner"; +import IconButton from "../elements/IconButton"; +import PowerSelector from "../elements/PowerSelector"; +import MemberAvatar from "../avatars/MemberAvatar"; +import PresenceLabel from "../rooms/PresenceLabel"; +import ShareDialog from "../dialogs/ShareDialog"; +import ErrorDialog from "../dialogs/ErrorDialog"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog"; +import InfoDialog from "../dialogs/InfoDialog"; -const _disambiguateDevices = (devices) => { +interface IDevice { + deviceId: string; + ambiguous?: boolean; + getDisplayName(): string; +} + +const disambiguateDevices = (devices: IDevice[]) => { const names = Object.create(null); for (let i = 0; i < devices.length; i++) { const name = devices[i].getDisplayName(); @@ -64,11 +84,11 @@ const _disambiguateDevices = (devices) => { } }; -export const getE2EStatus = (cli, userId, devices) => { +export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice[]): E2EStatus => { const isMe = userId === cli.getUserId(); const userTrust = cli.checkUserTrust(userId); if (!userTrust.isCrossSigningVerified()) { - return userTrust.wasCrossSigningVerified() ? "warning" : "normal"; + return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal; } const anyDeviceUnverified = devices.some(device => { @@ -81,10 +101,10 @@ export const getE2EStatus = (cli, userId, devices) => { const deviceTrust = cli.checkDeviceTrust(userId, deviceId); return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified(); }); - return anyDeviceUnverified ? "warning" : "verified"; + return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; }; -async function openDMForUser(matrixClient, userId) { +async function openDMForUser(matrixClient: MatrixClient, userId: string) { const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId); const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => { const room = matrixClient.getRoom(roomId); @@ -107,6 +127,7 @@ async function openDMForUser(matrixClient, userId) { const createRoomOptions = { dmUserId: userId, + encryption: undefined, }; if (privateShouldBeEncrypted()) { @@ -122,10 +143,12 @@ async function openDMForUser(matrixClient, userId) { } } - createRoom(createRoomOptions); + return createRoom(createRoomOptions); } -function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) { +type SetUpdating = (updating: boolean) => void; + +function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify: boolean, setUpdating: SetUpdating) { return useAsyncMemo(async () => { if (!canVerify) { return undefined; @@ -142,7 +165,7 @@ function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) { }, [cli, member, canVerify], undefined); } -function DeviceItem({userId, device}) { +function DeviceItem({userId, device}: {userId: string, device: IDevice}) { const cli = useContext(MatrixClientContext); const isMe = userId === cli.getUserId(); const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); @@ -169,8 +192,8 @@ function DeviceItem({userId, device}) { }; const deviceName = device.ambiguous ? - (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : - device.getDisplayName(); + (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : + device.getDisplayName(); let trustedLabel = null; if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); @@ -198,8 +221,7 @@ function DeviceItem({userId, device}) { } } -function DevicesSection({devices, userId, loading}) { - const Spinner = sdk.getComponent("elements.Spinner"); +function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) { const cli = useContext(MatrixClientContext); const userTrust = cli.checkUserTrust(userId); @@ -210,7 +232,7 @@ function DevicesSection({devices, userId, loading}) { return <Spinner />; } if (devices === null) { - return _t("Unable to load session list"); + return <>{_t("Unable to load session list")}</>; } const isMe = userId === cli.getUserId(); const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId)); @@ -285,7 +307,11 @@ function DevicesSection({devices, userId, loading}) { ); } -const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { +const UserOptionsSection: React.FC<{ + member: RoomMember; + isIgnored: boolean; + canInvite: boolean; +}> = ({member, isIgnored, canInvite}) => { const cli = useContext(MatrixClientContext); let ignoreButton = null; @@ -296,7 +322,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { const isMe = member.userId === cli.getUserId(); const onShareUserClick = () => { - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room member dialog', '', ShareDialog, { target: member, }); @@ -318,7 +343,10 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { }; ignoreButton = ( - <AccessibleButton onClick={onIgnoreToggle} className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}> + <AccessibleButton + onClick={onIgnoreToggle} + className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})} + > { isIgnored ? _t("Unignore") : _t("Ignore") } </AccessibleButton> ); @@ -341,11 +369,14 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { }); }; - readReceiptButton = ( - <AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field"> - { _t('Jump to read receipt') } - </AccessibleButton> - ); + const room = cli.getRoom(member.roomId); + if (room?.getEventReadUpTo(member.userId)) { + readReceiptButton = ( + <AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field"> + { _t('Jump to read receipt') } + </AccessibleButton> + ); + } insertPillButton = ( <AccessibleButton onClick={onInsertPillButton} className={"mx_UserInfo_field"}> @@ -367,7 +398,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { } }); } catch (err) { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t('Failed to invite'), description: ((err && err.message) ? err.message : _t("Operation failed")), @@ -413,8 +443,7 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => { ); }; -const _warnSelfDemote = async () => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); +const warnSelfDemote = async () => { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { title: _t("Demote yourself?"), description: @@ -430,7 +459,7 @@ const _warnSelfDemote = async () => { return confirmed; }; -const GenericAdminToolsContainer = ({children}) => { +const GenericAdminToolsContainer: React.FC<{}> = ({children}) => { return ( <div className="mx_UserInfo_container"> <h3>{ _t("Admin Tools") }</h3> @@ -441,7 +470,20 @@ const GenericAdminToolsContainer = ({children}) => { ); }; -const _isMuted = (member, powerLevelContent) => { +interface IPowerLevelsContent { + events?: Record<string, number>; + // eslint-disable-next-line camelcase + users_default?: number; + // eslint-disable-next-line camelcase + events_default?: number; + // eslint-disable-next-line camelcase + state_default?: number; + ban?: number; + kick?: number; + redact?: number; +} + +const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => { if (!powerLevelContent || !member) return false; const levelToSend = ( @@ -451,8 +493,8 @@ const _isMuted = (member, powerLevelContent) => { return member.powerLevel < levelToSend; }; -export const useRoomPowerLevels = (cli, room) => { - const [powerLevels, setPowerLevels] = useState({}); +export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => { + const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({}); const update = useCallback(() => { if (!room) { @@ -479,14 +521,19 @@ export const useRoomPowerLevels = (cli, room) => { return powerLevels; }; -const RoomKickButton = ({member, startUpdating, stopUpdating}) => { +interface IBaseProps { + member: RoomMember; + startUpdating(): void; + stopUpdating(): void; +} + +const RoomKickButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => { const cli = useContext(MatrixClientContext); // check if user can be kicked/disinvited if (member.membership !== "invite" && member.membership !== "join") return null; const onKick = async () => { - const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); const {finished} = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onKick', @@ -509,7 +556,6 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => { // get out of sync if we force setState here! console.log("Kick success"); }, function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Kick error: " + err); Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, { title: _t("Failed to kick"), @@ -526,7 +572,7 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => { </AccessibleButton>; }; -const RedactMessagesButton = ({member}) => { +const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => { const cli = useContext(MatrixClientContext); const onRedactAllMessages = async () => { @@ -554,7 +600,6 @@ const RedactMessagesButton = ({member}) => { const user = member.name; if (count === 0) { - const InfoDialog = sdk.getComponent("dialogs.InfoDialog"); Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, { title: _t("No recent messages by %(user)s found", {user}), description: @@ -563,14 +608,14 @@ const RedactMessagesButton = ({member}) => { </div>, }); } else { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { title: _t("Remove recent messages by %(user)s", {user}), description: <div> - <p>{ _t("You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?", {count, user}) }</p> - <p>{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }</p> + <p>{ _t("You are about to remove %(count)s messages by %(user)s. " + + "This cannot be undone. Do you wish to continue?", {count, user}) }</p> + <p>{ _t("For a large amount of messages, this might take some time. " + + "Please don't refresh your client in the meantime.") }</p> </div>, button: _t("Remove %(count)s messages", {count}), }); @@ -603,11 +648,10 @@ const RedactMessagesButton = ({member}) => { </AccessibleButton>; }; -const BanToggleButton = ({member, startUpdating, stopUpdating}) => { +const BanToggleButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => { const cli = useContext(MatrixClientContext); const onBanOrUnban = async () => { - const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); const {finished} = Modal.createTrackedDialog( 'Confirm User Action Dialog', 'onBanOrUnban', @@ -636,7 +680,6 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => { // get out of sync if we force setState here! console.log("Ban success"); }, function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Ban error: " + err); Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, { title: _t("Error"), @@ -661,22 +704,26 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => { </AccessibleButton>; }; -const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdating}) => { +interface IBaseRoomProps extends IBaseProps { + room: Room; + powerLevels: IPowerLevelsContent; +} + +const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels, startUpdating, stopUpdating}) => { const cli = useContext(MatrixClientContext); // Don't show the mute/unmute option if the user is not in the room if (member.membership !== "join") return null; - const isMuted = _isMuted(member, powerLevels); + const muted = isMuted(member, powerLevels); const onMuteToggle = async () => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const roomId = member.roomId; const target = member.userId; // if muting self, warn as it may be irreversible if (target === cli.getUserId()) { try { - if (!(await _warnSelfDemote())) return; + if (!(await warnSelfDemote())) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); return; @@ -692,7 +739,7 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin powerLevels.events_default ); let level; - if (isMuted) { // unmute + if (muted) { // unmute level = levelToSend; } else { // mute level = levelToSend - 1; @@ -718,16 +765,23 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin }; const classes = classNames("mx_UserInfo_field", { - mx_UserInfo_destructive: !isMuted, + mx_UserInfo_destructive: !muted, }); - const muteLabel = isMuted ? _t("Unmute") : _t("Mute"); + const muteLabel = muted ? _t("Unmute") : _t("Mute"); return <AccessibleButton className={classes} onClick={onMuteToggle}> { muteLabel } </AccessibleButton>; }; -const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpdating, powerLevels}) => { +const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({ + room, + children, + member, + startUpdating, + stopUpdating, + powerLevels, +}) => { const cli = useContext(MatrixClientContext); let kickButton; let banButton; @@ -786,7 +840,18 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd return <div />; }; -const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, stopUpdating}) => { +interface GroupMember { + userId: string; + displayname?: string; // XXX: GroupMember objects are inconsistent :(( + avatarUrl?: string; +} + +const GroupAdminToolsSection: React.FC<{ + groupId: string; + groupMember: GroupMember; + startUpdating(): void; + stopUpdating(): void; +}> = ({children, groupId, groupMember, startUpdating, stopUpdating}) => { const cli = useContext(MatrixClientContext); const [isPrivileged, setIsPrivileged] = useState(false); @@ -814,8 +879,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, }, [groupId, groupMember.userId]); if (isPrivileged) { - const _onKick = async () => { - const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + const onKick = async () => { const {finished} = Modal.createDialog(ConfirmUserActionDialog, { matrixClient: cli, groupMember, @@ -836,7 +900,6 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, member: null, }); }).catch((e) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, { title: _t('Error'), description: isInvited ? @@ -850,7 +913,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, }; const kickButton = ( - <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={_onKick}> + <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}> { isInvited ? _t('Disinvite') : _t('Remove from community') } </AccessibleButton> ); @@ -870,13 +933,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, return <div />; }; -const GroupMember = PropTypes.shape({ - userId: PropTypes.string.isRequired, - displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :(( - avatarUrl: PropTypes.string, -}); - -const useIsSynapseAdmin = (cli) => { +const useIsSynapseAdmin = (cli: MatrixClient) => { const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { cli.isSynapseAdministrator().then((isAdmin) => { @@ -888,14 +945,20 @@ const useIsSynapseAdmin = (cli) => { return isAdmin; }; -const useHomeserverSupportsCrossSigning = (cli) => { - return useAsyncMemo(async () => { +const useHomeserverSupportsCrossSigning = (cli: MatrixClient) => { + return useAsyncMemo<boolean>(async () => { return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"); }, [cli], false); }; -function useRoomPermissions(cli, room, user) { - const [roomPermissions, setRoomPermissions] = useState({ +interface IRoomPermissions { + modifyLevelMax: number; + canEdit: boolean; + canInvite: boolean; +} + +function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPermissions { + const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({ // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL modifyLevelMax: -1, canEdit: false, @@ -940,7 +1003,7 @@ function useRoomPermissions(cli, room, user) { updateRoomPermissions(); return () => { setRoomPermissions({ - maximalPowerLevel: -1, + modifyLevelMax: -1, canEdit: false, canInvite: false, }); @@ -950,14 +1013,18 @@ function useRoomPermissions(cli, room, user) { return roomPermissions; } -const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => { +const PowerLevelSection: React.FC<{ + user: User; + room: Room; + roomPermissions: IRoomPermissions; + powerLevels: IPowerLevelsContent; +}> = ({user, room, roomPermissions, powerLevels}) => { const [isEditing, setEditing] = useState(false); if (isEditing) { return (<PowerLevelEditor user={user} room={room} roomPermissions={roomPermissions} onFinished={() => setEditing(false)} />); } else { - const IconButton = sdk.getComponent('elements.IconButton'); const powerLevelUsersDefault = powerLevels.users_default || 0; const powerLevel = parseInt(user.powerLevel, 10); const modifyButton = roomPermissions.canEdit ? @@ -975,7 +1042,12 @@ const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => { } }; -const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { +const PowerLevelEditor: React.FC<{ + user: User; + room: Room; + roomPermissions: IRoomPermissions; + onFinished(): void; +}> = ({user, room, roomPermissions, onFinished}) => { const cli = useContext(MatrixClientContext); const [isUpdating, setIsUpdating] = useState(false); @@ -994,7 +1066,6 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { // get out of sync if we force setState here! console.log("Power change success"); }, function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to change power level " + err); Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { title: _t("Error"), @@ -1025,12 +1096,10 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { } const myUserId = cli.getUserId(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. if (myUserId === target) { try { - if (!(await _warnSelfDemote())) return; + if (!(await warnSelfDemote())) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); } @@ -1039,7 +1108,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { } const myPower = powerLevelEvent.getContent().users[myUserId]; - if (parseInt(myPower) === parseInt(powerLevel)) { + if (parseInt(myPower) === powerLevel) { const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { title: _t("Warning!"), description: @@ -1062,12 +1131,9 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; - const IconButton = sdk.getComponent('elements.IconButton'); - const Spinner = sdk.getComponent("elements.Spinner"); const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> : <IconButton icon="check" onClick={changePowerLevel} />; - const PowerSelector = sdk.getComponent('elements.PowerSelector'); return ( <div className="mx_UserInfo_profileField"> <PowerSelector @@ -1083,7 +1149,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => { ); }; -export const useDevices = (userId) => { +export const useDevices = (userId: string) => { const cli = useContext(MatrixClientContext); // undefined means yet to be loaded, null means failed to load, otherwise list of devices @@ -1094,7 +1160,7 @@ export const useDevices = (userId) => { let cancelled = false; - async function _downloadDeviceList() { + async function downloadDeviceList() { try { await cli.downloadKeys([userId], true); const devices = cli.getStoredDevicesForUser(userId); @@ -1104,13 +1170,13 @@ export const useDevices = (userId) => { return; } - _disambiguateDevices(devices); + disambiguateDevices(devices); setDevices(devices); } catch (err) { setDevices(null); } } - _downloadDeviceList(); + downloadDeviceList(); // Handle being unmounted return () => { @@ -1153,7 +1219,13 @@ export const useDevices = (userId) => { return devices; }; -const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { +const BasicUserInfo: React.FC<{ + room: Room; + member: User | RoomMember; + groupId: string; + devices: IDevice[]; + isRoomEncrypted: boolean; +}> = ({room, member, groupId, devices, isRoomEncrypted}) => { const cli = useContext(MatrixClientContext); const powerLevels = useRoomPowerLevels(cli, room); @@ -1186,7 +1258,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { const roomPermissions = useRoomPermissions(cli, room, member); const onSynapseDeactivate = useCallback(async () => { - const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { title: _t("Deactivate user?"), description: @@ -1207,7 +1278,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { console.error("Failed to deactivate user"); console.error(err); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, { title: _t('Failed to deactivate user'), description: ((err && err.message) ? err.message : _t("Operation failed")), @@ -1260,8 +1330,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { } if (pendingUpdateCount > 0) { - const Loader = sdk.getComponent("elements.Spinner"); - spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />; + spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />; } let memberDetails; @@ -1324,7 +1393,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { // HACK: only show a spinner if the device section spinner is not shown, // to avoid showing a double spinner // We should ask for a design that includes all the different loading states here - const Spinner = sdk.getComponent('elements.Spinner'); verifyButton = <Spinner />; } } @@ -1351,7 +1419,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { { securitySection } <UserOptionsSection - devices={devices} canInvite={roomPermissions.canInvite} isIgnored={isIgnored} member={member} /> @@ -1362,7 +1429,12 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { </React.Fragment>; }; -const UserInfoHeader = ({member, e2eStatus}) => { +type Member = User | RoomMember | GroupMember; + +const UserInfoHeader: React.FC<{ + member: Member; + e2eStatus: E2EStatus; +}> = ({member, e2eStatus}) => { const cli = useContext(MatrixClientContext); const onMemberAvatarClick = useCallback(() => { @@ -1370,7 +1442,6 @@ const UserInfoHeader = ({member, e2eStatus}) => { if (!avatarUrl) return; const httpUrl = cli.mxcUrlToHttp(avatarUrl); - const ImageView = sdk.getComponent("elements.ImageView"); const params = { src: httpUrl, name: member.name, @@ -1379,7 +1450,6 @@ const UserInfoHeader = ({member, e2eStatus}) => { Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); }, [cli, member]); - const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const avatarElement = ( <div className="mx_UserInfo_avatar"> <div> @@ -1421,10 +1491,13 @@ const UserInfoHeader = ({member, e2eStatus}) => { let presenceLabel = null; if (showPresence) { - const PresenceLabel = sdk.getComponent('rooms.PresenceLabel'); - presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo} - currentlyActive={presenceCurrentlyActive} - presenceState={presenceState} />; + presenceLabel = ( + <PresenceLabel + activeAgo={presenceLastActiveAgo} + currentlyActive={presenceCurrentlyActive} + presenceState={presenceState} + /> + ); } let statusLabel = null; @@ -1461,7 +1534,32 @@ const UserInfoHeader = ({member, e2eStatus}) => { </React.Fragment>; }; -const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => { +interface IProps { + user: Member; + groupId?: string; + room?: Room; + phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo; + onClose(): void; +} + +interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> { + user: Member; + groupId: void; + room: Room; + phase: RightPanelPhases.EncryptionPanel; + onClose(): void; +} + +type Props = IProps | IPropsWithEncryptionPanel; + +const UserInfo: React.FC<Props> = ({ + user, + groupId, + room, + onClose, + phase = RightPanelPhases.RoomMemberInfo, + ...props +}) => { const cli = useContext(MatrixClientContext); // fetch latest room member if we have a room, so we don't show historical information, falling back to user @@ -1485,7 +1583,7 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb <BasicUserInfo room={room} member={member} - groupId={groupId} + groupId={groupId as string} devices={devices} isRoomEncrypted={isRoomEncrypted} /> ); @@ -1493,7 +1591,12 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb case RightPanelPhases.EncryptionPanel: classes.push("mx_UserInfo_smallAvatar"); content = ( - <EncryptionPanel {...props} member={member} onClose={onClose} isRoomEncrypted={isRoomEncrypted} /> + <EncryptionPanel + {...props as React.ComponentProps<typeof EncryptionPanel>} + member={member} + onClose={onClose} + isRoomEncrypted={isRoomEncrypted} + /> ); break; } @@ -1504,23 +1607,24 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb previousPhase = RightPanelPhases.RoomMemberList; } - const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />; - return <BaseCard className={classes.join(" ")} header={header} onClose={onClose} previousPhase={previousPhase}> + let closeLabel = undefined; + if (phase === RightPanelPhases.EncryptionPanel) { + const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest; + if (verificationRequest && verificationRequest.pending) { + closeLabel = _t("Cancel"); + } + } + + const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />; + return <BaseCard + className={classes.join(" ")} + header={header} + onClose={onClose} + closeLabel={closeLabel} + previousPhase={previousPhase} + > { content } </BaseCard>; }; -UserInfo.propTypes = { - user: PropTypes.oneOfType([ - PropTypes.instanceOf(User), - PropTypes.instanceOf(RoomMember), - GroupMember, - ]).isRequired, - group: PropTypes.instanceOf(Group), - groupId: PropTypes.string, - room: PropTypes.instanceOf(Room), - - onClose: PropTypes.func, -}; - export default UserInfo; diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 230e71c000..104a9b9878 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -32,6 +32,9 @@ import WidgetStore from "../../../stores/WidgetStore"; import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import classNames from "classnames"; +import dis from "../../../dispatcher/dispatcher"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; +import { MatrixCapabilities } from "matrix-widget-api"; import RoomWidgetContextMenu from "../context_menus/RoomWidgetContextMenu"; interface IProps { diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 7c2eb83a94..d9b34b93ef 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -619,13 +619,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState> } private onFormatAction = (action: Formatting) => { - const range = getRangeForSelection( - this.editorRef.current, - this.props.model, - document.getSelection()); + const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); + // trim the range as we want it to exclude leading/trailing spaces + range.trim(); + if (range.length === 0) { return; } + this.historyManager.ensureLastChangesPushed(this.props.model); this.modifiedFlag = true; switch (action) { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index a1cc681a4c..c2b1af2ddc 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -34,6 +34,7 @@ import * as ObjectUtils from "../../../ObjectUtils"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {E2E_STATE} from "./E2EIcon"; import {toRem} from "../../../utils/units"; +import {WidgetType} from "../../../widgets/WidgetType"; import RoomAvatar from "../avatars/RoomAvatar"; const eventTileTypes = { @@ -111,6 +112,19 @@ export function getHandlerTile(ev) { } } + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) + if (type === "im.vector.modular.widgets") { + let type = ev.getContent()['type']; + if (!type) { + // deleted/invalid widget - try the past widget type + type = ev.getPrevContent()['type']; + } + + if (WidgetType.JITSI.matches(type)) { + return "messages.MJitsiWidgetEvent"; + } + } + return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type]; } @@ -627,16 +641,18 @@ export default class EventTile extends React.Component { const msgtype = content.msgtype; const eventType = this.props.mxEvent.getType(); + let tileHandler = getHandlerTile(this.props.mxEvent); + // Info messages are basically information about commands processed on a room const isBubbleMessage = eventType.startsWith("m.key.verification") || (eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) || - (eventType === "m.room.encryption"); + (eventType === "m.room.encryption") || + (tileHandler === "messages.MJitsiWidgetEvent"); let isInfoMessage = ( !isBubbleMessage && eventType !== 'm.room.message' && eventType !== 'm.sticker' && eventType !== 'm.room.create' ); - let tileHandler = getHandlerTile(this.props.mxEvent); // If we're showing hidden events in the timeline, we should use the // source tile when there's no regular tile for an event and also for // replace relations (which otherwise would display as a confusing @@ -902,6 +918,7 @@ export default class EventTile extends React.Component { highlights={this.props.highlights} highlightLink={this.props.highlightLink} onHeightChanged={this.props.onHeightChanged} + replacingEventId={this.props.replacingEventId} showUrlPreview={false} /> </div> </div> diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index ae122a3783..9da6e22847 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -121,8 +121,8 @@ export default class MemberList extends React.Component { this.setState(this._getMembersState(this.roomMembers())); this._listenForMembersChanges(); } - } else if (membership === "invite") { - // show the members we've got when invited + } else { + // show the members we already have loaded this.setState(this._getMembersState(this.roomMembers())); } } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index e6cd686e3c..71999fb04f 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +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. @@ -32,6 +33,10 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import {UIFeature} from "../../../settings/UIFeature"; +import WidgetStore from "../../../stores/WidgetStore"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); @@ -85,8 +90,15 @@ VideoCallButton.propTypes = { }; function HangupButton(props) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const onHangupClick = () => { + if (props.isConference) { + dis.dispatch({ + action: props.canEndConference ? 'end_conference' : 'hangup_conference', + room_id: props.roomId, + }); + return; + } + const call = CallHandler.sharedInstance().getCallForRoom(props.roomId); if (!call) { return; @@ -98,14 +110,28 @@ function HangupButton(props) { room_id: call.roomId, }); }; - return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup" + + let tooltip = _t("Hangup"); + if (props.isConference && props.canEndConference) { + tooltip = _t("End conference"); + } + + const canLeaveConference = !props.isConference ? true : props.isInConference; + return ( + <AccessibleTooltipButton + className="mx_MessageComposer_button mx_MessageComposer_hangup" onClick={onHangupClick} - title={_t('Hangup')} - />); + title={tooltip} + disabled={!canLeaveConference} + /> + ); } HangupButton.propTypes = { roomId: PropTypes.string.isRequired, + isConference: PropTypes.bool.isRequired, + canEndConference: PropTypes.bool, + isInConference: PropTypes.bool, }; const EmojiButton = ({addEmoji}) => { @@ -226,12 +252,17 @@ export default class MessageComposer extends React.Component { this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onTombstoneClick = this._onTombstoneClick.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this); + WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate); + ActiveWidgetStore.on('update', this._onActiveWidgetUpdate); this._dispatcherRef = null; + this.state = { isQuoting: Boolean(RoomViewStore.getQuotingEvent()), tombstone: this._getRoomTombstone(), canSendMessages: this.props.room.maySendMessage(), showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"), + hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room), + joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room), }; } @@ -247,6 +278,14 @@ export default class MessageComposer extends React.Component { } }; + _onWidgetUpdate = () => { + this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)}); + }; + + _onActiveWidgetUpdate = () => { + this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)}); + }; + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); @@ -277,6 +316,8 @@ export default class MessageComposer extends React.Component { if (this._roomStoreToken) { this._roomStoreToken.remove(); } + WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate); + ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate); dis.unregister(this.dispatcherRef); } @@ -392,9 +433,19 @@ export default class MessageComposer extends React.Component { } if (this.state.showCallButtons) { - if (callInProgress) { + if (this.state.hasConference) { + const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId); controls.push( - <HangupButton key="controls_hangup" roomId={this.props.room.roomId} />, + <HangupButton + roomId={this.props.room.roomId} + isConference={true} + canEndConference={canEndConf} + isInConference={this.state.joinedConference} + />, + ); + } else if (callInProgress) { + controls.push( + <HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />, ); } else { controls.push( diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index dba25a94cf..2faa0fea27 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -22,7 +22,6 @@ import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import AccessibleButton from '../elements/AccessibleButton'; import WidgetUtils from '../../../utils/WidgetUtils'; -import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import PersistedElement from "../elements/PersistedElement"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; @@ -30,6 +29,7 @@ import {ContextMenu} from "../../structures/ContextMenu"; import {WidgetType} from "../../../widgets/WidgetType"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {Action} from "../../../dispatcher/actions"; +import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; // This should be below the dialog level (4000), but above the rest of the UI (1000-2000). // We sit in a context menu, so this should be given to the context menu. @@ -212,9 +212,11 @@ export default class Stickerpicker extends React.Component { _sendVisibilityToWidget(visible) { if (!this.state.stickerpickerWidget) return; - const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id); - if (widgetMessaging && visible !== this._prevSentVisibility) { - widgetMessaging.sendVisibility(visible); + const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id); + if (messaging && visible !== this._prevSentVisibility) { + messaging.updateVisibility(visible).catch(err => { + console.error("Error updating widget visibility: ", err); + }); this._prevSentVisibility = visible; } } diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index 0b62f1fa81..8ae000f087 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -35,6 +35,7 @@ export default class ChangePassword extends React.Component { rowClassName: PropTypes.string, buttonClassName: PropTypes.string, buttonKind: PropTypes.string, + buttonLabel: PropTypes.string, confirm: PropTypes.bool, // Whether to autoFocus the new password input autoFocusNewPasswordInput: PropTypes.bool, @@ -271,7 +272,7 @@ export default class ChangePassword extends React.Component { /> </div> <AccessibleButton className={buttonClassName} kind={this.props.buttonKind} onClick={this.onClickChange}> - { _t('Change Password') } + { this.props.buttonLabel || _t('Change Password') } </AccessibleButton> </form> ); diff --git a/src/components/views/settings/ProfileSettings.js b/src/components/views/settings/ProfileSettings.js index 651aa9f48d..92851ccaa0 100644 --- a/src/components/views/settings/ProfileSettings.js +++ b/src/components/views/settings/ProfileSettings.js @@ -18,30 +18,23 @@ import React, {createRef} from 'react'; import {_t} from "../../../languageHandler"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Field from "../elements/Field"; -import {User} from "matrix-js-sdk"; import { getHostingLink } from '../../../utils/HostingLink'; import * as sdk from "../../../index"; +import {OwnProfileStore} from "../../../stores/OwnProfileStore"; +import Modal from "../../../Modal"; +import ErrorDialog from "../dialogs/ErrorDialog"; export default class ProfileSettings extends React.Component { constructor() { super(); const client = MatrixClientPeg.get(); - let user = client.getUser(client.getUserId()); - if (!user) { - // XXX: We shouldn't have to do this. - // There seems to be a condition where the User object won't exist until a room - // exists on the account. To work around this, we'll just create a temporary User - // and use that. - console.warn("User object not found - creating one for ProfileSettings"); - user = new User(client.getUserId()); - } - let avatarUrl = user.avatarUrl; + let avatarUrl = OwnProfileStore.instance.avatarMxc; if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); this.state = { - userId: user.userId, - originalDisplayName: user.rawDisplayName, - displayName: user.rawDisplayName, + userId: client.getUserId(), + originalDisplayName: OwnProfileStore.instance.displayName, + displayName: OwnProfileStore.instance.displayName, originalAvatarUrl: avatarUrl, avatarUrl: avatarUrl, avatarFile: null, @@ -84,21 +77,26 @@ export default class ProfileSettings extends React.Component { const client = MatrixClientPeg.get(); const newState = {}; - // TODO: What do we do about errors? + try { + if (this.state.originalDisplayName !== this.state.displayName) { + await client.setDisplayName(this.state.displayName); + newState.originalDisplayName = this.state.displayName; + } - if (this.state.originalDisplayName !== this.state.displayName) { - await client.setDisplayName(this.state.displayName); - newState.originalDisplayName = this.state.displayName; - } - - if (this.state.avatarFile) { - const uri = await client.uploadContent(this.state.avatarFile); - await client.setAvatarUrl(uri); - newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); - newState.originalAvatarUrl = newState.avatarUrl; - newState.avatarFile = null; - } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { - await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined + if (this.state.avatarFile) { + const uri = await client.uploadContent(this.state.avatarFile); + await client.setAvatarUrl(uri); + newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); + newState.originalAvatarUrl = newState.avatarUrl; + newState.avatarFile = null; + } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { + await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined + } + } catch (err) { + Modal.createTrackedDialog('Failed to save profile', '', ErrorDialog, { + title: _t("Failed to save your profile"), + description: ((err && err.message) ? err.message : _t("The operation could not be completed")), + }); } this.setState(newState); diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js index 99352a452e..49d683c42a 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js @@ -239,7 +239,7 @@ export default class RolesRoomSettingsTab extends React.Component { defaultValue: 50, }, "redact": { - desc: _t('Remove messages'), + desc: _t('Remove messages sent by others'), defaultValue: 50, }, "notifications.room": { diff --git a/src/createRoom.ts b/src/createRoom.ts index 09de265ebc..34eb65df4e 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -275,12 +275,17 @@ export async function _waitForMember(client: MatrixClient, roomId: string, userI * can encrypt to. */ export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) { - const usersDeviceMap = await client.downloadKeys(userIds); - // { "@user:host": { "DEVICE": {...}, ... }, ... } - return Object.values(usersDeviceMap).every((userDevices) => - // { "DEVICE": {...}, ... } - Object.keys(userDevices).length > 0, - ); + try { + const usersDeviceMap = await client.downloadKeys(userIds); + // { "@user:host": { "DEVICE": {...}, ... }, ... } + return Object.values(usersDeviceMap).every((userDevices) => + // { "DEVICE": {...}, ... } + Object.keys(userDevices).length > 0, + ); + } catch (e) { + console.error("Error determining if it's possible to encrypt to all users: ", e); + return false; // assume not + } } export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> { @@ -289,9 +294,9 @@ export async function ensureDMExists(client: MatrixClient, userId: string): Prom if (existingDMRoom) { roomId = existingDMRoom.roomId; } else { - let encryption; + let encryption: boolean = undefined; if (privateShouldBeEncrypted()) { - encryption = canEncryptToAllUsers(client, [userId]); + encryption = await canEncryptToAllUsers(client, [userId]); } roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false}); await _waitForMember(client, roomId, userId); diff --git a/src/editor/range.ts b/src/editor/range.ts index 27f59f34a9..838dfd8b98 100644 --- a/src/editor/range.ts +++ b/src/editor/range.ts @@ -18,6 +18,10 @@ import EditorModel from "./model"; import DocumentPosition, {Predicate} from "./position"; import {Part} from "./parts"; +const whitespacePredicate: Predicate = (index, offset, part) => { + return part.text[offset].trim() === ""; +}; + export default class Range { private _start: DocumentPosition; private _end: DocumentPosition; @@ -35,6 +39,11 @@ export default class Range { }); } + trim() { + this._start = this._start.forwardsWhile(this.model, whitespacePredicate); + this._end = this._end.backwardsWhile(this.model, whitespacePredicate); + } + expandBackwardsWhile(predicate: Predicate) { this._start = this._start.backwardsWhile(this.model, predicate); } diff --git a/src/hooks/useAsyncMemo.ts b/src/hooks/useAsyncMemo.ts index 11c7aca7f1..38c70de259 100644 --- a/src/hooks/useAsyncMemo.ts +++ b/src/hooks/useAsyncMemo.ts @@ -18,8 +18,8 @@ import {useState, useEffect, DependencyList} from 'react'; type Fn<T> = () => Promise<T>; -export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T) => { - const [value, setValue] = useState(initialValue); +export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T): T => { + const [value, setValue] = useState<T>(initialValue); useEffect(() => { fn().then(setValue); }, deps); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/hooks/useStateToggle.ts b/src/hooks/useStateToggle.ts index 85441df328..b50a923234 100644 --- a/src/hooks/useStateToggle.ts +++ b/src/hooks/useStateToggle.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {useState} from "react"; +import {Dispatch, SetStateAction, useState} from "react"; // Hook to simplify toggling of a boolean state value // Returns value, method to toggle boolean value and method to set the boolean value -export const useStateToggle = (initialValue: boolean) => { +export const useStateToggle = (initialValue: boolean): [boolean, () => void, Dispatch<SetStateAction<boolean>>] => { const [value, setValue] = useState(initialValue); const toggleValue = () => { setValue(!value); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f1c8317c3c..44b410d9a9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -50,12 +50,10 @@ "You cannot place a call with yourself.": "You cannot place a call with yourself.", "Call in Progress": "Call in Progress", "A call is currently being placed!": "A call is currently being placed!", - "End Call": "End Call", - "Remove the group call from the room?": "Remove the group call from the room?", - "Cancel": "Cancel", - "You don't have permission to remove the call from the room": "You don't have permission to remove the call from the room", "Permission Required": "Permission Required", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", + "End conference": "End conference", + "This will end the conference for everyone. Continue?": "This will end the conference for everyone. Continue?", "Replying With Files": "Replying With Files", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?", "Continue": "Continue", @@ -143,6 +141,7 @@ "Cancel entering passphrase?": "Cancel entering passphrase?", "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?", "Go Back": "Go Back", + "Cancel": "Cancel", "Setting up keys": "Setting up keys", "Messages": "Messages", "Actions": "Actions", @@ -277,9 +276,6 @@ "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s", "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", - "Group call modified by %(senderName)s": "Group call modified by %(senderName)s", - "Group call started by %(senderName)s": "Group call started by %(senderName)s", - "Group call ended by %(senderName)s": "Group call ended by %(senderName)s", "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s", "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s", "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s", @@ -456,6 +452,7 @@ "Support adding custom themes": "Support adding custom themes", "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", + "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", @@ -718,6 +715,8 @@ "Off": "Off", "On": "On", "Noisy": "Noisy", + "Failed to save your profile": "Failed to save your profile", + "The operation could not be completed": "The operation could not be completed", "<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain", "Profile": "Profile", "Display Name": "Display Name", @@ -971,7 +970,7 @@ "Change settings": "Change settings", "Kick users": "Kick users", "Ban users": "Ban users", - "Remove messages": "Remove messages", + "Remove messages sent by others": "Remove messages sent by others", "Notify everyone": "Notify everyone", "No users have specific privileges in this room": "No users have specific privileges in this room", "Privileged Users": "Privileged Users", @@ -1378,6 +1377,7 @@ "View Source": "View Source", "Encryption enabled": "Encryption enabled", "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.": "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.", + "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", "Encryption not enabled": "Encryption not enabled", "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "Error decrypting audio": "Error decrypting audio", @@ -1391,6 +1391,11 @@ "Invalid file%(extra)s": "Invalid file%(extra)s", "Error decrypting image": "Error decrypting image", "Show image": "Show image", + "Join the conference at the top of this room": "Join the conference at the top of this room", + "Join the conference from the room information card on the right": "Join the conference from the room information card on the right", + "Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s", + "Video conference updated by %(senderName)s": "Video conference updated by %(senderName)s", + "Video conference started by %(senderName)s": "Video conference started by %(senderName)s", "You have ignored this user, so their message is hidden. <a>Show anyways.</a>": "You have ignored this user, so their message is hidden. <a>Show anyways.</a>", "You verified %(name)s": "You verified %(name)s", "You cancelled verifying %(name)s": "You cancelled verifying %(name)s", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 737c882919..8b96a2e819 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -186,6 +186,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_dehydration": { + isFeature: true, + displayName: _td("Offline encrypted messaging using dehydrated devices"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "advancedRoomListLogging": { // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231 displayName: _td("Enable advanced debugging for the room list"), diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js index bf9ae3586c..4ae8dfeddb 100644 --- a/src/stores/ActiveWidgetStore.js +++ b/src/stores/ActiveWidgetStore.js @@ -17,6 +17,7 @@ limitations under the License. import EventEmitter from 'events'; import {MatrixClientPeg} from '../MatrixClientPeg'; +import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore"; /** * Stores information about the widgets active in the app right now: @@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter { super(); this._persistentWidgetId = null; - // A list of negotiated capabilities for each widget, by ID - // { - // widgetId: [caps...], - // } - this._capsByWidgetId = {}; - - // A WidgetMessaging instance for each widget ID - this._widgetMessagingByWidgetId = {}; - // What room ID each widget is associated with (if it's a room widget) this._roomIdByWidgetId = {}; @@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } - this._capsByWidgetId = {}; - this._widgetMessagingByWidgetId = {}; this._roomIdByWidgetId = {}; } @@ -76,9 +66,9 @@ class ActiveWidgetStore extends EventEmitter { if (id !== this._persistentWidgetId) return; const toDeleteId = this._persistentWidgetId; + WidgetMessagingStore.instance.stopMessagingById(id); + this.setWidgetPersistence(toDeleteId, false); - this.delWidgetMessaging(toDeleteId); - this.delWidgetCapabilities(toDeleteId); this.delRoomId(toDeleteId); } @@ -99,43 +89,6 @@ class ActiveWidgetStore extends EventEmitter { return this._persistentWidgetId; } - setWidgetCapabilities(widgetId, caps) { - this._capsByWidgetId[widgetId] = caps; - this.emit('update'); - } - - widgetHasCapability(widgetId, cap) { - return this._capsByWidgetId[widgetId] && this._capsByWidgetId[widgetId].includes(cap); - } - - delWidgetCapabilities(widgetId) { - delete this._capsByWidgetId[widgetId]; - this.emit('update'); - } - - setWidgetMessaging(widgetId, wm) { - // Stop any existing widget messaging first - this.delWidgetMessaging(widgetId); - this._widgetMessagingByWidgetId[widgetId] = wm; - this.emit('update'); - } - - getWidgetMessaging(widgetId) { - return this._widgetMessagingByWidgetId[widgetId]; - } - - delWidgetMessaging(widgetId) { - if (this._widgetMessagingByWidgetId[widgetId]) { - try { - this._widgetMessagingByWidgetId[widgetId].stop(); - } catch (e) { - console.error('Failed to stop listening for widgetMessaging events', e.message); - } - delete this._widgetMessagingByWidgetId[widgetId]; - this.emit('update'); - } - } - getRoomId(widgetId) { return this._roomIdByWidgetId[widgetId]; } diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index 1aa761e1c4..8983380fec 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> { /** * Gets the user's avatar as an HTTP URL of the given size. If the user's * avatar is not present, this returns null. - * @param size The size of the avatar + * @param size The size of the avatar. If zero, a full res copy of the avatar + * will be returned as an HTTP URL. * @returns The HTTP URL of the user's avatar */ - public getHttpAvatarUrl(size: number): string { + public getHttpAvatarUrl(size = 0): string { if (!this.avatarMxc) return null; - return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size); + const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through + return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); } protected async onNotReady() { diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 26e3f70b57..a6b7889243 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import SettingsStore from "../settings/SettingsStore"; import WidgetEchoStore from "../stores/WidgetEchoStore"; +import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import WidgetUtils from "../utils/WidgetUtils"; import {SettingLevel} from "../settings/SettingLevel"; import {WidgetType} from "../widgets/WidgetType"; @@ -120,6 +121,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> { } private loadRoomWidgets(room: Room) { + if (!room) return; const roomInfo = this.roomMap.get(room.roomId); roomInfo.widgets = []; this.generateApps(room).forEach(app => { @@ -160,17 +162,16 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> { let pinned = roomInfo && roomInfo.pinned[widgetId]; // Jitsi widgets should be pinned by default - if (pinned === undefined && WidgetType.JITSI.matches(this.widgetMap.get(widgetId).type)) pinned = true; + const widget = this.widgetMap.get(widgetId); + if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true; return pinned; } public canPin(widgetId: string) { - // only allow pinning up to a max of two as we do not yet have grid splits - // the only case it will go to three is if you have two and then a Jitsi gets added const roomId = this.getRoomId(widgetId); const roomInfo = this.getRoom(roomId); return roomInfo && Object.keys(roomInfo.pinned).filter(k => { - return roomInfo.widgets.some(app => app.id === k); + return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k); }).length < MAX_PINNED; } @@ -208,6 +209,24 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> { } return roomInfo.widgets; } + + public doesRoomHaveConference(room: Room): boolean { + const roomInfo = this.getRoom(room.roomId); + if (!roomInfo) return false; + + const currentWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + const hasPendingWidgets = WidgetEchoStore.roomHasPendingWidgetsOfType(room.roomId, [], WidgetType.JITSI); + return currentWidgets.length > 0 || hasPendingWidgets; + } + + public isJoinedToConferenceIn(room: Room): boolean { + const roomInfo = this.getRoom(room.roomId); + if (!roomInfo) return false; + + // A persistent conference widget indicates that we're participating + const widgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); + return widgets.some(w => ActiveWidgetStore.getWidgetPersistence(w.id)); + } } window.mxWidgetStore = WidgetStore.instance; diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts new file mode 100644 index 0000000000..b101a119a4 --- /dev/null +++ b/src/stores/widgets/ElementWidgetActions.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. + */ + +export enum ElementWidgetActions { + ClientReady = "im.vector.ready", + HangupCall = "im.vector.hangup", + OpenIntegrationManager = "integration_manager_open", +} diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts new file mode 100644 index 0000000000..1c24f70d0d --- /dev/null +++ b/src/stores/widgets/StopGapWidget.ts @@ -0,0 +1,266 @@ +/* + * 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 { Room } from "matrix-js-sdk/src/models/room"; +import { + ClientWidgetApi, + IStickerActionRequest, + IStickyActionRequest, + IWidget, + IWidgetApiRequest, + IWidgetApiRequestEmptyData, + IWidgetData, + MatrixCapabilities, + Widget, + WidgetApiFromWidgetAction, +} from "matrix-widget-api"; +import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; +import { EventEmitter } from "events"; +import { WidgetMessagingStore } from "./WidgetMessagingStore"; +import RoomViewStore from "../RoomViewStore"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { OwnProfileStore } from "../OwnProfileStore"; +import WidgetUtils from '../../utils/WidgetUtils'; +import { IntegrationManagers } from "../../integrations/IntegrationManagers"; +import SettingsStore from "../../settings/SettingsStore"; +import { WidgetType } from "../../widgets/WidgetType"; +import ActiveWidgetStore from "../ActiveWidgetStore"; +import { objectShallowClone } from "../../utils/objects"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ElementWidgetActions } from "./ElementWidgetActions"; + +// TODO: Destroy all of this code + +interface IAppTileProps { + // Note: these are only the props we care about + + app: IWidget; + room: Room; + userId: string; + creatorUserId: string; + waitForIframeLoad: boolean; + whitelistCapabilities: string[]; + userWidget: boolean; +} + +// TODO: Don't use this because it's wrong +class ElementWidget extends Widget { + constructor(w) { + super(w); + } + + public get templateUrl(): string { + if (WidgetType.JITSI.matches(this.type)) { + return WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: true, + auth: this.rawData?.auth, + }); + } + return super.templateUrl; + } + + public get rawData(): IWidgetData { + let conferenceId = super.rawData['conferenceId']; + if (conferenceId === undefined) { + // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets + const parsedUrl = new URL(this.templateUrl); + conferenceId = parsedUrl.searchParams.get("confId"); + } + return { + ...super.rawData, + theme: SettingsStore.getValue("theme"), + conferenceId, + }; + } +} + +export class StopGapWidget extends EventEmitter { + private messaging: ClientWidgetApi; + private mockWidget: Widget; + private scalarToken: string; + + constructor(private appTileProps: IAppTileProps) { + super(); + let app = appTileProps.app; + + // Backwards compatibility: not all old widgets have a creatorUserId + if (!app.creatorUserId) { + app = objectShallowClone(app); // clone to prevent accidental mutation + app.creatorUserId = MatrixClientPeg.get().getUserId(); + } + + this.mockWidget = new ElementWidget(app); + } + + public get widgetApi(): ClientWidgetApi { + return this.messaging; + } + + /** + * The URL to use in the iframe + */ + public get embedUrl(): string { + const templated = this.mockWidget.getCompleteUrl({ + currentRoomId: RoomViewStore.getRoomId(), + currentUserId: MatrixClientPeg.get().getUserId(), + userDisplayName: OwnProfileStore.instance.displayName, + userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + }); + + // Add in some legacy support sprinkles + // TODO: Replace these with proper widget params + // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 + const parsed = new URL(templated); + parsed.searchParams.set('widgetId', this.mockWidget.id); + parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); + + // Give the widget a scalar token if we're supposed to (more legacy) + // TODO: Stop doing this + if (this.scalarToken) { + parsed.searchParams.set('scalar_token', this.scalarToken); + } + + // Replace the encoded dollar signs back to dollar signs. They have no special meaning + // in HTTP, but URL parsers encode them anyways. + return parsed.toString().replace(/%24/g, '$'); + } + + /** + * The URL to use in the popout + */ + public get popoutUrl(): string { + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + return WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: false, + auth: this.mockWidget.rawData?.auth, + }); + } + return this.embedUrl; + } + + public get isManagedByManager(): boolean { + return !!this.scalarToken; + } + + public get started(): boolean { + return !!this.messaging; + } + + public start(iframe: HTMLIFrameElement) { + if (this.started) return; + const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []); + this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); + this.messaging.addEventListener("ready", () => this.emit("ready")); + WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); + + if (!this.appTileProps.userWidget && this.appTileProps.room) { + ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId); + } + + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + this.messaging.addEventListener("action:set_always_on_screen", + (ev: CustomEvent<IStickyActionRequest>) => { + if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack + } + }, + ); + } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { + this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`, + (ev: CustomEvent<IWidgetApiRequest>) => { + // Acknowledge first + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); + + // First close the stickerpicker + defaultDispatcher.dispatch({action: "stickerpicker_close"}); + + // Now open the integration manager + // TODO: Spec this interaction. + const data = ev.detail.data; + const integType = data?.integType + const integId = <string>data?.integId; + + // TODO: Open the right integration manager for the widget + if (SettingsStore.getValue("feature_many_integration_managers")) { + IntegrationManagers.sharedInstance().openAll( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } else { + IntegrationManagers.sharedInstance().getPrimaryManager().open( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } + }, + ); + + // TODO: Replace this event listener with appropriate driver functionality once the API + // establishes a sane way to send events back and forth. + this.messaging.addEventListener(`action:${WidgetApiFromWidgetAction.SendSticker}`, + (ev: CustomEvent<IStickerActionRequest>) => { + // Acknowledge first + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); + + // Send the sticker + defaultDispatcher.dispatch({ + action: 'm.sticker', + data: ev.detail.data, + widgetId: this.mockWidget.id, + }); + }, + ); + } + } + + public async prepare(): Promise<void> { + if (this.scalarToken) return; + const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget); + if (existingMessaging) this.messaging = existingMessaging; + try { + if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) { + const managers = IntegrationManagers.sharedInstance(); + if (managers.hasManager()) { + // TODO: Pick the right manager for the widget + const defaultManager = managers.getPrimaryManager(); + if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { + const scalar = defaultManager.getScalarClient(); + this.scalarToken = await scalar.getScalarToken(); + } + } + } + } catch (e) { + // All errors are non-fatal + console.error("Error preparing widget communications: ", e); + } + } + + public stop() { + if (ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) { + console.log("Skipping destroy - persistent widget"); + return; + } + if (!this.started) return; + WidgetMessagingStore.instance.stopMessaging(this.mockWidget); + ActiveWidgetStore.delRoomId(this.mockWidget.id); + } +} diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts new file mode 100644 index 0000000000..b54e4a5f7d --- /dev/null +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -0,0 +1,30 @@ +/* + * 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, WidgetDriver } from "matrix-widget-api"; +import { iterableUnion } from "../../utils/iterables"; + +// TODO: Purge this from the universe + +export class StopGapWidgetDriver extends WidgetDriver { + constructor(private allowedCapabilities: Capability[]) { + super(); + } + + public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { + return new Set(iterableUnion(requested, this.allowedCapabilities)); + } +} diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts new file mode 100644 index 0000000000..83d3ac7df8 --- /dev/null +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -0,0 +1,82 @@ +/* + * 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"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; +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<unknown> { + private static internalInstance = new WidgetMessagingStore(); + + // TODO: Fix uniqueness problem (widget IDs are not unique across the whole app) + private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi> + + public constructor() { + super(defaultDispatcher); + } + + public static get instance(): WidgetMessagingStore { + return WidgetMessagingStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise<any> { + // nothing to do + } + + protected async onReady(): Promise<any> { + // just in case + this.widgetMap.clear(); + } + + public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) { + this.stopMessaging(widget); + this.widgetMap.set(widget.id, widgetApi); + } + + public stopMessaging(widget: Widget) { + this.widgetMap.remove(widget.id)?.stop(); + } + + public getMessaging(widget: Widget): ClientWidgetApi { + return this.widgetMap.get(widget.id); + } + + /** + * Stops the widget messaging instance for a given widget ID. + * @param {string} widgetId The widget ID. + * @deprecated Widget IDs are not globally unique. + */ + public stopMessagingById(widgetId: string) { + this.widgetMap.remove(widgetId)?.stop(); + } + + /** + * Gets the widget messaging class for a given widget ID. + * @param {string} widgetId The widget ID. + * @returns {ClientWidgetApi} The widget API, or a falsey value if not found. + * @deprecated Widget IDs are not globally unique. + */ + public getMessagingForId(widgetId: string): ClientWidgetApi { + return this.widgetMap.get(widgetId); + } +} diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index d1daba7ca5..6cc95efb25 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -28,11 +28,11 @@ const WIDGET_WAIT_TIME = 20000; import SettingsStore from "../settings/SettingsStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import {IntegrationManagers} from "../integrations/IntegrationManagers"; -import {Capability} from "../widgets/WidgetApi"; import {Room} from "matrix-js-sdk/src/models/room"; import {WidgetType} from "../widgets/WidgetType"; import {objectClone} from "./objects"; import {_t} from "../languageHandler"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class WidgetUtils { /* Returns true if user is able to send state events to modify widgets in this room @@ -416,15 +416,14 @@ export default class WidgetUtils { static getCapWhitelistForAppTypeInRoomId(appType, roomId) { const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId); - const capWhitelist = enableScreenshots ? [Capability.Screenshot] : []; + const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : []; // Obviously anyone that can add a widget can claim it's a jitsi widget, // so this doesn't really offer much over the set of domains we load // widgets from at all, but it probably makes sense for sanity. if (WidgetType.JITSI.matches(appType)) { - capWhitelist.push(Capability.AlwaysOnScreen); + capWhitelist.push(MatrixCapabilities.AlwaysOnScreen); } - capWhitelist.push(Capability.ReceiveTerminate); return capWhitelist; } @@ -495,16 +494,4 @@ export default class WidgetUtils { IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id); } } - - static snapshotWidget(app) { - console.log("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(app.id).getScreenshot().catch((err) => { - console.error("Failed to get screenshot", err); - }).then((screenshot) => { - dis.dispatch({ - action: 'picture_snapshot', - file: screenshot, - }, true); - }); - } } diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts new file mode 100644 index 0000000000..56e0bca1b7 --- /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: Iterable<T>, b: Iterable<T>): Iterable<T> { + return arrayUnion(Array.from(a), Array.from(b)); +} diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 96832094f0..57d84bd33f 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -44,3 +44,26 @@ export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] { const diff = mapDiff(a, b); return arrayMerge(diff.removed, diff.added, diff.changed); } + +/** + * A Map<K, V> with added utility. + */ +export class EnhancedMap<K, V> extends Map<K, V> { + 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; + } + + public remove(key: K): V { + const v = this.get(key); + this.delete(key); + return v; + } +} diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts deleted file mode 100644 index 672cbf2a56..0000000000 --- a/src/widgets/WidgetApi.ts +++ /dev/null @@ -1,220 +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. -*/ - -// Dev note: This is largely inspired by Dimension. Used with permission. -// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts - -import { randomString } from "matrix-js-sdk/src/randomstring"; -import { EventEmitter } from "events"; -import { objectClone } from "../utils/objects"; - -export enum Capability { - Screenshot = "m.capability.screenshot", - Sticker = "m.sticker", - AlwaysOnScreen = "m.always_on_screen", - ReceiveTerminate = "im.vector.receive_terminate", -} - -export enum KnownWidgetActions { - GetSupportedApiVersions = "supported_api_versions", - TakeScreenshot = "screenshot", - GetCapabilities = "capabilities", - SendEvent = "send_event", - UpdateVisibility = "visibility", - GetOpenIDCredentials = "get_openid", - ReceiveOpenIDCredentials = "openid_credentials", - SetAlwaysOnScreen = "set_always_on_screen", - ClientReady = "im.vector.ready", - Terminate = "im.vector.terminate", -} - -export type WidgetAction = KnownWidgetActions | string; - -export enum WidgetApiType { - ToWidget = "toWidget", - FromWidget = "fromWidget", -} - -export interface WidgetRequest { - api: WidgetApiType; - widgetId: string; - requestId: string; - data: any; - action: WidgetAction; -} - -export interface ToWidgetRequest extends WidgetRequest { - api: WidgetApiType.ToWidget; -} - -export interface FromWidgetRequest extends WidgetRequest { - api: WidgetApiType.FromWidget; - response: any; -} - -export interface OpenIDCredentials { - accessToken: string; - tokenType: string; - matrixServerName: string; - expiresIn: number; -} - -/** - * Handles Element <--> Widget interactions for embedded/standalone widgets. - * - * Emitted events: - * - terminate(wait): client requested the widget to terminate. - * Call the argument 'wait(promise)' to postpone the finalization until - * the given promise resolves. - */ -export class WidgetApi extends EventEmitter { - private readonly origin: string; - private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {}; - private readonly readyPromise: Promise<any>; - private readyPromiseResolve: () => void; - private openIDCredentialsCallback: () => void; - public openIDCredentials: OpenIDCredentials; - - /** - * Set this to true if your widget is expecting a ready message from the client. False otherwise (default). - */ - public expectingExplicitReady = false; - - constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) { - super(); - - this.origin = new URL(currentUrl).origin; - - this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve); - - window.addEventListener("message", event => { - if (event.origin !== this.origin) return; // ignore: invalid origin - if (!event.data) return; // invalid schema - if (event.data.widgetId !== this.widgetId) return; // not for us - - const payload = <WidgetRequest>event.data; - if (payload.api === WidgetApiType.ToWidget && payload.action) { - console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`); - - if (payload.action === KnownWidgetActions.GetCapabilities) { - this.onCapabilitiesRequest(<ToWidgetRequest>payload); - if (!this.expectingExplicitReady) { - this.readyPromiseResolve(); - } - } else if (payload.action === KnownWidgetActions.ClientReady) { - this.readyPromiseResolve(); - - // Automatically acknowledge so we can move on - this.replyToRequest(<ToWidgetRequest>payload, {}); - } else if (payload.action === KnownWidgetActions.Terminate) { - // Finalization needs to be async, so postpone with a promise - let finalizePromise = Promise.resolve(); - const wait = (promise) => { - finalizePromise = finalizePromise.then(() => promise); - }; - this.emit('terminate', wait); - Promise.resolve(finalizePromise).then(() => { - // Acknowledge that we're shut down now - this.replyToRequest(<ToWidgetRequest>payload, {}); - }); - } else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) { - // Save OpenID credentials - this.setOpenIDCredentials(<ToWidgetRequest>payload); - this.replyToRequest(<ToWidgetRequest>payload, {}); - } else { - console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`); - } - } else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) { - console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`); - const handler = this.inFlightRequests[payload.requestId]; - delete this.inFlightRequests[payload.requestId]; - handler(<FromWidgetRequest>payload); - } else { - console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`); - } - }); - } - - public setOpenIDCredentials(value: WidgetRequest) { - const data = value.data; - if (data.state === 'allowed') { - this.openIDCredentials = { - accessToken: data.access_token, - tokenType: data.token_type, - matrixServerName: data.matrix_server_name, - expiresIn: data.expires_in, - } - } else if (data.state === 'blocked') { - this.openIDCredentials = null; - } - if (['allowed', 'blocked'].includes(data.state) && this.openIDCredentialsCallback) { - this.openIDCredentialsCallback() - } - } - - public requestOpenIDCredentials(credentialsResponseCallback: () => void) { - this.openIDCredentialsCallback = credentialsResponseCallback; - this.callAction( - KnownWidgetActions.GetOpenIDCredentials, - {}, - this.setOpenIDCredentials, - ); - } - - public waitReady(): Promise<any> { - return this.readyPromise; - } - - private replyToRequest(payload: ToWidgetRequest, reply: any) { - if (!window.parent) return; - - const request: ToWidgetRequest & {response?: any} = objectClone(payload); - request.response = reply; - - window.parent.postMessage(request, this.origin); - } - - private onCapabilitiesRequest(payload: ToWidgetRequest) { - return this.replyToRequest(payload, {capabilities: this.requestedCapabilities}); - } - - public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) { - if (!window.parent) return; - - const request: FromWidgetRequest = { - api: WidgetApiType.FromWidget, - widgetId: this.widgetId, - action: action, - requestId: randomString(160), - data: payload, - response: {}, // Not used at this layer - it's used when the client responds - }; - - if (callback) { - this.inFlightRequests[request.requestId] = callback; - } - - console.log(`[WidgetAPI] Sending request: `, request); - window.parent.postMessage(request, "*"); - } - - public setAlwaysOnScreen(onScreen: boolean): Promise<any> { - return new Promise<any>(resolve => { - this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, null); - resolve(); // SetAlwaysOnScreen is currently fire-and-forget, but that could change. - }); - } -} diff --git a/src/widgets/WidgetType.ts b/src/widgets/WidgetType.ts index e4b37e639c..e42f3ffa9b 100644 --- a/src/widgets/WidgetType.ts +++ b/src/widgets/WidgetType.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +// TODO: Move to matrix-widget-api export class WidgetType { public static readonly JITSI = new WidgetType("m.jitsi", "jitsi"); public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker"); diff --git a/test/editor/range-test.js b/test/editor/range-test.js index b69ed9eb53..60055af824 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -88,4 +88,19 @@ describe('editor/range', function() { expect(model.parts[1].text).toBe("man"); expect(model.parts.length).toBe(2); }); + it('range trim spaces off both ends', () => { + const renderer = createRenderer(); + const pc = createPartCreator(); + const model = new EditorModel([ + pc.plain("abc abc abc"), + ], pc, renderer); + const range = model.startRange( + model.positionForOffset(3, false), // at end of first `abc` + model.positionForOffset(8, false), // at start of last `abc` + ); + + expect(range.parts[0].text).toBe(" abc "); + range.trim(); + expect(range.parts[0].text).toBe("abc"); + }); }); diff --git a/yarn.lock b/yarn.lock index 9ecf43d7a4..51ff681783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5953,6 +5953,11 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== +matrix-widget-api@^0.1.0-beta.2: + version "0.1.0-beta.2" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.2.tgz#367da1ccd26b711f73fc5b6e02edf55ac2ea2692" + integrity sha512-q5g5RZN+RRjM4HmcJ+LYoQAYrB1wzyERmoQ+LvKbTV/+9Ov36Kp0QEP8CleSXEd5WLp6bkRlt60axDaY6pWGmg== + mdast-util-compact@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"