Merge pull request #5266 from matrix-org/t3chguy/feat/widgets

Pinned widgets work
This commit is contained in:
Michael Telatynski 2020-10-21 12:41:20 +01:00 committed by GitHub
commit fb3b5d2f5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1122 additions and 930 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="14" y="2" width="8" height="8" rx="2" fill="#0DBD8B"/>
<rect x="14" y="14" width="8" height="8" rx="2" fill="#0DBD8B"/>
<rect x="2" y="14" width="8" height="8" rx="2" fill="#0DBD8B"/>
<rect x="2" y="2" width="8" height="8" rx="2" fill="#0DBD8B"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View file

@ -1,11 +1,21 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" fill="url(#paint0_linear)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3V9.5H0.00390625L0.00390625 10.5H2V17H0.00390625L0.00390625 18H2V20H3V18H9.5039V20.0005H10.5039V18H17V20H18V18H20.0039V17H18V10.5H20.0039V9.5H18V3H20.0039V2H18V0L17 0V2H10.5039V0.000488281L9.5039 0.000488281V2H3V0L2 0V2H0.00390625L0.00390625 3H2ZM17 3H10.5039V9.5H17V3ZM17 10.5H10.5039V17H17V10.5ZM9.5039 10.5V17H3V10.5H9.5039ZM9.5039 3V9.5H3V3H9.5039Z" fill="white" fill-opacity="0.3" style="mix-blend-mode:lighten"/>
<circle opacity="0.8" cx="10.0039" cy="10" r="7.5" stroke="white"/>
<defs>
<linearGradient id="paint0_linear" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#60A6FF"/>
<stop offset="1" stop-color="#418DED"/>
</linearGradient>
</defs>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<rect width="20" height="20" rx="4" fill="url(#paint0_linear)"/>
<path d="M2.49609 0V20" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<path d="M20 2.5L1.60531e-06 2.5" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<path d="M20 10L1.60531e-06 10" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<path d="M20 17.5H1.60531e-06" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<path d="M10 0.000488281V20.0005" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<path d="M17.4961 0V20" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<circle opacity="0.8" cx="10" cy="10" r="7.5" stroke="white"/>
</g>
<defs>
<linearGradient id="paint0_linear" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#60A6FF"/>
<stop offset="1" stop-color="#418DED"/>
</linearGradient>
<clipPath id="clip0">
<rect width="20" height="20.0005" rx="4" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 900 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,6 +1,6 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.99461" y="1.00002" width="18" height="18" rx="2" fill="white" stroke="#FF4B55" stroke-width="2"/>
<rect x="2.96777" y="2" width="16.9843" height="5" fill="#FF4B55"/>
<rect x="4.96533" y="9" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/>
<rect x="11.9585" y="13.0005" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FF4B55"/>
<path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/>
<rect x="3.96826" y="8.99951" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/>
<rect x="10.9614" y="13" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/>
</svg>

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 430 B

View file

@ -1,5 +1,5 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.49609" y="0.500488" width="19" height="19" rx="3.5" fill="#17191C" stroke="#17191C"/>
<path d="M18.9961 10.0005C18.9961 14.4188 15.4144 18.0005 10.9961 18.0005C6.57782 18.0005 2.99609 14.4188 2.99609 10.0005C2.99609 5.58221 6.57782 2.00049 10.9961 2.00049C15.4144 2.00049 18.9961 5.58221 18.9961 10.0005Z" fill="white"/>
<path d="M10.9961 6.00049V9.81299L13.4961 11.5005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="18" height="18" rx="3" fill="#17191C" stroke="#17191C" stroke-width="2"/>
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10Z" fill="white"/>
<path d="M10 6V9.8125L12.5 11.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 469 B

View file

@ -1,4 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="-0.000976562" y="0.000488281" width="20" height="20" rx="4" fill="#FCC639"/>
<path d="M1.99902 7.00049H17.999V16.5005C17.999 17.3289 17.3274 18.0005 16.499 18.0005H3.49902C2.6706 18.0005 1.99902 17.3289 1.99902 16.5005V7.00049Z" fill="white"/>
<rect width="20" height="20" rx="4" fill="#FCC639"/>
<path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 364 B

After

Width:  |  Height:  |  Size: 259 B

View file

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#5ABFF2"/>
<path d="M3 7.875C3 6.83947 3.83947 6 4.875 6H11.1875C12.223 6 13.0625 6.83947 13.0625 7.875V12.875C13.0625 13.9105 12.223 14.75 11.1875 14.75H4.875C3.83947 14.75 3 13.9105 3 12.875V7.875Z" fill="white"/>
<path d="M14.375 8.44644L16.1208 7.11039C16.4806 6.83502 17 7.09158 17 7.54468V13.0396C17 13.5199 16.4251 13.7669 16.0767 13.4363L14.375 11.8214V8.44644Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View file

@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 6C2 3.79086 3.79086 2 6 2H18C20.2091 2 22 3.79086 22 6V18C22 20.2091 20.2091 22 18 22H6C3.79086 22 2 20.2091 2 18V6ZM11 8C11 9.65685 9.65685 11 8 11C6.34315 11 5 9.65685 5 8C5 6.34315 6.34315 5 8 5C9.65685 5 11 6.34315 11 8ZM8 19C9.65685 19 11 17.6569 11 16C11 14.3431 9.65685 13 8 13C6.34315 13 5 14.3431 5 16C5 17.6569 6.34315 19 8 19ZM19 16C19 17.6569 17.6569 19 16 19C14.3431 19 13 17.6569 13 16C13 14.3431 14.3431 13 16 13C17.6569 13 19 14.3431 19 16ZM16 11C17.6569 11 19 9.65685 19 8C19 6.34315 17.6569 5 16 5C14.3431 5 13 6.34315 13 8C13 9.65685 14.3431 11 16 11Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 742 B

View file

@ -1,5 +0,0 @@
<svg width="3" height="15" viewBox="0 0 3 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 3C2.32843 3 3 2.32843 3 1.5C3 0.671573 2.32843 0 1.5 0C0.671573 0 0 0.671573 0 1.5C0 2.32843 0.671573 3 1.5 3Z" fill="#9FA9BA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 9C2.32843 9 3 8.32843 3 7.5C3 6.67157 2.32843 6 1.5 6C0.671573 6 0 6.67157 0 7.5C0 8.32843 0.671573 9 1.5 9Z" fill="#9FA9BA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 15C2.32843 15 3 14.3284 3 13.5C3 12.6716 2.32843 12 1.5 12C0.671573 12 0 12.6716 0 13.5C0 14.3284 0.671573 15 1.5 15Z" fill="#9FA9BA"/>
</svg>

Before

Width:  |  Height:  |  Size: 655 B

View file

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

View file

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

View file

@ -208,6 +208,7 @@ $panel-divider-color: #dee1f3;
// ********************
$widget-menu-bar-bg-color: $secondary-accent-color;
$widget-body-bg-color: #fff;
// ********************

View file

@ -208,6 +208,7 @@ $pinned-color: $notice-secondary-color;
// ********************
$widget-menu-bar-bg-color: $secondary-accent-color;
$widget-body-bg-color: #FFF;
// ********************

View file

@ -416,8 +416,9 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
return menuOptions;
};
export const useContextMenu = (): [boolean, RefObject<HTMLElement>, () => void, () => void, (val: boolean) => void] => {
const button = useRef<HTMLElement>(null);
type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val: boolean) => void];
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
setIsOpen(true);

