diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a22954c3f..34a3498f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1) + + * [Release] Adjust for new widget messaging APIs + [\#5342](https://github.com/matrix-org/matrix-react-sdk/pull/5342) + * [Release] Fix Jitsi OpenIDC auth + [\#5335](https://github.com/matrix-org/matrix-react-sdk/pull/5335) + Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0) diff --git a/README.md b/README.md index 4db02418ba..73afe34df0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ are currently filed against vector-im/element-web rather than this project). Translation Status ================== -[![Translation status](https://translate.riot.im/widgets/element-web/-/multi-auto.svg)](https://translate.riot.im/engage/element-web/?utm_source=widget) +[![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget) Developer Guide =============== diff --git a/package.json b/package.json index ee4094601a..bb47fc6401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.6.0", + "version": "3.6.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/release.sh b/release.sh index e2cefcbe74..4742f00dea 100755 --- a/release.sh +++ b/release.sh @@ -32,9 +32,7 @@ do echo "Upgrading $i to $latestver..." yarn add -E $i@$latestver git add -u - # The `-e` flag opens the editor and gives you a chance to check - # the upgrade for correctness. - git commit -m "Upgrade $i to $latestver" -e + git commit -m "Upgrade $i to $latestver" fi fi done diff --git a/res/css/_components.scss b/res/css/_components.scss index a8ac9be1ab..657d77974f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -13,6 +13,7 @@ @import "./structures/_HeaderButtons.scss"; @import "./structures/_HomePage.scss"; @import "./structures/_LeftPanel.scss"; +@import "./structures/_LeftPanelWidget.scss"; @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; @import "./structures/_MyGroups.scss"; @@ -51,11 +52,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/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss new file mode 100644 index 0000000000..4df651d7b6 --- /dev/null +++ b/res/css/structures/_LeftPanelWidget.scss @@ -0,0 +1,145 @@ +/* +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. +*/ + +.mx_LeftPanelWidget { + // largely based on RoomSublist + margin-left: 8px; + margin-bottom: 4px; + + .mx_LeftPanelWidget_headerContainer { + display: flex; + align-items: center; + + height: 24px; + color: $roomlist-header-color; + margin-top: 4px; + + .mx_LeftPanelWidget_stickable { + flex: 1; + max-width: 100%; + + display: flex; + align-items: center; + } + + .mx_LeftPanelWidget_headerText { + flex: 1; + max-width: calc(100% - 16px); + line-height: $font-16px; + font-size: $font-13px; + font-weight: 600; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + .mx_LeftPanelWidget_collapseBtn { + display: inline-block; + position: relative; + width: 14px; + height: 14px; + margin-right: 6px; + + &::before { + content: ''; + width: 18px; + height: 18px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_LeftPanelWidget_collapseBtn_collapsed::before { + transform: rotate(-90deg); + } + } + } + } + + .mx_LeftPanelWidget_resizeBox { + position: relative; + + display: flex; + flex-direction: column; + overflow: visible; // let the resize handle out + } + + .mx_AppTileFullWidth { + flex: 1 0 0; + overflow: hidden; + // need this to be flex otherwise the overflow hidden from above + // sometimes vertically centers the clipped list ... no idea why it would do this + // as the box model should be top aligned. Happens in both FF and Chromium + display: flex; + flex-direction: column; + box-sizing: border-box; + + mask-image: linear-gradient(0deg, transparent, black 4px); + } + + .mx_LeftPanelWidget_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + position: absolute; + top: -24px !important; // override from library - puts it in the margin-top of the headerContainer + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; + } + + &:hover .mx_LeftPanelWidget_resizerHandle { + opacity: 0.8; + background-color: $primary-fg-color; + } + + .mx_LeftPanelWidget_maximizeButton { + margin-left: 8px; + margin-right: 7px; + position: relative; + width: 24px; + height: 24px; + border-radius: 32px; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 4px; + left: 4px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + background: $muted-fg-color; + } + } +} + +.mx_LeftPanelWidget_maximizeButtonTooltip { + margin-top: -3px; +} 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/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index fecac40e4e..6a352d46a3 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -230,6 +230,10 @@ limitations under the License. align-items: center; justify-content: center; } + + &.mx_UserMenu_contextMenu_hostingLink { + padding-top: 0; + } } .mx_IconizedContextMenu_icon { 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/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 543940fb78..82bba40167 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -59,10 +59,6 @@ limitations under the License. width: calc(100% - 22px); } - &.mx_RoomSublist_headerContainer_stickyBottom { - bottom: 0; - } - // We don't have a top style because the top is dependent on the room list header's // height, and is therefore calculated in JS. // The class, mx_RoomSublist_headerContainer_stickyTop, is applied though. 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/CallHandler.tsx b/src/CallHandler.tsx index b788ec7da1..e303dd3819 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -262,6 +262,12 @@ export default class CallHandler { Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { title, description, }); + } else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) { + this.play(AudioID.Busy); + Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { + title: _t("Answered Elsewhere"), + description: _t("The call was answered on another device."), + }); } else { this.play(AudioID.CallEnd); } diff --git a/src/Searching.js b/src/Searching.js index b1507e6a49..f65b8920b3 100644 --- a/src/Searching.js +++ b/src/Searching.js @@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven let oldestEventFrom = previousSearchResult.oldestEventFrom; response.highlights = previousSearchResult.highlights; - if (localEvents && serverEvents) { + if (localEvents && serverEvents && serverEvents.results) { // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. @@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); - } else if (serverEvents) { + } else if (serverEvents && serverEvents.results) { // This is a pagination call fetching more events from the server, // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older @@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice) { +function restoreEncryptionInfo(searchResultSlice = []) { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); @@ -517,7 +517,7 @@ async function combinedPagination(searchResult) { }, }; - const oldResultCount = searchResult.results.length; + const oldResultCount = searchResult.results ? searchResult.results.length : 0; // Let the client process the combined result. const result = client._processRoomEventsSearch(searchResult, response); diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 434b931296..b49a90d175 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn // onFocus should be called when the index gained focus in any manner // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition -export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => { +export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => { const context = useContext(RovingTabIndexContext); let ref = useRef(null); 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/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 262d12a700..4445ff3ff8 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; +import LeftPanelWidget from "./LeftPanelWidget"; interface IProps { isMinimized: boolean; @@ -142,7 +143,7 @@ export default class LeftPanel extends React.Component { const bottomEdge = list.offsetHeight + list.scrollTop; const sublists = list.querySelectorAll(".mx_RoomSublist"); - const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = list.clientWidth - headerRightMargin; // We track which styles we want on a target before making the changes to avoid @@ -213,10 +214,19 @@ export default class LeftPanel extends React.Component { if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); } + + const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const newBottom = `${offset}px`; + if (header.style.bottom !== newBottom) { + header.style.bottom = newBottom; + } } else { if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); } + if (header.style.bottom) { + header.style.removeProperty('bottom'); + } } if (style.stickyTop || style.stickyBottom) { @@ -388,7 +398,6 @@ export default class LeftPanel extends React.Component { const roomList = { {roomList} + { !this.props.isMinimized && } ); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx new file mode 100644 index 0000000000..4daec76d08 --- /dev/null +++ b/src/components/structures/LeftPanelWidget.tsx @@ -0,0 +1,149 @@ +/* +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, useEffect, useMemo} from "react"; +import {Resizable} from "re-resizable"; +import classNames from "classnames"; + +import AccessibleButton from "../views/elements/AccessibleButton"; +import {useRovingTabIndex} from "../../accessibility/RovingTabIndex"; +import {Key} from "../../Keyboard"; +import {useLocalStorageState} from "../../hooks/useLocalStorageState"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils"; +import {useAccountData} from "../../hooks/useAccountData"; +import AppTile from "../views/elements/AppTile"; +import {useSettingValue} from "../../hooks/useSettings"; + +interface IProps { + onResize(): void; +} + +const MIN_HEIGHT = 100; +const MAX_HEIGHT = 500; // or 50% of the window height +const INITIAL_HEIGHT = 280; + +const LeftPanelWidget: React.FC = ({ onResize }) => { + const cli = useContext(MatrixClientContext); + + const mWidgetsEvent = useAccountData>(cli, "m.widgets"); + const leftPanelWidgetId = useSettingValue("Widgets.leftPanel"); + const app = useMemo(() => { + if (!mWidgetsEvent || !leftPanelWidgetId) return null; + const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId); + if (!widgetConfig) return null; + + return WidgetUtils.makeAppConfig( + widgetConfig.state_key, + widgetConfig.content, + widgetConfig.sender, + null, + widgetConfig.id); + }, [mWidgetsEvent, leftPanelWidgetId]); + + const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); + const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); + useEffect(onResize, [expanded]); + + const [onFocus, isActive, ref] = useRovingTabIndex(); + const tabIndex = isActive ? 0 : -1; + + if (!app) return null; + + let content; + if (expanded) { + content = { + setHeight(height + d.height); + }} + handleWrapperClass="mx_LeftPanelWidget_resizerHandles" + handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}} + className="mx_LeftPanelWidget_resizeBox" + enable={{ top: true }} + > + + ; + } + + return
+
{ + switch (ev.key) { + case Key.ARROW_LEFT: + ev.stopPropagation(); + setExpanded(false); + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + setExpanded(true); + break; + } + } + }} + > +
+ { + setExpanded(e => !e); + }} + > + + { WidgetUtils.getWidgetName(app) } + + + {/* Code for the maximise button for once we have full screen widgets */} + {/* { + }} + className="mx_LeftPanelWidget_maximizeButton" + tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip" + title={_t("Maximize")} + />*/} +
+
+ + { content } +
; +}; + +export default LeftPanelWidget; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 15ea20618e..03277a84f9 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { ICollapseConfig } from "../../resizer/distributors/collapse"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -205,13 +206,8 @@ class LoggedInView extends React.Component { }; _createResizer() { - const classNames = { - handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", - }; let size; - const collapseConfig = { + const collapseConfig: ICollapseConfig = { toggleSize: 260 - 50, onCollapsed: (collapsed) => { if (collapsed) { @@ -234,7 +230,11 @@ class LoggedInView extends React.Component { }, }; const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); - resizer.setClassNames(classNames); + resizer.setClassNames({ + handle: "mx_ResizeHandle", + vertical: "mx_ResizeHandle_vertical", + reverse: "mx_ResizeHandle_reverse", + }); return resizer; } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2952568e2f..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) { @@ -1258,7 +1277,7 @@ export default class RoomView extends React.Component { } if (!this.state.searchResults.next_batch) { - if (this.state.searchResults.results.length == 0) { + if (!this.state.searchResults?.results?.length) { ret.push(
  • { _t("No results") }

  • , @@ -1282,7 +1301,7 @@ export default class RoomView extends React.Component { let lastRoomId; - for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) { + for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) { const result = this.state.searchResults.results[i]; const mxEv = result.context.getEvent(); @@ -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', @@ -1944,7 +1970,7 @@ export default class RoomView extends React.Component { if (this.state.searchResults) { // show searching spinner - if (this.state.searchResults.results === undefined) { + if (this.state.searchResults.count === undefined) { searchResultsPanel = (
    ); @@ -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/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 81d25e6a0c..64ee94628e 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -257,7 +257,7 @@ export default class UserMenu extends React.Component { const signupLink = getHostingLink("user-context-menu"); if (signupLink) { hostingLink = ( -
    +
    {_t( "Upgrade to your own domain", {}, { @@ -452,7 +452,8 @@ export default class UserMenu extends React.Component { public render() { const avatarSize = 32; // should match border-radius of the avatar - const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); + const userId = MatrixClientPeg.get().getUserId(); + const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); @@ -507,7 +508,7 @@ export default class UserMenu extends React.Component {
    , "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; + if (!props.room) return true; // user widgets always have permissions + + 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 +77,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 +117,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 +138,8 @@ export default class AppTile extends React.Component { if (this._sgWidget) { this._sgWidget.stop(); } + + SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef); } _resetWidget(newProps) { @@ -167,21 +171,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 +183,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 +212,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 +222,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 +232,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 +250,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 +260,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 +279,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 +303,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 +316,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 +330,67 @@ 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) { + // only possible for room widgets, can assert this.props.room here + 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 } -