From ada6d1aa46ee909eb27e055faa255193a3416d05 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 9 Oct 2020 08:42:21 +0100 Subject: [PATCH] Iterate PR Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/_components.scss | 1 - .../context_menus/_WidgetContextMenu.scss | 36 -- res/css/views/right_panel/_BaseCard.scss | 2 +- .../views/right_panel/_RoomSummaryCard.scss | 4 +- res/css/views/right_panel/_WidgetCard.scss | 49 ++- res/css/views/rooms/_AppsDrawer.scss | 33 +- res/css/views/rooms/_RoomHeader.scss | 7 + res/img/element-icons/room/apps.svg | 6 + res/img/element-icons/room/integrations.svg | 3 - res/img/icon_context.svg | 5 - src/components/structures/RoomView.tsx | 33 +- .../context_menus/RoomWidgetContextMenu.tsx | 77 +++- .../views/context_menus/WidgetContextMenu.js | 142 ------ src/components/views/elements/AppTile.js | 413 +++++------------- .../views/elements/PersistedElement.js | 2 + .../views/elements/PersistentApp.js | 3 - .../views/right_panel/WidgetCard.tsx | 59 +-- src/components/views/rooms/AppsDrawer.js | 35 +- src/components/views/rooms/RoomHeader.js | 14 + src/components/views/rooms/Stickerpicker.js | 3 - src/dispatcher/actions.ts | 10 - .../payloads/AppTileActionPayload.ts | 23 - src/i18n/strings/en_EN.json | 8 +- src/utils/WidgetUtils.js | 12 + 24 files changed, 273 insertions(+), 707 deletions(-) delete mode 100644 res/css/views/context_menus/_WidgetContextMenu.scss create mode 100644 res/img/element-icons/room/apps.svg delete mode 100644 res/img/element-icons/room/integrations.svg delete mode 100644 res/img/icon_context.svg delete mode 100644 src/components/views/context_menus/WidgetContextMenu.js delete mode 100644 src/dispatcher/payloads/AppTileActionPayload.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 06cdbdcb4b..e103bd90ce 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -55,7 +55,6 @@ @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; -@import "./views/context_menus/_WidgetContextMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; diff --git a/res/css/views/context_menus/_WidgetContextMenu.scss b/res/css/views/context_menus/_WidgetContextMenu.scss deleted file mode 100644 index 60b7b93f99..0000000000 --- a/res/css/views/context_menus/_WidgetContextMenu.scss +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundaction 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_WidgetContextMenu { - padding: 6px; - - .mx_WidgetContextMenu_option { - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; - } - - .mx_WidgetContextMenu_separator { - margin-top: 0; - margin-bottom: 0; - border-bottom-style: none; - border-left-style: none; - border-right-style: none; - border-top-style: solid; - border-top-width: 1px; - border-color: $menu-border-color; - } -} diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index cfc8b335dd..9a5a59bda8 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -130,7 +130,7 @@ limitations under the License. } &.mx_AccessibleButton_disabled { - padding: 10px 12px; + padding-right: 12px; &::after { content: unset; } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 73fcc255e3..ab7807d2a2 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -157,9 +157,7 @@ limitations under the License. } .mx_RoomSummaryCard_Button { - padding-left: 12px; - padding-top: 6px; - padding-bottom: 6px; + padding: 6px 24px 6px 12px; color: $tertiary-fg-color; flex: 1; diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss index 315fd5213c..a90e744a5a 100644 --- a/res/css/views/right_panel/_WidgetCard.scss +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -24,34 +24,35 @@ limitations under the License. border: 0; } - &.mx_WidgetCard_noEdit { - .mx_AccessibleButton_kind_secondary { - margin: 0 12px; + .mx_BaseCard_header { + display: inline-flex; - &:first-child { - // expand the Pin to room primary action - flex-grow: 1; - } + & > h2 { + margin-right: 0; + flex-grow: 1; } - } - .mx_WidgetCard_optionsButton { - position: relative; - height: 18px; - width: 26px; - - &::before { - content: ""; - position: absolute; - width: 20px; + .mx_WidgetCard_optionsButton { + position: relative; + margin-right: 44px; height: 20px; - top: 6px; - left: 20px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - background-color: $secondary-fg-color; + width: 20px; + min-width: 20px; // prevent crushing by the flexbox + padding: 0; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 4px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + background-color: $secondary-fg-color; + } } } } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 451704bd88..79892fa7a2 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -118,12 +118,6 @@ $MiniAppTileHeight: 200px; height: $MiniAppTileHeight; } -.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 { @@ -143,11 +137,7 @@ $MiniAppTileHeight: 200px; flex-direction: row; align-items: center; justify-content: space-between; - cursor: pointer; width: 100%; -} - -.mx_AppTileMenuBar_expanded { padding-bottom: 5px; } @@ -179,31 +169,12 @@ $MiniAppTileHeight: 200px; margin: 0 3px; } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_minimise { - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); - background-color: $accent-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_maximise { - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); - background-color: $accent-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { +.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { // TODO replace icon mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); } .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { - mask-image: url('$(res)/img/icon_context.svg'); -} - -.mx_AppTileMenuBarWidgetDelete { - filter: none; -} - -.mx_AppTileMenuBarWidget:hover { - border: 1px solid $primary-fg-color; - border-radius: 2px; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); } .mx_AppTileBody { diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index d240877507..a23a44906f 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -241,6 +241,13 @@ limitations under the License. width: 26px; } +.mx_RoomHeader_appsButton::before { + mask-image: url('$(res)/img/element-icons/room/apps.svg'); +} +.mx_RoomHeader_appsButton_highlight::before { + background-color: $accent-color; +} + .mx_RoomHeader_searchButton::before { mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } diff --git a/res/img/element-icons/room/apps.svg b/res/img/element-icons/room/apps.svg new file mode 100644 index 0000000000..c90704752c --- /dev/null +++ b/res/img/element-icons/room/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/element-icons/room/integrations.svg b/res/img/element-icons/room/integrations.svg deleted file mode 100644 index 3a39506411..0000000000 --- a/res/img/element-icons/room/integrations.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/res/img/icon_context.svg b/res/img/icon_context.svg deleted file mode 100644 index 600c5bbd1d..0000000000 --- a/res/img/icon_context.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fcb2d274c1..f245e89208 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -71,6 +71,8 @@ import RoomHeader from "../views/rooms/RoomHeader"; import TintableSvg from "../views/elements/TintableSvg"; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import WidgetStore from "../../stores/WidgetStore"; +import {UPDATE_EVENT} from "../../stores/AsyncStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -180,6 +182,7 @@ export interface IState { e2eStatus?: E2EStatus; rejecting?: boolean; rejectError?: Error; + hasPinnedWidgets: boolean; } export default class RoomView extends React.Component { @@ -231,6 +234,7 @@ export default class RoomView extends React.Component { canReply: false, useIRCLayout: SettingsStore.getValue("useIRCLayout"), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), + hasPinnedWidgets: false, }; this.dispatcherRef = dis.register(this.onAction); @@ -250,7 +254,9 @@ export default class RoomView extends React.Component { this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); - WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, this.onReadReceiptsChange); this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); @@ -262,6 +268,18 @@ export default class RoomView extends React.Component { this.onRoomViewStoreUpdate(true); } + private onWidgetStoreUpdate = () => { + if (this.state.room) { + this.checkWidgets(this.state.room); + } + } + + private checkWidgets = (room) => { + this.setState({ + hasPinnedWidgets: WidgetStore.instance.getApps(room, true).length > 0, + }) + }; + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), @@ -584,7 +602,8 @@ export default class RoomView extends React.Component { this.rightPanelStoreToken.remove(); } - WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); if (this.showReadReceiptsWatchRef) { SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); @@ -828,6 +847,7 @@ export default class RoomView extends React.Component { this.calculateRecommendedVersion(room); this.updateE2EStatus(room); this.updatePermissions(room); + this.checkWidgets(room); }; private async calculateRecommendedVersion(room: Room) { @@ -1357,6 +1377,13 @@ export default class RoomView extends React.Component { dis.fire(Action.FocusComposer); }; + private onAppsClick = () => { + dis.dispatch({ + action: "appsDrawer", // TODO should this go into the RVS? + show: !this.state.showApps, + }); + }; + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', @@ -2060,6 +2087,8 @@ export default class RoomView extends React.Component { onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} + onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} + appsShown={this.state.showApps} />
diff --git a/src/components/views/context_menus/RoomWidgetContextMenu.tsx b/src/components/views/context_menus/RoomWidgetContextMenu.tsx index 1757498f4d..e904605faa 100644 --- a/src/components/views/context_menus/RoomWidgetContextMenu.tsx +++ b/src/components/views/context_menus/RoomWidgetContextMenu.tsx @@ -20,27 +20,58 @@ import {MatrixCapabilities} from "matrix-widget-api"; import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; import {ChevronFace} from "../../structures/ContextMenu"; import {_t} from "../../../languageHandler"; -import {IApp} from "../../../stores/WidgetStore"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload"; -import {Action} from "../../../dispatcher/actions"; +import WidgetStore, {IApp} from "../../../stores/WidgetStore"; import WidgetUtils from "../../../utils/WidgetUtils"; import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; import RoomContext from "../../../contexts/RoomContext"; +import dis from "../../../dispatcher/dispatcher"; +import SettingsStore from "../../../settings/SettingsStore"; +import {SettingLevel} from "../../../settings/SettingLevel"; +import Modal from "../../../Modal"; +import QuestionDialog from "../dialogs/QuestionDialog"; interface IProps extends React.ComponentProps { app: IApp; + showUnpin?: boolean; + // override delete handler + onDeleteClick?(): void; } -const RoomWidgetContextMenu: React.FC = ({ onFinished, app, ...props}) => { - const {roomId} = useContext(RoomContext); +const RoomWidgetContextMenu: React.FC = ({ onFinished, app, onDeleteClick, showUnpin, ...props}) => { + const {room, roomId} = useContext(RoomContext); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); + const canModify = WidgetUtils.canUserModifyWidgets(roomId); + + let unpinButton; + if (showUnpin) { + const onUnpinClick = () => { + WidgetStore.instance.unpinWidget(app.id); + }; + + unpinButton = ; + } + + let editButton; + if (canModify && WidgetUtils.isManagedByManager(app)) { + const onEditClick = () => { + WidgetUtils.editWidget(room, app); + }; + + editButton = + } let snapshotButton; if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { const onSnapshotClick = () => { - WidgetUtils.snapshotWidget(app); + widgetMessaging?.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); onFinished(); }; @@ -48,29 +79,45 @@ const RoomWidgetContextMenu: React.FC = ({ onFinished, app, ...props}) = } let deleteButton; - if (WidgetUtils.canUserModifyWidgets(roomId)) { + if (onDeleteClick || canModify) { const onDeleteClick = () => { - defaultDispatcher.dispatch({ - action: Action.AppTileDelete, - widgetId: app.id, + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, }); onFinished(); }; - deleteButton = ; + deleteButton = ; } const onRevokeClick = () => { - defaultDispatcher.dispatch({ - action: Action.AppTileRevoke, - widgetId: app.id, + console.info("Revoking permission for widget to load: " + app.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[app.eventId] = false; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. }); onFinished(); }; return + { unpinButton } { snapshotButton } + { editButton } { deleteButton } diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js deleted file mode 100644 index 6ed32daa5c..0000000000 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ /dev/null @@ -1,142 +0,0 @@ -/* -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 React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler'; -import {MenuItem} from "../../structures/ContextMenu"; - -export default class WidgetContextMenu extends React.Component { - static propTypes = { - onFinished: PropTypes.func, - - // Callback for when the revoke button is clicked. Required. - onRevokeClicked: PropTypes.func.isRequired, - - // Callback for when the unpin button is clicked. If absent, unpin will be hidden. - onUnpinClicked: PropTypes.func, - - // Callback for when the snapshot button is clicked. Button not shown - // without a callback. - onSnapshotClicked: PropTypes.func, - - // Callback for when the reload button is clicked. Button not shown - // without a callback. - onReloadClicked: PropTypes.func, - - // Callback for when the edit button is clicked. Button not shown - // without a callback. - onEditClicked: PropTypes.func, - - // Callback for when the delete button is clicked. Button not shown - // without a callback. - onDeleteClicked: PropTypes.func, - }; - - proxyClick(fn) { - fn(); - if (this.props.onFinished) this.props.onFinished(); - } - - // XXX: It's annoying that our context menus require us to hit onFinished() to close :( - - onEditClicked = () => { - this.proxyClick(this.props.onEditClicked); - }; - - onReloadClicked = () => { - this.proxyClick(this.props.onReloadClicked); - }; - - onSnapshotClicked = () => { - this.proxyClick(this.props.onSnapshotClicked); - }; - - onDeleteClicked = () => { - this.proxyClick(this.props.onDeleteClicked); - }; - - onRevokeClicked = () => { - this.proxyClick(this.props.onRevokeClicked); - }; - - onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked); - - render() { - const options = []; - - if (this.props.onEditClicked) { - options.push( - - {_t("Edit")} - , - ); - } - - if (this.props.onUnpinClicked) { - options.push( - - {_t("Unpin")} - , - ); - } - - if (this.props.onReloadClicked) { - options.push( - - {_t("Reload")} - , - ); - } - - if (this.props.onSnapshotClicked) { - options.push( - - {_t("Take picture")} - , - ); - } - - if (this.props.onDeleteClicked) { - options.push( - - {_t("Remove for everyone")} - , - ); - } - - // Push this last so it appears last. It's always present. - options.push( - - {_t("Remove for me")} - , - ); - - // Put separators between the options - if (options.length > 1) { - const length = options.length; - for (let i = 0; i < length - 1; i++) { - const sep =
; - - // Insert backwards so the insertions don't affect our math on where to place them. - // We also use our cached length to avoid worrying about options.length changing - options.splice(length - 1 - i, 0, sep); - } - } - - return
{options}
; - } -} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 3405d4ff16..a60c18cb0d 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -22,55 +22,48 @@ import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import AccessibleButton from './AccessibleButton'; -import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; import Spinner from './Spinner'; -import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; import SettingsStore from "../../../settings/SettingsStore"; -import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; -import PersistedElement from "./PersistedElement"; +import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu"; +import PersistedElement, {getPersistKey} from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; import {SettingLevel} from "../../../settings/SettingLevel"; -import WidgetStore from "../../../stores/WidgetStore"; -import {Action} from "../../../dispatcher/actions"; import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; import {MatrixCapabilities} from "matrix-widget-api"; +import RoomWidgetContextMenu from "../context_menus/RoomWidgetContextMenu"; export default class AppTile extends React.Component { constructor(props) { super(props); // The key used for PersistedElement - this._persistKey = 'widget_' + this.props.app.id; + this._persistKey = getPersistKey(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._onEditClick = this._onEditClick.bind(this); - this._onDeleteClick = this._onDeleteClick.bind(this); - this._onRevokeClicked = this._onRevokeClicked.bind(this); - this._onSnapshotClick = this._onSnapshotClick.bind(this); - this.onClickMenuBar = this.onClickMenuBar.bind(this); - this._onMinimiseClick = this._onMinimiseClick.bind(this); - this._grantWidgetPermission = this._grantWidgetPermission.bind(this); - this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this); - this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this); - this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); - this._contextMenuButton = createRef(); - this._menu_bar = createRef(); + + this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange); } + // This is a function to make the impact of calling SettingsStore slightly less + hasPermissionToLoad = (props) => { + if (this._usingLocalWidget()) return true; + + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); + return !!currentlyAllowedWidgets[props.app.eventId]; + }; + /** * Set initial component state when the App wUrl (widget URL) is being updated. * Component props *must* be passed (rather than relying on this.props). @@ -78,28 +71,35 @@ export default class AppTile extends React.Component { * @return {Object} Updated component state to be set with setState */ _getNewState(newProps) { - // This is a function to make the impact of calling SettingsStore slightly less - const hasPermissionToLoad = () => { - if (this._usingLocalWidget()) return true; - - const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); - return !!currentlyAllowedWidgets[newProps.app.eventId]; - }; - 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), // 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(), + hasPermissionToLoad: newProps.userId === newProps.creatorUserId || this.hasPermissionToLoad(newProps), error: null, - deleting: false, widgetPageTitle: newProps.widgetPageTitle, menuDisplayed: false, }; } + onAllowedWidgetsChange = () => { + const hasPermissionToLoad = + this.props.userId === this.prop.creatorUserId || this.hasPermissionToLoad(this.props); + + if (this.state.hasPermissionToLoad && !hasPermissionToLoad) { + // Force the widget to be non-persistent (able to be deleted/forgotten) + ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); + PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); + } + + this.setState({ + hasPermissionToLoad, + }); + }; + isMixedContent() { const parentContentProtocol = window.location.protocol; const u = url.parse(this.props.app.url); @@ -114,7 +114,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) { + if (this.state.hasPermissionToLoad) { this._startWidget(); } @@ -135,6 +135,8 @@ export default class AppTile extends React.Component { if (this._sgWidget) { this._sgWidget.stop(); } + + SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef); } _resetWidget(newProps) { @@ -165,21 +167,8 @@ export default class AppTile extends React.Component { UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); - if (this.props.show && this.state.hasPermissionToLoad) { - this._resetWidget(nextProps); - } - } - - if (nextProps.show && !this.props.show) { - // We assume that persisted widgets are loaded and don't need a spinner. - if (this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey)) { - this.setState({ - loading: true, - }); - } - // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this._startWidget(); + this._resetWidget(nextProps); } } @@ -190,35 +179,6 @@ export default class AppTile extends React.Component { } } - _canUserModify() { - // User widgets should always be modifiable by their creator - if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) { - return true; - } - // Check if the current user can modify widgets in the current room - return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); - } - - _onEditClick() { - console.log("Edit widget ID ", this.props.app.id); - if (this.props.onEditClick) { - this.props.onEditClick(); - } else { - WidgetUtils.editWidget(this.props.room, this.props.app); - } - } - - _onSnapshotClick() { - this._sgWidget.widgetApi.takeScreenshot().then(data => { - dis.dispatch({ - action: 'picture_snapshot', - file: data.screenshot, - }); - }).catch(err => { - console.error("Failed to take screenshot: ", err); - }); - } - /** * Ends all widget interaction, such as cancelling calls and disabling webcams. * @private @@ -244,57 +204,6 @@ export default class AppTile extends React.Component { this._sgWidget.stop(); } - /* If user has permission to modify widgets, delete the widget, - * otherwise revoke access for the widget to load in the user's browser - */ - _onDeleteClick() { - if (this.props.onDeleteClick) { - this.props.onDeleteClick(); - } else if (this._canUserModify()) { - // Show delete confirmation dialog - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) { - return; - } - this.setState({deleting: true}); - - this._endWidgetActions().then(() => { - return WidgetUtils.setRoomWidget( - this.props.room.roomId, - this.props.app.id, - ); - }).catch((e) => { - console.error('Failed to delete widget', e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - - Modal.createTrackedDialog('Failed to remove widget', '', ErrorDialog, { - title: _t('Failed to remove widget'), - description: _t('An error ocurred whilst trying to remove the widget from the room'), - }); - }).finally(() => { - this.setState({deleting: false}); - }); - }, - }); - } - } - - _onUnpinClicked = () => { - WidgetStore.instance.unpinWidget(this.props.app.id); - } - - _onRevokeClicked() { - console.info("Revoke widget permissions - %s", this.props.app.id); - this._revokeWidgetPermission(); - } - _onWidgetReady = () => { this.setState({loading: false}); if (WidgetType.JITSI.matches(this.props.app.type)) { @@ -302,7 +211,7 @@ export default class AppTile extends React.Component { } }; - _onAction(payload) { + _onAction = payload => { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': @@ -312,19 +221,11 @@ export default class AppTile extends React.Component { console.warn('Ignoring sticker message. Invalid capability'); } break; - - case Action.AppTileDelete: - this._onDeleteClick(); - break; - - case Action.AppTileRevoke: - this._onRevokeClicked(); - break; } } - } + }; - _grantWidgetPermission() { + _grantWidgetPermission = () => { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); @@ -338,26 +239,7 @@ export default class AppTile extends React.Component { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. }); - } - - _revokeWidgetPermission() { - const roomId = this.props.room.roomId; - console.info("Revoking permission for widget to load: " + this.props.app.eventId); - const current = SettingsStore.getValue("allowedWidgets", roomId); - current[this.props.app.eventId] = false; - SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { - this.setState({hasPermissionToLoad: false}); - - // Force the widget to be non-persistent (able to be deleted/forgotten) - 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. - }); - } + }; formatAppTileName() { let appTileName = "No name"; @@ -367,29 +249,6 @@ export default class AppTile extends React.Component { return appTileName; } - onClickMenuBar(ev) { - ev.preventDefault(); - - // Ignore clicks on menu bar children - if (ev.target !== this._menu_bar.current) { - return; - } - - // Toggle the view state of the apps drawer - if (this.props.userWidget) { - this._onMinimiseClick(); - } else { - if (this.props.show) { - // if we were being shown, end the widget as we're about to be minimized. - this._endWidgetActions(); - } - dis.dispatch({ - action: 'appsDrawer', - show: !this.props.show, - }); - } - } - /** * Whether we're using a local version of the widget rather than loading the * actual widget URL @@ -415,16 +274,11 @@ export default class AppTile extends React.Component { ); } - _onMinimiseClick(e) { - if (this.props.onMinimiseClick) { - this.props.onMinimiseClick(); - } - } - - _onPopoutWidgetClick() { + // TODO replace with full screen interactions + _onPopoutWidgetClick = () => { // Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them // 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) { + if (WidgetType.JITSI.matches(this.props.app.type)) { this._endWidgetActions().then(() => { if (this.iframe) { // Reload iframe @@ -437,13 +291,7 @@ export default class AppTile extends React.Component { // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), { 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.iframe.src = this.iframe.src; - } + }; _onContextMenuClick = () => { this.setState({ menuDisplayed: true }); @@ -456,11 +304,6 @@ export default class AppTile extends React.Component { render() { let appTileBody; - // Don't render widget if it is in the process of being deleted - if (this.state.deleting) { - return
; - } - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // because that would allow the iframe to programmatically remove the sandbox attribute, but // this would only be for content hosted on the same origin as the element client: anything @@ -475,71 +318,66 @@ export default class AppTile extends React.Component { const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); - if (this.props.show) { - const loadingElement = ( -
- + const loadingElement = ( +
+ +
+ ); + if (!this.state.hasPermissionToLoad) { + const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + appTileBody = ( +
+
); - if (!this.state.hasPermissionToLoad) { - const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); + } else if (this.state.initialising) { + appTileBody = ( +
+ { loadingElement } +
+ ); + } else { + if (this.isMixedContent()) { appTileBody = (
- -
- ); - } else if (this.state.initialising) { - appTileBody = ( -
- { loadingElement } +
); } else { - if (this.isMixedContent()) { - appTileBody = ( -
- -
- ); - } else { - appTileBody = ( -
- { this.state.loading && loadingElement } -