View file

@ -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<IProps, IState> {
@ -250,7 +253,9 @@ export default class RoomView extends React.Component<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
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}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className="mx_RoomView_body">

View file

@ -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<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp;
}
const WidgetAvatar: React.FC<IProps> = ({ 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 (
<BaseAvatar
{...props}
name={app.id}
className={classNames("mx_WidgetAvatar", className)}
// MSC2765
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
urls={iconUrls}
width={width}
height={height}
/>
)
};
export default WidgetAvatar;

View file

@ -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(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
{_t("Edit")}
</MenuItem>,
);
}
if (this.props.onUnpinClicked) {
options.push(
<MenuItem className="mx_WidgetContextMenu_option" onClick={this.onUnpinClicked} key="unpin">
{_t("Unpin")}
</MenuItem>,
);
}
if (this.props.onReloadClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
{_t("Reload")}
</MenuItem>,
);
}
if (this.props.onSnapshotClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
{_t("Take picture")}
</MenuItem>,
);
}
if (this.props.onDeleteClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
{_t("Remove for everyone")}
</MenuItem>,
);
}
// Push this last so it appears last. It's always present.
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
{_t("Remove for me")}
</MenuItem>,
);
// Put separators between the options
if (options.length > 1) {
const length = options.length;
for (let i = 0; i < length - 1; i++) {
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
// 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 <div className="mx_WidgetContextMenu">{options}</div>;
}
}

View file

@ -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<typeof IconizedContextMenu> {
app: IApp;
userWidget?: boolean;
showUnpin?: boolean;
// override delete handler
onDeleteClick?(): void;
}
const WidgetContextMenu: React.FC<IProps> = ({
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 = <IconizedContextMenuOption onClick={onUnpinClick} label={_t("Unpin")} />;
}
let editButton;
if (canModify && WidgetUtils.isManagedByManager(app)) {
const onEditClick = () => {
WidgetUtils.editWidget(room, app);
onFinished();
};
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
}
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 = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
}
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 = <IconizedContextMenuOption
onClick={onDeleteClick || onDeleteClickDefault}
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
/>;
}
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 = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
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 = <IconizedContextMenuOption onClick={onClick} label={_t("Move left")} />;
}
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(app.id, 1);
onFinished();
};
moveRightButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move right")} />;
}
return <IconizedContextMenu {...props} chevronFace={ChevronFace.None} onFinished={onFinished}>
<IconizedContextMenuOptionList>
{ editButton }
{ revokeButton }
{ deleteButton }
{ snapshotButton }
{ moveLeftButton }
{ moveRightButton }
{ unpinButton }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
export default WidgetContextMenu;

View file

