diff --git a/res/css/_components.scss b/res/css/_components.scss index 18cf0c52d8..e3f03dabb6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -51,11 +51,11 @@ @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss"; +@import "./views/avatars/_WidgetAvatar.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @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/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index f4e46a8e94..812a7f8472 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -79,7 +79,6 @@ limitations under the License. height: 100%; } -.mx_MatrixChat > .mx_LeftPanel2:hover + .mx_ResizeHandle_horizontal, .mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { position: relative; diff --git a/src/dispatcher/payloads/AppTileActionPayload.ts b/res/css/views/avatars/_WidgetAvatar.scss similarity index 72% rename from src/dispatcher/payloads/AppTileActionPayload.ts rename to res/css/views/avatars/_WidgetAvatar.scss index 3cdb0f8c1f..8e5cfb54d8 100644 --- a/src/dispatcher/payloads/AppTileActionPayload.ts +++ b/res/css/views/avatars/_WidgetAvatar.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ActionPayload } from "../payloads"; -import { Action } from "../actions"; - -export interface AppTileActionPayload extends ActionPayload { - action: Action.AppTileDelete | Action.AppTileRevoke; - widgetId: string; +.mx_WidgetAvatar { + border-radius: 4px; } 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 3ff3b52531..9a5a59bda8 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -128,6 +128,13 @@ limitations under the License. mask-size: 20px; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } + + &.mx_AccessibleButton_disabled { + 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 0031d3a64c..36882f4e8b 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -110,28 +110,107 @@ limitations under the License. .mx_RoomSummaryCard_appsGroup { .mx_RoomSummaryCard_Button { - padding-left: 12px; + // this button is special so we have to override some of the original styling + // as we will be applying it in its children + padding: 0; + height: auto; color: $tertiary-fg-color; - span { - color: $primary-fg-color; + .mx_RoomSummaryCard_icon_app { + padding: 10px 48px 10px 12px; // based on typical mx_RoomSummaryCard_Button padding + text-overflow: ellipsis; + overflow: hidden; + + .mx_BaseAvatar_image { + vertical-align: top; + margin-right: 12px; + } + + span { + color: $primary-fg-color; + } } - img { - vertical-align: top; - margin-right: 12px; - border-radius: 4px; + .mx_RoomSummaryCard_app_pinToggle, + .mx_RoomSummaryCard_app_options { + position: absolute; + top: 0; + height: 100%; // to give bigger interactive zone + width: 24px; + padding: 12px 4px; + box-sizing: border-box; + min-width: 24px; // prevent flexbox crushing + + &:hover { + &::after { + content: ''; + position: absolute; + height: 24px; + width: 24px; + top: 8px; // equal to padding-top of parent + left: 0; + border-radius: 12px; + background-color: rgba(141, 151, 165, 0.1); + } + } + + &::before { + content: ''; + position: absolute; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px; + background-color: $icon-button-color; + } + } + + .mx_RoomSummaryCard_app_pinToggle { + right: 24px; + + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + .mx_RoomSummaryCard_app_options { + right: 48px; + display: none; + + &::before { + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + + &.mx_RoomSummaryCard_Button_pinned { + &::after { + opacity: 0.2; + } + + .mx_RoomSummaryCard_app_pinToggle::before { + background-color: $accent-color; + } + } + + &:hover { + .mx_RoomSummaryCard_icon_app { + padding-right: 72px; + } + + .mx_RoomSummaryCard_app_options { + display: unset; + } } &::before { content: unset; } - } - .mx_RoomSummaryCard_icon_app_pinned::after { - mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); - background-color: $accent-color; - transform: unset; + &::after { + top: 8px; // re-align based on the height change + pointer-events: none; // pass through to the real button + } } } 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 6e3ffbe5f0..8731d22660 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -47,53 +47,100 @@ $MiniAppTileHeight: 200px; opacity: 0.8; background: $primary-fg-color; } + + .mx_ResizeHandle_horizontal::before { + position: absolute; + left: 3px; + top: 50%; + transform: translate(0, -50%); + + height: 64px; // to match width of the ones on roomlist + width: 4px; + border-radius: 4px; + + content: ''; + + background-color: $primary-fg-color; + opacity: 0.8; + } } } +.mx_AppsContainer_resizer { + margin-bottom: 8px; +} + .mx_AppsContainer { display: flex; flex-direction: row; align-items: stretch; justify-content: center; height: 100%; - margin-bottom: 8px; + width: 100%; + flex: 1; + min-height: 0; + + .mx_AppTile:first-of-type { + border-left-width: 8px; + border-radius: 10px 0 0 10px; + } + .mx_AppTile:last-of-type { + border-right-width: 8px; + border-radius: 0 10px 10px 0; + } + + .mx_ResizeHandle_horizontal { + position: relative; + + > div { + width: 0; + } + } } -.mx_AppsDrawer_minimised .mx_AppsContainer { - // override the re-resizable inline styles - height: inherit !important; - min-height: inherit !important; -} +// TODO this should be 300px but that's too large +$MinWidth: 240px; -.mx_AddWidget_button { - order: 2; - cursor: pointer; - padding: 0; - margin: -3px auto 5px 0; - color: $accent-color; - font-size: $font-12px; +.mx_AppsDrawer_2apps .mx_AppTile { + width: 50%; + + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } +} +.mx_AppsDrawer_3apps .mx_AppTile { + width: 33%; + + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } } .mx_AppTile { width: 50%; - border: 5px solid $widget-menu-bar-bg-color; - border-radius: 4px; + min-width: $MinWidth; + border: 8px solid $widget-menu-bar-bg-color; + border-left-width: 5px; + border-right-width: 5px; display: flex; flex-direction: column; - - & + .mx_AppTile { - margin-left: 5px; - } + box-sizing: border-box; + background-color: $widget-menu-bar-bg-color; } .mx_AppTileFullWidth { - width: 100%; + width: 100% !important; // to override the inline style set by the resizer margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; display: flex; flex-direction: column; + background-color: $widget-menu-bar-bg-color; } .mx_AppTile_mini { @@ -105,12 +152,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 { @@ -130,19 +171,20 @@ $MiniAppTileHeight: 200px; flex-direction: row; align-items: center; justify-content: space-between; - cursor: pointer; width: 100%; -} - -.mx_AppTileMenuBar_expanded { - padding-bottom: 5px; + padding-top: 2px; + padding-bottom: 8px; } .mx_AppTileMenuBarTitle { - display: flex; - flex-direction: row; - align-items: center; - pointer-events: none; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .mx_WidgetAvatar { + margin-right: 12px; + } } .mx_AppTileMenuBarTitle > :last-child { @@ -166,37 +208,20 @@ $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 { 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 { height: 100%; width: 100%; overflow: hidden; + border-radius: 8px; + background-color: $widget-body-bg-color; } .mx_AppTileBody_mini { @@ -231,7 +256,6 @@ $MiniAppTileHeight: 200px; .mx_AppPermissionWarning { text-align: center; - background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; @@ -296,6 +320,10 @@ $MiniAppTileHeight: 200px; font-weight: bold; position: relative; height: 100%; + + // match bg of border so that the cut corners have the right fill + background-color: $widget-body-bg-color !important; + border-radius: 8px; } .mx_AppLoading .mx_Spinner { @@ -323,10 +351,6 @@ $MiniAppTileHeight: 200px; 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; 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/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index d99276b70a..94f42efe83 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -16,6 +16,10 @@ border-bottom: none; } + .mx_AppTileMenuBar { + padding: 0; + } + iframe { // Sticker picker depends on the fixed height previously used for all tiles height: 273px; 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/default_app.svg b/res/img/element-icons/room/default_app.svg index 08734170df..baf9bc37fa 100644 --- a/res/img/element-icons/room/default_app.svg +++ b/res/img/element-icons/room/default_app.svg @@ -1,11 +1,21 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg index 5bced115cf..fc440b4553 100644 --- a/res/img/element-icons/room/default_cal.svg +++ b/res/img/element-icons/room/default_cal.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/default_clock.svg b/res/img/element-icons/room/default_clock.svg index cc21716d15..c7f453aadd 100644 --- a/res/img/element-icons/room/default_clock.svg +++ b/res/img/element-icons/room/default_clock.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/res/img/element-icons/room/default_doc.svg b/res/img/element-icons/room/default_doc.svg index 93e7507be3..aff393ffd5 100644 --- a/res/img/element-icons/room/default_doc.svg +++ b/res/img/element-icons/room/default_doc.svg @@ -1,4 +1,4 @@ - - + + diff --git a/res/img/element-icons/room/default_video.svg b/res/img/element-icons/room/default_video.svg new file mode 100644 index 0000000000..022f1f43b1 --- /dev/null +++ b/res/img/element-icons/room/default_video.svg @@ -0,0 +1,5 @@ + + + + + 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/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 6e0c9acdfe..bd709473ef 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -131,6 +131,7 @@ $notice-secondary-color: $roomlist-header-color; $panel-divider-color: transparent; $widget-menu-bar-bg-color: $header-panel-bg-color; +$widget-body-bg-color: rgba(141, 151, 165, 0.2); // event tile lifecycle $event-sending-color: $text-secondary-color; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index efde7b3747..60d44b1c31 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -126,6 +126,7 @@ $roomtile-selected-bg-color: #1A1D23; $panel-divider-color: $header-panel-border-color; $widget-menu-bar-bg-color: $header-panel-bg-color; +$widget-body-bg-color: #1A1D23; // event tile lifecycle $event-sending-color: $text-secondary-color; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index f77226cbca..52fb1c8ef2 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -208,6 +208,7 @@ $panel-divider-color: #dee1f3; // ******************** $widget-menu-bar-bg-color: $secondary-accent-color; +$widget-body-bg-color: #fff; // ******************** diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index d137373bd5..0c5e271860 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -208,6 +208,7 @@ $pinned-color: $notice-secondary-color; // ******************** $widget-menu-bar-bg-color: $secondary-accent-color; +$widget-body-bg-color: #FFF; // ******************** diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 884f77aba5..fa0d6682dd 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -416,8 +416,9 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None return menuOptions; }; -export const useContextMenu = (): [boolean, RefObject, () => void, () => void, (val: boolean) => void] => { - const button = useRef(null); +type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: boolean) => void]; +export const useContextMenu = (): ContextMenuTuple => { + const button = useRef(null); const [isOpen, setIsOpen] = useState(false); const open = () => { setIsOpen(true); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0226cecf15..4b2fa67c1c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -72,6 +72,8 @@ import TintableSvg from "../views/elements/TintableSvg"; import {XOR} from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call"; +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 { @@ -250,7 +253,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 +267,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.getPinnedApps(room.roomId).length > 0, + }) + }; + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), @@ -584,7 +601,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); @@ -823,6 +841,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) { @@ -1352,6 +1371,13 @@ export default class RoomView extends React.Component { dis.fire(Action.FocusComposer); }; + private onAppsClick = () => { + dis.dispatch({ + action: "appsDrawer", + show: !this.state.showApps, + }); + }; + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', @@ -2054,6 +2080,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/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx new file mode 100644 index 0000000000..04cfce7670 --- /dev/null +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -0,0 +1,58 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {ComponentProps, useContext} from 'react'; +import classNames from 'classnames'; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; + +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {IApp} from "../../../stores/WidgetStore"; +import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; + +interface IProps extends Omit, "name" | "url" | "urls"> { + app: IApp; +} + +const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { + const cli = useContext(MatrixClientContext); + + let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; + // heuristics for some better icons until Widgets support their own icons + if (app.type.includes("jitsi")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")]; + } else if (app.type.includes("meeting") || app.type.includes("calendar")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")]; + } else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")]; + } else if (app.type.includes("clock")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")]; + } + + return ( + + ) +}; + +export default WidgetAvatar; 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/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx new file mode 100644 index 0000000000..7656e70341 --- /dev/null +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -0,0 +1,177 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useContext} from "react"; +import {MatrixCapabilities} from "matrix-widget-api"; + +import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; +import {ChevronFace} from "../../structures/ContextMenu"; +import {_t} from "../../../languageHandler"; +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"; +import {WidgetType} from "../../../widgets/WidgetType"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; + +interface IProps extends React.ComponentProps { + app: IApp; + userWidget?: boolean; + showUnpin?: boolean; + // override delete handler + onDeleteClick?(): void; +} + +const WidgetContextMenu: React.FC = ({ + onFinished, + app, + userWidget, + onDeleteClick, + showUnpin, + ...props +}) => { + const cli = useContext(MatrixClientContext); + const {room, roomId} = useContext(RoomContext); + + const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); + const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + + let unpinButton; + if (showUnpin) { + const onUnpinClick = () => { + WidgetStore.instance.unpinWidget(app.id); + onFinished(); + }; + + unpinButton = ; + } + + let editButton; + if (canModify && WidgetUtils.isManagedByManager(app)) { + const onEditClick = () => { + WidgetUtils.editWidget(room, app); + onFinished(); + }; + + editButton = ; + } + + let snapshotButton; + if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const onSnapshotClick = () => { + widgetMessaging?.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); + onFinished(); + }; + + snapshotButton = ; + } + + let deleteButton; + if (onDeleteClick || canModify) { + const onDeleteClickDefault = () => { + // 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 = ; + } + + let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId]; + if (isAllowedWidget === undefined) { + isAllowedWidget = app.creatorUserId === cli.getUserId(); + } + + const isLocalWidget = WidgetType.JITSI.matches(app.type); + let revokeButton; + if (!userWidget && !isLocalWidget && isAllowedWidget) { + const onRevokeClick = () => { + 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(); + }; + + revokeButton = ; + } + + const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId); + const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id); + + let moveLeftButton; + if (showUnpin && widgetIndex > 0) { + const onClick = () => { + WidgetStore.instance.movePinnedWidget(app.id, -1); + onFinished(); + }; + + moveLeftButton = ; + } + + let moveRightButton; + if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { + const onClick = () => { + WidgetStore.instance.movePinnedWidget(app.id, 1); + onFinished(); + }; + + moveRightButton = ; + } + + return + + { editButton } + { revokeButton } + { deleteButton } + { snapshotButton } + { moveLeftButton } + { moveRightButton } + { unpinButton } + + ; +}; + +export default WidgetContextMenu; + diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 29e79dc396..b7c7b78e63 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps { tooltip?: React.ReactNode; tooltipClassName?: string; forceHide?: boolean; + yOffset?: number; } interface IState { @@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent :
; return ( { + if (this._usingLocalWidget()) return true; + + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); + if (currentlyAllowedWidgets[props.app.eventId] === undefined) { + return props.userId === props.creatorUserId; + } + 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). @@ -79,28 +76,32 @@ 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: this.hasPermissionToLoad(newProps), error: null, - deleting: false, widgetPageTitle: newProps.widgetPageTitle, menuDisplayed: false, }; } + onAllowedWidgetsChange = () => { + const hasPermissionToLoad = 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); @@ -115,7 +116,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(); } @@ -136,6 +137,8 @@ export default class AppTile extends React.Component { if (this._sgWidget) { this._sgWidget.stop(); } + + SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef); } _resetWidget(newProps) { @@ -167,21 +170,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); } } @@ -192,35 +182,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 @@ -250,57 +211,6 @@ export default class AppTile extends React.Component { this._sgWidget.stop({forceDestroy: true}); } - /* 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(); - } - _onWidgetPrepared = () => { this.setState({loading: false}); }; @@ -311,7 +221,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': @@ -321,19 +231,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); @@ -347,26 +249,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"; @@ -376,32 +259,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(); - } else { - // restart the widget actions - this._resetWidget(this.props); - } - 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 @@ -421,22 +278,18 @@ export default class AppTile extends React.Component { return ( + { name } { title ? titleSpacer : '' }{ title } ); } - _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 @@ -449,13 +302,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 }); @@ -468,11 +315,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 @@ -487,71 +329,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 } -