diff --git a/package.json b/package.json index 9b7d80ca73..b85191dc22 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-focus-lock": "^2.4.1", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", + "rfc4648": "^1.4.0", "sanitize-html": "^1.27.1", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 6be417f631..fee3d61153 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -15,18 +15,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* -the tile title bar is 5 (top border) + 12 (title, buttons) + 5 (bottom padding) px = 22px -the body is assumed to be 300px (assumed by at least the sticker pickerm, perhaps elsewhere), -so the body height would be 300px - 22px (room for title bar) = 278px -BUT! the sticker picker also assumes it's a little less high than that because the iframe -for the sticker picker doesn't have any padding or margin on it's bottom. -so subtracking another 5px, which brings us at 273px. -*/ -$AppsDrawerBodyHeight: 273px; +$MiniAppTileHeight: 114px; .mx_AppsDrawer { - margin: 5px; + margin: 5px 5px 5px 18px; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + + .mx_AppsContainer_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + // This is positioned directly below frame + position: absolute; + bottom: -8px !important; // override from library + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; + } + + &:hover { + .mx_AppsContainer_resizerHandle { + opacity: 0.8; + background: $primary-fg-color; + } + } } .mx_AppsDrawer_hidden { @@ -36,15 +57,23 @@ $AppsDrawerBodyHeight: 273px; .mx_AppsContainer { display: flex; flex-direction: row; - align-items: center; + align-items: stretch; justify-content: center; + height: 100%; + margin-bottom: 8px; +} + +.mx_AppsDrawer_minimised .mx_AppsContainer { + // override the re-resizable inline styles + height: inherit !important; + min-height: inherit !important; } .mx_AddWidget_button { order: 2; cursor: pointer; padding: 0; - margin: 5px auto 5px auto; + margin: -3px auto 5px 0; color: $accent-color; font-size: $font-12px; } @@ -65,40 +94,52 @@ $AppsDrawerBodyHeight: 273px; .mx_AppTile { max-width: 960px; width: 50%; - margin-right: 5px; border: 5px solid $widget-menu-bar-bg-color; border-radius: 4px; -} + display: flex; + flex-direction: column; -.mx_AppTile:last-child { - margin-right: 1px; + & + .mx_AppTile { + margin-left: 5px; + } } .mx_AppTileFullWidth { max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; + display: flex; + flex-direction: column; } .mx_AppTile_mini { max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; + display: flex; + flex-direction: column; + height: $MiniAppTileHeight; } -.mx_AppTile_persistedWrapper { - height: $AppsDrawerBodyHeight; +.mx_AppTile.mx_AppTile_minimised, +.mx_AppTileFullWidth.mx_AppTile_minimised, +.mx_AppTile_mini.mx_AppTile_minimised { + height: 14px; } +.mx_AppTile .mx_AppTile_persistedWrapper, +.mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTile_mini .mx_AppTile_persistedWrapper { - height: 114px; - min-width: 300px; + flex: 1; +} + +.mx_AppTile_persistedWrapper div { + width: 100%; + height: 100%; } .mx_AppTileMenuBar { @@ -110,6 +151,7 @@ $AppsDrawerBodyHeight: 273px; align-items: center; justify-content: space-between; cursor: pointer; + width: 100%; } .mx_AppTileMenuBar_expanded { @@ -172,7 +214,7 @@ $AppsDrawerBodyHeight: 273px; } .mx_AppTileBody { - height: $AppsDrawerBodyHeight; + height: 100%; width: 100%; overflow: hidden; } @@ -183,6 +225,13 @@ $AppsDrawerBodyHeight: 273px; overflow: hidden; } +.mx_AppTile .mx_AppTileBody, +.mx_AppTileFullWidth .mx_AppTileBody, +.mx_AppTile_mini .mx_AppTileBody_mini { + height: inherit; + flex: 1; +} + .mx_AppTileBody_mini iframe { border: none; width: 100%; @@ -191,7 +240,7 @@ $AppsDrawerBodyHeight: 273px; .mx_AppTileBody iframe { width: 100%; - height: $AppsDrawerBodyHeight; + height: 100%; overflow: hidden; border: none; padding: 0; @@ -331,7 +380,7 @@ form.mx_Custom_Widget_Form div { align-items: center; font-weight: bold; position: relative; - height: $AppsDrawerBodyHeight; + height: 100%; } .mx_AppLoading .mx_Spinner { @@ -358,3 +407,16 @@ form.mx_Custom_Widget_Form div { .mx_AppLoading iframe { display: none; } + +.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle { + display: none; +} + +/* Avoid apptile iframes capturing mouse event focus when resizing */ +.mx_AppsDrawer_resizing iframe { + pointer-events: none; +} + +.mx_AppsDrawer_resizing .mx_AppTile_persistedWrapper { + z-index: 1; +} diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 8d1b68dd99..4d26d8a312 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -36,6 +36,10 @@ limitations under the License. } } + .mx_AppTile_persistedWrapper div { + min-width: 300px; + } + .mx_IncomingCallBox { min-width: 250px; background-color: $primary-bg-color; diff --git a/src/CallHandler.js b/src/CallHandler.js index 18f6aeb98a..27e8e34e16 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd -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. @@ -67,6 +67,7 @@ import {generateHumanReadableId} from "./utils/NamingUtils"; import {Jitsi} from "./widgets/Jitsi"; import {WidgetType} from "./widgets/WidgetType"; import {SettingLevel} from "./settings/SettingLevel"; +import {base32} from "rfc4648"; global.mxCalls = { //room_id: MatrixCall @@ -388,10 +389,21 @@ async function _startCallApp(roomId, type) { return; } - const confId = `JitsiConference${generateHumanReadableId()}`; const jitsiDomain = Jitsi.getInstance().preferredDomain; + const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); + let confId; + if (jitsiAuth === 'openidtoken-jwt') { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + confId = base32.stringify(Buffer.from(roomId), { pad: false }); + } else { + // Create a random human readable conference ID + confId = `JitsiConference${generateHumanReadableId()}`; + } - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl(); + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets const parsedUrl = new URL(widgetUrl); @@ -403,6 +415,7 @@ async function _startCallApp(roomId, type) { conferenceId: confId, isAudioOnly: type === 'voice', domain: jitsiDomain, + auth: jitsiAuth, }; const widgetId = ( diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 661ab74e6f..1dc4a8abc1 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -154,6 +154,19 @@ export const Commands = [ }, category: CommandCategories.messages, }), + new Command({ + command: 'lenny', + args: '', + description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'), + runFn: function(roomId, args) { + let message = '( ͡° ͜ʖ ͡°)'; + if (args) { + message = message + ' ' + args; + } + return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + }, + category: CommandCategories.messages, + }), new Command({ command: 'plain', args: '', diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 6aed08c39d..c68e926ac1 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -186,7 +186,14 @@ export default class WidgetMessaging { isUserWidget: this.isUserWidget, onFinished: async (confirm) => { - const responseBody = {success: 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); diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 83f70eb72a..5dadba983a 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1322,7 +1322,7 @@ export default class GroupView extends React.Component { - + { this._getMembershipSection() } { this._getGroupSection() } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index e427eb92cb..33e7c4a238 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -257,6 +257,12 @@ class LoggedInView extends React.Component { window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.notifyLeftHandleResized(); }, + onResizeStart: () => { + this.props.resizeNotifier.startResizing(); + }, + onResizeStop: () => { + this.props.resizeNotifier.stopResizing(); + }, }; const resizer = new Resizer( this._resizeContainer.current, @@ -650,12 +656,13 @@ class LoggedInView extends React.Component { break; case PageTypes.UserView: - pageElement = ; + pageElement = ; break; case PageTypes.GroupView: pageElement = ; break; } diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 800ed76bb9..47dfe83ad6 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -19,9 +19,18 @@ import React from 'react'; import { Resizable } from 're-resizable'; export default class MainSplit extends React.Component { - _onResized = (event, direction, refToElement, delta) => { + _onResizeStart = () => { + this.props.resizeNotifier.startResizing(); + }; + + _onResize = () => { + this.props.resizeNotifier.notifyRightHandleResized(); + }; + + _onResizeStop = (event, direction, refToElement, delta) => { + this.props.resizeNotifier.stopResizing(); window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width); - } + }; _loadSidePanelSize() { let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10); @@ -58,7 +67,9 @@ export default class MainSplit extends React.Component { bottomLeft: false, topLeft: false, }} - onResizeStop={this._onResized} + onResizeStart={this._onResizeStart} + onResize={this._onResize} + onResizeStop={this._onResizeStop} className="mx_RightPanel_ResizeWrapper" handleClasses={{left: "mx_RightPanel_ResizeHandle"}} > diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 11416b29fb..4dd2a7f75e 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -225,11 +225,12 @@ export default class RightPanel extends React.Component { const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo'); let panel =
; + const roomId = this.props.room ? this.props.room.roomId : undefined; switch (this.state.phase) { case RightPanelPhases.RoomMemberList: - if (this.props.room.roomId) { - panel = ; + if (roomId) { + panel = ; } break; case RightPanelPhases.GroupMemberList: @@ -244,8 +245,8 @@ export default class RightPanel extends React.Component { case RightPanelPhases.EncryptionPanel: panel = ; break; case RightPanelPhases.Room3pidMemberInfo: - panel = ; + panel = ; break; case RightPanelPhases.GroupMemberInfo: panel = ; break; case RightPanelPhases.FilePanel: - panel = ; + panel = ; break; } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index ed2e5645e9..d98a19ebe8 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1545,9 +1545,9 @@ export default class RoomView extends React.Component { // header + footer + status + give us at least 120px of scrollback at all times. let auxPanelMaxHeight = window.innerHeight - - (83 + // height of RoomHeader + (54 + // height of RoomHeader 36 + // height of the status area - 72 + // minimum height of the message compmoser + 51 + // minimum height of the message compmoser 120); // amount of desired scrollback // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway @@ -1884,15 +1884,19 @@ export default class RoomView extends React.Component { } const auxPanel = ( - + { aux } ); @@ -2090,10 +2094,7 @@ export default class RoomView extends React.Component { onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} /> - +
{auxPanel}
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 4e3e00f221..b4f5195803 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -163,7 +163,7 @@ export default class ScrollPanel extends React.Component { this._pendingFillRequests = {b: null, f: null}; if (this.props.resizeNotifier) { - this.props.resizeNotifier.on("middlePanelResized", this.onResize); + this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } this.resetScrollState(); @@ -193,11 +193,12 @@ export default class ScrollPanel extends React.Component { this.unmounted = true; if (this.props.resizeNotifier) { - this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize); } } onScroll = ev => { + if (this.props.resizeNotifier.isResizing) return; // skip scroll events caused by resizing debuglog("onScroll", this._getScrollNode().scrollTop); this._scrollTimeout.restart(); this._saveScrollState(); @@ -207,6 +208,7 @@ export default class ScrollPanel extends React.Component { }; onResize = () => { + debuglog("onResize"); this.checkScroll(); // update preventShrinkingState if present if (this.preventShrinkingState) { @@ -236,7 +238,6 @@ export default class ScrollPanel extends React.Component { // when scrolled all the way down. E.g. Chrome 72 on debian. // so check difference <= 1; return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; - }; // returns the vertical height in the given direction that can be removed from diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index 694592af88..8e21771bb9 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -80,7 +80,9 @@ export default class UserView extends React.Component { const RightPanel = sdk.getComponent('structures.RightPanel'); const MainSplit = sdk.getComponent('structures.MainSplit'); const panel = ; - return (); + return ( + + ); } else { return (
); } diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 53769fb5a6..a20bf0dd0a 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -124,7 +124,11 @@ export default class LoginComponent extends React.Component { 'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.sso': () => this._renderSsoStep("sso"), }; + } + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { this._initLoginLogic(); } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 299025f949..1c93841afb 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -310,20 +310,7 @@ export default class AppTile extends React.Component { if (this.props.onEditClick) { this.props.onEditClick(); } else { - // TODO: Open the right manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } + WidgetUtils.editWidget(this.props.room, this.props.app); } } @@ -626,7 +613,10 @@ export default class AppTile extends React.Component { if (WidgetType.JITSI.matches(this.props.app.type)) { console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true}); + 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); @@ -637,7 +627,10 @@ export default class AppTile extends React.Component { _getPopoutUrl() { if (WidgetType.JITSI.matches(this.props.app.type)) { return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: false}), + WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: false, + auth: this.props.app.data ? this.props.app.data.auth : null, + }), this.props.app.type, ); } else { @@ -804,14 +797,16 @@ export default class AppTile extends React.Component { const showMinimiseButton = this.props.showMinimise && this.props.show; const showMaximiseButton = this.props.showMinimise && !this.props.show; - let appTileClass; + let appTileClasses; if (this.props.miniMode) { - appTileClass = 'mx_AppTile_mini'; + appTileClasses = {mx_AppTile_mini: true}; } else if (this.props.fullWidth) { - appTileClass = 'mx_AppTileFullWidth'; + appTileClasses = {mx_AppTileFullWidth: true}; } else { - appTileClass = 'mx_AppTile'; + appTileClasses = {mx_AppTile: true}; } + appTileClasses.mx_AppTile_minimised = !this.props.show; + appTileClasses = classNames(appTileClasses); const menuBarClasses = classNames({ mx_AppTileMenuBar: true, @@ -843,7 +838,7 @@ export default class AppTile extends React.Component { } return -
+
{ this.props.showMenubar &&
diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js index 7f9bfdebf4..9a64b7c7c4 100644 --- a/src/components/views/elements/PersistedElement.js +++ b/src/components/views/elements/PersistedElement.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; - +import {throttle} from "lodash"; import ResizeObserver from 'resize-observer-polyfill'; import dis from '../../../dispatcher/dispatcher'; @@ -156,7 +156,7 @@ export default class PersistedElement extends React.Component { child.style.display = visible ? 'block' : 'none'; } - updateChildPosition(child, parent) { + updateChildPosition = throttle((child, parent) => { if (!child || !parent) return; const parentRect = parent.getBoundingClientRect(); @@ -167,9 +167,9 @@ export default class PersistedElement extends React.Component { width: parentRect.width + 'px', height: parentRect.height + 'px', }); - } + }, 100, {trailing: true, leading: true}); render() { - return
; + return
; } } diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index e5f217dd90..66922df0f8 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -57,11 +57,14 @@ export default class PowerSelector extends React.Component { customValue: this.props.value, selectValue: 0, }; - - this._initStateFromProps(this.props); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this._initStateFromProps(this.props); + } + // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { this._initStateFromProps(newProps); diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index 518bb133ce..b98211e5c8 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -20,7 +20,7 @@ limitations under the License. import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import {Group, RoomMember, User} from 'matrix-js-sdk'; +import {Group, RoomMember, User, Room} from 'matrix-js-sdk'; import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; import * as sdk from '../../../index'; @@ -1471,11 +1471,9 @@ const UserInfoHeader = ({onClose, member, e2eStatus}) => { ; }; -const UserInfo = ({user, groupId, roomId, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => { +const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => { const cli = useContext(MatrixClientContext); - // Load room if we are given a room id and memoize it - this can be undefined for User Info/Group Member Info - const room = useMemo(() => roomId ? cli.getRoom(roomId) : null, [cli, roomId]); // fetch latest room member if we have a room, so we don't show historical information, falling back to user const member = useMemo(() => room ? (room.getMember(user.userId) || user) : user, [room, user]); @@ -1529,7 +1527,7 @@ UserInfo.propTypes = { ]).isRequired, group: PropTypes.instanceOf(Group), groupId: PropTypes.string, - roomId: PropTypes.string, + room: PropTypes.instanceOf(Room), onClose: PropTypes.func, }; diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 50f53d20c2..fca46b453f 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {useState} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import AppTile from '../elements/AppTile'; @@ -29,6 +29,10 @@ import WidgetEchoStore from "../../../stores/WidgetEchoStore"; import AccessibleButton from '../elements/AccessibleButton'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; +import classNames from 'classnames'; +import {Resizable} from "re-resizable"; +import {useLocalStorageState} from "../../../hooks/useLocalStorageState"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; // The maximum number of widgets that can be added in a room const MAX_WIDGETS = 2; @@ -37,6 +41,7 @@ export default class AppsDrawer extends React.Component { static propTypes = { userId: PropTypes.string.isRequired, room: PropTypes.object.isRequired, + resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired, showApps: PropTypes.bool, // Should apps be rendered hide: PropTypes.bool, // If rendered, should apps drawer be visible }; @@ -161,7 +166,7 @@ export default class AppsDrawer extends React.Component { return (); }); - if (apps.length == 0) { - return
; + if (apps.length === 0) { + return
; } let addWidget; @@ -202,14 +207,68 @@ export default class AppsDrawer extends React.Component { spinner = ; } + const classes = classNames({ + "mx_AppsDrawer": true, + "mx_AppsDrawer_hidden": this.props.hide, + "mx_AppsDrawer_fullWidth": apps.length < 2, + "mx_AppsDrawer_minimised": !this.props.showApps, + }); + return ( -
-
+
+ { apps } { spinner } -
+ { this._canUserModify() && addWidget }
); } } + +const PersistentVResizer = ({ + id, + minHeight, + maxHeight, + className, + handleWrapperClass, + handleClass, + resizeNotifier, + children, +}) => { + const [height, setHeight] = useLocalStorageState("pvr_" + id, 100); + const [resizing, setResizing] = useState(false); + + return { + if (!resizing) setResizing(true); + resizeNotifier.startResizing(); + }} + onResize={() => { + resizeNotifier.notifyTimelineHeightChanged(); + }} + onResizeStop={(e, dir, ref, d) => { + setHeight(height + d.height); + if (resizing) setResizing(false); + resizeNotifier.stopResizing(); + }} + handleWrapperClass={handleWrapperClass} + handleClasses={{bottom: handleClass}} + className={classNames(className, { + mx_AppsDrawer_resizing: resizing, + })} + enable={{bottom: true}} + > + { children } + ; +}; diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index fc31d66160..1f6f104487 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -204,6 +204,7 @@ export default class AuxPanel extends React.Component { maxHeight={this.props.maxHeight} showApps={this.props.showApps} hide={this.props.hideAppsDrawer} + resizeNotifier={this.props.resizeNotifier} />; let stateViews = null; diff --git a/src/hooks/useLocalStorageState.ts b/src/hooks/useLocalStorageState.ts new file mode 100644 index 0000000000..ce3b574f86 --- /dev/null +++ b/src/hooks/useLocalStorageState.ts @@ -0,0 +1,44 @@ +/* +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 {Dispatch, SetStateAction, useCallback, useEffect, useState} from "react"; + +const getValue = (key: string, initialValue: T): T => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + return initialValue; + } +}; + +// Hook behaving like useState but persisting the value to localStorage. Returns same as useState +export const useLocalStorageState = (key: string, initialValue: T) => { + const lsKey = "mx_" + key; + + const [value, setValue] = useState(getValue(lsKey, initialValue)); + + useEffect(() => { + setValue(getValue(lsKey, initialValue)); + }, [lsKey, initialValue]); + + const _setValue: Dispatch> = useCallback((v: T) => { + window.localStorage.setItem(lsKey, JSON.stringify(v)); + setValue(v); + }, [lsKey]); + + return [value, _setValue]; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b73d2b25fe..9e8d76509a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -149,6 +149,7 @@ "Command error": "Command error", "Usage": "Usage", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Prepends ¯\\_(ツ)_/¯ to a plain-text message", + "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message", "Sends a message as plain text, without interpreting it as markdown": "Sends a message as plain text, without interpreting it as markdown", "Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown", "Searches DuckDuckGo for results": "Searches DuckDuckGo for results", diff --git a/src/resizer/resizer.js b/src/resizer/resizer.js index 2234fc5bdf..1e75bf3bdf 100644 --- a/src/resizer/resizer.js +++ b/src/resizer/resizer.js @@ -105,6 +105,9 @@ export default class Resizer { if (this.classNames.resizing) { this.container.classList.add(this.classNames.resizing); } + if (this.config.onResizeStart) { + this.config.onResizeStart(); + } const {sizer, distributor} = this._createSizerAndDistributor(resizeHandle); distributor.start(); @@ -119,6 +122,9 @@ export default class Resizer { if (this.classNames.resizing) { this.container.classList.remove(this.classNames.resizing); } + if (this.config.onResizeStop) { + this.config.onResizeStop(); + } distributor.finish(); body.removeEventListener("mouseup", finishResize, false); document.removeEventListener("mouseleave", finishResize, false); diff --git a/src/resizer/sizer.js b/src/resizer/sizer.js index 50861d34d5..4ce9232457 100644 --- a/src/resizer/sizer.js +++ b/src/resizer/sizer.js @@ -56,6 +56,18 @@ export default class Sizer { return this.vertical ? this.container.offsetTop : this.container.offsetLeft; } + /** @return {number} container offset to document */ + _getPageOffset() { + let element = this.container; + let offset = 0; + while (element) { + const pos = this.vertical ? element.offsetTop : element.offsetLeft; + offset = offset + pos; + element = element.offsetParent; + } + return offset; + } + setItemSize(item, size) { if (this.vertical) { item.style.height = `${Math.round(size)}px`; @@ -80,9 +92,9 @@ export default class Sizer { offsetFromEvent(event) { const pos = this.vertical ? event.pageY : event.pageX; if (this.reverse) { - return (this._getOffset() + this.getTotalSize()) - pos; + return (this._getPageOffset() + this.getTotalSize()) - pos; } else { - return pos - this._getOffset(); + return pos - this._getPageOffset(); } } } diff --git a/src/settings/WatchManager.ts b/src/settings/WatchManager.ts index d51439459c..ea2f158ef6 100644 --- a/src/settings/WatchManager.ts +++ b/src/settings/WatchManager.ts @@ -18,11 +18,10 @@ import { SettingLevel } from "./SettingLevel"; export type CallbackFn = (changedInRoomId: string, atLevel: SettingLevel, newValAtLevel: any) => void; -const IRRELEVANT_ROOM = Symbol("any room"); +const IRRELEVANT_ROOM: string = null; interface RoomWatcherMap { - // @ts-ignore - TS wants string-only keys but we know better - https://github.com/Microsoft/TypeScript/issues/1863 - [roomId: string | symbol]: CallbackFn[]; + [roomId: string]: CallbackFn[]; } /** @@ -69,7 +68,7 @@ export class WatchManager { if (!inRoomId) { // Fire updates to all the individual room watchers too, as they probably // care about the change higher up. - callbacks.push(...Object.values(roomWatchers).reduce((r, a) => [...r, ...a], [])); + callbacks.push(...Object.values(roomWatchers).flat(1)); } else if (roomWatchers[IRRELEVANT_ROOM]) { callbacks.push(...roomWatchers[IRRELEVANT_ROOM]); } diff --git a/src/stores/WidgetEchoStore.js b/src/stores/WidgetEchoStore.js index a5e7b12da0..7dd093d45e 100644 --- a/src/stores/WidgetEchoStore.js +++ b/src/stores/WidgetEchoStore.js @@ -93,13 +93,13 @@ class WidgetEchoStore extends EventEmitter { if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {}; this._roomWidgetEcho[roomId][widgetId] = state; - this.emit('update'); + this.emit('update', roomId, widgetId); } removeRoomWidgetEcho(roomId, widgetId) { delete this._roomWidgetEcho[roomId][widgetId]; if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId]; - this.emit('update'); + this.emit('update', roomId, widgetId); } } diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js index 5467716576..512946828b 100644 --- a/src/utils/ResizeNotifier.js +++ b/src/utils/ResizeNotifier.js @@ -31,6 +31,19 @@ export default class ResizeNotifier extends EventEmitter { // with default options, will call fn once at first call, and then every x ms // if there was another call in that timespan this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); + this._isResizing = false; + } + + get isResizing() { + return this._isResizing; + } + + startResizing() { + this._isResizing = true; + } + + stopResizing() { + this._isResizing = false; } _noisyMiddlePanel() { diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 645953210d..be176d042f 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -405,6 +405,7 @@ export default class WidgetUtils { app.creatorUserId = senderUserId; app.id = appId; + app.roomId = roomId; app.eventId = eventId; app.name = app.name || app.type; @@ -448,16 +449,21 @@ export default class WidgetUtils { return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); } - static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean}={}) { + static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string}={}) { // NB. we can't just encodeURIComponent all of these because the $ signs need to be there - const queryString = [ + const queryStringParts = [ 'conferenceDomain=$domain', 'conferenceId=$conferenceId', 'isAudioOnly=$isAudioOnly', 'displayName=$matrix_display_name', 'avatarUrl=$matrix_avatar_url', 'userId=$matrix_user_id', - ].join('&'); + 'roomId=$matrix_room_id', + ]; + if (opts.auth) { + queryStringParts.push(`auth=${opts.auth}`); + } + const queryString = queryStringParts.join('&'); let baseUrl = window.location; if (window.location.protocol !== "https:" && !opts.forLocalRender) { @@ -471,4 +477,13 @@ export default class WidgetUtils { const url = new URL("jitsi.html#" + queryString, baseUrl); // this strips hash fragment from baseUrl return url.href; } + + static editWidget(room, app) { + // TODO: Open the right manager for the widget + if (SettingsStore.getValue("feature_many_integration_managers")) { + IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id); + } else { + IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id); + } + } } diff --git a/src/utils/permalinks/Permalinks.js b/src/utils/permalinks/Permalinks.js index 466d1ed57d..3e510ffee9 100644 --- a/src/utils/permalinks/Permalinks.js +++ b/src/utils/permalinks/Permalinks.js @@ -332,6 +332,9 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { if (permalinkParts.roomIdOrAlias) { const eventIdPart = permalinkParts.eventId ? `/${permalinkParts.eventId}` : ''; permalink = `#/room/${permalinkParts.roomIdOrAlias}${eventIdPart}`; + if (permalinkParts.viaServers.length > 0) { + permalink += new SpecPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers); + } } else if (permalinkParts.groupId) { permalink = `#/group/${permalinkParts.groupId}`; } else if (permalinkParts.userId) { diff --git a/src/widgets/Jitsi.ts b/src/widgets/Jitsi.ts index a52f8182aa..ca8de4468a 100644 --- a/src/widgets/Jitsi.ts +++ b/src/widgets/Jitsi.ts @@ -34,6 +34,30 @@ export class Jitsi { return this.domain || 'jitsi.riot.im'; } + /** + * Checks for auth needed by looking up a well-known file + * + * If the file does not exist, we assume no auth. + * + * See https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + */ + public async getJitsiAuth(): Promise { + if (!this.preferredDomain) { + return null; + } + let data; + try { + const response = await fetch(`https://${this.preferredDomain}/.well-known/element/jitsi`); + data = await response.json(); + } catch (error) { + return null; + } + if (data.auth) { + return data.auth; + } + return null; + } + public start() { const cli = MatrixClientPeg.get(); cli.on("WellKnown.client", this.update); diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index 15603e9437..672cbf2a56 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -34,6 +34,7 @@ export enum KnownWidgetActions { GetCapabilities = "capabilities", SendEvent = "send_event", UpdateVisibility = "visibility", + GetOpenIDCredentials = "get_openid", ReceiveOpenIDCredentials = "openid_credentials", SetAlwaysOnScreen = "set_always_on_screen", ClientReady = "im.vector.ready", @@ -64,6 +65,13 @@ export interface FromWidgetRequest extends WidgetRequest { response: any; } +export interface OpenIDCredentials { + accessToken: string; + tokenType: string; + matrixServerName: string; + expiresIn: number; +} + /** * Handles Element <--> Widget interactions for embedded/standalone widgets. * @@ -73,10 +81,12 @@ export interface FromWidgetRequest extends WidgetRequest { * the given promise resolves. */ export class WidgetApi extends EventEmitter { - private origin: string; + private readonly origin: string; private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {}; - private readyPromise: Promise; + private readonly readyPromise: Promise; 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). @@ -120,6 +130,10 @@ export class WidgetApi extends EventEmitter { // Acknowledge that we're shut down now this.replyToRequest(payload, {}); }); + } else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) { + // Save OpenID credentials + this.setOpenIDCredentials(payload); + this.replyToRequest(payload, {}); } else { console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`); } @@ -134,6 +148,32 @@ export class WidgetApi extends EventEmitter { }); } + 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 { return this.readyPromise; } diff --git a/yarn.lock b/yarn.lock index 5bd2be1567..ec099bbf7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7557,6 +7557,11 @@ retry@^0.10.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= +rfc4648@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.4.0.tgz#c75b2856ad2e2d588b6ddb985d556f1f7f2a2abd" + integrity sha512-3qIzGhHlMHA6PoT6+cdPKZ+ZqtxkIvg8DZGKA5z6PQ33/uuhoJ+Ws/D/J9rXW6gXodgH8QYlz2UCl+sdUDmNIg== + rimraf@2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"