@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
tooltip?: React.ReactNode;
tooltipClassName?: string;
forceHide?: boolean;
yOffset?: number;
}
interface IState {
@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {title, tooltip, children, tooltipClassName, forceHide, ...props} = this.props;
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title}
yOffset={yOffset}
/> : <div />;
return (
<AccessibleButton

View file

@ -22,56 +22,53 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import Spinner from './Spinner';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement";
import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement, {getPersistKey} from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType";
import {SettingLevel} from "../../../settings/SettingLevel";
import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
import {MatrixCapabilities} from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
export default class AppTile extends React.Component {
constructor(props) {
super(props);
// The key used for PersistedElement
this._persistKey = 'widget_' + this.props.app.id;
this._persistKey = getPersistKey(this.props.app.id);
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props);
this._onAction = this._onAction.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onRevokeClicked = this._onRevokeClicked.bind(this);
this._onSnapshotClick = this._onSnapshotClick.bind(this);
this.onClickMenuBar = this.onClickMenuBar.bind(this);
this._onMinimiseClick = this._onMinimiseClick.bind(this);
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef();
this._menu_bar = createRef();
this._allowedWidgetsWatchRef = SettingsStore.watchSetting("allowedWidgets", null, this.onAllowedWidgetsChange);
}
// This is a function to make the impact of calling SettingsStore slightly less
hasPermissionToLoad = (props) => {
if (this._usingLocalWidget()) return true;
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
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 (
<span>
<WidgetAvatar app={this.props.app} />
<b>{ name }</b>
<span>{ title ? titleSpacer : '' }{ title }</span>
</span>
);
}
_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 <div />;
}
// 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 = (
<div className="mx_AppLoading_spinner_fadeIn">
<Spinner message={_t("Loading...")} />
const loadingElement = (
<div className="mx_AppLoading_spinner_fadeIn">
<Spinner message={_t("Loading...")} />
</div>
);
if (!this.state.hasPermissionToLoad) {
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
<div className={appTileBodyClass}>
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
);
if (!this.state.hasPermissionToLoad) {
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
} else if (this.state.initialising) {
appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
</div>
);
} else {
if (this.isMixedContent()) {
appTileBody = (
<div className={appTileBodyClass}>
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
);
} else if (this.state.initialising) {
appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
<AppWarning errorMsg="Error - Mixed content" />
</div>
);
} else {
if (this.isMixedContent()) {
appTileBody = (
<div className={appTileBodyClass}>
<AppWarning errorMsg="Error - Mixed content" />
</div>
);
} else {
appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
allowFullScreen={true}
sandbox={sandboxFlags}
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be
// re-mounted later when we do.
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
{appTileBody}
</PersistedElement>
</div>;
}
appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement }
<iframe
allow={iframeFeatures}
ref={this._iframeRefChange}
src={this._sgWidget.embedUrl}
allowFullScreen={true}
sandbox={sandboxFlags}
/>
</div>
);
// if the widget would be allowed to remain on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be
// re-mounted later when we do.
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Also wrap the PersistedElement in a div to fix the height, otherwise
// AppTile's border is in the wrong place
appTileBody = <div className="mx_AppTile_persistedWrapper">
<PersistedElement persistKey={this._persistKey}>
{appTileBody}
</PersistedElement>
</div>;
}
}
}
const showMinimiseButton = this.props.showMinimise && this.props.show;
const showMaximiseButton = this.props.showMinimise && !this.props.show;
let appTileClasses;
if (this.props.miniMode) {
appTileClasses = {mx_AppTile_mini: true};
@ -560,73 +397,37 @@ export default class AppTile extends React.Component {
} else {
appTileClasses = {mx_AppTile: true};
}
appTileClasses.mx_AppTile_minimised = !this.props.show;
appTileClasses = classNames(appTileClasses);
const menuBarClasses = classNames({
mx_AppTileMenuBar: true,
mx_AppTileMenuBar_expanded: this.props.show,
});
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}>
<WidgetContextMenu
onUnpinClicked={
ActiveWidgetStore.getWidgetPersistence(this.props.app.id) ? null : this._onUnpinClicked
}
onRevokeClicked={this._onRevokeClicked}
onEditClicked={showEditButton ? this._onEditClick : undefined}
onDeleteClicked={showDeleteButton ? this._onDeleteClick : undefined}
onSnapshotClicked={showPictureSnapshotButton ? this._onSnapshotClick : undefined}
onReloadClicked={this.props.showReload ? this._onReloadWidgetClick : undefined}
onFinished={this._closeContextMenu}
/>
</ContextMenu>
<RoomWidgetContextMenu
{...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
app={this.props.app}
onFinished={this._closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
/>
);
}
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
{ /* Minimise widget */ }
{ showMinimiseButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_minimise"
title={_t('Minimize widget')}
onClick={this._onMinimiseClick}
/> }
{ /* Maximise widget */ }
{ showMaximiseButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
title={_t('Maximize widget')}
onClick={this._onMinimiseClick}
/> }
{ /* Title */ }
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ /* Popout widget */ }
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
{ /* Context menu */ }
{ <ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t('More options')}
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
@ -657,8 +458,6 @@ AppTile.propTypes = {
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool,
// Should the AppTile render itself
show: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour)
@ -667,19 +466,10 @@ AppTile.propTypes = {
onMinimiseClick: PropTypes.func,
// Optionally hide the tile title
showTitle: PropTypes.bool,
// Optionally hide the tile minimise icon
showMinimise: PropTypes.bool,
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the delete icon
showDelete: PropTypes.bool,
// Optionally hide the popout widget icon
showPopout: PropTypes.bool,
// Optionally show the reload widget icon
// This is not currently intended for use with production widgets. However
// it can be useful when developing persistent widgets in order to avoid
// having to reload all of Element to get new widget content.
showReload: PropTypes.bool,
// Widget capabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
@ -692,10 +482,7 @@ AppTile.defaultProps = {
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showMinimise: true,
showDelete: true,
showPopout: true,
showReload: false,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,

View file

@ -173,3 +173,5 @@ export default class PersistedElement extends React.Component {
return <div ref={this.collectChildContainer} />;
}
}
export const getPersistKey = (appId: string) => 'widget_' + appId;

View file

@ -79,13 +79,10 @@ export default class PersistentApp extends React.Component {
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
show={true}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
showDelete={false}
showMinimise={false}
miniMode={true}
showMenubar={false}
/>;

View file

@ -36,6 +36,7 @@ interface IProps {
// the react element to put into the tooltip
label: React.ReactNode;
forceOnRight?: boolean;
yOffset?: number;
}
export default class Tooltip extends React.Component<IProps> {
@ -46,6 +47,7 @@ export default class Tooltip extends React.Component<IProps> {
public static readonly defaultProps = {
visible: true,
yOffset: 0,
};
// Create a wrapper for the tooltip outside the parent and attach it to the body element
@ -82,9 +84,9 @@ export default class Tooltip extends React.Component<IProps> {
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
}
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8;
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
} else {
style.left = parentBox.right + window.pageXOffset + 6;
}

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, {useCallback, useState, useEffect, useContext} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useIsEncrypted } from '../../../hooks/useIsEncrypted';
@ -37,12 +36,14 @@ import WidgetUtils from "../../../utils/WidgetUtils";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip";
import BaseAvatar from "../avatars/BaseAvatar";
import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore";
import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature";
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
interface IProps {
room: Room;
@ -68,11 +69,11 @@ const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => {
};
export const useWidgets = (room: Room) => {
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room));
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room.roomId));
const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings
setApps([...WidgetStore.instance.getApps(room)]);
setApps([...WidgetStore.instance.getApps(room.roomId)]);
}, [room]);
useEffect(updateApps, [room]);
@ -82,8 +83,92 @@ export const useWidgets = (room: Room) => {
return apps;
};
interface IAppRowProps {
app: IApp;
}
const AppRow: React.FC<IAppRowProps> = ({ app }) => {
const name = WidgetUtils.getWidgetName(app);
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
const subtitle = dataTitle && " - " + dataTitle;
const onOpenWidgetClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.Widget,
refireParams: {
widgetId: app.id,
},
});
};
const isPinned = WidgetStore.instance.isPinned(app.id);
const togglePin = isPinned
? () => { WidgetStore.instance.unpinWidget(app.id); }
: () => { WidgetStore.instance.pinWidget(app.id); };
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <WidgetContextMenu
chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right}
bottom={window.innerHeight - rect.top}
onFinished={closeMenu}
app={app}
/>;
}
const cannotPin = !isPinned && !WidgetStore.instance.canPin(app.id);
let pinTitle: string;
if (cannotPin) {
pinTitle = _t("You can only pin up to %(count)s widgets", { count: MAX_PINNED });
} else {
pinTitle = isPinned ? _t("Unpin") : _t("Pin");
}
const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", {
mx_RoomSummaryCard_Button_pinned: isPinned,
});
return <div className={classes} ref={handle}>
<AccessibleTooltipButton
className="mx_RoomSummaryCard_icon_app"
onClick={onOpenWidgetClick}
// only show a tooltip if the widget is pinned
title={isPinned ? _t("Unpin a widget to view it in this panel") : ""}
forceHide={!isPinned}
disabled={isPinned}
yOffset={-48}
>
<WidgetAvatar app={app} />
<span>{name}</span>
{ subtitle }
</AccessibleTooltipButton>
<ContextMenuTooltipButton
className="mx_RoomSummaryCard_app_options"
isExpanded={menuDisplayed}
onClick={openMenu}
title={_t("Options")}
yOffset={-24}
/>
<AccessibleTooltipButton
className="mx_RoomSummaryCard_app_pinToggle"
onClick={togglePin}
title={pinTitle}
disabled={cannotPin}
yOffset={-24}
/>
{ contextMenu }
</div>;
};
const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
const cli = useContext(MatrixClientContext);
const apps = useWidgets(room);
const onManageIntegrations = () => {
@ -100,65 +185,7 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
};
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => {
const name = WidgetUtils.getWidgetName(app);
const dataTitle = WidgetUtils.getWidgetDataTitle(app);
const subtitle = dataTitle && " - " + dataTitle;
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("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")];
}
if (app.avatar_url) { // MSC2765
iconUrls.unshift(getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop"));
}
const isPinned = WidgetStore.instance.isPinned(app.id);
const classes = classNames("mx_RoomSummaryCard_icon_app", {
mx_RoomSummaryCard_icon_app_pinned: isPinned,
});
if (isPinned) {
const onClick = () => {
WidgetStore.instance.unpinWidget(app.id);
};
return <AccessibleTooltipButton
key={app.id}
className={classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", classes)}
onClick={onClick}
title={_t("Unpin app")}
>
<BaseAvatar name={app.id} urls={iconUrls} width={20} height={20} />
<span>{name}</span>
{ subtitle }
</AccessibleTooltipButton>
}
const onOpenWidgetClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.Widget,
refireParams: {
widgetId: app.id,
},
});
};
return (
<Button key={app.id} className={classes} onClick={onOpenWidgetClick}>
<BaseAvatar name={app.id} urls={iconUrls} width={20} height={20} />
<span>{name}</span>
{ subtitle }
</Button>
);
}) }
{ apps.map(app => <AppRow key={app.id} app={app} />) }
<AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }

