diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index 2e8f07b7ef..0a708a8edc 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -34,7 +34,7 @@ limitations under the License.
width: 100%;
}
-.mx_MessageComposer_row div:last-child{
+.mx_MessageComposer_row > div:last-child{
padding-right: 0;
}
diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js
index effd96dacf..86eaa0b59b 100644
--- a/src/WidgetMessaging.js
+++ b/src/WidgetMessaging.js
@@ -94,6 +94,16 @@ export default class WidgetMessaging {
});
}
+ 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.widgetUrl);
diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js
index 0e2df890f3..daac294d12 100644
--- a/src/components/structures/ContextualMenu.js
+++ b/src/components/structures/ContextualMenu.js
@@ -26,9 +26,21 @@ import PropTypes from 'prop-types';
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
-module.exports = {
- ContextualMenuContainerId: "mx_ContextualMenu_Container",
+const ContextualMenuContainerId = "mx_ContextualMenu_Container";
+function getOrCreateContainer() {
+ let container = document.getElementById(ContextualMenuContainerId);
+
+ if (!container) {
+ container = document.createElement("div");
+ container.id = ContextualMenuContainerId;
+ document.body.appendChild(container);
+ }
+
+ return container;
+}
+
+export default class ContextualMenu extends React.Component {
propTypes: {
top: PropTypes.number,
bottom: PropTypes.number,
@@ -45,39 +57,18 @@ module.exports = {
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
- },
- getOrCreateContainer: function() {
- let container = document.getElementById(this.ContextualMenuContainerId);
-
- if (!container) {
- container = document.createElement("div");
- container.id = this.ContextualMenuContainerId;
- document.body.appendChild(container);
- }
-
- return container;
- },
-
- createMenu: function(Element, props) {
- const self = this;
-
- const closeMenu = function(...args) {
- ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
-
- if (props && props.onFinished) {
- props.onFinished.apply(null, args);
- }
- };
-
- // Close the menu on window resize
- const windowResize = function() {
- closeMenu();
- };
+ // If true, insert an invisible screen-sized element behind the
+ // menu that when clicked will close it.
+ hasBackground: PropTypes.bool,
+ }
+ render() {
const position = {};
let chevronFace = null;
+ const props = this.props;
+
if (props.top) {
position.top = props.top;
} else {
@@ -158,21 +149,40 @@ module.exports = {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
+ const ElementClass = props.elementClass;
+
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
- const menu = (
-
-
- { chevron }
-
-
-
-
+ return
+
+ { chevron }
+
- );
+ { props.hasBackground &&
}
+
+
;
+ }
+}
- ReactDOM.render(menu, this.getOrCreateContainer());
+export function createMenu(ElementClass, props) {
+ const closeMenu = function(...args) {
+ ReactDOM.unmountComponentAtNode(getOrCreateContainer());
- return {close: closeMenu};
- },
-};
+ if (props && props.onFinished) {
+ props.onFinished.apply(null, args);
+ }
+ };
+
+ // We only reference closeMenu once per call to createMenu
+ const menu =
;
+
+ ReactDOM.render(menu, getOrCreateContainer());
+
+ return {close: closeMenu};
+}
diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js
index 63cf459987..38b6fc200b 100644
--- a/src/components/views/elements/AppTile.js
+++ b/src/components/views/elements/AppTile.js
@@ -169,6 +169,13 @@ export default class AppTile extends React.Component {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
+ componentDidUpdate() {
+ // Allow parents to access widget messaging
+ if (this.props.collectWidgetMessaging) {
+ this.props.collectWidgetMessaging(this.widgetMessaging);
+ }
+ }
+
componentWillUnmount() {
// Widget action listeners
dis.unregister(this.dispatcherRef);
@@ -357,6 +364,9 @@ export default class AppTile extends React.Component {
if (!this.widgetMessaging) {
this._onInitialLoad();
}
+ if (this._exposeWidgetMessaging) {
+ this._exposeWidgetMessaging(this.widgetMessaging);
+ }
}
/**
@@ -394,6 +404,7 @@ export default class AppTile extends React.Component {
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
});
+
this.setState({loading: false});
}
diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js
new file mode 100644
index 0000000000..c4bac27b4e
--- /dev/null
+++ b/src/components/views/elements/PersistedElement.js
@@ -0,0 +1,114 @@
+/*
+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 React = require('react');
+const ReactDOM = require('react-dom');
+
+// Shamelessly ripped off Modal.js. There's probably a better way
+// of doing reusable widgets like dialog boxes & menus where we go and
+// pass in a custom control as the actual body.
+
+const ContainerId = "mx_PersistedElement";
+
+function getOrCreateContainer() {
+ let container = document.getElementById(ContainerId);
+
+ if (!container) {
+ container = document.createElement("div");
+ container.id = ContainerId;
+ document.body.appendChild(container);
+ }
+
+ return container;
+}
+
+// Greater than that of the ContextualMenu
+const PE_Z_INDEX = 3000;
+
+/*
+ * Class of component that renders its children in a separate ReactDOM virtual tree
+ * in a container element appended to document.body.
+ *
+ * This prevents the children from being unmounted when the parent of PersistedElement
+ * unmounts, allowing them to persist.
+ *
+ * When PE is unmounted, it hides the children using CSS. When mounted or updated, the
+ * children are made visible and are positioned into a div that is given the same
+ * bounding rect as the parent of PE.
+ */
+export default class PersistedElement extends React.Component {
+ constructor() {
+ super();
+ this.collectChildContainer = this.collectChildContainer.bind(this);
+ this.collectChild = this.collectChild.bind(this);
+ }
+
+ collectChildContainer(ref) {
+ this.childContainer = ref;
+ }
+
+ collectChild(ref) {
+ this.child = ref;
+ this.updateChild();
+ }
+
+ componentDidMount() {
+ this.updateChild();
+ }
+
+ componentDidUpdate() {
+ this.updateChild();
+ }
+
+ componentWillUnmount() {
+ this.updateChildVisibility(this.child, false);
+ }
+
+ updateChild() {
+ this.updateChildPosition(this.child, this.childContainer);
+ this.updateChildVisibility(this.child, true);
+ }
+
+ updateChildVisibility(child, visible) {
+ if (!child) return;
+ child.style.display = visible ? 'block' : 'none';
+ }
+
+ updateChildPosition(child, parent) {
+ if (!child || !parent) return;
+
+ const parentRect = parent.getBoundingClientRect();
+ Object.assign(child.style, {
+ position: 'absolute',
+ top: parentRect.top + 'px',
+ left: parentRect.left + 'px',
+ width: parentRect.width + 'px',
+ height: parentRect.height + 'px',
+ zIndex: PE_Z_INDEX,
+ });
+ }
+
+ render() {
+ const content =
+ {this.props.children}
+
;
+
+ ReactDOM.render(content, getOrCreateContainer());
+
+ return
;
+ }
+}
+
diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js
index 9b489c3e38..0584cd6b0a 100644
--- a/src/components/views/rooms/Stickerpicker.js
+++ b/src/components/views/rooms/Stickerpicker.js
@@ -17,7 +17,6 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import Widgets from '../../../utils/widgets';
import AppTile from '../elements/AppTile';
-import ContextualMenu from '../../structures/ContextualMenu';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
@@ -36,21 +35,28 @@ export default class Stickerpicker extends React.Component {
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
this._onWidgetAction = this._onWidgetAction.bind(this);
+ this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);
+ this._collectWidgetMessaging = this._collectWidgetMessaging.bind(this);
+
this.popoverWidth = 300;
this.popoverHeight = 300;
this.state = {
showStickers: false,
imError: null,
+ stickerpickerX: null,
+ stickerpickerY: null,
+ stickerpickerWidget: null,
+ widgetId: null,
};
}
_removeStickerpickerWidgets() {
console.warn('Removing Stickerpicker widgets');
- if (this.widgetId) {
- this.scalarClient.disableWidgetAssets(widgetType, this.widgetId).then(() => {
+ if (this.state.widgetId) {
+ this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
@@ -59,8 +65,7 @@ export default class Stickerpicker extends React.Component {
console.warn('No widget ID specified, not disabling assets');
}
- // Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet
- setTimeout(() => this.stickersMenu.close());
+ this.setState({showStickers: false});
Widgets.removeStickerpickerWidgets().then(() => {
this.forceUpdate();
}).catch((e) => {
@@ -69,6 +74,9 @@ export default class Stickerpicker extends React.Component {
}
componentDidMount() {
+ // Close the sticker picker when the window resizes
+ window.addEventListener('resize', this._onResize);
+
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
@@ -82,14 +90,24 @@ export default class Stickerpicker extends React.Component {
if (!this.state.imError) {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
+ const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0];
+ this.setState({
+ stickerpickerWidget,
+ widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
+ });
}
componentWillUnmount() {
+ window.removeEventListener('resize', this._onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
+ componentDidUpdate(prevProps, prevState) {
+ this._sendVisibilityToWidget(this.state.showStickers);
+ }
+
_imError(errorMsg, e) {
console.error(errorMsg, e);
this.setState({
@@ -102,9 +120,7 @@ export default class Stickerpicker extends React.Component {
if (payload.action === "user_widget_updated") {
this.forceUpdate();
} else if (payload.action === "stickerpicker_close") {
- // Wrap this in a timeout in order to avoid the DOM node from being
- // pulled from under its feet
- setTimeout(() => this.stickersMenu.close());
+ this.setState({showStickers: false});
}
}
@@ -127,6 +143,21 @@ export default class Stickerpicker extends React.Component {
);
}
+ _collectWidgetMessaging(widgetMessaging) {
+ this._appWidgetMessaging = widgetMessaging;
+
+ // Do this now instead of in componentDidMount because we might not have had the
+ // reference to widgetMessaging when mounting
+ this._sendVisibilityToWidget(true);
+ }
+
+ _sendVisibilityToWidget(visible) {
+ if (this._appWidgetMessaging && visible !== this._prevSentVisibility) {
+ this._appWidgetMessaging.sendVisibility(visible);
+ this._prevSentVisibility = visible;
+ }
+ }
+
_getStickerpickerContent() {
// Handle Integration Manager errors
if (this.state._imError) {
@@ -137,14 +168,18 @@ export default class Stickerpicker extends React.Component {
// TODO - Add support for Stickerpickers from multiple app stores.
// Render content from multiple stickerpack sources, each within their
// own iframe, within the stickerpicker UI element.
- const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0];
+ const stickerpickerWidget = this.state.stickerpickerWidget;
let stickersContent;
+ // Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
+ // not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
+ // updated.
+ const PersistedElement = sdk.getComponent("elements.PersistedElement");
+
// Load stickerpack content
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
- this.widgetId = stickerpickerWidget.id;
stickersContent = (
@@ -157,7 +192,9 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
+
+
);
@@ -187,12 +225,7 @@ export default class Stickerpicker extends React.Component {
// Default content to show if stickerpicker widget not added
console.warn("No available sticker picker widgets");
stickersContent = this._defaultStickerpickerContent();
- this.widgetId = null;
- this.forceUpdate();
}
- this.setState({
- showStickers: false,
- });
return stickersContent;
}
@@ -202,29 +235,17 @@ export default class Stickerpicker extends React.Component {
* @param {Event} e Event that triggered the function
*/
_onShowStickersClick(e) {
- const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset - 42;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
- // const self = this;
- this.stickersMenu = ContextualMenu.createMenu(GenericElementContextMenu, {
- chevronOffset: 10,
- chevronFace: 'bottom',
- left: x,
- top: y,
- menuWidth: this.popoverWidth,
- menuHeight: this.popoverHeight,
- element: this._getStickerpickerContent(),
- onFinished: this._onFinished,
- menuPaddingTop: 0,
- menuPaddingLeft: 0,
- menuPaddingRight: 0,
+
+ this.setState({
+ showStickers: true,
+ stickerPickerX: x,
+ stickerPickerY: y,
});
-
-
- this.setState({showStickers: true});
}
/**
@@ -232,7 +253,14 @@ export default class Stickerpicker extends React.Component {
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
- setTimeout(() => this.stickersMenu.close());
+ this.setState({showStickers: false});
+ }
+
+ /**
+ * Called when the window is resized
+ */
+ _onResize() {
+ this.setState({showStickers: false});
}
/**
@@ -251,20 +279,37 @@ export default class Stickerpicker extends React.Component {
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
- this.widgetId,
+ this.state.widgetId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
- // Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet
- setTimeout(() => this.stickersMenu.close());
+ this.setState({showStickers: false});
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
+ const ContextualMenu = sdk.getComponent('structures.ContextualMenu');
+ const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
let stickersButton;
+
+ const stickerPicker =
+ {stickersButton}
+ {this.state.showStickers && stickerPicker}
+
;
}
}