Merge pull request #1888 from matrix-org/luke/instant-sticker-picker

Instant Sticker Picker
This commit is contained in:
Luke Barnard 2018-05-11 18:12:57 +01:00 committed by GitHub
commit eabcbb349c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 275 additions and 82 deletions

View file

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

View file

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

View file

@ -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 = (
<div className={className} style={position}>
<div className={menuClasses} style={menuStyle}>
{ chevron }
<Element {...props} onFinished={closeMenu} onResize={windowResize} />
</div>
<div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
<style>{ chevronCSS }</style>
return <div className={className} style={position}>
<div className={menuClasses} style={menuStyle}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
);
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu}></div> }
<style>{ chevronCSS }</style>
</div>;
}
}
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 = <ContextualMenu
{...props}
hasBackground={true}
elementClass={ElementClass}
closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind
windowResize={closeMenu} // eslint-disable-line react/jsx-no-bind
/>;
ReactDOM.render(menu, getOrCreateContainer());
return {close: closeMenu};
}

View file

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

View file

@ -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 = <div ref={this.collectChild}>
{this.props.children}
</div>;
ReactDOM.render(content, getOrCreateContainer());
return <div ref={this.collectChildContainer}></div>;
}
}

View file

@ -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 = (
<div className='mx_Stickers_content_container'>
@ -157,7 +192,9 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
@ -177,9 +214,10 @@ export default class Stickerpicker extends React.Component {
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker']}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true}
/>
</PersistedElement>
</div>
</div>
);
@ -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 = <ContextualMenu
elementClass={GenericElementContextMenu}
chevronOffset={10}
chevronFace={'bottom'}
left={this.state.stickerPickerX}
top={this.state.stickerPickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
element={this._getStickerpickerContent()}
onFinished={this._onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
/>;
if (this.state.showStickers) {
// Show hide-stickers button
stickersButton =
@ -289,6 +334,9 @@ export default class Stickerpicker extends React.Component {
<TintableSvg src="img/icons-show-stickers.svg" width="35" height="35" />
</div>;
}
return stickersButton;
return <div>
{stickersButton}
{this.state.showStickers && stickerPicker}
</div>;
}
}