View file

@ -20,7 +20,6 @@ import {Room} from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils";
import AccessibleButton from "../elements/AccessibleButton";
import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard";
@ -30,16 +29,7 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import classNames from "classnames";
import dis from "../../../dispatcher/dispatcher";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { MatrixCapabilities } from "matrix-widget-api";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
interface IProps {
room: Room;
@ -69,111 +59,22 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
// Don't render anything as we are about to transition
if (!app || isPinned) return null;
const header = <React.Fragment>
<h2>{ WidgetUtils.getWidgetName(app) }</h2>
</React.Fragment>;
const canModify = WidgetUtils.canUserModifyWidgets(room.roomId);
let contextMenu;
if (menuDisplayed) {
let snapshotButton;
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
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);
});
closeMenu();
};
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
}
let deleteButton;
if (canModify) {
const onDeleteClick = () => {
defaultDispatcher.dispatch<AppTileActionPayload>({
action: Action.AppTileDelete,
widgetId: app.id,
});
closeMenu();
};
deleteButton = <IconizedContextMenuOption onClick={onDeleteClick} label={_t("Remove for everyone")} />;
}
const onRevokeClick = () => {
defaultDispatcher.dispatch<AppTileActionPayload>({
action: Action.AppTileRevoke,
widgetId: app.id,
});
closeMenu();
};
const rect = handle.current.getBoundingClientRect();
contextMenu = (
<IconizedContextMenu
<WidgetContextMenu
chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right}
bottom={window.innerHeight - rect.top}
right={window.innerWidth - rect.right - 12}
top={rect.bottom + 12}
onFinished={closeMenu}
>
<IconizedContextMenuOptionList>
{ snapshotButton }
{ deleteButton }
<IconizedContextMenuOption onClick={onRevokeClick} label={_t("Remove for me")} />
</IconizedContextMenuOptionList>
</IconizedContextMenu>
app={app}
/>
);
}
const onPinClick = () => {
WidgetStore.instance.pinWidget(app.id);
};
const onEditClick = () => {
WidgetUtils.editWidget(room, app);
};
let editButton;
if (canModify) {
editButton = <AccessibleButton kind="secondary" onClick={onEditClick}>
{ _t("Edit") }
</AccessibleButton>;
}
const pinButtonClasses = canModify ? "" : "mx_WidgetCard_widePinButton";
let pinButton;
if (WidgetStore.instance.canPin(app.id)) {
pinButton = <AccessibleButton
kind="secondary"
onClick={onPinClick}
className={pinButtonClasses}
>
{ _t("Pin to room") }
</AccessibleButton>;
} else {
pinButton = <AccessibleTooltipButton
title={_t("You can only pin 2 widgets at a time")}
tooltipClassName="mx_WidgetCard_maxPinnedTooltip"
kind="secondary"
className={pinButtonClasses}
disabled
>
{ _t("Pin to room") }
</AccessibleTooltipButton>;
}
const footer = <React.Fragment>
{ editButton }
{ pinButton }
const header = <React.Fragment>
<h2>{ WidgetUtils.getWidgetName(app) }</h2>
<ContextMenuButton
kind="secondary"
className="mx_WidgetCard_optionsButton"
@ -182,16 +83,12 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
isExpanded={menuDisplayed}
label={_t("Options")}
/>
{ contextMenu }
</React.Fragment>;
return <BaseCard
header={header}
footer={footer}
className={classNames("mx_WidgetCard", {
mx_WidgetCard_noEdit: !canModify,
})}
className="mx_WidgetCard"
onClose={onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {Resizable} from "re-resizable";
@ -24,15 +24,16 @@ import AppTile from '../elements/AppTile';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import AccessibleButton from '../elements/AccessibleButton';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import {useLocalStorageState} from "../../../hooks/useLocalStorageState";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import WidgetStore from "../../../stores/WidgetStore";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
export default class AppsDrawer extends React.Component {
static propTypes = {
@ -52,6 +53,11 @@ export default class AppsDrawer extends React.Component {
this.state = {
apps: this._getApps(),
};
this._resizeContainer = null;
this.resizer = this._createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
}
componentDidMount() {
@ -64,6 +70,10 @@ export default class AppsDrawer extends React.Component {
ScalarMessaging.stopListening();
WidgetStore.instance.off(this.props.room.roomId, this._updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
if (this._resizeContainer) {
this.resizer.detach();
}
this.props.resizeNotifier.off("isResizing", this.onIsResizing);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -73,6 +83,95 @@ export default class AppsDrawer extends React.Component {
this._updateApps();
}
onIsResizing = (resizing) => {
this.setState({ resizing });
if (!resizing) {
this._relaxResizer();
}
};
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
const collapseConfig = {
onResizeStart: () => {
this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
},
onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
// persist to localStorage
localStorage.setItem(this._getStorageKey(), JSON.stringify([
this.state.apps.map(app => app.id),
...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
]));
},
};
// pass a truthy container for now, we won't call attach until we update it
const resizer = new Resizer({}, PercentageDistributor, collapseConfig);
resizer.setClassNames(classNames);
return resizer;
}
_collectResizer = (ref) => {
if (this._resizeContainer) {
this.resizer.detach();
}
if (ref) {
this.resizer.container = ref;
this.resizer.attach();
}
this._resizeContainer = ref;
this._loadResizerPreferences();
};
_getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`;
_getAppsHash = (apps) => apps.map(app => app.id).join("~");
componentDidUpdate(prevProps, prevState) {
if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
this._loadResizerPreferences();
}
}
_relaxResizer = () => {
const distributors = this.resizer.getDistributors();
// relax all items if they had any overconstrained flexboxes
distributors.forEach(d => d.start());
distributors.forEach(d => d.finish());
};
_loadResizerPreferences = () => {
try {
const [[...lastIds], ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey()));
// Every app was included in the last split, reuse the last sizes
if (this.state.apps.length <= lastIds.length && this.state.apps.every((app, i) => lastIds[i] === app.id)) {
sizes.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
if (distributor) {
distributor.size = size;
distributor.finish();
}
});
return;
}
} catch (e) {
// this is expected
}
if (this.state.apps) {
const distributors = this.resizer.getDistributors();
distributors.forEach(d => d.item.clearSize());
distributors.forEach(d => d.start());
distributors.forEach(d => d.finish());
}
};
onAction = (action) => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
@ -91,7 +190,7 @@ export default class AppsDrawer extends React.Component {
}
};
_getApps = () => WidgetStore.instance.getApps(this.props.room, true);
_getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
_updateApps = () => {
this.setState({
@ -99,15 +198,6 @@ export default class AppsDrawer extends React.Component {
});
};
_canUserModify() {
try {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
} catch (err) {
console.error(err);
return false;
}
}
_launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll();
@ -116,12 +206,9 @@ export default class AppsDrawer extends React.Component {
}
}
onClickAddWidget = (e) => {
e.preventDefault();
this._launchManageIntegrations();
};
render() {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
@ -131,7 +218,6 @@ export default class AppsDrawer extends React.Component {
fullWidth={arr.length < 2}
room={this.props.room}
userId={this.props.userId}
show={this.props.showApps}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
@ -143,21 +229,6 @@ export default class AppsDrawer extends React.Component {
return <div />;
}
let addWidget;
if (this.props.showApps &&
this._canUserModify()
) {
addWidget = <AccessibleButton
onClick={this.onClickAddWidget}
className={this.state.apps.length<2 ?
'mx_AddWidget_button mx_AddWidget_button_full_width' :
'mx_AddWidget_button'
}
title={_t('Add a widget')}>
[+] { _t('Add a widget') }
</AccessibleButton>;
}
let spinner;
if (
apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets(
@ -170,9 +241,11 @@ export default class AppsDrawer extends React.Component {
}
const classes = classNames({
"mx_AppsDrawer": true,
"mx_AppsDrawer_fullWidth": apps.length < 2,
"mx_AppsDrawer_minimised": !this.props.showApps,
mx_AppsDrawer: true,
mx_AppsDrawer_fullWidth: apps.length < 2,
mx_AppsDrawer_resizing: this.state.resizing,
mx_AppsDrawer_2apps: apps.length === 2,
mx_AppsDrawer_3apps: apps.length === 3,
});
return (
@ -182,13 +255,20 @@ export default class AppsDrawer extends React.Component {
minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle"
className="mx_AppsContainer"
className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier}
>
{ apps }
{ spinner }
<div className="mx_AppsContainer" ref={this._collectResizer}>
{ apps.map((app, i) => {
if (i < 1) return app;
return <React.Fragment key={app.key}>
<ResizeHandle reverse={i > apps.length / 2} />
{ app }
</React.Fragment>;
}) }
</div>
</PersistentVResizer>
{ this._canUserModify() && addWidget }
{ spinner }
</div>
);
}
@ -205,14 +285,12 @@ const PersistentVResizer = ({
children,
}) => {
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
const [resizing, setResizing] = useState(false);
return <Resizable
size={{height: Math.min(height, maxHeight)}}
minHeight={minHeight}
maxHeight={maxHeight}
onResizeStart={() => {
if (!resizing) setResizing(true);
resizeNotifier.startResizing();
}}
onResize={() => {
@ -220,14 +298,11 @@ const PersistentVResizer = ({
}}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
if (resizing) setResizing(false);
resizeNotifier.stopResizing();
}}
handleWrapperClass={handleWrapperClass}
handleClasses={{bottom: handleClass}}
className={classNames(className, {
mx_AppsDrawer_resizing: resizing,
})}
className={className}
enable={{bottom: true}}
>
{ children }

View file

@ -42,6 +42,8 @@ export default class RoomHeader extends React.Component {
onLeaveClick: PropTypes.func,
onCancelClick: PropTypes.func,
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
};
static defaultProps = {
@ -230,6 +232,17 @@ export default class RoomHeader extends React.Component {
title={_t("Forget room")} />;
}
let appsButton;
if (this.props.onAppsClick) {
appsButton =
<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")} />;
}
let searchButton;
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
@ -243,6 +256,7 @@ export default class RoomHeader extends React.Component {
<div className="mx_RoomHeader_buttons">
{ pinnedEventsButton }
{ forgetButton }
{ appsButton }
{ searchButton }
</div>;

View file

@ -272,13 +272,10 @@ export default class Stickerpicker extends React.Component {
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
show={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showMinimise={true}
showDelete={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}

View file

@ -94,14 +94,4 @@ export enum Action {
* Trigged after the phase of the right panel is set. Should be used with AfterRightPanelPhaseChangePayload.
*/
AfterRightPanelPhaseChange = "after_right_panel_phase_change",
/**
* Requests that the AppTile deletes the widget. Should be used with the AppTileActionPayload.
*/
AppTileDelete = "appTile_delete",
/**
* Requests that the AppTile revokes the widget. Should be used with the AppTileActionPayload.
*/
AppTileRevoke = "appTile_revoke",
}

View file

@ -1031,7 +1031,6 @@
"Remove %(phone)s?": "Remove %(phone)s?",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"Phone Number": "Phone Number",
"Add a widget": "Add a widget",
"Drop File Here": "Drop File Here",
"Drop file here to upload": "Drop file here to upload",
"This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
@ -1113,6 +1112,8 @@
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
"Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Invites": "Invites",
"Favourites": "Favourites",
@ -1278,8 +1279,11 @@
"Yours, or the other users session": "Yours, or the other users session",
"Members": "Members",
"Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin": "Unpin",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Widgets": "Widgets",
"Unpin app": "Unpin app",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
"Add widgets, bridges & bots": "Add widgets, bridges & bots",
"Not encrypted": "Not encrypted",
@ -1302,7 +1306,6 @@
"Invite": "Invite",
"Share Link to User": "Share Link to User",
"Direct message": "Direct message",
"Options": "Options",
"Demote yourself?": "Demote yourself?",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
"Demote": "Demote",
@ -1366,12 +1369,6 @@
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji",
"Take a picture": "Take a picture",
"Remove for everyone": "Remove for everyone",
"Remove for me": "Remove for me",
"Edit": "Edit",
"Pin to room": "Pin to room",
"You can only pin 2 widgets at a time": "You can only pin 2 widgets at a time",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -1390,6 +1387,7 @@
"Error decrypting audio": "Error decrypting audio",
"React": "React",
"Reply": "Reply",
"Edit": "Edit",
"Message Actions": "Message Actions",
"Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment",
@ -1482,15 +1480,7 @@
"Widgets do not use message encryption.": "Widgets do not use message encryption.",
"Widget added by": "Widget added by",
"This widget may use cookies.": "This widget may use cookies.",
"Delete Widget": "Delete Widget",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Failed to remove widget": "Failed to remove widget",
"An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room",
"Minimize widget": "Minimize widget",
"Maximize widget": "Maximize widget",
"Popout widget": "Popout widget",
"More options": "More options",
"Use the <a>Desktop app</a> to see all encrypted files": "Use the <a>Desktop app</a> to see all encrypted files",
"Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
@ -1923,9 +1913,14 @@
"Set status": "Set status",
"Set a new status...": "Set a new status...",
"View Community": "View Community",
"Unpin": "Unpin",
"Reload": "Reload",
"Take picture": "Take picture",
"Take a picture": "Take a picture",
"Delete Widget": "Delete Widget",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Remove for everyone": "Remove for everyone",
"Revoke permissions": "Revoke permissions",
"Move left": "Move left",
"Move right": "Move right",
"This room is public": "This room is public",
"Away": "Away",
"User Status": "User Status",

View file

@ -43,6 +43,14 @@ export default class FixedDistributor<C extends IConfig, I extends ResizeItem<an
this.beforeOffset = item.offset();
}
public get size() {
return this.item.getSize();
}
public set size(size: string) {
this.item.setRawSize(size);
}
public resize(size: number) {
this.item.setSize(size);
}
@ -51,7 +59,11 @@ export default class FixedDistributor<C extends IConfig, I extends ResizeItem<an
this.resize(offset - this.beforeOffset);
}
public start() {}
public start() {
this.item.start();
}
public finish() {}
public finish() {
this.item.finish();
}
}

View file

@ -0,0 +1,49 @@
/*
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 Sizer from "../sizer";
import FixedDistributor from "./fixed";
import {IConfig} from "../resizer";
class PercentageSizer extends Sizer {
public start(item: HTMLElement) {
if (this.vertical) {
item.style.minHeight = null;
} else {
item.style.minWidth = null;
}
}
public finish(item: HTMLElement) {
const parent = item.offsetParent as HTMLElement;
if (!parent) return;
if (this.vertical) {
const p = ((item.offsetHeight / parent.offsetHeight) * 100).toFixed(2) + "%";
item.style.minHeight = p;
item.style.height = p;
} else {
const p = ((item.offsetWidth / parent.offsetWidth) * 100).toFixed(2) + "%";
item.style.minWidth = p;
item.style.width = p;
}
}
}
export default class PercentageDistributor extends FixedDistributor<IConfig> {
static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean) {
return new PercentageSizer(containerElement, vertical, reverse);
}
}

View file

@ -15,5 +15,6 @@ limitations under the License.
*/
export {default as FixedDistributor} from "./distributors/fixed";
export {default as PercentageDistributor} from "./distributors/percentage";
export {default as CollapseDistributor} from "./distributors/collapse";
export {default as Resizer} from "./resizer";

View file

@ -39,9 +39,7 @@ export default class ResizeItem<C extends IConfig = IConfig> {
private advance(forwards: boolean) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ?
this.domNode.previousElementSibling :
this.domNode.nextElementSibling;
let handle = this.reverse ? this.domNode.previousElementSibling : this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
@ -75,8 +73,24 @@ export default class ResizeItem<C extends IConfig = IConfig> {
return this.sizer.getItemOffset(this.domNode);
}
public setSize(size: number) {
public start() {
this.sizer.start(this.domNode);
}
public finish() {
this.sizer.finish(this.domNode);
}
public getSize() {
return this.sizer.getDesiredItemSize(this.domNode);
}
public setRawSize(size: string) {
this.sizer.setItemSize(this.domNode, size);
}
public setSize(size: number) {
this.setRawSize(`${Math.round(size)}px`);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {throttle} from "lodash";
import FixedDistributor from "./distributors/fixed";
import ResizeItem from "./item";
import Sizer from "./sizer";
@ -43,7 +45,7 @@ export default class Resizer<C extends IConfig = IConfig> {
constructor(
public container: HTMLElement,
private readonly distributorCtor: {
new(item: ResizeItem): FixedDistributor<C>;
new(item: ResizeItem): FixedDistributor<C, any>;
createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem;
createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer;
},
@ -67,10 +69,12 @@ export default class Resizer<C extends IConfig = IConfig> {
public attach() {
this.container.addEventListener("mousedown", this.onMouseDown, false);
window.addEventListener("resize", this.onResize);
}
public detach() {
this.container.removeEventListener("mousedown", this.onMouseDown, false);
window.removeEventListener("resize", this.onResize);
}
/**
@ -97,11 +101,11 @@ export default class Resizer<C extends IConfig = IConfig> {
}
}
public isReverseResizeHandle(el: HTMLElement) {
public isReverseResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.reverse);
}
public isResizeHandle(el: HTMLElement) {
public isResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.handle);
}
@ -136,10 +140,10 @@ export default class Resizer<C extends IConfig = IConfig> {
if (this.classNames.resizing) {
this.container.classList.remove(this.classNames.resizing);
}
distributor.finish();
if (this.config.onResizeStop) {
this.config.onResizeStop();
}
distributor.finish();
body.removeEventListener("mouseup", finishResize, false);
document.removeEventListener("mouseleave", finishResize, false);
body.removeEventListener("mousemove", onMouseMove, false);
@ -149,7 +153,24 @@ export default class Resizer<C extends IConfig = IConfig> {
body.addEventListener("mousemove", onMouseMove, false);
};
private createSizerAndDistributor(resizeHandle: HTMLDivElement) {
private onResize = throttle(() => {
const distributors = this.getDistributors();
// relax all items if they had any overconstrained flexboxes
distributors.forEach(d => d.start());
distributors.forEach(d => d.finish());
}, 100, {trailing: true, leading: true});
public getDistributors = () => {
return this.getResizeHandles().map(handle => {
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
});
};
private createSizerAndDistributor(
resizeHandle: HTMLDivElement,
): {sizer: Sizer, distributor: FixedDistributor<any>} {
const vertical = resizeHandle.classList.contains(this.classNames.vertical);
const reverse = this.isReverseResizeHandle(resizeHandle);
const Distributor = this.distributorCtor;
@ -159,9 +180,10 @@ export default class Resizer<C extends IConfig = IConfig> {
return {sizer, distributor};
}
private getResizeHandles(): HTMLDivElement[] {
private getResizeHandles() {
if (!this.container.children) return [];
return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(<HTMLElement>el);
}) as HTMLDivElement[];
}) as HTMLElement[];
}
}

View file

@ -68,11 +68,19 @@ export default class Sizer {
return offset;
}
public setItemSize(item: HTMLElement, size: number) {
public getDesiredItemSize(item: HTMLElement) {
if (this.vertical) {
item.style.height = `${Math.round(size)}px`;
return item.style.height;
} else {
item.style.width = `${Math.round(size)}px`;
return item.style.width;
}
}
public setItemSize(item: HTMLElement, size: string) {
if (this.vertical) {
item.style.height = size;
} else {
item.style.width = size;
}
}
@ -84,6 +92,10 @@ export default class Sizer {
}
}
public start(item: HTMLElement) {}
public finish(item: HTMLElement) {}
/**
@param {MouseEvent} event the mouse event
@return {number} the distance between the cursor and the edge of the container,

View file

@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore";
import WidgetEchoStore from "../stores/WidgetEchoStore";
import RoomViewStore from "../stores/RoomViewStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils";
import {SettingLevel} from "../settings/SettingLevel";
@ -46,6 +47,8 @@ interface IRoomWidgets {
pinned: Record<string, boolean>;
}
export const MAX_PINNED = 3;
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> {
@ -68,7 +71,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private initRoom(roomId: string) {
if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, {
pinned: {},
pinned: {}, // ordered
widgets: [],
});
}
@ -156,27 +159,34 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
public isPinned(widgetId: string) {
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
let pinned = roomInfo && roomInfo.pinned[widgetId];
// Jitsi widgets should be pinned by default
const widget = this.widgetMap.get(widgetId);
if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true;
return pinned;
return !!this.getPinnedApps(roomId).find(w => w.id === widgetId);
}
public canPin(widgetId: string) {
// only allow pinning up to a max of two as we do not yet have grid splits
// the only case it will go to three is if you have two and then a Jitsi gets added
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
return roomInfo && Object.keys(roomInfo.pinned).filter(k => {
return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k);
}).length < 2;
return this.getPinnedApps(roomId).length < MAX_PINNED;
}
public pinWidget(widgetId: string) {
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return;
// When pinning, first confirm all the widgets (Jitsi) which were autopinned so that the order is correct
const autoPinned = this.getPinnedApps(roomId).filter(app => !roomInfo.pinned[app.id]);
autoPinned.forEach(app => {
this.setPinned(app.id, true);
});
this.setPinned(widgetId, true);
// Show the apps drawer upon the user pinning a widget
if (RoomViewStore.getRoomId() === this.getRoomId(widgetId)) {
defaultDispatcher.dispatch({
action: "appsDrawer",
show: true,
})
}
}
public unpinWidget(widgetId: string) {
@ -187,6 +197,10 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return;
if (roomInfo.pinned[widgetId] === false && value) {
// delete this before write to maintain the correct object insertion order
delete roomInfo.pinned[widgetId];
}
roomInfo.pinned[widgetId] = value;
// Clean up the pinned record
@ -201,13 +215,61 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.emit(UPDATE_EVENT);
}
public getApps(room: Room, pinned?: boolean): IApp[] {
const roomInfo = this.getRoom(room.roomId);
if (!roomInfo) return [];
if (pinned) {
return roomInfo.widgets.filter(app => this.isPinned(app.id));
public movePinnedWidget(widgetId: string, delta: 1 | -1) {
// TODO simplify this by changing the storage medium of pinned to an array once the Jitsi default-on goes away
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
if (!roomInfo || roomInfo.pinned[widgetId] === false) return;
const pinnedApps = this.getPinnedApps(roomId).map(app => app.id);
const i = pinnedApps.findIndex(id => id === widgetId);
if (delta > 0) {
pinnedApps.splice(i, 2, pinnedApps[i + 1], pinnedApps[i]);
} else {
pinnedApps.splice(i - 1, 2, pinnedApps[i], pinnedApps[i - 1]);
}
return roomInfo.widgets;
const reorderedPinned: IRoomWidgets["pinned"] = {};
pinnedApps.forEach(id => {
reorderedPinned[id] = true;
});
Object.keys(roomInfo.pinned).forEach(id => {
if (reorderedPinned[id] === undefined) {
reorderedPinned[id] = roomInfo.pinned[id];
}
});
roomInfo.pinned = reorderedPinned;
SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned);
this.emit(roomId);
this.emit(UPDATE_EVENT);
}
public getPinnedApps(roomId: string): IApp[] {
// returns the apps in the order they were pinned with, up to the maximum
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return [];
// Show Jitsi widgets even if the user already had the maximum pinned, instead of their latest pinned,
// except if the user already explicitly unpinned the Jitsi widget
const priorityWidget = roomInfo.widgets.find(widget => {
return roomInfo.pinned[widget.id] === undefined && WidgetType.JITSI.matches(widget.type);
});
const order = Object.keys(roomInfo.pinned).filter(k => roomInfo.pinned[k]);
let apps = order.map(wId => this.widgetMap.get(wId)).filter(Boolean);
apps = apps.slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED);
if (priorityWidget) {
apps.push(priorityWidget);
}
return apps;
}
public getApps(roomId: string): IApp[] {
const roomInfo = this.getRoom(roomId);
return roomInfo?.widgets || [];
}
public doesRoomHaveConference(room: Room): boolean {

View file

@ -40,10 +40,12 @@ export default class ResizeNotifier extends EventEmitter {
startResizing() {
this._isResizing = true;
this.emit("isResizing", true);
}
stopResizing() {
this._isResizing = false;
this.emit("isResizing", false);
}
_noisyMiddlePanel() {

View file

@ -459,6 +459,7 @@ export default class WidgetUtils {
'avatarUrl=$matrix_avatar_url',
'userId=$matrix_user_id',
'roomId=$matrix_room_id',
'theme=$theme',
];
if (opts.auth) {
queryStringParts.push(`auth=${opts.auth}`);
@ -494,4 +495,16 @@ export default class WidgetUtils {
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
}
}
static isManagedByManager(app) {
if (WidgetUtils.isScalarUrl(app.url)) {
const managers = IntegrationManagers.sharedInstance();
if (managers.hasManager()) {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
return WidgetUtils.isScalarUrl(defaultManager.apiUrl);
}
}
return false;
}
}