Merge branch 'develop' into katex

This commit is contained in:
Aleks Kissinger 2020-10-25 12:28:17 +00:00
commit 4536f51ec0
157 changed files with 3749 additions and 2177 deletions

View file

@ -1,3 +1,110 @@
Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1)
* [Release] Adjust for new widget messaging APIs
[\#5342](https://github.com/matrix-org/matrix-react-sdk/pull/5342)
* [Release] Fix Jitsi OpenIDC auth
[\#5335](https://github.com/matrix-org/matrix-react-sdk/pull/5335)
Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0)
* Upgrade JS SDK to 8.5.0
* [Release] Fix templating for v1 jitsi widgets
[\#5306](https://github.com/matrix-org/matrix-react-sdk/pull/5306)
* [Release] Use new preparing event for widget communications
[\#5304](https://github.com/matrix-org/matrix-react-sdk/pull/5304)
Changes in [3.6.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0-rc.1) (2020-10-07)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0...v3.6.0-rc.1)
* Upgrade JS SDK to 8.5.0-rc.1
* Update from Weblate
[\#5297](https://github.com/matrix-org/matrix-react-sdk/pull/5297)
* Fix edited replies being wrongly treated as big emoji
[\#5295](https://github.com/matrix-org/matrix-react-sdk/pull/5295)
* Fix StopGapWidget infinitely recursing
[\#5294](https://github.com/matrix-org/matrix-react-sdk/pull/5294)
* Fix editing and redactions not updating the Reply Thread
[\#5281](https://github.com/matrix-org/matrix-react-sdk/pull/5281)
* Hide Jump to Read Receipt button for users who have not yet sent an RR
[\#5282](https://github.com/matrix-org/matrix-react-sdk/pull/5282)
* fix img tags not always being rendered correctly
[\#5279](https://github.com/matrix-org/matrix-react-sdk/pull/5279)
* Hopefully fix righhtpanel crash
[\#5293](https://github.com/matrix-org/matrix-react-sdk/pull/5293)
* Fix naive pinning limit and app tile widgetMessaging NPE
[\#5283](https://github.com/matrix-org/matrix-react-sdk/pull/5283)
* Show server errors from saving profile settings
[\#5272](https://github.com/matrix-org/matrix-react-sdk/pull/5272)
* Update copy for `redact` permission
[\#5273](https://github.com/matrix-org/matrix-react-sdk/pull/5273)
* Remove width limit on widgets
[\#5265](https://github.com/matrix-org/matrix-react-sdk/pull/5265)
* Fix call container avatar initial centering
[\#5280](https://github.com/matrix-org/matrix-react-sdk/pull/5280)
* Fix right panel for peeking rooms
[\#5268](https://github.com/matrix-org/matrix-react-sdk/pull/5268)
* Add support for dehydrated devices
[\#5239](https://github.com/matrix-org/matrix-react-sdk/pull/5239)
* Use Own Profile Store for the Profile Settings
[\#5277](https://github.com/matrix-org/matrix-react-sdk/pull/5277)
* null-guard defaultAvatarUrlForString
[\#5270](https://github.com/matrix-org/matrix-react-sdk/pull/5270)
* Choose first result on enter in the emoji picker
[\#5257](https://github.com/matrix-org/matrix-react-sdk/pull/5257)
* Fix room directory clipping links in the room's topic
[\#5276](https://github.com/matrix-org/matrix-react-sdk/pull/5276)
* Decorate failed e2ee downgrade attempts better
[\#5278](https://github.com/matrix-org/matrix-react-sdk/pull/5278)
* MELS use latest avatar rather than the first avatar
[\#5262](https://github.com/matrix-org/matrix-react-sdk/pull/5262)
* Fix Encryption Panel close button clashing with Base Card
[\#5261](https://github.com/matrix-org/matrix-react-sdk/pull/5261)
* Wrap canEncryptToAllUsers in a try/catch to handle server errors
[\#5275](https://github.com/matrix-org/matrix-react-sdk/pull/5275)
* Fix conditional on communities prototype room creation dialog
[\#5274](https://github.com/matrix-org/matrix-react-sdk/pull/5274)
* Fix ensureDmExists for encryption detection
[\#5271](https://github.com/matrix-org/matrix-react-sdk/pull/5271)
* Switch to using the Widget API SDK for widget messaging
[\#5171](https://github.com/matrix-org/matrix-react-sdk/pull/5171)
* Ensure package links exist when releasing
[\#5269](https://github.com/matrix-org/matrix-react-sdk/pull/5269)
* Fix the call preview when not in same room as the call
[\#5267](https://github.com/matrix-org/matrix-react-sdk/pull/5267)
* Make the hangup button do things for conference calls
[\#5223](https://github.com/matrix-org/matrix-react-sdk/pull/5223)
* Render Jitsi widget state events in a more obvious way
[\#5222](https://github.com/matrix-org/matrix-react-sdk/pull/5222)
* Make the PIP Jitsi look and feel like the 1:1 PIP
[\#5226](https://github.com/matrix-org/matrix-react-sdk/pull/5226)
* Trim range when formatting so that it excludes leading/trailing spaces
[\#5263](https://github.com/matrix-org/matrix-react-sdk/pull/5263)
* Fix button label on the Set Password Dialog
[\#5264](https://github.com/matrix-org/matrix-react-sdk/pull/5264)
* fix link to classic yarn's `yarn link`
[\#5259](https://github.com/matrix-org/matrix-react-sdk/pull/5259)
* Fix index mismatch between username colors styles and custom theming
[\#5256](https://github.com/matrix-org/matrix-react-sdk/pull/5256)
* Disable autocompletion on security key input during login
[\#5258](https://github.com/matrix-org/matrix-react-sdk/pull/5258)
* fix uninitialised state and eventlistener leak in RoomUpgradeWarningBar
[\#5255](https://github.com/matrix-org/matrix-react-sdk/pull/5255)
* Only set title when it changes
[\#5254](https://github.com/matrix-org/matrix-react-sdk/pull/5254)
* Convert CallHandler to typescript
[\#5248](https://github.com/matrix-org/matrix-react-sdk/pull/5248)
* Retry loading i18n language if it fails
[\#5209](https://github.com/matrix-org/matrix-react-sdk/pull/5209)
* Rework profile area for user and room settings to be more clear
[\#5243](https://github.com/matrix-org/matrix-react-sdk/pull/5243)
* Validation improve pattern for derived data
[\#5241](https://github.com/matrix-org/matrix-react-sdk/pull/5241)
Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28) Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.0)

View file

@ -18,7 +18,7 @@ are currently filed against vector-im/element-web rather than this project).
Translation Status Translation Status
================== ==================
[![Translation status](https://translate.riot.im/widgets/element-web/-/multi-auto.svg)](https://translate.riot.im/engage/element-web/?utm_source=widget) [![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget)
Developer Guide Developer Guide
=============== ===============

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.5.0", "version": "3.6.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -81,7 +81,7 @@
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.3", "matrix-widget-api": "^0.1.0-beta.5",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"pako": "^1.0.11", "pako": "^1.0.11",
"parse5": "^5.1.1", "parse5": "^5.1.1",

View file

@ -32,9 +32,7 @@ do
echo "Upgrading $i to $latestver..." echo "Upgrading $i to $latestver..."
yarn add -E $i@$latestver yarn add -E $i@$latestver
git add -u git add -u
# The `-e` flag opens the editor and gives you a chance to check git commit -m "Upgrade $i to $latestver"
# the upgrade for correctness.
git commit -m "Upgrade $i to $latestver" -e
fi fi
fi fi
done done

View file

@ -208,12 +208,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
border: 0; border: 0;
} }
/* applied to side-panels and messagepanel when in RoomSettings */
.mx_fadable {
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
// These are magic constants which are excluded from tinting, to let themes // These are magic constants which are excluded from tinting, to let themes
// (which only have CSS, unlike skins) tell the app what their non-tinted // (which only have CSS, unlike skins) tell the app what their non-tinted
// colourscheme is by inspecting the stylesheet DOM. // colourscheme is by inspecting the stylesheet DOM.

View file

@ -13,6 +13,7 @@
@import "./structures/_HeaderButtons.scss"; @import "./structures/_HeaderButtons.scss";
@import "./structures/_HomePage.scss"; @import "./structures/_HomePage.scss";
@import "./structures/_LeftPanel.scss"; @import "./structures/_LeftPanel.scss";
@import "./structures/_LeftPanelWidget.scss";
@import "./structures/_MainSplit.scss"; @import "./structures/_MainSplit.scss";
@import "./structures/_MatrixChat.scss"; @import "./structures/_MatrixChat.scss";
@import "./structures/_MyGroups.scss"; @import "./structures/_MyGroups.scss";
@ -26,7 +27,7 @@
@import "./structures/_ScrollPanel.scss"; @import "./structures/_ScrollPanel.scss";
@import "./structures/_SearchBox.scss"; @import "./structures/_SearchBox.scss";
@import "./structures/_TabbedView.scss"; @import "./structures/_TabbedView.scss";
@import "./structures/_TagPanel.scss"; @import "./structures/_GroupFilterPanel.scss";
@import "./structures/_ToastContainer.scss"; @import "./structures/_ToastContainer.scss";
@import "./structures/_UploadBar.scss"; @import "./structures/_UploadBar.scss";
@import "./structures/_UserMenu.scss"; @import "./structures/_UserMenu.scss";
@ -51,11 +52,11 @@
@import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss";
@import "./views/avatars/_WidgetAvatar.scss";
@import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss";
@import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss";
@import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss";
@import "./views/context_menus/_TagTileContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss";
@import "./views/context_menus/_WidgetContextMenu.scss";
@import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss";
@ -74,6 +75,7 @@
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss";
@import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss";

View file

@ -22,7 +22,7 @@ limitations under the License.
} }
.mx_CustomRoomTagPanel { .mx_CustomRoomTagPanel {
background-color: $tagpanel-bg-color; background-color: $groupFilterPanel-bg-color;
max-height: 40vh; max-height: 40vh;
} }

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_TagPanel { .mx_GroupFilterPanel {
flex: 1; flex: 1;
background-color: $tagpanel-bg-color; background-color: $groupFilterPanel-bg-color;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -26,49 +26,49 @@ limitations under the License.
min-height: 0; min-height: 0;
} }
.mx_TagPanel_items_selected { .mx_GroupFilterPanel_items_selected {
cursor: pointer; cursor: pointer;
} }
.mx_TagPanel .mx_TagPanel_divider { .mx_GroupFilterPanel .mx_GroupFilterPanel_divider {
height: 0px; height: 0px;
width: 90%; width: 90%;
border: none; border: none;
border-bottom: 1px solid $tagpanel-divider-color; border-bottom: 1px solid $groupFilterPanel-divider-color;
} }
.mx_TagPanel .mx_TagPanel_scroller { .mx_GroupFilterPanel .mx_GroupFilterPanel_scroller {
flex-grow: 1; flex-grow: 1;
width: 100%; width: 100%;
} }
.mx_TagPanel .mx_TagPanel_tagTileContainer { .mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 6px; padding-top: 6px;
} }
.mx_TagPanel .mx_TagPanel_tagTileContainer > div { .mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer > div {
margin: 6px 0; margin: 6px 0;
} }
.mx_TagPanel .mx_TagTile { .mx_GroupFilterPanel .mx_TagTile {
// opacity: 0.5; // opacity: 0.5;
position: relative; position: relative;
} }
.mx_TagPanel .mx_TagTile.mx_TagTile_prototype { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype {
padding: 3px; padding: 3px;
} }
.mx_TagPanel .mx_TagTile:focus, .mx_GroupFilterPanel .mx_TagTile:focus,
.mx_TagPanel .mx_TagTile:hover, .mx_GroupFilterPanel .mx_TagTile:hover,
.mx_TagPanel .mx_TagTile.mx_TagTile_selected { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected {
// opacity: 1; // opacity: 1;
} }
.mx_TagPanel .mx_TagTile.mx_TagTile_selected_prototype { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype {
background-color: $primary-bg-color; background-color: $primary-bg-color;
border-radius: 6px; border-radius: 6px;
} }
@ -108,7 +108,7 @@ limitations under the License.
} }
} }
.mx_TagPanel .mx_TagTile_plus { .mx_GroupFilterPanel .mx_TagTile_plus {
margin-bottom: 12px; margin-bottom: 12px;
height: 32px; height: 32px;
width: 32px; width: 32px;
@ -132,7 +132,7 @@ limitations under the License.
} }
} }
.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected::before {
content: ''; content: '';
height: 100%; height: 100%;
background-color: $accent-color; background-color: $accent-color;
@ -142,7 +142,7 @@ limitations under the License.
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
} }
.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { .mx_GroupFilterPanel .mx_TagTile.mx_AccessibleButton:focus {
filter: none; filter: none;
} }

View file

@ -14,29 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$tagPanelWidth: 56px; // only applies in this file, used for calculations $groupFilterPanelWidth: 56px; // only applies in this file, used for calculations
.mx_LeftPanel { .mx_LeftPanel {
background-color: $roomlist-bg-color; background-color: $roomlist-bg-color;
min-width: 260px; min-width: 260px;
max-width: 50%; max-width: 50%;
// Create a row-based flexbox for the TagPanel and the room list // Create a row-based flexbox for the GroupFilterPanel and the room list
display: flex; display: flex;
.mx_LeftPanel_tagPanelContainer { .mx_LeftPanel_GroupFilterPanelContainer {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
flex-basis: $tagPanelWidth; flex-basis: $groupFilterPanelWidth;
height: 100%; height: 100%;
// Create another flexbox so the TagPanel fills the container // Create another flexbox so the GroupFilterPanel fills the container
display: flex; display: flex;
// TagPanel handles its own CSS // GroupFilterPanel handles its own CSS
} }
&:not(.mx_LeftPanel_hasTagPanel) { &:not(.mx_LeftPanel_hasGroupFilterPanel) {
.mx_LeftPanel_roomListContainer { .mx_LeftPanel_roomListContainer {
width: 100%; width: 100%;
} }
@ -45,7 +45,7 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations
// Note: The 'room list' in this context is actually everything that isn't the tag // Note: The 'room list' in this context is actually everything that isn't the tag
// panel, such as the menu options, breadcrumbs, filtering, etc // panel, such as the menu options, breadcrumbs, filtering, etc
.mx_LeftPanel_roomListContainer { .mx_LeftPanel_roomListContainer {
width: calc(100% - $tagPanelWidth); width: calc(100% - $groupFilterPanelWidth);
background-color: $roomlist-bg-color; background-color: $roomlist-bg-color;
// Create another flexbox (this time a column) for the room list components // Create another flexbox (this time a column) for the room list components
@ -169,10 +169,10 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations
min-width: unset; min-width: unset;
// We have to forcefully set the width to override the resizer's style attribute. // We have to forcefully set the width to override the resizer's style attribute.
&.mx_LeftPanel_hasTagPanel { &.mx_LeftPanel_hasGroupFilterPanel {
width: calc(68px + $tagPanelWidth) !important; width: calc(68px + $groupFilterPanelWidth) !important;
} }
&:not(.mx_LeftPanel_hasTagPanel) { &:not(.mx_LeftPanel_hasGroupFilterPanel) {
width: 68px !important; width: 68px !important;
} }

View file

@ -0,0 +1,145 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_LeftPanelWidget {
// largely based on RoomSublist
margin-left: 8px;
margin-bottom: 4px;
.mx_LeftPanelWidget_headerContainer {
display: flex;
align-items: center;
height: 24px;
color: $roomlist-header-color;
margin-top: 4px;
.mx_LeftPanelWidget_stickable {
flex: 1;
max-width: 100%;
display: flex;
align-items: center;
}
.mx_LeftPanelWidget_headerText {
flex: 1;
max-width: calc(100% - 16px);
line-height: $font-16px;
font-size: $font-13px;
font-weight: 600;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.mx_LeftPanelWidget_collapseBtn {
display: inline-block;
position: relative;
width: 14px;
height: 14px;
margin-right: 6px;
&::before {
content: '';
width: 18px;
height: 18px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $roomlist-header-color;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
&.mx_LeftPanelWidget_collapseBtn_collapsed::before {
transform: rotate(-90deg);
}
}
}
}
.mx_LeftPanelWidget_resizeBox {
position: relative;
display: flex;
flex-direction: column;
overflow: visible; // let the resize handle out
}
.mx_AppTileFullWidth {
flex: 1 0 0;
overflow: hidden;
// need this to be flex otherwise the overflow hidden from above
// sometimes vertically centers the clipped list ... no idea why it would do this
// as the box model should be top aligned. Happens in both FF and Chromium
display: flex;
flex-direction: column;
box-sizing: border-box;
mask-image: linear-gradient(0deg, transparent, black 4px);
}
.mx_LeftPanelWidget_resizerHandle {
cursor: ns-resize;
border-radius: 3px;
// Override styles from library
width: unset !important;
height: 4px !important;
position: absolute;
top: -24px !important; // override from library - puts it in the margin-top of the headerContainer
// Together, these make the bar 64px wide
// These are also overridden from the library
left: calc(50% - 32px) !important;
right: calc(50% - 32px) !important;
}
&:hover .mx_LeftPanelWidget_resizerHandle {
opacity: 0.8;
background-color: $primary-fg-color;
}
.mx_LeftPanelWidget_maximizeButton {
margin-left: 8px;
margin-right: 7px;
position: relative;
width: 24px;
height: 24px;
border-radius: 32px;
&::before {
content: '';
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/feather-customised/widget/maximise.svg');
background: $muted-fg-color;
}
}
}
.mx_LeftPanelWidget_maximizeButtonTooltip {
margin-top: -3px;
}

View file

@ -79,7 +79,6 @@ limitations under the License.
height: 100%; height: 100%;
} }
.mx_MatrixChat > .mx_LeftPanel2:hover + .mx_ResizeHandle_horizontal,
.mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { .mx_MatrixChat > .mx_ResizeHandle_horizontal:hover {
position: relative; position: relative;

View file

@ -230,6 +230,10 @@ limitations under the License.
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
&.mx_UserMenu_contextMenu_hostingLink {
padding-top: 0;
}
} }
.mx_IconizedContextMenu_icon { .mx_IconizedContextMenu_icon {

View file

@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ActionPayload } from "../payloads"; .mx_WidgetAvatar {
import { Action } from "../actions"; border-radius: 4px;
export interface AppTileActionPayload extends ActionPayload {
action: Action.AppTileDelete | Action.AppTileRevoke;
widgetId: string;
} }

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

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,13 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; .mx_ModalWidgetDialog {
.mx_ModalWidgetDialog_warning {
margin-bottom: 24px;
export default function(dest, src) { > img {
for (const i in src) { vertical-align: middle;
if (src.hasOwnProperty(i)) { margin-right: 8px;
dest[i] = src[i];
} }
} }
return dest;
.mx_ModalWidgetDialog_buttons {
float: right;
margin-top: 24px;
.mx_AccessibleButton + .mx_AccessibleButton {
margin-left: 8px;
}
}
iframe {
width: 100%;
height: 450px;
border: 0;
border-radius: 8px;
}
} }

View file

@ -25,7 +25,7 @@ limitations under the License.
.mx_AccessibleButton_hasKind { .mx_AccessibleButton_hasKind {
padding: 7px 18px; padding: 7px 18px;
text-align: center; text-align: center;
border-radius: 4px; border-radius: 8px;
display: inline-block; display: inline-block;
font-size: $font-14px; font-size: $font-14px;
} }

View file

@ -128,6 +128,13 @@ limitations under the License.
mask-size: 20px; mask-size: 20px;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); 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_appsGroup {
.mx_RoomSummaryCard_Button { .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; color: $tertiary-fg-color;
span { .mx_RoomSummaryCard_icon_app {
color: $primary-fg-color; 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 { .mx_RoomSummaryCard_app_pinToggle,
vertical-align: top; .mx_RoomSummaryCard_app_options {
margin-right: 12px; position: absolute;
border-radius: 4px; 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 { &::before {
content: unset; content: unset;
} }
}
.mx_RoomSummaryCard_icon_app_pinned::after { &::after {
mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); top: 8px; // re-align based on the height change
background-color: $accent-color; pointer-events: none; // pass through to the real button
transform: unset; }
} }
} }

View file

@ -24,34 +24,35 @@ limitations under the License.
border: 0; border: 0;
} }
&.mx_WidgetCard_noEdit { .mx_BaseCard_header {
.mx_AccessibleButton_kind_secondary { display: inline-flex;
margin: 0 12px;
&:first-child { & > h2 {
// expand the Pin to room primary action margin-right: 0;
flex-grow: 1; flex-grow: 1;
}
} }
}
.mx_WidgetCard_optionsButton { .mx_WidgetCard_optionsButton {
position: relative; position: relative;
height: 18px; margin-right: 44px;
width: 26px;
&::before {
content: "";
position: absolute;
width: 20px;
height: 20px; height: 20px;
top: 6px; width: 20px;
left: 20px; min-width: 20px; // prevent crushing by the flexbox
mask-repeat: no-repeat; padding: 0;
mask-position: center;
mask-size: contain; &::before {
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); content: "";
background-color: $secondary-fg-color; 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,11 +47,27 @@ $MiniAppTileHeight: 200px;
opacity: 0.8; opacity: 0.8;
background: $primary-fg-color; 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_AppsDrawer_hidden { .mx_AppsContainer_resizer {
display: none; margin-bottom: 8px;
} }
.mx_AppsContainer { .mx_AppsContainer {
@ -60,53 +76,71 @@ $MiniAppTileHeight: 200px;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
height: 100%; 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 { // TODO this should be 300px but that's too large
// override the re-resizable inline styles $MinWidth: 240px;
height: inherit !important;
min-height: inherit !important;
}
.mx_AddWidget_button { .mx_AppsDrawer_2apps .mx_AppTile {
order: 2; width: 50%;
cursor: pointer;
padding: 0;
margin: -3px auto 5px 0;
color: $accent-color;
font-size: $font-12px;
}
.mx_SetAppURLDialog_input { &:nth-child(3) {
border-radius: 3px; flex-grow: 1;
border: 1px solid $input-border-color; width: 0 !important;
padding: 9px; min-width: $MinWidth !important;
color: $primary-hairline-color; }
background-color: $primary-bg-color; }
font-size: $font-15px; .mx_AppsDrawer_3apps .mx_AppTile {
width: 33%;
&:nth-child(3) {
flex-grow: 1;
width: 0 !important;
min-width: $MinWidth !important;
}
} }
.mx_AppTile { .mx_AppTile {
width: 50%; width: 50%;
border: 5px solid $widget-menu-bar-bg-color; min-width: $MinWidth;
border-radius: 4px; border: 8px solid $widget-menu-bar-bg-color;
border-left-width: 5px;
border-right-width: 5px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box;
& + .mx_AppTile { background-color: $widget-menu-bar-bg-color;
margin-left: 5px;
}
} }
.mx_AppTileFullWidth { .mx_AppTileFullWidth {
width: 100%; width: 100% !important; // to override the inline style set by the resizer
margin: 0; margin: 0;
padding: 0; padding: 0;
border: 5px solid $widget-menu-bar-bg-color; border: 5px solid $widget-menu-bar-bg-color;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $widget-menu-bar-bg-color;
} }
.mx_AppTile_mini { .mx_AppTile_mini {
@ -118,12 +152,6 @@ $MiniAppTileHeight: 200px;
height: $MiniAppTileHeight; 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_AppTile .mx_AppTile_persistedWrapper,
.mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTileFullWidth .mx_AppTile_persistedWrapper,
.mx_AppTile_mini .mx_AppTile_persistedWrapper { .mx_AppTile_mini .mx_AppTile_persistedWrapper {
@ -143,19 +171,20 @@ $MiniAppTileHeight: 200px;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
cursor: pointer;
width: 100%; width: 100%;
} padding-top: 2px;
padding-bottom: 8px;
.mx_AppTileMenuBar_expanded {
padding-bottom: 5px;
} }
.mx_AppTileMenuBarTitle { .mx_AppTileMenuBarTitle {
display: flex; line-height: 20px;
flex-direction: row; white-space: nowrap;
align-items: center; overflow: hidden;
pointer-events: none; text-overflow: ellipsis;
.mx_WidgetAvatar {
margin-right: 12px;
}
} }
.mx_AppTileMenuBarTitle > :last-child { .mx_AppTileMenuBarTitle > :last-child {
@ -179,37 +208,20 @@ $MiniAppTileHeight: 200px;
margin: 0 3px; margin: 0 3px;
} }
.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_minimise {
mask-image: url('$(res)/img/feather-customised/widget/minimise.svg');
background-color: $accent-color;
}
.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_maximise {
mask-image: url('$(res)/img/feather-customised/widget/maximise.svg');
background-color: $accent-color;
}
.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout {
mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); mask-image: url('$(res)/img/feather-customised/widget/external-link.svg');
} }
.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu {
mask-image: url('$(res)/img/icon_context.svg'); mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
}
.mx_AppTileMenuBarWidgetDelete {
filter: none;
}
.mx_AppTileMenuBarWidget:hover {
border: 1px solid $primary-fg-color;
border-radius: 2px;
} }
.mx_AppTileBody { .mx_AppTileBody {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border-radius: 8px;
background-color: $widget-body-bg-color;
} }
.mx_AppTileBody_mini { .mx_AppTileBody_mini {
@ -242,75 +254,8 @@ $MiniAppTileHeight: 200px;
display: block; display: block;
} }
.mx_AppTileMenuBarWidgetPadding {
margin-right: 5px;
}
.mx_AppIconTile {
background-color: $lightbox-bg-color;
border: 1px solid rgba(0, 0, 0, 0);
width: 200px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
transition: 0.3s;
border-radius: 3px;
margin: 5px;
display: inline-block;
}
.mx_AppIconTile.mx_AppIconTile_active {
color: $accent-color;
border-color: $accent-color;
}
.mx_AppIconTile:hover {
border: 1px solid $accent-color;
box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5);
}
.mx_AppIconTile_content {
padding: 2px 16px;
height: 60px;
overflow: hidden;
}
.mx_AppIconTile_content h4 {
margin-top: 5px;
margin-bottom: 2px;
}
.mx_AppIconTile_content p {
margin-top: 0;
margin-bottom: 5px;
font-size: smaller;
}
.mx_AppIconTile_image {
padding: 10px;
max-width: 100px;
max-height: 100px;
width: auto;
height: auto;
}
.mx_AppIconTile_imageContainer {
text-align: center;
width: 100%;
background-color: white;
border-radius: 3px 3px 0 0;
height: 155px;
display: flex;
justify-content: center;
align-items: center;
}
form.mx_Custom_Widget_Form div {
margin-top: 10px;
margin-bottom: 10px;
}
.mx_AppPermissionWarning { .mx_AppPermissionWarning {
text-align: center; text-align: center;
background-color: $widget-menu-bar-bg-color;
display: flex; display: flex;
height: 100%; height: 100%;
flex-direction: column; flex-direction: column;
@ -375,6 +320,10 @@ form.mx_Custom_Widget_Form div {
font-weight: bold; font-weight: bold;
position: relative; position: relative;
height: 100%; 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 { .mx_AppLoading .mx_Spinner {
@ -402,10 +351,6 @@ form.mx_Custom_Widget_Form div {
display: none; display: none;
} }
.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle {
display: none;
}
/* Avoid apptile iframes capturing mouse event focus when resizing */ /* Avoid apptile iframes capturing mouse event focus when resizing */
.mx_AppsDrawer_resizing iframe { .mx_AppsDrawer_resizing iframe {
pointer-events: none; pointer-events: none;

View file

@ -70,7 +70,7 @@ limitations under the License.
} }
.mx_MemberInfo_avatar { .mx_MemberInfo_avatar {
background: $tagpanel-bg-color; background: $groupFilterPanel-bg-color;
margin-bottom: 16px; margin-bottom: 16px;
} }

View file

@ -241,6 +241,13 @@ limitations under the License.
width: 26px; 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 { .mx_RoomHeader_searchButton::before {
mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); mask-image: url('$(res)/img/element-icons/room/search-inset.svg');
} }

View file

@ -59,10 +59,6 @@ limitations under the License.
width: calc(100% - 22px); width: calc(100% - 22px);
} }
&.mx_RoomSublist_headerContainer_stickyBottom {
bottom: 0;
}
// We don't have a top style because the top is dependent on the room list header's // We don't have a top style because the top is dependent on the room list header's
// height, and is therefore calculated in JS. // height, and is therefore calculated in JS.
// The class, mx_RoomSublist_headerContainer_stickyTop, is applied though. // The class, mx_RoomSublist_headerContainer_stickyTop, is applied though.

View file

@ -16,6 +16,10 @@
border-bottom: none; border-bottom: none;
} }
.mx_AppTileMenuBar {
padding: 0;
}
iframe { iframe {
// Sticker picker depends on the fixed height previously used for all tiles // Sticker picker depends on the fixed height previously used for all tiles
height: 273px; height: 273px;

View file

@ -85,6 +85,7 @@ limitations under the License.
.mx_AvatarSetting_avatarPlaceholder { .mx_AvatarSetting_avatarPlaceholder {
display: block; display: block;
height: 90px; height: 90px;
width: inherit;
border-radius: 90px; border-radius: 90px;
cursor: pointer; cursor: pointer;
} }

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"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" fill="url(#paint0_linear)"/> <g clip-path="url(#clip0)">
<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"/> <rect width="20" height="20" rx="4" fill="url(#paint0_linear)"/>
<circle opacity="0.8" cx="10.0039" cy="10" r="7.5" stroke="white"/> <path d="M2.49609 0V20" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<defs> <path d="M20 2.5L1.60531e-06 2.5" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<linearGradient id="paint0_linear" x1="10" y1="0" x2="10" y2="20" gradientUnits="userSpaceOnUse"> <path d="M20 10L1.60531e-06 10" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<stop stop-color="#60A6FF"/> <path d="M20 17.5H1.60531e-06" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
<stop offset="1" stop-color="#418DED"/> <path d="M10 0.000488281V20.0005" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
</linearGradient> <path d="M17.4961 0V20" stroke="white" stroke-opacity="0.5" style="mix-blend-mode:lighten"/>
</defs> <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> </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"> <svg width="20" height="20" viewBox="0 0 20 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 width="20" height="20" rx="4" fill="#FF4B55"/>
<rect x="2.96777" y="2" width="16.9843" height="5" fill="#FF4B55"/> <path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/>
<rect x="4.96533" y="9" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/> <rect x="3.96826" y="8.99951" 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"/> <rect x="10.9614" y="13" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/>
</svg> </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"> <svg width="20" height="20" viewBox="0 0 20 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"/> <rect x="1" y="1" width="18" height="18" rx="3" fill="#17191C" stroke="#17191C" stroke-width="2"/>
<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="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.9961 6.00049V9.81299L13.4961 11.5005" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 6V9.8125L12.5 11.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </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"> <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"/> <rect 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"/> <path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/>
</svg> </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

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#737D8C" style="mix-blend-mode:multiply"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 303 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

@ -39,7 +39,7 @@ $info-plinth-fg-color: #888;
$preview-bar-bg-color: $header-panel-bg-color; $preview-bar-bg-color: $header-panel-bg-color;
$tagpanel-bg-color: rgba(38, 39, 43, 0.82); $groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
$inverted-bg-color: $base-color; $inverted-bg-color: $base-color;
// used by AddressSelector // used by AddressSelector
@ -98,7 +98,7 @@ $roomheader-color: $text-primary-color;
$roomheader-bg-color: $bg-color; $roomheader-bg-color: $bg-color;
$roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3); $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3);
$roomheader-addroom-fg-color: $text-primary-color; $roomheader-addroom-fg-color: $text-primary-color;
$tagpanel-button-color: $header-panel-text-primary-color; $groupFilterPanel-button-color: $header-panel-text-primary-color;
$groupheader-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color;
$rightpanel-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color;
$icon-button-color: #8E99A4; $icon-button-color: #8E99A4;
@ -118,7 +118,7 @@ $roomlist-bg-color: rgba(33, 38, 44, 0.90);
$roomlist-header-color: $tertiary-fg-color; $roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
@ -131,6 +131,7 @@ $notice-secondary-color: $roomlist-header-color;
$panel-divider-color: transparent; $panel-divider-color: transparent;
$widget-menu-bar-bg-color: $header-panel-bg-color; $widget-menu-bar-bg-color: $header-panel-bg-color;
$widget-body-bg-color: rgba(141, 151, 165, 0.2);
// event tile lifecycle // event tile lifecycle
$event-sending-color: $text-secondary-color; $event-sending-color: $text-secondary-color;
@ -187,7 +188,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000; $kbd-border-color: #000000;
$tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color; $interactive-tooltip-bg-color: $base-color;
@ -202,7 +203,7 @@ $appearance-tab-border-color: $room-highlight-color;
// blur amounts for left left panel (only for element theme, used in _mods.scss) // blur amounts for left left panel (only for element theme, used in _mods.scss)
$roomlist-background-blur-amount: 60px; $roomlist-background-blur-amount: 60px;
$tagpanel-background-blur-amount: 30px; $groupFilterPanel-background-blur-amount: 30px;
$composer-shadow-color: rgba(0, 0, 0, 0.28); $composer-shadow-color: rgba(0, 0, 0, 0.28);

View file

@ -3,7 +3,7 @@
@import "../../light/css/_fonts.scss"; @import "../../light/css/_fonts.scss";
@import "../../light/css/_light.scss"; @import "../../light/css/_light.scss";
// important this goes before _mods, // important this goes before _mods,
// as $tagpanel-background-blur-amount and // as $groupFilterPanel-background-blur-amount and
// $roomlist-background-blur-amount // $roomlist-background-blur-amount
// are overridden in _dark.scss // are overridden in _dark.scss
@import "_dark.scss"; @import "_dark.scss";

View file

@ -37,8 +37,8 @@ $info-plinth-fg-color: #888;
$preview-bar-bg-color: $header-panel-bg-color; $preview-bar-bg-color: $header-panel-bg-color;
$tagpanel-bg-color: $base-color; $groupFilterPanel-bg-color: $base-color;
$inverted-bg-color: $tagpanel-bg-color; $inverted-bg-color: $groupFilterPanel-bg-color;
// used by AddressSelector // used by AddressSelector
$selected-color: $room-highlight-color; $selected-color: $room-highlight-color;
@ -95,7 +95,7 @@ $topleftmenu-color: $text-primary-color;
$roomheader-color: $text-primary-color; $roomheader-color: $text-primary-color;
$roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity $roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity
$roomheader-addroom-fg-color: $text-primary-color; $roomheader-addroom-fg-color: $text-primary-color;
$tagpanel-button-color: $header-panel-text-primary-color; $groupFilterPanel-button-color: $header-panel-text-primary-color;
$groupheader-button-color: $header-panel-text-primary-color; $groupheader-button-color: $header-panel-text-primary-color;
$rightpanel-button-color: $header-panel-text-primary-color; $rightpanel-button-color: $header-panel-text-primary-color;
$icon-button-color: $header-panel-text-primary-color; $icon-button-color: $header-panel-text-primary-color;
@ -115,7 +115,7 @@ $roomlist-bg-color: $header-panel-bg-color;
$roomsublist-divider-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
@ -126,6 +126,7 @@ $roomtile-selected-bg-color: #1A1D23;
$panel-divider-color: $header-panel-border-color; $panel-divider-color: $header-panel-border-color;
$widget-menu-bar-bg-color: $header-panel-bg-color; $widget-menu-bar-bg-color: $header-panel-bg-color;
$widget-body-bg-color: #1A1D23;
// event tile lifecycle // event tile lifecycle
$event-sending-color: $text-secondary-color; $event-sending-color: $text-secondary-color;
@ -182,7 +183,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000; $kbd-border-color: #000000;
$tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color; $interactive-tooltip-bg-color: $base-color;

View file

@ -67,8 +67,8 @@ $preview-bar-bg-color: #f7f7f7;
$secondary-accent-color: #f2f5f8; $secondary-accent-color: #f2f5f8;
$tertiary-accent-color: #d3efe1; $tertiary-accent-color: #d3efe1;
$tagpanel-bg-color: #27303a; $groupFilterPanel-bg-color: #27303a;
$inverted-bg-color: $tagpanel-bg-color; $inverted-bg-color: $groupFilterPanel-bg-color;
// used by RoomDirectory permissions // used by RoomDirectory permissions
$plinth-bg-color: $secondary-accent-color; $plinth-bg-color: $secondary-accent-color;
@ -162,7 +162,7 @@ $roomheader-color: #45474a;
$roomheader-bg-color: $primary-bg-color; $roomheader-bg-color: $primary-bg-color;
$roomheader-addroom-bg-color: #91a1c0; $roomheader-addroom-bg-color: #91a1c0;
$roomheader-addroom-fg-color: $accent-fg-color; $roomheader-addroom-fg-color: $accent-fg-color;
$tagpanel-button-color: #91a1c0; $groupFilterPanel-button-color: #91a1c0;
$groupheader-button-color: #91a1c0; $groupheader-button-color: #91a1c0;
$rightpanel-button-color: #91a1c0; $rightpanel-button-color: #91a1c0;
$icon-button-color: #91a1c0; $icon-button-color: #91a1c0;
@ -182,7 +182,7 @@ $roomlist-bg-color: $header-panel-bg-color;
$roomlist-header-color: $primary-fg-color; $roomlist-header-color: $primary-fg-color;
$roomsublist-divider-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
@ -208,6 +208,7 @@ $panel-divider-color: #dee1f3;
// ******************** // ********************
$widget-menu-bar-bg-color: $secondary-accent-color; $widget-menu-bar-bg-color: $secondary-accent-color;
$widget-body-bg-color: #fff;
// ******************** // ********************
@ -305,7 +306,7 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: $reaction-row-button-border-color; $kbd-border-color: $reaction-row-button-border-color;
$tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff; $tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: #27303a; $interactive-tooltip-bg-color: #27303a;

View file

@ -49,7 +49,7 @@ $roomtile-selected-bg-color: var(--roomlist-highlights-color);
// //
// --sidebar-color // --sidebar-color
$interactive-tooltip-bg-color: var(--sidebar-color); $interactive-tooltip-bg-color: var(--sidebar-color);
$tagpanel-bg-color: var(--sidebar-color); $groupFilterPanel-bg-color: var(--sidebar-color);
$tooltip-timeline-bg-color: var(--sidebar-color); $tooltip-timeline-bg-color: var(--sidebar-color);
$dialog-backdrop-color: var(--sidebar-color-50pct); $dialog-backdrop-color: var(--sidebar-color-50pct);
$roomlist-button-bg-color: var(--sidebar-color-15pct); $roomlist-button-bg-color: var(--sidebar-color-15pct);

View file

@ -62,7 +62,7 @@ $preview-bar-bg-color: #f7f7f7;
$secondary-accent-color: #f2f5f8; $secondary-accent-color: #f2f5f8;
$tertiary-accent-color: #d3efe1; $tertiary-accent-color: #d3efe1;
$tagpanel-bg-color: rgba(232, 232, 232, 0.77); $groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77);
// used by RoomDirectory permissions // used by RoomDirectory permissions
$plinth-bg-color: $secondary-accent-color; $plinth-bg-color: $secondary-accent-color;
@ -156,7 +156,7 @@ $roomheader-color: #45474a;
$roomheader-bg-color: $primary-bg-color; $roomheader-bg-color: $primary-bg-color;
$roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2); $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2);
$roomheader-addroom-fg-color: #5c6470; $roomheader-addroom-fg-color: #5c6470;
$tagpanel-button-color: #91A1C0; $groupFilterPanel-button-color: #91A1C0;
$groupheader-button-color: #91A1C0; $groupheader-button-color: #91A1C0;
$rightpanel-button-color: #91A1C0; $rightpanel-button-color: #91A1C0;
$icon-button-color: #C1C6CD; $icon-button-color: #C1C6CD;
@ -176,7 +176,7 @@ $roomlist-bg-color: rgba(245, 245, 245, 0.90);
$roomlist-header-color: $tertiary-fg-color; $roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color;
$tagpanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
@ -208,6 +208,7 @@ $pinned-color: $notice-secondary-color;
// ******************** // ********************
$widget-menu-bar-bg-color: $secondary-accent-color; $widget-menu-bar-bg-color: $secondary-accent-color;
$widget-body-bg-color: #FFF;
// ******************** // ********************
@ -320,7 +321,7 @@ $appearance-tab-border-color: $input-darker-bg-color;
// blur amounts for left left panel (only for element theme, used in _mods.scss) // blur amounts for left left panel (only for element theme, used in _mods.scss)
$roomlist-background-blur-amount: 40px; $roomlist-background-blur-amount: 40px;
$tagpanel-background-blur-amount: 20px; $groupFilterPanel-background-blur-amount: 20px;
$composer-shadow-color: rgba(0, 0, 0, 0.04); $composer-shadow-color: rgba(0, 0, 0, 0.04);

View file

@ -6,14 +6,14 @@
@supports (backdrop-filter: none) { @supports (backdrop-filter: none) {
.mx_LeftPanel { .mx_LeftPanel {
background-image: var(--avatar-url); background-image: var(--avatar-url, unset);
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: left top; background-position: left top;
} }
.mx_TagPanel { .mx_GroupFilterPanel {
backdrop-filter: blur($tagpanel-background-blur-amount); backdrop-filter: blur($groupFilterPanel-background-blur-amount);
} }
.mx_LeftPanel .mx_LeftPanel_roomListContainer { .mx_LeftPanel .mx_LeftPanel_roomListContainer {

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr"; import * as ModernizrStatic from "modernizr";
import ContentMessages from "../ContentMessages"; import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg"; import { IMatrixClientPeg } from "../MatrixClientPeg";
@ -31,6 +32,9 @@ import type {Renderer} from "react-dom";
import RightPanelStore from "../stores/RightPanelStore"; import RightPanelStore from "../stores/RightPanelStore";
import WidgetStore from "../stores/WidgetStore"; import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler"; import CallHandler from "../CallHandler";
import {Analytics} from "../Analytics";
import UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
declare global { declare global {
interface Window { interface Window {
@ -55,6 +59,9 @@ declare global {
mxRightPanelStore: RightPanelStore; mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore; mxWidgetStore: WidgetStore;
mxCallHandler: CallHandler; mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
} }
interface Document { interface Document {

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler'; import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import Modal from './Modal'; import Modal from './Modal';
@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
const hashVarRegex = /#\/(group|room|user)\/.*$/; const hashVarRegex = /#\/(group|room|user)\/.*$/;
// Remove all but the first item in the hash path. Redact unexpected hashes. // Remove all but the first item in the hash path. Redact unexpected hashes.
function getRedactedHash(hash) { function getRedactedHash(hash: string): string {
// Don't leak URLs we aren't expecting - they could contain tokens/PII // Don't leak URLs we aren't expecting - they could contain tokens/PII
const match = hashRegex.exec(hash); const match = hashRegex.exec(hash);
if (!match) { if (!match) {
@ -44,7 +44,7 @@ function getRedactedHash(hash) {
// Return the current origin, path and hash separated with a `/`. This does // Return the current origin, path and hash separated with a `/`. This does
// not include query parameters. // not include query parameters.
function getRedactedUrl() { function getRedactedUrl(): string {
const { origin, hash } = window.location; const { origin, hash } = window.location;
let { pathname } = window.location; let { pathname } = window.location;
@ -56,7 +56,25 @@ function getRedactedUrl() {
return origin + pathname + getRedactedHash(hash); return origin + pathname + getRedactedHash(hash);
} }
const customVariables = { interface IData {
/* eslint-disable camelcase */
gt_ms?: string;
e_c?: string;
e_a?: string;
e_n?: string;
e_v?: string;
ping?: string;
/* eslint-enable camelcase */
}
interface IVariable {
id: number;
expl: string; // explanation
example: string; // example value
getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
}
const customVariables: Record<string, IVariable> = {
// The Matomo installation at https://matomo.riot.im is currently configured // The Matomo installation at https://matomo.riot.im is currently configured
// with a limit of 10 custom variables. // with a limit of 10 custom variables.
'App Platform': { 'App Platform': {
@ -120,7 +138,7 @@ const customVariables = {
}, },
}; };
function whitelistRedact(whitelist, str) { function whitelistRedact(whitelist: string[], str: string): string {
if (whitelist.includes(str)) return str; if (whitelist.includes(str)) return str;
return '<redacted>'; return '<redacted>';
} }
@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc"; const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts"; const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() { function getUid(): string {
try { try {
let data = localStorage && localStorage.getItem(UID_KEY); let data = localStorage && localStorage.getItem(UID_KEY);
if (!data && localStorage) { if (!data && localStorage) {
@ -145,32 +163,36 @@ function getUid() {
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
class Analytics { export class Analytics {
private baseUrl: URL = null;
private siteId: string = null;
private visitVariables: Record<number, [string, string]> = {}; // {[id: number]: [name: string, value: string]}
private firstPage = true;
private heartbeatIntervalID: number = null;
private readonly creationTs: string;
private readonly lastVisitTs: string;
private readonly visitCount: string;
constructor() { constructor() {
this.baseUrl = null;
this.siteId = null;
this.visitVariables = {};
this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY); this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs && localStorage) { if (!this.creationTs && localStorage) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime()); localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
} }
this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY); this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0; this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
if (localStorage) { if (localStorage) {
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1); localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
} }
} }
get disabled() { public get disabled() {
return !this.baseUrl; return !this.baseUrl;
} }
canEnable() { public canEnable() {
const config = SdkConfig.get(); const config = SdkConfig.get();
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
} }
@ -179,67 +201,67 @@ class Analytics {
* Enable Analytics if initialized but disabled * Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing * otherwise try and initalize, no-op if piwik config missing
*/ */
async enable() { public async enable() {
if (!this.disabled) return; if (!this.disabled) return;
if (!this.canEnable()) return; if (!this.canEnable()) return;
const config = SdkConfig.get(); const config = SdkConfig.get();
this.baseUrl = new URL("piwik.php", config.piwik.url); this.baseUrl = new URL("piwik.php", config.piwik.url);
// set constants // set constants
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
this.baseUrl.searchParams.set("apiv", 1); // API version to use this.baseUrl.searchParams.set("apiv", "1"); // API version to use
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
// set user parameters // set user parameters
this.baseUrl.searchParams.set("_id", getUid()); // uuid this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
if (this.lastVisitTs) { if (this.lastVisitTs) {
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
} }
const platform = PlatformPeg.get(); const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName()); this.setVisitVariable('App Platform', platform.getHumanReadableName());
try { try {
this._setVisitVariable('App Version', await platform.getAppVersion()); this.setVisitVariable('App Version', await platform.getAppVersion());
} catch (e) { } catch (e) {
this._setVisitVariable('App Version', 'unknown'); this.setVisitVariable('App Version', 'unknown');
} }
this._setVisitVariable('Chosen Language', getCurrentLanguage()); this.setVisitVariable('Chosen Language', getCurrentLanguage());
const hostname = window.location.hostname; const hostname = window.location.hostname;
if (hostname === 'riot.im') { if (hostname === 'riot.im') {
this._setVisitVariable('Instance', window.location.pathname); this.setVisitVariable('Instance', window.location.pathname);
} else if (hostname.endsWith('.element.io')) { } else if (hostname.endsWith('.element.io')) {
this._setVisitVariable('Instance', hostname.replace('.element.io', '')); this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
} }
let installedPWA = "unknown"; let installedPWA = "unknown";
try { try {
// Known to work at least for desktop Chrome // Known to work at least for desktop Chrome
installedPWA = window.matchMedia('(display-mode: standalone)').matches; installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
} catch (e) { } } catch (e) { }
this._setVisitVariable('Installed PWA', installedPWA); this.setVisitVariable('Installed PWA', installedPWA);
let touchInput = "unknown"; let touchInput = "unknown";
try { try {
// MDN claims broad support across browsers // MDN claims broad support across browsers
touchInput = window.matchMedia('(pointer: coarse)').matches; touchInput = String(window.matchMedia('(pointer: coarse)').matches);
} catch (e) { } } catch (e) { }
this._setVisitVariable('Touch Input', touchInput); this.setVisitVariable('Touch Input', touchInput);
// start heartbeat // start heartbeat
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL); this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
} }
/** /**
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage * Disable Analytics, stop the heartbeat and clear identifiers from localStorage
*/ */
disable() { public disable() {
if (this.disabled) return; if (this.disabled) return;
this.trackEvent('Analytics', 'opt-out'); this.trackEvent('Analytics', 'opt-out');
window.clearInterval(this._heartbeatIntervalID); window.clearInterval(this.heartbeatIntervalID);
this.baseUrl = null; this.baseUrl = null;
this.visitVariables = {}; this.visitVariables = {};
localStorage.removeItem(UID_KEY); localStorage.removeItem(UID_KEY);
@ -248,7 +270,7 @@ class Analytics {
localStorage.removeItem(LAST_VISIT_TS_KEY); localStorage.removeItem(LAST_VISIT_TS_KEY);
} }
async _track(data) { private async _track(data: IData) {
if (this.disabled) return; if (this.disabled) return;
const now = new Date(); const now = new Date();
@ -264,13 +286,13 @@ class Analytics {
s: now.getSeconds(), s: now.getSeconds(),
}; };
const url = new URL(this.baseUrl); const url = new URL(this.baseUrl.toString()); // copy
for (const key in params) { for (const key in params) {
url.searchParams.set(key, params[key]); url.searchParams.set(key, params[key]);
} }
try { try {
await window.fetch(url, { await window.fetch(url.toString(), {
method: "GET", method: "GET",
mode: "no-cors", mode: "no-cors",
cache: "no-cache", cache: "no-cache",
@ -281,14 +303,14 @@ class Analytics {
} }
} }
ping() { public ping() {
this._track({ this._track({
ping: 1, ping: "1",
}); });
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
} }
trackPageChange(generationTimeMs) { public trackPageChange(generationTimeMs?: number) {
if (this.disabled) return; if (this.disabled) return;
if (this.firstPage) { if (this.firstPage) {
// De-duplicate first page // De-duplicate first page
@ -303,11 +325,11 @@ class Analytics {
} }
this._track({ this._track({
gt_ms: generationTimeMs, gt_ms: String(generationTimeMs),
}); });
} }
trackEvent(category, action, name, value) { public trackEvent(category: string, action: string, name?: string, value?: string) {
if (this.disabled) return; if (this.disabled) return;
this._track({ this._track({
e_c: category, e_c: category,
@ -317,12 +339,12 @@ class Analytics {
}); });
} }
_setVisitVariable(key, value) { private setVisitVariable(key: keyof typeof customVariables, value: string) {
if (this.disabled) return; if (this.disabled) return;
this.visitVariables[customVariables[key].id] = [key, value]; this.visitVariables[customVariables[key].id] = [key, value];
} }
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
if (this.disabled) return; if (this.disabled) return;
const config = SdkConfig.get(); const config = SdkConfig.get();
@ -330,16 +352,16 @@ class Analytics {
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || []; const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
} }
setBreadcrumbs(state) { public setBreadcrumbs(state: boolean) {
if (this.disabled) return; if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
} }
showDetailsModal = () => { public showDetailsModal = () => {
let rows = []; let rows = [];
if (!this.disabled) { if (!this.disabled) {
rows = Object.values(this.visitVariables); rows = Object.values(this.visitVariables);
@ -360,7 +382,7 @@ class Analytics {
'e.g. <CurrentPageURL>', 'e.g. <CurrentPageURL>',
{}, {},
{ {
CurrentPageURL: getRedactedUrl(), CurrentPageURL: getRedactedUrl,
}, },
), ),
}, },
@ -401,7 +423,7 @@ class Analytics {
}; };
} }
if (!global.mxAnalytics) { if (!window.mxAnalytics) {
global.mxAnalytics = new Analytics(); window.mxAnalytics = new Analytics();
} }
export default global.mxAnalytics; export default window.mxAnalytics;

View file

@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {User} from "matrix-js-sdk/src/models/user";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(member, width, height, resizeMethod) { export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url; let url: string;
if (member && member.getAvatarUrl) { if (member && member.getAvatarUrl) {
url = member.getAvatarUrl( url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
return url; return url;
} }
export function avatarUrlForUser(user, width, height, resizeMethod) { export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
const url = getHttpUriForMxc( const url = getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
return url; return url;
} }
function isValidHexColor(color) { function isValidHexColor(color: string): boolean {
return typeof color === "string" && return typeof color === "string" &&
(color.length === 7 || color.lengh === 9) && (color.length === 7 || color.length === 9) &&
color.charAt(0) === "#" && color.charAt(0) === "#" &&
!color.substr(1).split("").some(c => isNaN(parseInt(c, 16))); !color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
} }
function urlForColor(color) { function urlForColor(color: string): string {
const size = 40; const size = 40;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = size; canvas.width = size;
@ -79,9 +84,9 @@ function urlForColor(color) {
// XXX: Ideally we'd clear this cache when the theme changes // XXX: Ideally we'd clear this cache when the theme changes
// but since this function is at global scope, it's a bit // but since this function is at global scope, it's a bit
// hard to install a listener here, even if there were a clear event to listen to // hard to install a listener here, even if there were a clear event to listen to
const colorToDataURLCache = new Map(); const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s) { export function defaultAvatarUrlForString(s: string): string {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8']; const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
let total = 0; let total = 0;
@ -113,7 +118,7 @@ export function defaultAvatarUrlForString(s) {
* @param {string} name * @param {string} name
* @return {string} the first letter * @return {string} the first letter
*/ */
export function getInitialLetter(name) { export function getInitialLetter(name: string): string {
if (!name) { if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy. // XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied"); console.trace("`name` argument to `getInitialLetter` not supplied");
@ -146,7 +151,7 @@ export function getInitialLetter(name) {
return firstChar.toUpperCase(); return firstChar.toUpperCase();
} }
export function avatarUrlForRoom(room, width, height, resizeMethod) { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl( const explicitRoomAvatar = room.getAvatarUrl(

View file

@ -77,13 +77,29 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore"; import WidgetStore from "./stores/WidgetStore";
import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call";
import Analytics from './Analytics';
// until we ts-ify the js-sdk voip code enum AudioID {
type Call = any; Ring = 'ringAudio',
Ringback = 'ringbackAudio',
CallEnd = 'callendAudio',
Busy = 'busyAudio',
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
export default class CallHandler { export default class CallHandler {
private calls = new Map<string, Call>(); private calls = new Map<string, MatrixCall>();
private audioPromises = new Map<string, Promise<void>>(); private audioPromises = new Map<AudioID, Promise<void>>();
static sharedInstance() { static sharedInstance() {
if (!window.mxCallHandler) { if (!window.mxCallHandler) {
@ -108,20 +124,20 @@ export default class CallHandler {
} }
} }
getCallForRoom(roomId: string): Call { getCallForRoom(roomId: string): MatrixCall {
return this.calls.get(roomId) || null; return this.calls.get(roomId) || null;
} }
getAnyActiveCall() { getAnyActiveCall() {
for (const call of this.calls.values()) { for (const call of this.calls.values()) {
if (call.state !== "ended") { if (call.state !== CallState.Ended) {
return call; return call;
} }
} }
return null; return null;
} }
play(audioId: string) { play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead // TODO: Attach an invisible element for this instead
// which listens? // which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement; const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -150,7 +166,7 @@ export default class CallHandler {
} }
} }
pause(audioId: string) { pause(audioId: AudioID) {
// TODO: Attach an invisible element for this instead // TODO: Attach an invisible element for this instead
// which listens? // which listens?
const audio = document.getElementById(audioId) as HTMLMediaElement; const audio = document.getElementById(audioId) as HTMLMediaElement;
@ -164,8 +180,19 @@ export default class CallHandler {
} }
} }
private setCallListeners(call: Call) { private matchesCallForThisRoom(call: MatrixCall) {
call.on("error", (err) => { // We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const callForThisRoom = this.getCallForRoom(call.roomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
call.on(CallEvent.Error, (err) => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callError', 'error', err);
console.error("Call error:", err); console.error("Call error:", err);
if ( if (
MatrixClientPeg.get().getTurnServers().length === 0 && MatrixClientPeg.get().getTurnServers().length === 0 &&
@ -180,74 +207,103 @@ export default class CallHandler {
description: err.message, description: err.message,
}); });
}); });
call.on("hangup", () => { call.on(CallEvent.Hangup, () => {
if (!this.matchesCallForThisRoom(call)) return;
Analytics.trackEvent('voip', 'callHangup');
this.removeCallForRoom(call.roomId); this.removeCallForRoom(call.roomId);
}); });
// map web rtc states to dummy UI state call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
// ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing if (!this.matchesCallForThisRoom(call)) return;
call.on("state", (newState, oldState) => {
if (newState === "ringing") { this.setCallState(call, newState);
this.setCallState(call, call.roomId, "ringing");
this.pause("ringbackAudio"); switch (oldState) {
} else if (newState === "invite_sent") { case CallState.Ringing:
this.setCallState(call, call.roomId, "ringback"); this.pause(AudioID.Ring);
this.play("ringbackAudio"); break;
} else if (newState === "ended" && oldState === "connected") { case CallState.InviteSent:
this.removeCallForRoom(call.roomId); this.pause(AudioID.Ringback);
this.pause("ringbackAudio"); break;
this.play("callendAudio");
} else if (newState === "ended" && oldState === "invite_sent" &&
(call.hangupParty === "remote" ||
(call.hangupParty === "local" && call.hangupReason === "invite_timeout")
)) {
this.setCallState(call, call.roomId, "busy");
this.pause("ringbackAudio");
this.play("busyAudio");
Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
title: _t('Call Timeout'),
description: _t('The remote side failed to pick up') + '.',
});
} else if (oldState === "invite_sent") {
this.setCallState(call, call.roomId, "stop_ringback");
this.pause("ringbackAudio");
} else if (oldState === "ringing") {
this.setCallState(call, call.roomId, "stop_ringing");
this.pause("ringbackAudio");
} else if (newState === "connected") {
this.setCallState(call, call.roomId, "connected");
this.pause("ringbackAudio");
} }
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
break;
case CallState.InviteSent:
this.play(AudioID.Ringback);
break;
case CallState.Ended:
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
this.removeCallForRoom(call.roomId);
if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
)) {
this.play(AudioID.Busy);
let title;
let description;
if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined");
description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these
// strings need proper input from design anyway, so let's
// not change this string until we have a proper one.
description = _t('The remote side failed to pick up') + '.';
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
}
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description,
});
} else if (call.hangupReason === CallErrorCode.AnsweredElsewhere) {
this.play(AudioID.Busy);
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
description: _t("The call was answered on another device."),
});
} else {
this.play(AudioID.CallEnd);
}
}
});
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
if (call.state === CallState.Ringing) {
this.pause(AudioID.Ring);
} else if (call.state === CallState.InviteSent) {
this.pause(AudioID.Ringback);
}
this.calls.set(newCall.roomId, newCall);
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
}); });
} }
private setCallState(call: Call, roomId: string, status: string) { private setCallState(call: MatrixCall, status: CallState) {
console.log( console.log(
`Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`, `Call state in ${call.roomId} changed to ${status}`,
); );
if (call) {
this.calls.set(roomId, call);
} else {
this.calls.delete(roomId);
}
if (status === "ringing") {
this.play("ringAudio");
} else if (call && call.call_state === "ringing") {
this.pause("ringAudio");
}
if (call) {
call.call_state = status;
}
dis.dispatch({ dis.dispatch({
action: 'call_state', action: 'call_state',
room_id: roomId, room_id: call.roomId,
state: status, state: status,
}); });
} }
private removeCallForRoom(roomId: string) { private removeCallForRoom(roomId: string) {
this.setCallState(null, roomId, null); this.calls.delete(roomId);
} }
private showICEFallbackPrompt() { private showICEFallbackPrompt() {
@ -279,36 +335,40 @@ export default class CallHandler {
}, null, true); }, null, true);
} }
private onAction = (payload: ActionPayload) => {
const placeCall = (newCall) => {
this.setCallListeners(newCall);
if (payload.type === 'voice') {
newCall.placeVoiceCall();
} else if (payload.type === 'video') {
newCall.placeVideoCall(
payload.remote_element,
payload.local_element,
);
} else if (payload.type === 'screensharing') {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(newCall.roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
newCall.placeScreenSharingCall(
payload.remote_element,
payload.local_element,
);
} else {
console.error("Unknown conf call type: %s", payload.type);
}
}
private placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId);
this.calls.set(roomId, call);
this.setCallListeners(call);
if (type === PlaceCallType.Voice) {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall(
remoteElement,
localElement,
);
} else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
call.placeScreenSharingCall(remoteElement, localElement);
} else {
console.error("Unknown conf call type: %s", type);
}
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) { switch (payload.action) {
case 'place_call': case 'place_call':
{ {
@ -343,8 +403,8 @@ export default class CallHandler {
return; return;
} else if (members.length === 2) { } else if (members.length === 2) {
console.info("Place %s call in %s", payload.type, payload.room_id); console.info("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call); this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({
action: "place_conference_call", action: "place_conference_call",
@ -358,6 +418,7 @@ export default class CallHandler {
break; break;
case 'place_conference_call': case 'place_conference_call':
console.info("Place conference call in %s", payload.room_id); console.info("Place conference call in %s", payload.room_id);
Analytics.trackEvent('voip', 'placeConferenceCall');
this.startCallApp(payload.room_id, payload.type); this.startCallApp(payload.room_id, payload.type);
break; break;
case 'end_conference': case 'end_conference':
@ -383,24 +444,29 @@ export default class CallHandler {
return; return;
} }
const call = payload.call; const call = payload.call as MatrixCall;
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(call.roomId, call)
this.setCallListeners(call); this.setCallListeners(call);
this.setCallState(call, call.roomId, "ringing");
} }
break; break;
case 'hangup': case 'hangup':
case 'reject':
if (!this.calls.get(payload.room_id)) { if (!this.calls.get(payload.room_id)) {
return; // no call to hangup return; // no call to hangup
} }
this.calls.get(payload.room_id).hangup(); if (payload.action === 'reject') {
this.calls.get(payload.room_id).reject();
} else {
this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false);
}
this.removeCallForRoom(payload.room_id); this.removeCallForRoom(payload.room_id);
break; break;
case 'answer': case 'answer':
if (!this.calls.get(payload.room_id)) { if (!this.calls.has(payload.room_id)) {
return; // no call to answer return; // no call to answer
} }
this.calls.get(payload.room_id).answer(); this.calls.get(payload.room_id).answer();
this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected");
dis.dispatch({ dis.dispatch({
action: "view_room", action: "view_room",
room_id: payload.room_id, room_id: payload.room_id,

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import extend from './extend';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixClient} from "matrix-js-sdk/src/client";
@ -497,7 +496,7 @@ export default class ContentMessages {
if (file.type.indexOf('image/') === 0) { if (file.type.indexOf('image/') === 0) {
content.msgtype = 'm.image'; content.msgtype = 'm.image';
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
extend(content.info, imageInfo); Object.assign(content.info, imageInfo);
resolve(); resolve();
}, (e) => { }, (e) => {
console.error(e); console.error(e);
@ -510,7 +509,7 @@ export default class ContentMessages {
} else if (file.type.indexOf('video/') === 0) { } else if (file.type.indexOf('video/') === 0) {
content.msgtype = 'm.video'; content.msgtype = 'm.video';
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
extend(content.info, videoInfo); Object.assign(content.info, videoInfo);
resolve(); resolve();
}, (e) => { }, (e) => {
content.msgtype = 'm.file'; content.msgtype = 'm.file';

View file

@ -17,7 +17,7 @@ limitations under the License.
import { _t } from './languageHandler'; import { _t } from './languageHandler';
function getDaysArray() { function getDaysArray(): string[] {
return [ return [
_t('Sun'), _t('Sun'),
_t('Mon'), _t('Mon'),
@ -29,7 +29,7 @@ function getDaysArray() {
]; ];
} }
function getMonthsArray() { function getMonthsArray(): string[] {
return [ return [
_t('Jan'), _t('Jan'),
_t('Feb'), _t('Feb'),
@ -46,11 +46,11 @@ function getMonthsArray() {
]; ];
} }
function pad(n) { function pad(n: number): string {
return (n < 10 ? '0' : '') + n; return (n < 10 ? '0' : '') + n;
} }
function twelveHourTime(date, showSeconds=false) { function twelveHourTime(date: Date, showSeconds = false): string {
let hours = date.getHours() % 12; let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes()); const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) {
return `${hours}:${minutes}${ampm}`; return `${hours}:${minutes}${ampm}`;
} }
export function formatDate(date, showTwelveHour=false) { export function formatDate(date: Date, showTwelveHour = false): string {
const now = new Date(); const now = new Date();
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) {
return formatFullDate(date, showTwelveHour); return formatFullDate(date, showTwelveHour);
} }
export function formatFullDateNoTime(date) { export function formatFullDateNoTime(date: Date): string {
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) {
}); });
} }
export function formatFullDate(date, showTwelveHour=false) { export function formatFullDate(date: Date, showTwelveHour = false): string {
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) {
}); });
} }
export function formatFullTime(date, showTwelveHour=false) { export function formatFullTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) { if (showTwelveHour) {
return twelveHourTime(date, true); return twelveHourTime(date, true);
} }
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
} }
export function formatTime(date, showTwelveHour=false) { export function formatTime(date: Date, showTwelveHour = false): string {
if (showTwelveHour) { if (showTwelveHour) {
return twelveHourTime(date); return twelveHourTime(date);
} }
@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) {
} }
const MILLIS_IN_DAY = 86400000; const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate, nextEventDate) { export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) { if (!nextEventDate || !prevEventDate) {
return false; return false;
} }

View file

@ -23,6 +23,7 @@ import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg';
import SecurityCustomisations from "./customisations/Security";
import EventIndexPeg from './indexing/EventIndexPeg'; import EventIndexPeg from './indexing/EventIndexPeg';
import createMatrixClient from './utils/createMatrixClient'; import createMatrixClient from './utils/createMatrixClient';
import Analytics from './Analytics'; import Analytics from './Analytics';
@ -567,6 +568,8 @@ function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void
localStorage.setItem("mx_device_id", credentials.deviceId); localStorage.setItem("mx_device_id", credentials.deviceId);
} }
SecurityCustomisations.persistCredentials?.(credentials);
console.log(`Session persisted for ${credentials.userId}`); console.log(`Session persisted for ${credentials.userId}`);
} }

View file

@ -22,6 +22,7 @@ limitations under the License.
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { IMatrixClientCreds } from "./MatrixClientPeg"; import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
interface ILoginOptions { interface ILoginOptions {
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
@ -222,11 +223,15 @@ export async function sendLoginRequest(
} }
} }
return { const creds: IMatrixClientCreds = {
homeserverUrl: hsUrl, homeserverUrl: hsUrl,
identityServerUrl: isUrl, identityServerUrl: isUrl,
userId: data.user_id, userId: data.user_id,
deviceId: data.device_id, deviceId: data.device_id,
accessToken: data.access_token, accessToken: data.access_token,
}; };
SecurityCustomisations.examineLoginResponse?.(data, creds);
return creds;
} }

View file

@ -17,6 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
import {MatrixClient} from 'matrix-js-sdk/src/client'; import {MatrixClient} from 'matrix-js-sdk/src/client';
import {MemoryStore} from 'matrix-js-sdk/src/store/memory'; import {MemoryStore} from 'matrix-js-sdk/src/store/memory';
import * as utils from 'matrix-js-sdk/src/utils'; import * as utils from 'matrix-js-sdk/src/utils';
@ -249,8 +250,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
} }
private createClient(creds: IMatrixClientCreds): void { private createClient(creds: IMatrixClientCreds): void {
// TODO: Make these opts typesafe with the js-sdk const opts: ICreateClientOpts = {
const opts = {
baseUrl: creds.homeserverUrl, baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl, idBaseUrl: creds.identityServerUrl,
accessToken: creds.accessToken, accessToken: creds.accessToken,

View file

@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
interface IModal<T extends any[]> { export interface IModal<T extends any[]> {
elem: React.ReactNode; elem: React.ReactNode;
className?: string; className?: string;
beforeClosePromise?: Promise<boolean>; beforeClosePromise?: Promise<boolean>;
@ -38,7 +38,7 @@ interface IModal<T extends any[]> {
close(...args: T): void; close(...args: T): void;
} }
interface IHandle<T extends any[]> { export interface IHandle<T extends any[]> {
finished: Promise<T>; finished: Promise<T>;
close(...args: T): void; close(...args: T): void;
} }
@ -132,7 +132,7 @@ export class ModalManager {
public createTrackedDialogAsync<T extends any[]>( public createTrackedDialogAsync<T extends any[]>(
analyticsAction: string, analyticsAction: string,
analyticsInfo: string, analyticsInfo: string,
...rest: Parameters<ModalManager["appendDialogAsync"]> ...rest: Parameters<ModalManager["createDialogAsync"]>
) { ) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialogAsync<T>(...rest); return this.createDialogAsync<T>(...rest);

View file

@ -218,7 +218,7 @@ export const Notifier = {
// calculated value. It is determined based upon whether or not the master rule is enabled // calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference. // and other flags. Setting it here would cause a circular reference.
Analytics.trackEvent('Notifier', 'Set Enabled', enable); Analytics.trackEvent('Notifier', 'Set Enabled', String(enable));
// make sure that we persist the current setting audio_enabled setting // make sure that we persist the current setting audio_enabled setting
// before changing anything // before changing anything
@ -287,7 +287,7 @@ export const Notifier = {
setPromptHidden: function(hidden: boolean, persistent = true) { setPromptHidden: function(hidden: boolean, persistent = true) {
this.toolbarHidden = hidden; this.toolbarHidden = hidden;
Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden));
hideNotificationsToast(); hideNotificationsToast();

View file

@ -19,30 +19,34 @@ limitations under the License.
import {MatrixClientPeg} from "./MatrixClientPeg"; import {MatrixClientPeg} from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
import Timer from './utils/Timer'; import Timer from './utils/Timer';
import {ActionPayload} from "./dispatcher/payloads";
// Time in ms after that a user is considered as unavailable/away // Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
enum State {
Online = "online",
Offline = "offline",
Unavailable = "unavailable",
}
class Presence { class Presence {
constructor() { private unavailableTimer: Timer = null;
this._activitySignal = null; private dispatcherRef: string = null;
this._unavailableTimer = null; private state: State = null;
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
/** /**
* Start listening the user activity to evaluate his presence state. * Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the homeserver. * Any state change will be sent to the homeserver.
*/ */
async start() { public async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer // the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction); this.dispatcherRef = dis.register(this.onAction);
while (this._unavailableTimer) { while (this.unavailableTimer) {
try { try {
await this._unavailableTimer.finished(); await this.unavailableTimer.finished();
this.setState("unavailable"); this.setState(State.Unavailable);
} catch (e) { /* aborted, stop got called */ } } catch (e) { /* aborted, stop got called */ }
} }
} }
@ -50,14 +54,14 @@ class Presence {
/** /**
* Stop tracking user activity * Stop tracking user activity
*/ */
stop() { public stop() {
if (this._dispatcherRef) { if (this.dispatcherRef) {
dis.unregister(this._dispatcherRef); dis.unregister(this.dispatcherRef);
this._dispatcherRef = null; this.dispatcherRef = null;
} }
if (this._unavailableTimer) { if (this.unavailableTimer) {
this._unavailableTimer.abort(); this.unavailableTimer.abort();
this._unavailableTimer = null; this.unavailableTimer = null;
} }
} }
@ -65,14 +69,14 @@ class Presence {
* Get the current presence state. * Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum) * @returns {string} the presence state (see PRESENCE enum)
*/ */
getState() { public getState() {
return this.state; return this.state;
} }
_onAction(payload) { private onAction = (payload: ActionPayload) => {
if (payload.action === 'user_activity') { if (payload.action === 'user_activity') {
this.setState("online"); this.setState(State.Online);
this._unavailableTimer.restart(); this.unavailableTimer.restart();
} }
} }
@ -81,13 +85,11 @@ class Presence {
* If the state has changed, the homeserver will be notified. * If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum) * @param {string} newState the new presence state (see PRESENCE enum)
*/ */
async setState(newState) { private async setState(newState: State) {
if (newState === this.state) { if (newState === this.state) {
return; return;
} }
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
const oldState = this.state; const oldState = this.state;
this.state = newState; this.state = newState;

View file

@ -13,9 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { _t } from './languageHandler'; import { _t } from './languageHandler';
export function levelRoleMap(usersDefault) { export function levelRoleMap(usersDefault: number) {
return { return {
undefined: _t('Default'), undefined: _t('Default'),
0: _t('Restricted'), 0: _t('Restricted'),
@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) {
}; };
} }
export function textualPowerLevel(level, usersDefault) { export function textualPowerLevel(level: number, usersDefault: number): string {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) { if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level]; return LEVEL_ROLE_MAP[level];

View file

@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
let oldestEventFrom = previousSearchResult.oldestEventFrom; let oldestEventFrom = previousSearchResult.oldestEventFrom;
response.highlights = previousSearchResult.highlights; response.highlights = previousSearchResult.highlights;
if (localEvents && serverEvents) { if (localEvents && serverEvents && serverEvents.results) {
// This is a first search call, combine the events from the server and // This is a first search call, combine the events from the server and
// the local index. Note where our oldest event came from, we shall // the local index. Note where our oldest event came from, we shall
// fetch the next batch of events from the other source. // fetch the next batch of events from the other source.
@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
oldestEventFrom = "local"; oldestEventFrom = "local";
} }
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
} else if (serverEvents) { } else if (serverEvents && serverEvents.results) {
// This is a pagination call fetching more events from the server, // This is a pagination call fetching more events from the server,
// meaning that our oldest event was in the local index. // meaning that our oldest event was in the local index.
// Change the source of the oldest event if our server event is older // Change the source of the oldest event if our server event is older
@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
return response; return response;
} }
function restoreEncryptionInfo(searchResultSlice) { function restoreEncryptionInfo(searchResultSlice = []) {
for (let i = 0; i < searchResultSlice.length; i++) { for (let i = 0; i < searchResultSlice.length; i++) {
const timeline = searchResultSlice[i].context.getTimeline(); const timeline = searchResultSlice[i].context.getTimeline();
@ -517,7 +517,7 @@ async function combinedPagination(searchResult) {
}, },
}; };
const oldResultCount = searchResult.results.length; const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result. // Let the client process the combined result.
const result = client._processRoomEventsSearch(searchResult, response); const result = client._processRoomEventsSearch(searchResult, response);

View file

@ -14,32 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; import * as sdk from './index';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { isSecureBackupRequired } from './utils/WellKnownUtils';
import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
// This stores the secret storage private keys in memory for the JS SDK. This is // This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times // only meant to act as a cache to avoid prompting the user multiple times
// during the same single operation. Use `accessSecretStorage` below to scope a // during the same single operation. Use `accessSecretStorage` below to scope a
// single secret storage operation, as it will clear the cached keys once the // single secret storage operation, as it will clear the cached keys once the
// operation ends. // operation ends.
let secretStorageKeys = {}; let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo = {}; let secretStorageKeyInfo: Record<string, ISecretStorageKeyInfo> = {};
let secretStorageBeingAccessed = false; let secretStorageBeingAccessed = false;
let nonInteractive = false; let nonInteractive = false;
let dehydrationCache = {}; let dehydrationCache: {
key?: Uint8Array,
keyInfo?: ISecretStorageKeyInfo,
} = {};
function isCachingAllowed() { function isCachingAllowed(): boolean {
return secretStorageBeingAccessed; return secretStorageBeingAccessed;
} }
@ -50,7 +56,7 @@ function isCachingAllowed() {
* *
* @returns {bool} * @returns {bool}
*/ */
export function isSecretStorageBeingAccessed() { export function isSecretStorageBeingAccessed(): boolean {
return secretStorageBeingAccessed; return secretStorageBeingAccessed;
} }
@ -60,7 +66,7 @@ export class AccessCancelledError extends Error {
} }
} }
async function confirmToDismiss() { async function confirmToDismiss(): Promise<boolean> {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const [sure] = await Modal.createDialog(QuestionDialog, { const [sure] = await Modal.createDialog(QuestionDialog, {
title: _t("Cancel entering passphrase?"), title: _t("Cancel entering passphrase?"),
@ -72,7 +78,9 @@ async function confirmToDismiss() {
return !sure; return !sure;
} }
function makeInputToKey(keyInfo) { function makeInputToKey(
keyInfo: ISecretStorageKeyInfo,
): (keyParams: { passphrase: string, recoveryKey: string }) => Promise<Uint8Array> {
return async ({ passphrase, recoveryKey }) => { return async ({ passphrase, recoveryKey }) => {
if (passphrase) { if (passphrase) {
return deriveKey( return deriveKey(
@ -86,7 +94,10 @@ function makeInputToKey(keyInfo) {
}; };
} }
async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { async function getSecretStorageKey(
{ keys: keyInfos }: { keys: Record<string, ISecretStorageKeyInfo> },
ssssItemName,
): Promise<[string, Uint8Array]> {
const keyInfoEntries = Object.entries(keyInfos); const keyInfoEntries = Object.entries(keyInfos);
if (keyInfoEntries.length > 1) { if (keyInfoEntries.length > 1) {
throw new Error("Multiple storage key requests not implemented"); throw new Error("Multiple storage key requests not implemented");
@ -100,11 +111,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
if (dehydrationCache.key) { if (dehydrationCache.key) {
if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo); cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key]; return [keyId, dehydrationCache.key];
} }
} }
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (secret storage)")
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
return [keyId, keyFromCustomisations];
}
if (nonInteractive) { if (nonInteractive) {
throw new Error("Could not unlock non-interactively"); throw new Error("Could not unlock non-interactively");
} }
@ -139,12 +157,21 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
const key = await inputToKey(input); const key = await inputToKey(input);
// Save to cache to avoid future prompts in the current session // Save to cache to avoid future prompts in the current session
cacheSecretStorageKey(keyId, key, keyInfo); cacheSecretStorageKey(keyId, keyInfo, key);
return [keyId, key]; return [keyId, key];
} }
export async function getDehydrationKey(keyInfo, checkFunc) { export async function getDehydrationKey(
keyInfo: ISecretStorageKeyInfo,
checkFunc: (Uint8Array) => void,
): Promise<Uint8Array> {
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Using key from security customisations (dehydration)")
return keyFromCustomisations;
}
const inputToKey = makeInputToKey(keyInfo); const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
AccessSecretStorageDialog, AccessSecretStorageDialog,
@ -185,20 +212,24 @@ export async function getDehydrationKey(keyInfo, checkFunc) {
return key; return key;
} }
function cacheSecretStorageKey(keyId, key, keyInfo) { function cacheSecretStorageKey(
keyId: string,
keyInfo: ISecretStorageKeyInfo,
key: Uint8Array,
): void {
if (isCachingAllowed()) { if (isCachingAllowed()) {
secretStorageKeys[keyId] = key; secretStorageKeys[keyId] = key;
secretStorageKeyInfo[keyId] = keyInfo; secretStorageKeyInfo[keyId] = keyInfo;
} }
} }
const onSecretRequested = async function({ async function onSecretRequested(
user_id: userId, userId: string,
device_id: deviceId, deviceId: string,
request_id: requestId, requestId: string,
name, name: string,
device_trust: deviceTrust, deviceTrust: IDeviceTrustLevel,
}) { ): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (userId !== client.getUserId()) { if (userId !== client.getUserId()) {
@ -233,16 +264,16 @@ const onSecretRequested = async function({
return key && encodeBase64(key); return key && encodeBase64(key);
} }
console.warn("onSecretRequested didn't recognise the secret named ", name); console.warn("onSecretRequested didn't recognise the secret named ", name);
}; }
export const crossSigningCallbacks = { export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey, getSecretStorageKey,
cacheSecretStorageKey, cacheSecretStorageKey,
onSecretRequested, onSecretRequested,
getDehydrationKey, getDehydrationKey,
}; };
export async function promptForBackupPassphrase() { export async function promptForBackupPassphrase(): Promise<Uint8Array> {
let key; let key;
const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
@ -292,7 +323,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
/* priority = */ false, /* priority = */ false,
/* static = */ true, /* static = */ true,
/* options = */ { /* options = */ {
onBeforeClose(reason) { onBeforeClose: async (reason) => {
// If Secure Backup is required, you cannot leave the modal. // If Secure Backup is required, you cannot leave the modal.
if (reason === "backgroundClick") { if (reason === "backgroundClick") {
return !isSecureBackupRequired(); return !isSecureBackupRequired();
@ -329,20 +360,25 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
const keyId = Object.keys(secretStorageKeys)[0]; const keyId = Object.keys(secretStorageKeys)[0];
if (keyId && SettingsStore.getValue("feature_dehydration")) { if (keyId && SettingsStore.getValue("feature_dehydration")) {
const dehydrationKeyInfo = let dehydrationKeyInfo = {};
secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
? {passphrase: secretStorageKeyInfo[keyId].passphrase} dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
: {}; }
console.log("Setting dehydration key"); console.log("Setting dehydration key");
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
} else if (!keyId) {
console.warn("Not setting dehydration key: no SSSS key found");
} else { } else {
console.log("Not setting dehydration key: no SSSS key found"); console.log("Not setting dehydration key: feature disabled");
} }
} }
// `return await` needed here to ensure `finally` block runs after the // `return await` needed here to ensure `finally` block runs after the
// inner operation completes. // inner operation completes.
return await func(); return await func();
} catch (e) {
SecurityCustomisations.catchAccessSecretStorageError?.(e);
console.error(e);
} finally { } finally {
// Clear secret storage key cache now that work is complete // Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false; secretStorageBeingAccessed = false;
@ -354,7 +390,9 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
} }
// FIXME: this function name is a bit of a mouthful // FIXME: this function name is a bit of a mouthful
export async function tryToUnlockSecretStorageWithDehydrationKey(client) { export async function tryToUnlockSecretStorageWithDehydrationKey(
client: MatrixClient,
): Promise<void> {
const key = dehydrationCache.key; const key = dehydrationCache.key;
let restoringBackup = false; let restoringBackup = false;
if (key && await client.isSecretStorageReady()) { if (key && await client.isSecretStorageReady()) {
@ -366,10 +404,10 @@ export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
// we also need to set a new dehydrated device to replace the // we also need to set a new dehydrated device to replace the
// device we rehydrated // device we rehydrated
const dehydrationKeyInfo = let dehydrationKeyInfo = {};
dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) {
? {passphrase: dehydrationCache.keyInfo.passphrase} dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase };
: {}; }
await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
// and restore from backup // and restore from backup

View file

@ -198,59 +198,30 @@ function textForRelatedGroupsEvent(ev) {
function textForServerACLEvent(ev) { function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const changes = [];
const current = ev.getContent(); const current = ev.getContent();
const prev = { const prev = {
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
allow_ip_literals: !(prevContent.allow_ip_literals === false), allow_ip_literals: !(prevContent.allow_ip_literals === false),
}; };
let text = ""; let text = "";
if (prev.deny.length === 0 && prev.allow.length === 0) { if (prev.deny.length === 0 && prev.allow.length === 0) {
text = `${senderDisplayName} set server ACLs for this room: `; text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
} else { } else {
text = `${senderDisplayName} changed the server ACLs for this room: `; text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
} }
if (!Array.isArray(current.allow)) { if (!Array.isArray(current.allow)) {
current.allow = []; current.allow = [];
} }
/* If we know for sure everyone is banned, don't bother showing the diff view */
// If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) { if (current.allow.length === 0) {
return text + "🎉 All servers are banned from participating! This room can no longer be used."; return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used.");
} }
if (!Array.isArray(current.deny)) { return text;
current.deny = [];
}
const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv));
const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv));
const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv));
const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv));
if (bannedServers.length > 0) {
changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`);
}
if (unbannedServers.length > 0) {
changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`);
}
if (allowedServers.length > 0) {
changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`);
}
if (unallowedServers.length > 0) {
changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`);
}
if (prev.allow_ip_literals !== current.allow_ip_literals) {
const allowban = current.allow_ip_literals ? "allowed" : "banned";
changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`);
}
return text + changes.join(" ");
} }
function textForMessageEvent(ev) { function textForMessageEvent(ev) {
@ -329,14 +300,27 @@ function textForCallHangupEvent(event) {
reason = _t('(not supported by this browser)'); reason = _t('(not supported by this browser)');
} else if (eventContent.reason) { } else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") { if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all
reason = _t('(could not connect media)'); reason = _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died
reason = _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices
reason = _t("(their device couldn't start the camera / microphone)");
} else if (eventContent.reason === "unknown_error") {
// An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about,
// in which case we show the error code)
reason = _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") { } else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)'); reason = _t('(no answer)');
} else if (eventContent.reason === "user hangup") { } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
// workaround for https://github.com/vector-im/element-web/issues/5178 // workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is // it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :( // interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623 // https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore)
reason = ''; reason = '';
} else { } else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
@ -345,6 +329,11 @@ function textForCallHangupEvent(event) {
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
} }
function textForCallRejectEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', {senderName});
}
function textForCallInviteEvent(event) { function textForCallInviteEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event? // FIXME: Find a better way to determine this from the event?
@ -574,6 +563,7 @@ const handlers = {
'm.call.invite': textForCallInviteEvent, 'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent, 'm.call.answer': textForCallAnswerEvent,
'm.call.hangup': textForCallHangupEvent, 'm.call.hangup': textForCallHangupEvent,
'm.call.reject': textForCallRejectEvent,
}; };
const stateHandlers = { const stateHandlers = {

View file

@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
* see doc on the userActive* functions for what these mean. * see doc on the userActive* functions for what these mean.
*/ */
export default class UserActivity { export default class UserActivity {
constructor(windowObj, documentObj) { private readonly activeNowTimeout: Timer;
this._window = windowObj; private readonly activeRecentlyTimeout: Timer;
this._document = documentObj; private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
this._attachedActiveNowTimers = []; constructor(private readonly window: Window, private readonly document: Document) {
this._attachedActiveRecentlyTimers = []; this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
} }
static sharedInstance() { static sharedInstance() {
if (global.mxUserActivity === undefined) { if (window.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document); window.mxUserActivity = new UserActivity(window, document);
} }
return global.mxUserActivity; return window.mxUserActivity;
} }
/** /**
@ -69,8 +66,8 @@ export default class UserActivity {
* later on when the user does become active. * later on when the user does become active.
* @param {Timer} timer the timer to use * @param {Timer} timer the timer to use
*/ */
timeWhileActiveNow(timer) { public timeWhileActiveNow(timer: Timer) {
this._timeWhile(timer, this._attachedActiveNowTimers); this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) { if (this.userActiveNow()) {
timer.start(); timer.start();
} }
@ -85,14 +82,14 @@ export default class UserActivity {
* later on when the user does become active. * later on when the user does become active.
* @param {Timer} timer the timer to use * @param {Timer} timer the timer to use
*/ */
timeWhileActiveRecently(timer) { public timeWhileActiveRecently(timer: Timer) {
this._timeWhile(timer, this._attachedActiveRecentlyTimers); this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) { if (this.userActiveRecently()) {
timer.start(); timer.start();
} }
} }
_timeWhile(timer, attachedTimers) { private timeWhile(timer: Timer, attachedTimers: Timer[]) {
// important this happens first // important this happens first
const index = attachedTimers.indexOf(timer); const index = attachedTimers.indexOf(timer);
if (index === -1) { if (index === -1) {
@ -112,36 +109,36 @@ export default class UserActivity {
/** /**
* Start listening to user activity * Start listening to user activity
*/ */
start() { public start() {
this._document.addEventListener('mousedown', this._onUserActivity); this.document.addEventListener('mousedown', this.onUserActivity);
this._document.addEventListener('mousemove', this._onUserActivity); this.document.addEventListener('mousemove', this.onUserActivity);
this._document.addEventListener('keydown', this._onUserActivity); this.document.addEventListener('keydown', this.onUserActivity);
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged); this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this._window.addEventListener("blur", this._onWindowBlurred); this.window.addEventListener("blur", this.onWindowBlurred);
this._window.addEventListener("focus", this._onUserActivity); this.window.addEventListener("focus", this.onUserActivity);
// can't use document.scroll here because that's only the document // can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture. // itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is // also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message. // fired when the view scrolls down for a new message.
this._window.addEventListener('wheel', this._onUserActivity, { this.window.addEventListener('wheel', this.onUserActivity, {
passive: true, capture: true, passive: true,
capture: true,
}); });
} }
/** /**
* Stop tracking user activity * Stop tracking user activity
*/ */
stop() { public stop() {
this._document.removeEventListener('mousedown', this._onUserActivity); this.document.removeEventListener('mousedown', this.onUserActivity);
this._document.removeEventListener('mousemove', this._onUserActivity); this.document.removeEventListener('mousemove', this.onUserActivity);
this._document.removeEventListener('keydown', this._onUserActivity); this.document.removeEventListener('keydown', this.onUserActivity);
this._window.removeEventListener('wheel', this._onUserActivity, { this.window.removeEventListener('wheel', this.onUserActivity, {
passive: true, capture: true, capture: true,
}); });
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged); this.window.removeEventListener("blur", this.onWindowBlurred);
this._window.removeEventListener("blur", this._onWindowBlurred); this.window.removeEventListener("focus", this.onUserActivity);
this._window.removeEventListener("focus", this._onUserActivity);
} }
/** /**
@ -151,8 +148,8 @@ export default class UserActivity {
* user's attention at any given moment. * user's attention at any given moment.
* @returns {boolean} true if user is currently 'active' * @returns {boolean} true if user is currently 'active'
*/ */
userActiveNow() { public userActiveNow() {
return this._activeNowTimeout.isRunning(); return this.activeNowTimeout.isRunning();
} }
/** /**
@ -163,27 +160,27 @@ export default class UserActivity {
* (or they may have gone to make tea and left the window focused). * (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently * @returns {boolean} true if user has been active recently
*/ */
userActiveRecently() { public userActiveRecently() {
return this._activeRecentlyTimeout.isRunning(); return this.activeRecentlyTimeout.isRunning();
} }
_onPageVisibilityChanged(e) { private onPageVisibilityChanged = e => {
if (this._document.visibilityState === "hidden") { if (this.document.visibilityState === "hidden") {
this._activeNowTimeout.abort(); this.activeNowTimeout.abort();
this._activeRecentlyTimeout.abort(); this.activeRecentlyTimeout.abort();
} else { } else {
this._onUserActivity(e); this.onUserActivity(e);
} }
} };
_onWindowBlurred() { private onWindowBlurred = () => {
this._activeNowTimeout.abort(); this.activeNowTimeout.abort();
this._activeRecentlyTimeout.abort(); this.activeRecentlyTimeout.abort();
} };
_onUserActivity(event) { private onUserActivity = (event: MouseEvent) => {
// ignore anything if the window isn't focused // ignore anything if the window isn't focused
if (!this._document.hasFocus()) return; if (!this.document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") { if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
@ -195,25 +192,25 @@ export default class UserActivity {
} }
dis.dispatch({action: 'user_activity'}); dis.dispatch({action: 'user_activity'});
if (!this._activeNowTimeout.isRunning()) { if (!this.activeNowTimeout.isRunning()) {
this._activeNowTimeout.start(); this.activeNowTimeout.start();
dis.dispatch({action: 'user_activity_start'}); dis.dispatch({action: 'user_activity_start'});
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout); UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else { } else {
this._activeNowTimeout.restart(); this.activeNowTimeout.restart();
} }
if (!this._activeRecentlyTimeout.isRunning()) { if (!this.activeRecentlyTimeout.isRunning()) {
this._activeRecentlyTimeout.start(); this.activeRecentlyTimeout.start();
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout); UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else { } else {
this._activeRecentlyTimeout.restart(); this.activeRecentlyTimeout.restart();
} }
} };
async _runTimersUntilTimeout(attachedTimers, timeout) { private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) {
attachedTimers.forEach((t) => t.start()); attachedTimers.forEach((t) => t.start());
try { try {
await timeout.finished(); await timeout.finished();

View file

@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {MatrixClientPeg} from "./MatrixClientPeg"; import {MatrixClientPeg} from "./MatrixClientPeg";
import { _t } from './languageHandler'; import { _t } from './languageHandler';
export function usersTypingApartFromMeAndIgnored(room) { export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
return usersTyping( return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers()));
room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()),
);
} }
export function usersTypingApartFromMe(room) { export function usersTypingApartFromMe(room: Room): RoomMember[] {
return usersTyping( return usersTyping(room, [MatrixClientPeg.get().getUserId()]);
room, [MatrixClientPeg.get().credentials.userId],
);
} }
/** /**
@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) {
* to exclude, return a list of user objects who are typing. * to exclude, return a list of user objects who are typing.
* @param {Room} room: room object to get users from. * @param {Room} room: room object to get users from.
* @param {string[]} exclude: list of user mxids to exclude. * @param {string[]} exclude: list of user mxids to exclude.
* @returns {string[]} list of user objects who are typing. * @returns {RoomMember[]} list of user objects who are typing.
*/ */
export function usersTyping(room, exclude) { export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] {
const whoIsTyping = []; const whoIsTyping = [];
if (exclude === undefined) {
exclude = [];
}
const memberKeys = Object.keys(room.currentState.members); const memberKeys = Object.keys(room.currentState.members);
for (let i = 0; i < memberKeys.length; ++i) { for (let i = 0; i < memberKeys.length; ++i) {
const userId = memberKeys[i]; const userId = memberKeys[i];
@ -57,20 +52,21 @@ export function usersTyping(room, exclude) {
return whoIsTyping; return whoIsTyping;
} }
export function whoIsTypingString(whoIsTyping, limit) { export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string {
let othersCount = 0; let othersCount = 0;
if (whoIsTyping.length > limit) { if (whoIsTyping.length > limit) {
othersCount = whoIsTyping.length - limit + 1; othersCount = whoIsTyping.length - limit + 1;
} }
if (whoIsTyping.length === 0) { if (whoIsTyping.length === 0) {
return ''; return '';
} else if (whoIsTyping.length === 1) { } else if (whoIsTyping.length === 1) {
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
} }
const names = whoIsTyping.map(function(m) {
return m.name; const names = whoIsTyping.map(m => m.name);
});
if (othersCount>=1) { if (othersCount >= 1) {
return _t('%(names)s and %(count)s others are typing …', { return _t('%(names)s and %(count)s others are typing …', {
names: names.slice(0, limit - 1).join(', '), names: names.slice(0, limit - 1).join(', '),
count: othersCount, count: othersCount,

View file

@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
// onFocus should be called when the index gained focus in any manner // onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => { export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext); const context = useContext(RovingTabIndexContext);
let ref = useRef<HTMLElement>(null); let ref = useRef<HTMLElement>(null);

View file

@ -17,14 +17,14 @@ limitations under the License.
import Analytics from '../Analytics'; import Analytics from '../Analytics';
import { asyncAction } from './actionCreators'; import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore'; import GroupFilterOrderStore from '../stores/GroupFilterOrderStore';
import { AsyncActionPayload } from "../dispatcher/payloads"; import { AsyncActionPayload } from "../dispatcher/payloads";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
export default class TagOrderActions { export default class TagOrderActions {
/** /**
* Creates an action thunk that will do an asynchronous request to * Creates an action thunk that will do an asynchronous request to
* move a tag in TagOrderStore to destinationIx. * move a tag in GroupFilterOrderStore to destinationIx.
* *
* @param {MatrixClient} matrixClient the matrix client to set the * @param {MatrixClient} matrixClient the matrix client to set the
* account data on. * account data on.
@ -36,8 +36,8 @@ export default class TagOrderActions {
*/ */
public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload { public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload {
// Only commit tags if the state is ready, i.e. not null // Only commit tags if the state is ready, i.e. not null
let tags = TagOrderStore.getOrderedTags(); let tags = GroupFilterOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (!tags) { if (!tags) {
return; return;
} }
@ -47,7 +47,7 @@ export default class TagOrderActions {
removedTags = removedTags.filter((t) => t !== tag); removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId(); const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => { return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
@ -83,8 +83,8 @@ export default class TagOrderActions {
*/ */
public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload { public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload {
// Don't change tags, just removedTags // Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags(); const tags = GroupFilterOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) { if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need // Return a thunk that doesn't do anything, we don't even need
@ -94,7 +94,7 @@ export default class TagOrderActions {
removedTags.push(tag); removedTags.push(tag);
const storeId = TagOrderStore.getStoreId(); const storeId = GroupFilterOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => { return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag'); Analytics.trackEvent('TagOrderActions', 'removeTag');

View file

@ -32,6 +32,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
import SecurityCustomisations from "../../../../customisations/Security";
const PHASE_LOADING = 0; const PHASE_LOADING = 0;
const PHASE_LOADERROR = 1; const PHASE_LOADERROR = 1;
@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._passphraseField = createRef(); this._passphraseField = createRef();
this._fetchBackupInfo(); MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this.state.accountPassword) { if (this.state.accountPassword) {
// If we have an account password in memory, let's simplify and // If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device // assume it means password auth is also supported for device
@ -110,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._queryKeyUploadAuth(); this._queryKeyUploadAuth();
} }
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); this._getInitialPhase();
} }
componentWillUnmount() { componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
} }
_getInitialPhase() {
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
if (keyFromCustomisations) {
console.log("Created key via customisations, jumping to bootstrap step");
this._recoveryKey = {
privateKey: keyFromCustomisations,
};
this._bootstrapSecretStorage();
return;
}
this._fetchBackupInfo();
}
async _fetchBackupInfo() { async _fetchBackupInfo() {
try { try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();

View file

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

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import TagOrderStore from '../../stores/TagOrderStore'; import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions'; import GroupActions from '../../actions/GroupActions';
@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile"; import UserTagTile from "../views/elements/UserTagTile";
class TagPanel extends React.Component { class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
state = { state = {
@ -44,13 +44,13 @@ class TagPanel extends React.Component {
this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync); this.context.on("sync", this._onClientSync);
this._tagOrderStoreToken = TagOrderStore.addListener(() => { this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
this.setState({ this.setState({
orderedTags: TagOrderStore.getOrderedTags() || [], orderedTags: GroupFilterOrderStore.getOrderedTags() || [],
selectedTags: TagOrderStore.getSelectedTags(), selectedTags: GroupFilterOrderStore.getSelectedTags(),
}); });
}); });
// This could be done by anything with a matrix client // This could be done by anything with a matrix client
@ -61,8 +61,8 @@ class TagPanel extends React.Component {
this.unmounted = true; this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership); this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync); this.context.removeListener("sync", this._onClientSync);
if (this._tagOrderStoreToken) { if (this._groupFilterOrderStoreToken) {
this._tagOrderStoreToken.remove(); this._groupFilterOrderStoreToken.remove();
} }
} }
@ -98,7 +98,7 @@ class TagPanel extends React.Component {
return ( return (
<div> <div>
<UserTagTile /> <UserTagTile />
<hr className="mx_TagPanel_divider" /> <hr className="mx_GroupFilterPanel_divider" />
</div> </div>
); );
} }
@ -117,8 +117,8 @@ class TagPanel extends React.Component {
}); });
const itemsSelected = this.state.selectedTags.length > 0; const itemsSelected = this.state.selectedTags.length > 0;
const classes = classNames('mx_TagPanel', { const classes = classNames('mx_GroupFilterPanel', {
mx_TagPanel_items_selected: itemsSelected, mx_GroupFilterPanel_items_selected: itemsSelected,
}); });
let createButton = ( let createButton = (
@ -141,7 +141,7 @@ class TagPanel extends React.Component {
return <div className={classes} onClick={this.onClearFilterClick}> return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar <AutoHideScrollbar
className="mx_TagPanel_scroller" className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 // XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253 // instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
@ -152,7 +152,7 @@ class TagPanel extends React.Component {
> >
{ (provided, snapshot) => ( { (provided, snapshot) => (
<div <div
className="mx_TagPanel_tagTileContainer" className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef} ref={provided.innerRef}
> >
{ this.renderGlobalIcon() } { this.renderGlobalIcon() }
@ -168,4 +168,4 @@ class TagPanel extends React.Component {
</div>; </div>;
} }
} }
export default TagPanel; export default GroupFilterPanel;

View file

@ -620,7 +620,7 @@ export default class GroupView extends React.Component {
profileForm: newProfileForm, profileForm: newProfileForm,
// Indicate that FlairStore needs to be poked to show this change // Indicate that FlairStore needs to be poked to show this change
// in TagTile (TagPanel), Flair and GroupTile (MyGroups). // in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups).
avatarChanged: true, avatarChanged: true,
}); });
}).catch((e) => { }).catch((e) => {
@ -649,7 +649,6 @@ export default class GroupView extends React.Component {
editing: false, editing: false,
summary: null, summary: null,
}); });
dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId); this._initGroupStore(this.props.groupId);
if (this.state.avatarChanged) { if (this.state.avatarChanged) {
@ -870,10 +869,7 @@ export default class GroupView extends React.Component {
{ _t('Add rooms to this community') } { _t('Add rooms to this community') }
</div> </div>
</AccessibleButton>) : <div />; </AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
});
return <div className="mx_GroupView_rooms"> return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header"> <div className="mx_GroupView_rooms_header">
<h3> <h3>
@ -884,9 +880,7 @@ export default class GroupView extends React.Component {
</div> </div>
{ this.state.groupRoomsLoading ? { this.state.groupRoomsLoading ?
<Spinner /> : <Spinner /> :
<RoomDetailList <RoomDetailList rooms={this.state.groupRooms} />
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
} }
</div>; </div>;
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
import * as React from "react"; import * as React from "react";
import { createRef } from "react"; import { createRef } from "react";
import TagPanel from "./TagPanel"; import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames"; import classNames from "classnames";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
@ -38,6 +38,7 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults"; import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -46,7 +47,7 @@ interface IProps {
interface IState { interface IState {
showBreadcrumbs: boolean; showBreadcrumbs: boolean;
showTagPanel: boolean; showGroupFilterPanel: boolean;
} }
// List of CSS classes which should be included in keyboard navigation within the room list // List of CSS classes which should be included in keyboard navigation within the room list
@ -60,7 +61,7 @@ const cssClasses = [
export default class LeftPanel extends React.Component<IProps, IState> { export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string; private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string; private bgImageWatcherRef: string;
private focusedElement = null; private focusedElement = null;
private isDoingStickyHeaders = false; private isDoingStickyHeaders = false;
@ -70,7 +71,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = { this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible, showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
}; };
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -78,8 +79,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
this.bgImageWatcherRef = SettingsStore.watchSetting( this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate); "RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
}); });
// We watch the middle panel because we don't actually get resized, the middle panel does. // We watch the middle panel because we don't actually get resized, the middle panel does.
@ -88,7 +89,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
public componentWillUnmount() { public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef); SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef); SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -119,8 +120,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (settingBgMxc) { if (settingBgMxc) {
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize);
} }
const avatarUrlProp = `url(${avatarUrl})`; const avatarUrlProp = `url(${avatarUrl})`;
if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { if (!avatarUrl) {
document.body.style.removeProperty("--avatar-url");
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp); document.body.style.setProperty("--avatar-url", avatarUrlProp);
} }
}; };
@ -139,7 +143,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin; const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid // We track which styles we want on a target before making the changes to avoid
@ -210,10 +214,19 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
} }
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
}
} else { } else {
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
} }
if (header.style.bottom) {
header.style.removeProperty('bottom');
}
} }
if (style.stickyTop || style.stickyBottom) { if (style.stickyTop || style.stickyBottom) {
@ -375,9 +388,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
public render(): React.ReactNode { public render(): React.ReactNode {
const tagPanel = !this.state.showTagPanel ? null : ( const groupFilterPanel = !this.state.showGroupFilterPanel ? null : (
<div className="mx_LeftPanel_tagPanelContainer"> <div className="mx_LeftPanel_GroupFilterPanelContainer">
<TagPanel /> <GroupFilterPanel />
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null} {SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
</div> </div>
); );
@ -385,7 +398,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const roomList = <RoomList const roomList = <RoomList
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
resizeNotifier={null} resizeNotifier={null}
collapsed={false}
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
@ -394,7 +406,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const containerClasses = classNames({ const containerClasses = classNames({
"mx_LeftPanel": true, "mx_LeftPanel": true,
"mx_LeftPanel_hasTagPanel": !!tagPanel, "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel,
"mx_LeftPanel_minimized": this.props.isMinimized, "mx_LeftPanel_minimized": this.props.isMinimized,
}); });
@ -405,7 +417,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{tagPanel} {groupFilterPanel}
<aside className="mx_LeftPanel_roomListContainer"> <aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
@ -423,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList} {roomList}
</div> </div>
</div> </div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
</aside> </aside>
</div> </div>
); );

View file

@ -0,0 +1,149 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import {Resizable} from "re-resizable";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton";
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
import {Key} from "../../Keyboard";
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings";
interface IProps {
onResize(): void;
}
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
const leftPanelWidgetId = useSettingValue("Widgets.leftPanel");
const app = useMemo(() => {
if (!mWidgetsEvent || !leftPanelWidgetId) return null;
const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId);
if (!widgetConfig) return null;
return WidgetUtils.makeAppConfig(
widgetConfig.state_key,
widgetConfig.content,
widgetConfig.sender,
null,
widgetConfig.id);
}, [mWidgetsEvent, leftPanelWidgetId]);
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;
if (!app) return null;
let content;
if (expanded) {
content = <Resizable
size={{height} as any}
minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
onResize={onResize}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
}}
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
className="mx_LeftPanelWidget_resizeBox"
enable={{ top: true }}
>
<AppTile
app={app}
fullWidth
show
showMenubar={false}
userWidget
userId={cli.getUserId()}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
/>
</Resizable>;
}
return <div className="mx_LeftPanelWidget">
<div
onFocus={onFocus}
className="mx_LeftPanelWidget_headerContainer"
onKeyDown={(ev: React.KeyboardEvent) => {
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
setExpanded(false);
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
setExpanded(true);
break;
}
}
}}
>
<div className="mx_LeftPanelWidget_stickable">
<AccessibleButton
onFocus={onFocus}
inputRef={ref}
tabIndex={tabIndex}
className="mx_LeftPanelWidget_headerText"
role="treeitem"
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
}}
>
<span className={classNames({
"mx_LeftPanelWidget_collapseBtn": true,
"mx_LeftPanelWidget_collapseBtn_collapsed": !expanded,
})} />
<span>{ WidgetUtils.getWidgetName(app) }</span>
</AccessibleButton>
{/* Code for the maximise button for once we have full screen widgets */}
{/*<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={() => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
/>*/}
</div>
</div>
{ content }
</div>;
};
export default LeftPanelWidget;

View file

@ -52,6 +52,7 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer"; import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -71,9 +72,6 @@ interface IProps {
viaServers?: string[]; viaServers?: string[];
hideToSRUsers: boolean; hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
middleDisabled: boolean;
leftDisabled: boolean;
rightDisabled: boolean;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
page_type: string; page_type: string;
autoJoin: boolean; autoJoin: boolean;
@ -100,10 +98,6 @@ interface IUsageLimit {
} }
interface IState { interface IState {
mouseDown?: {
x: number;
y: number;
};
syncErrorData?: { syncErrorData?: {
error: { error: {
data: IUsageLimit; data: IUsageLimit;
@ -151,7 +145,6 @@ class LoggedInView extends React.Component<IProps, IState> {
super(props, context); super(props, context);
this.state = { this.state = {
mouseDown: undefined,
syncErrorData: undefined, syncErrorData: undefined,
// use compact timeline view // use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
@ -213,12 +206,8 @@ class LoggedInView extends React.Component<IProps, IState> {
}; };
_createResizer() { _createResizer() {
const classNames = { let size;
handle: "mx_ResizeHandle", const collapseConfig: ICollapseConfig = {
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
const collapseConfig = {
toggleSize: 260 - 50, toggleSize: 260 - 50,
onCollapsed: (collapsed) => { onCollapsed: (collapsed) => {
if (collapsed) { if (collapsed) {
@ -228,22 +217,24 @@ class LoggedInView extends React.Component<IProps, IState> {
dis.dispatch({action: "show_left_panel"}, true); dis.dispatch({action: "show_left_panel"}, true);
} }
}, },
onResized: (size) => { onResized: (_size) => {
window.localStorage.setItem("mx_lhs_size", '' + size); size = _size;
this.props.resizeNotifier.notifyLeftHandleResized(); this.props.resizeNotifier.notifyLeftHandleResized();
}, },
onResizeStart: () => { onResizeStart: () => {
this.props.resizeNotifier.startResizing(); this.props.resizeNotifier.startResizing();
}, },
onResizeStop: () => { onResizeStop: () => {
window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.stopResizing(); this.props.resizeNotifier.stopResizing();
}, },
}; };
const resizer = new Resizer( const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
this._resizeContainer.current, resizer.setClassNames({
CollapseDistributor, handle: "mx_ResizeHandle",
collapseConfig); vertical: "mx_ResizeHandle_vertical",
resizer.setClassNames(classNames); reverse: "mx_ResizeHandle_reverse",
});
return resizer; return resizer;
} }
@ -518,8 +509,8 @@ class LoggedInView extends React.Component<IProps, IState> {
// Could be "GroupTile +groupId:domain" // Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop(); const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the TagPanel receives an // Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from TagOrderStore before the previous // optimistic update from GroupFilterOrderStore before the previous
// state is shown. // state is shown.
dis.dispatch(TagOrderActions.moveTag( dis.dispatch(TagOrderActions.moveTag(
this._matrixClient, this._matrixClient,
@ -550,48 +541,6 @@ class LoggedInView extends React.Component<IProps, IState> {
), true); ), true);
}; };
_onMouseDown = (ev) => {
// When the panels are disabled, clicking on them results in a mouse event
// which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group).
if (this.props.leftDisabled && this.props.rightDisabled) {
const targetClasses = new Set(ev.target.className.split(' '));
if (
targetClasses.has('mx_MatrixChat') ||
targetClasses.has('mx_MatrixChat_middlePanel') ||
targetClasses.has('mx_RoomView')
) {
this.setState({
mouseDown: {
x: ev.pageX,
y: ev.pageY,
},
});
}
}
};
_onMouseUp = (ev) => {
if (!this.state.mouseDown) return;
const deltaX = ev.pageX - this.state.mouseDown.x;
const deltaY = ev.pageY - this.state.mouseDown.y;
const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY));
const maxRadius = 5; // People shouldn't be straying too far, hopefully
// Note: we track how far the user moved their mouse to help
// combat against https://github.com/vector-im/element-web/issues/7158
if (distance < maxRadius) {
// This is probably a real click, and not a drag
dis.dispatch({ action: 'close_settings' });
}
// Always clear the mouseDown state to ensure we don't accidentally
// use stale values due to the mouseDown checks.
this.setState({mouseDown: null});
};
render() { render() {
const RoomView = sdk.getComponent('structures.RoomView'); const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView'); const UserView = sdk.getComponent('structures.UserView');
@ -611,7 +560,6 @@ class LoggedInView extends React.Component<IProps, IState> {
oobData={this.props.roomOobData} oobData={this.props.roomOobData}
viaServers={this.props.viaServers} viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'} key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/>; />;
break; break;
@ -659,8 +607,6 @@ class LoggedInView extends React.Component<IProps, IState> {
onKeyDown={this._onReactKeyDown} onKeyDown={this._onReactKeyDown}
className='mx_MatrixChat_wrapper' className='mx_MatrixChat_wrapper'
aria-hidden={this.props.hideToSRUsers} aria-hidden={this.props.hideToSRUsers}
onMouseDown={this._onMouseDown}
onMouseUp={this._onMouseUp}
> >
<ToastContainer /> <ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}> <DragDropContext onDragEnd={this._onDragEnd}>

View file

@ -181,9 +181,6 @@ interface IState {
currentUserId?: string; currentUserId?: string;
// this is persisted as mx_lhs_size, loaded in LoggedInView // this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: boolean; collapseLhs: boolean;
leftDisabled: boolean;
middleDisabled: boolean;
// the right panel's disabled state is tracked in its store.
// Parameters used in the registration dance with the IS // Parameters used in the registration dance with the IS
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
register_client_secret?: string; register_client_secret?: string;
@ -236,8 +233,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state = { this.state = {
view: Views.LOADING, view: Views.LOADING,
collapseLhs: false, collapseLhs: false,
leftDisabled: false,
middleDisabled: false,
hideToSRUsers: false, hideToSRUsers: false,
@ -710,14 +705,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.state.resizeNotifier.notifyLeftHandleResized(); this.state.resizeNotifier.notifyLeftHandleResized();
}); });
break; break;
case 'panel_disable': {
this.setState({
leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
middleDisabled: payload.middleDisabled || false,
// We don't track the right panel being disabled here - it's tracked in the store.
});
break;
}
case 'on_logged_in': case 'on_logged_in':
if ( if (
!Lifecycle.isSoftLogout() && !Lifecycle.isSoftLogout() &&

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../index'; import * as sdk from '../../index';
@ -304,14 +303,8 @@ export default class RightPanel extends React.Component {
break; break;
} }
const classes = classNames("mx_RightPanel", "mx_fadable", {
"collapsed": this.props.collapsed,
"mx_fadable_faded": this.props.disabled,
"dark-panel": true,
});
return ( return (
<aside className={classes} id="mx_RightPanel"> <aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
{ panel } { panel }
</aside> </aside>
); );

View file

@ -30,7 +30,7 @@ import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import TagOrderStore from "../../stores/TagOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore"; import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
@ -49,7 +49,7 @@ export default class RoomDirectory extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const selectedCommunityId = TagOrderStore.getSelectedTags()[0]; const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = { this.state = {
publicRooms: [], publicRooms: [],
loading: true, loading: true,

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015-2020 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -26,6 +24,7 @@ import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions"; import {Action} from "../../dispatcher/actions";
import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -46,10 +45,12 @@ export default class RoomStatusBar extends React.Component {
// Used to suggest to the user to invite someone // Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool, sentMessageAndIsAlone: PropTypes.bool,
// true if there is an active call in this room (means we show // The active call in the room, if any (means we show the call bar
// the 'Active Call' text in the status bar if there is nothing // along with the status of the call)
// more interesting) callState: PropTypes.string,
hasActiveCall: PropTypes.bool,
// The type of the call in progress, or null if no call is in progress
callType: PropTypes.string,
// true if the room is being peeked at. This affects components that shouldn't // true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room. // logically be shown when peeking, such as a prompt to invite people to a room.
@ -121,6 +122,12 @@ export default class RoomStatusBar extends React.Component {
}); });
}; };
_showCallBar() {
return (this.props.callState &&
(this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing)
);
}
_onResendAllClick = () => { _onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
@ -153,7 +160,7 @@ export default class RoomStatusBar extends React.Component {
// indicate other sizes. // indicate other sizes.
_getSize() { _getSize() {
if (this._shouldShowConnectionError() || if (this._shouldShowConnectionError() ||
this.props.hasActiveCall || this._showCallBar() ||
this.props.sentMessageAndIsAlone this.props.sentMessageAndIsAlone
) { ) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
@ -165,7 +172,7 @@ export default class RoomStatusBar extends React.Component {
// return suitable content for the image on the left of the status bar. // return suitable content for the image on the left of the status bar.
_getIndicator() { _getIndicator() {
if (this.props.hasActiveCall) { if (this._showCallBar()) {
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
return ( return (
<TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" /> <TintableSvg src={require("../../../res/img/element-icons/room/in-call.svg")} width="23" height="20" />
@ -269,6 +276,25 @@ export default class RoomStatusBar extends React.Component {
</div>; </div>;
} }
_getCallStatusText() {
switch (this.props.callState) {
case CallState.CreateOffer:
case CallState.InviteSent:
return _t('Calling...');
case CallState.Connecting:
case CallState.CreateAnswer:
return _t('Call connecting...');
case CallState.Connected:
return _t('Active call');
case CallState.WaitLocalMedia:
if (this.props.callType === CallType.Video) {
return _t('Starting camera...');
} else {
return _t('Starting microphone...');
}
}
}
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent() { _getContent() {
if (this._shouldShowConnectionError()) { if (this._shouldShowConnectionError()) {
@ -291,10 +317,10 @@ export default class RoomStatusBar extends React.Component {
return this._getUnsentMessageContent(); return this._getUnsentMessageContent();
} }
if (this.props.hasActiveCall) { if (this._showCallBar()) {
return ( return (
<div className="mx_RoomStatusBar_callBar"> <div className="mx_RoomStatusBar_callBar">
<b>{ _t('Active call') }</b> <b>{ this._getCallStatusText() }</b>
</div> </div>
); );
} }

View file

@ -71,6 +71,9 @@ import RoomHeader from "../views/rooms/RoomHeader";
import TintableSvg from "../views/elements/TintableSvg"; import TintableSvg from "../views/elements/TintableSvg";
import {XOR} from "../../@types/common"; import {XOR} from "../../@types/common";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; 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; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -104,7 +107,6 @@ interface IProps {
viaServers?: string[]; viaServers?: string[];
autoJoin?: boolean; autoJoin?: boolean;
disabled?: boolean;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
// Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU)
@ -141,7 +143,7 @@ export interface IState {
}>; }>;
searchHighlights?: string[]; searchHighlights?: string[];
searchInProgress?: boolean; searchInProgress?: boolean;
callState?: string; callState?: CallState;
guestsCanJoin: boolean; guestsCanJoin: boolean;
canPeek: boolean; canPeek: boolean;
showApps: boolean; showApps: boolean;
@ -180,6 +182,7 @@ export interface IState {
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
rejecting?: boolean; rejecting?: boolean;
rejectError?: Error; rejectError?: Error;
hasPinnedWidgets?: boolean;
} }
export default class RoomView extends React.Component<IProps, IState> { 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.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); 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.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange); this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange);
@ -262,6 +267,18 @@ export default class RoomView extends React.Component<IProps, IState> {
this.onRoomViewStoreUpdate(true); 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 = () => { private onReadReceiptsChange = () => {
this.setState({ this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
@ -479,7 +496,7 @@ export default class RoomView extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
const call = this.getCallForRoom(); const call = this.getCallForRoom();
const callState = call ? call.call_state : "ended"; const callState = call ? call.state : null;
this.setState({ this.setState({
callState: callState, callState: callState,
}); });
@ -584,7 +601,8 @@ export default class RoomView extends React.Component<IProps, IState> {
this.rightPanelStoreToken.remove(); this.rightPanelStoreToken.remove();
} }
WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
if (this.showReadReceiptsWatchRef) { if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
@ -712,14 +730,9 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const call = this.getCallForRoom(); const call = this.getCallForRoom();
let callState = "ended";
if (call) {
callState = call.call_state;
}
this.setState({ this.setState({
callState: callState, callState: call ? call.state : null,
}); });
break; break;
} }
@ -828,6 +841,7 @@ export default class RoomView extends React.Component<IProps, IState> {
this.calculateRecommendedVersion(room); this.calculateRecommendedVersion(room);
this.updateE2EStatus(room); this.updateE2EStatus(room);
this.updatePermissions(room); this.updatePermissions(room);
this.checkWidgets(room);
}; };
private async calculateRecommendedVersion(room: Room) { private async calculateRecommendedVersion(room: Room) {
@ -1263,7 +1277,7 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
if (!this.state.searchResults.next_batch) { if (!this.state.searchResults.next_batch) {
if (this.state.searchResults.results.length == 0) { if (!this.state.searchResults?.results?.length) {
ret.push(<li key="search-top-marker"> ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2> <h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>, </li>,
@ -1287,7 +1301,7 @@ export default class RoomView extends React.Component<IProps, IState> {
let lastRoomId; let lastRoomId;
for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) { for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) {
const result = this.state.searchResults.results[i]; const result = this.state.searchResults.results[i];
const mxEv = result.context.getEvent(); const mxEv = result.context.getEvent();
@ -1357,6 +1371,13 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
}; };
private onAppsClick = () => {
dis.dispatch({
action: "appsDrawer",
show: !this.state.showApps,
});
};
private onLeaveClick = () => { private onLeaveClick = () => {
dis.dispatch({ dis.dispatch({
action: 'leave_room', action: 'leave_room',
@ -1605,7 +1626,7 @@ export default class RoomView extends React.Component<IProps, IState> {
/** /**
* get any current call for this room * get any current call for this room
*/ */
private getCallForRoom() { private getCallForRoom(): MatrixCall {
if (!this.state.room) { if (!this.state.room) {
return null; return null;
} }
@ -1742,10 +1763,13 @@ export default class RoomView extends React.Component<IProps, IState> {
// We have successfully loaded this room, and are not previewing. // We have successfully loaded this room, and are not previewing.
// Display the "normal" room view. // Display the "normal" room view.
const call = this.getCallForRoom(); let activeCall = null;
let inCall = false; {
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { // New block because this variable doesn't need to hang around for the rest of the function
inCall = true; const call = this.getCallForRoom();
if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
activeCall = call;
}
} }
const scrollheaderClasses = classNames({ const scrollheaderClasses = classNames({
@ -1764,7 +1788,8 @@ export default class RoomView extends React.Component<IProps, IState> {
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
sentMessageAndIsAlone={this.state.isAlone} sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall} callState={this.state.callState}
callType={activeCall ? activeCall.type : null}
isPeeking={myMembership !== "join"} isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick} onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick} onStopWarningClick={this.onStopAloneWarningClick}
@ -1853,7 +1878,6 @@ export default class RoomView extends React.Component<IProps, IState> {
draggingFile={this.state.draggingFile} draggingFile={this.state.draggingFile}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
showApps={this.state.showApps} showApps={this.state.showApps}
hideAppsDrawer={false}
onResize={this.onResize} onResize={this.onResize}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
> >
@ -1872,7 +1896,6 @@ export default class RoomView extends React.Component<IProps, IState> {
<MessageComposer <MessageComposer
room={this.state.room} room={this.state.room}
callState={this.state.callState} callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps} showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
@ -1890,10 +1913,10 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
} }
if (inCall) { if (activeCall) {
let zoomButton; let videoMuteButton; let zoomButton; let videoMuteButton;
if (call.type === "video") { if (activeCall.type === CallType.Video) {
zoomButton = ( zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}> <div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title={_t("Fill screen")}>
<TintableSvg <TintableSvg
@ -1908,10 +1931,11 @@ export default class RoomView extends React.Component<IProps, IState> {
videoMuteButton = videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<TintableSvg <TintableSvg
src={call.isLocalVideoMuted() ? src={activeCall.isLocalVideoMuted() ?
require("../../../res/img/element-icons/call/video-muted.svg") : require("../../../res/img/element-icons/call/video-muted.svg") :
require("../../../res/img/element-icons/call/video-call.svg")} require("../../../res/img/element-icons/call/video-call.svg")}
alt={call.isLocalVideoMuted() ? _t("Click to unmute video") : _t("Click to mute video")} alt={activeCall.isLocalVideoMuted() ? _t("Click to unmute video") :
_t("Click to mute video")}
width="" width=""
height="27" height="27"
/> />
@ -1920,10 +1944,10 @@ export default class RoomView extends React.Component<IProps, IState> {
const voiceMuteButton = const voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<TintableSvg <TintableSvg
src={call.isMicrophoneMuted() ? src={activeCall.isMicrophoneMuted() ?
require("../../../res/img/element-icons/call/voice-muted.svg") : require("../../../res/img/element-icons/call/voice-muted.svg") :
require("../../../res/img/element-icons/call/voice-unmuted.svg")} require("../../../res/img/element-icons/call/voice-unmuted.svg")}
alt={call.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")} alt={activeCall.isMicrophoneMuted() ? _t("Click to unmute audio") : _t("Click to mute audio")}
width="21" width="21"
height="26" height="26"
/> />
@ -1946,7 +1970,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.state.searchResults) { if (this.state.searchResults) {
// show searching spinner // show searching spinner
if (this.state.searchResults.results === undefined) { if (this.state.searchResults.count === undefined) {
searchResultsPanel = ( searchResultsPanel = (
<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" /> <div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />
); );
@ -2027,10 +2051,6 @@ export default class RoomView extends React.Component<IProps, IState> {
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded, "mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
}); });
const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", {
"mx_fadable_faded": this.props.disabled,
});
const showRightPanel = this.state.room && this.state.showRightPanel; const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} /> ? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
@ -2041,7 +2061,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
const mainClasses = classNames("mx_RoomView", { const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: inCall, mx_RoomView_inCall: Boolean(activeCall),
}); });
return ( return (
@ -2060,9 +2080,11 @@ export default class RoomView extends React.Component<IProps, IState> {
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
appsShown={this.state.showApps}
/> />
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}> <MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className={fadableSectionClasses}> <div className="mx_RoomView_body">
{auxPanel} {auxPanel}
<div className={timelineClasses}> <div className={timelineClasses}>
{topUnreadMessagesBar} {topUnreadMessagesBar}

View file

@ -44,7 +44,7 @@ import IconizedContextMenu, {
} from "../views/context_menus/IconizedContextMenu"; } from "../views/context_menus/IconizedContextMenu";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import * as fbEmitter from "fbemitter"; import * as fbEmitter from "fbemitter";
import TagOrderStore from "../../stores/TagOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import { showCommunityInviteDialog } from "../../RoomInvite"; import { showCommunityInviteDialog } from "../../RoomInvite";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
@ -87,7 +87,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -257,7 +257,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const signupLink = getHostingLink("user-context-menu"); const signupLink = getHostingLink("user-context-menu");
if (signupLink) { if (signupLink) {
hostingLink = ( hostingLink = (
<div className="mx_UserMenu_contextMenu_header"> <div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_hostingLink">
{_t( {_t(
"<a>Upgrade</a> to your own domain", {}, "<a>Upgrade</a> to your own domain", {},
{ {
@ -452,7 +452,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
public render() { public render() {
const avatarSize = 32; // should match border-radius of the avatar const avatarSize = 32; // should match border-radius of the avatar
const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); const userId = MatrixClientPeg.get().getUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
@ -507,7 +508,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
<div className="mx_UserMenu_row"> <div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer"> <span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar <BaseAvatar
idName={displayName} idName={userId}
name={displayName} name={displayName}
url={avatarUrl} url={avatarUrl}
width={avatarSize} width={avatarSize}

View file

@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
interface IProps { interface IProps {
// Room may be left unset here, but if it is, // Room may be left unset here, but if it is,
@ -32,7 +33,7 @@ interface IProps {
oobData?: any; oobData?: any;
width?: number; width?: number;
height?: number; height?: number;
resizeMethod?: string; resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean; viewAvatarOnClick?: boolean;
} }

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

@ -0,0 +1,165 @@
/*
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 * as React from 'react';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import {
ClientWidgetApi,
IModalWidgetCloseRequest,
IModalWidgetOpenRequestData,
IModalWidgetReturnData,
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
} from "matrix-widget-api";
import {StopGapWidgetDriver} from "../../../stores/widgets/StopGapWidgetDriver";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RoomViewStore from "../../../stores/RoomViewStore";
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
sourceWidgetId: string;
onFinished(success: boolean, data?: IModalWidgetReturnData): void;
}
interface IState {
messaging?: ClientWidgetApi;
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private readonly widget: Widget;
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {};
constructor(props) {
super(props);
this.widget = new Widget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.get().getUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
}
public componentDidMount() {
const driver = new StopGapWidgetDriver( []);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({messaging});
}
public componentWillUnmount() {
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.stop();
}
private onReady = () => {
this.state.messaging.sendWidgetConfig(this.props.widgetDefinition);
};
private onLoad = () => {
this.state.messaging.once("ready", this.onReady);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>) => {
this.props.onFinished(true, ev.detail.data);
}
public render() {
const templated = this.widget.getCompleteUrl({
currentRoomId: RoomViewStore.getRoomId(),
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
});
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set('widgetId', this.widget.id);
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
const widgetUrl = parsed.toString().replace(/%24/g, '$');
let buttons;
if (this.props.widgetDefinition.buttons) {
// show first button rightmost for a more natural specification
buttons = this.props.widgetDefinition.buttons.slice(0, MAX_BUTTONS).reverse().map(def => {
let kind = "secondary";
switch (def.kind) {
case ModalButtonKind.Primary:
kind = "primary";
break;
case ModalButtonKind.Secondary:
kind = "primary_outline";
break
case ModalButtonKind.Danger:
kind = "danger";
break;
}
const onClick = () => {
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
};
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
{ def.label }
</AccessibleButton>;
});
}
return <BaseDialog
title={this.props.widgetDefinition.name || _t("Modal Widget")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="16"
width="16"
alt=""
/>
{_t("Data on this screen is shared with %(widgetDomain)s", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
ref={this.appFrame}
sandbox="allow-forms allow-scripts allow-same-origin"
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons">
{ buttons }
</div>
</BaseDialog>;
}
}

View file

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

View file

@ -22,56 +22,54 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import AppPermission from './AppPermission'; import AppPermission from './AppPermission';
import AppWarning from './AppWarning'; import AppWarning from './AppWarning';
import Spinner from './Spinner'; import Spinner from './Spinner';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames'; import classNames from 'classnames';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import {aboveLeftOf, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement"; import PersistedElement, {getPersistKey} from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType"; import {WidgetType} from "../../../widgets/WidgetType";
import {SettingLevel} from "../../../settings/SettingLevel"; import {SettingLevel} from "../../../settings/SettingLevel";
import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
import {MatrixCapabilities} from "matrix-widget-api"; import {MatrixCapabilities} from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
export default class AppTile extends React.Component { export default class AppTile extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
// The key used for PersistedElement // 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 = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared); this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady); this._sgWidget.on("ready", this._onWidgetReady);
this.iframe = null; // ref to the iframe (callback style) this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props); 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._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;
if (!props.room) return true; // user widgets always have permissions
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
return props.userId === props.creatorUserId;
}
return !!currentlyAllowedWidgets[props.app.eventId];
};
/** /**
* Set initial component state when the App wUrl (widget URL) is being updated. * Set initial component state when the App wUrl (widget URL) is being updated.
* Component props *must* be passed (rather than relying on this.props). * Component props *must* be passed (rather than relying on this.props).
@ -79,28 +77,32 @@ export default class AppTile extends React.Component {
* @return {Object} Updated component state to be set with setState * @return {Object} Updated component state to be set with setState
*/ */
_getNewState(newProps) { _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 { return {
initialising: true, // True while we are mangling the widget URL initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading // True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
// Assume that widget has permission to load if we are the user who // 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 // added it to the room, or if explicitly granted by the user
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), hasPermissionToLoad: this.hasPermissionToLoad(newProps),
error: null, error: null,
deleting: false,
widgetPageTitle: newProps.widgetPageTitle, widgetPageTitle: newProps.widgetPageTitle,
menuDisplayed: false, 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() { isMixedContent() {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url); const u = url.parse(this.props.app.url);
@ -115,7 +117,7 @@ export default class AppTile extends React.Component {
componentDidMount() { componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load // 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(); this._startWidget();
} }
@ -136,6 +138,8 @@ export default class AppTile extends React.Component {
if (this._sgWidget) { if (this._sgWidget) {
this._sgWidget.stop(); this._sgWidget.stop();
} }
SettingsStore.unwatchSetting(this._allowedWidgetsWatchRef);
} }
_resetWidget(newProps) { _resetWidget(newProps) {
@ -167,21 +171,8 @@ export default class AppTile extends React.Component {
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) { if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps); 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) { if (this.state.hasPermissionToLoad) {
this._startWidget(); this._resetWidget(nextProps);
} }
} }
@ -192,35 +183,6 @@ export default class AppTile extends React.Component {
} }
} }
_canUserModify() {
// User widgets should always be modifiable by their creator
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
return true;
}
// Check if the current user can modify widgets in the current room
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
}
_onEditClick() {
console.log("Edit widget ID ", this.props.app.id);
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
WidgetUtils.editWidget(this.props.room, this.props.app);
}
}
_onSnapshotClick() {
this._sgWidget.widgetApi.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
}
/** /**
* Ends all widget interaction, such as cancelling calls and disabling webcams. * Ends all widget interaction, such as cancelling calls and disabling webcams.
* @private * @private
@ -240,61 +202,14 @@ export default class AppTile extends React.Component {
this.iframe.src = 'about:blank'; this.iframe.src = 'about:blank';
} }
if (WidgetType.JITSI.matches(this.props.app.type)) {
dis.dispatch({action: 'hangup_conference'});
}
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop(); 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 = () => { _onWidgetPrepared = () => {
@ -307,7 +222,7 @@ export default class AppTile extends React.Component {
} }
}; };
_onAction(payload) { _onAction = payload => {
if (payload.widgetId === this.props.app.id) { if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
@ -317,19 +232,11 @@ export default class AppTile extends React.Component {
console.warn('Ignoring sticker message. Invalid capability'); console.warn('Ignoring sticker message. Invalid capability');
} }
break; break;
case Action.AppTileDelete:
this._onDeleteClick();
break;
case Action.AppTileRevoke:
this._onRevokeClicked();
break;
} }
} }
} };
_grantWidgetPermission() { _grantWidgetPermission = () => {
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId); console.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId); const current = SettingsStore.getValue("allowedWidgets", roomId);
@ -343,26 +250,7 @@ export default class AppTile extends React.Component {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // 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() { formatAppTileName() {
let appTileName = "No name"; let appTileName = "No name";
@ -372,29 +260,6 @@ export default class AppTile extends React.Component {
return appTileName; return appTileName;
} }
onClickMenuBar(ev) {
ev.preventDefault();
// Ignore clicks on menu bar children
if (ev.target !== this._menu_bar.current) {
return;
}
// Toggle the view state of the apps drawer
if (this.props.userWidget) {
this._onMinimiseClick();
} else {
if (this.props.show) {
// if we were being shown, end the widget as we're about to be minimized.
this._endWidgetActions();
}
dis.dispatch({
action: 'appsDrawer',
show: !this.props.show,
});
}
}
/** /**
* Whether we're using a local version of the widget rather than loading the * Whether we're using a local version of the widget rather than loading the
* actual widget URL * actual widget URL
@ -414,22 +279,18 @@ export default class AppTile extends React.Component {
return ( return (
<span> <span>
<WidgetAvatar app={this.props.app} />
<b>{ name }</b> <b>{ name }</b>
<span>{ title ? titleSpacer : '' }{ title }</span> <span>{ title ? titleSpacer : '' }{ title }</span>
</span> </span>
); );
} }
_onMinimiseClick(e) { // TODO replace with full screen interactions
if (this.props.onMinimiseClick) { _onPopoutWidgetClick = () => {
this.props.onMinimiseClick();
}
}
_onPopoutWidgetClick() {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them // 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). // 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(() => { this._endWidgetActions().then(() => {
if (this.iframe) { if (this.iframe) {
// Reload iframe // Reload iframe
@ -442,13 +303,7 @@ export default class AppTile extends React.Component {
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), Object.assign(document.createElement('a'),
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); { 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 = () => { _onContextMenuClick = () => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
@ -461,11 +316,6 @@ export default class AppTile extends React.Component {
render() { render() {
let appTileBody; 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 // 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 // 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 // this would only be for content hosted on the same origin as the element client: anything
@ -480,71 +330,67 @@ export default class AppTile extends React.Component {
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
if (this.props.show) { const loadingElement = (
const loadingElement = ( <div className="mx_AppLoading_spinner_fadeIn">
<div className="mx_AppLoading_spinner_fadeIn"> <Spinner message={_t("Loading...")} />
<Spinner message={_t("Loading...")} /> </div>
);
if (!this.state.hasPermissionToLoad) {
// only possible for room widgets, can assert this.props.room here
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
<div className={appTileBodyClass}>
<AppPermission
roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId}
url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission}
/>
</div> </div>
); );
if (!this.state.hasPermissionToLoad) { } else if (this.state.initialising) {
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); appTileBody = (
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
</div>
);
} else {
if (this.isMixedContent()) {
appTileBody = ( appTileBody = (
<div className={appTileBodyClass}> <div className={appTileBodyClass}>
<AppPermission <AppWarning errorMsg="Error - Mixed content" />
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 }
</div> </div>
); );
} else { } else {
if (this.isMixedContent()) { appTileBody = (
appTileBody = ( <div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
<div className={appTileBodyClass}> { this.state.loading && loadingElement }
<AppWarning errorMsg="Error - Mixed content" /> <iframe
</div> allow={iframeFeatures}
); ref={this._iframeRefChange}
} else { src={this._sgWidget.embedUrl}
appTileBody = ( allowFullScreen={true}
<div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}> sandbox={sandboxFlags}
{ this.state.loading && loadingElement } />
<iframe </div>
allow={iframeFeatures} );
ref={this._iframeRefChange} // if the widget would be allowed to remain on screen, we must put it in
src={this._sgWidget.embedUrl} // a PersistedElement from the get-go, otherwise the iframe will be
allowFullScreen={true} // re-mounted later when we do.
sandbox={sandboxFlags} if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
/> const PersistedElement = sdk.getComponent("elements.PersistedElement");
</div> // Also wrap the PersistedElement in a div to fix the height, otherwise
); // AppTile's border is in the wrong place
// if the widget would be allowed to remain on screen, we must put it in appTileBody = <div className="mx_AppTile_persistedWrapper">
// a PersistedElement from the get-go, otherwise the iframe will be <PersistedElement persistKey={this._persistKey}>
// re-mounted later when we do. {appTileBody}
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) { </PersistedElement>
const PersistedElement = sdk.getComponent("elements.PersistedElement"); </div>;
// 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; let appTileClasses;
if (this.props.miniMode) { if (this.props.miniMode) {
appTileClasses = {mx_AppTile_mini: true}; appTileClasses = {mx_AppTile_mini: true};
@ -553,73 +399,37 @@ export default class AppTile extends React.Component {
} else { } else {
appTileClasses = {mx_AppTile: true}; appTileClasses = {mx_AppTile: true};
} }
appTileClasses.mx_AppTile_minimised = !this.props.show;
appTileClasses = classNames(appTileClasses); appTileClasses = classNames(appTileClasses);
const menuBarClasses = classNames({
mx_AppTileMenuBar: true,
mx_AppTileMenuBar_expanded: this.props.show,
});
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { 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 = (
<ContextMenu {...aboveLeftOf(elementRect, null)} onFinished={this._closeContextMenu}> <RoomWidgetContextMenu
<WidgetContextMenu {...aboveLeftOf(this._contextMenuButton.current.getBoundingClientRect(), null)}
onUnpinClicked={ app={this.props.app}
ActiveWidgetStore.getWidgetPersistence(this.props.app.id) ? null : this._onUnpinClicked onFinished={this._closeContextMenu}
} showUnpin={!this.props.userWidget}
onRevokeClicked={this._onRevokeClicked} userWidget={this.props.userWidget}
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>
); );
} }
return <React.Fragment> return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}> <div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar && { 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)}}> <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() } { this.props.showTitle && this._getTileTitle() }
</span> </span>
<span className="mx_AppTileMenuBarWidgets"> <span className="mx_AppTileMenuBarWidgets">
{ /* Popout widget */ }
{ this.props.showPopout && <AccessibleButton { this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout" className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')} title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick} onClick={this._onPopoutWidgetClick}
/> } /> }
{ /* Context menu */ }
{ <ContextMenuButton { <ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu" className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t('More options')} label={_t("Options")}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton} inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick} onClick={this._onContextMenuClick}
@ -638,7 +448,9 @@ AppTile.displayName = 'AppTile';
AppTile.propTypes = { AppTile.propTypes = {
app: PropTypes.object.isRequired, app: PropTypes.object.isRequired,
room: PropTypes.object.isRequired, // If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: PropTypes.object,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false. // This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool, fullWidth: PropTypes.bool,
@ -650,8 +462,6 @@ AppTile.propTypes = {
creatorUserId: PropTypes.string, creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool, waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool, showMenubar: PropTypes.bool,
// Should the AppTile render itself
show: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour) // Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func, onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour) // Optional onDeleteClickHandler (overrides default behaviour)
@ -660,19 +470,10 @@ AppTile.propTypes = {
onMinimiseClick: PropTypes.func, onMinimiseClick: PropTypes.func,
// Optionally hide the tile title // Optionally hide the tile title
showTitle: PropTypes.bool, showTitle: PropTypes.bool,
// Optionally hide the tile minimise icon
showMinimise: PropTypes.bool,
// Optionally handle minimise button pointer events (default false) // Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool, handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the delete icon
showDelete: PropTypes.bool,
// Optionally hide the popout widget icon // Optionally hide the popout widget icon
showPopout: PropTypes.bool, 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) // Widget capabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX // NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events. // basic widget capabilities, e.g. injecting sticker message events.
@ -685,10 +486,7 @@ AppTile.defaultProps = {
waitForIframeLoad: true, waitForIframeLoad: true,
showMenubar: true, showMenubar: true,
showTitle: true, showTitle: true,
showMinimise: true,
showDelete: true,
showPopout: true, showPopout: true,
showReload: false,
handleMinimisePointerEvents: false, handleMinimisePointerEvents: false,
whitelistCapabilities: [], whitelistCapabilities: [],
userWidget: false, userWidget: false,

View file

@ -21,6 +21,8 @@ import {throttle} from "lodash";
import ResizeObserver from 'resize-observer-polyfill'; import ResizeObserver from 'resize-observer-polyfill';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -144,9 +146,11 @@ export default class PersistedElement extends React.Component {
} }
renderApp() { renderApp() {
const content = <div ref={this.collectChild} style={this.props.style}> const content = <MatrixClientContext.Provider value={MatrixClientPeg.get()}>
{this.props.children} <div ref={this.collectChild} style={this.props.style}>
</div>; {this.props.children}
</div>
</MatrixClientContext.Provider>;
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey)); ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
} }
@ -173,3 +177,5 @@ export default class PersistedElement extends React.Component {
return <div ref={this.collectChildContainer} />; return <div ref={this.collectChildContainer} />;
} }
} }
export const getPersistKey = (appId: string) => 'widget_' + appId;

View file

@ -58,6 +58,11 @@ export default class PersistentApp extends React.Component {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId); const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId);
if (this.state.roomId !== persistentWidgetInRoomId) { if (this.state.roomId !== persistentWidgetInRoomId) {
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId); const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// Sanity check the room - the widget may have been destroyed between render cycles, and
// thus no room is associated anymore.
if (!persistentWidgetInRoom) return null;
// get the widget data // get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => { const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId(); return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
@ -74,13 +79,10 @@ export default class PersistentApp extends React.Component {
fullWidth={true} fullWidth={true}
room={persistentWidgetInRoom} room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId} userId={MatrixClientPeg.get().credentials.userId}
show={true}
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist} whitelistCapabilities={capWhitelist}
showDelete={false}
showMinimise={false}
miniMode={true} miniMode={true}
showMenubar={false} showMenubar={false}
/>; />;

View file

@ -26,12 +26,12 @@ import * as FormattingUtils from '../../../utils/FormattingUtils';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import TagOrderStore from '../../../stores/TagOrderStore'; import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents // A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to: // a thing to click on for the user to filter the visible rooms in the RoomList to:
// - Rooms that are part of the group // - Rooms that are part of the group
// - Direct messages with members of the group // - Direct messages with members of the group
@ -142,7 +142,7 @@ export default class TagTile extends React.Component {
mx_TagTile_selected_prototype: this.props.selected && isPrototype, mx_TagTile_selected_prototype: this.props.selected && isPrototype,
}); });
const badge = TagOrderStore.getGroupBadge(this.props.tag); const badge = GroupFilterOrderStore.getGroupBadge(this.props.tag);
let badgeElement; let badgeElement;
if (badge && !this.state.hover && !this.props.menuDisplayed) { if (badge && !this.state.hover && !this.props.menuDisplayed) {
const badgeClasses = classNames({ const badgeClasses = classNames({

View file

@ -36,6 +36,7 @@ interface IProps {
// the react element to put into the tooltip // the react element to put into the tooltip
label: React.ReactNode; label: React.ReactNode;
forceOnRight?: boolean; forceOnRight?: boolean;
yOffset?: number;
} }
export default class Tooltip extends React.Component<IProps> { export default class Tooltip extends React.Component<IProps> {
@ -46,6 +47,7 @@ export default class Tooltip extends React.Component<IProps> {
public static readonly defaultProps = { public static readonly defaultProps = {
visible: true, visible: true,
yOffset: 0,
}; };
// Create a wrapper for the tooltip outside the parent and attach it to the body element // 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); 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) { 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 { } else {
style.left = parentBox.right + window.pageXOffset + 6; style.left = parentBox.right + window.pageXOffset + 6;
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import * as fbEmitter from "fbemitter"; import * as fbEmitter from "fbemitter";
import TagOrderStore from "../../../stores/TagOrderStore"; import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import AccessibleTooltipButton from "./AccessibleTooltipButton"; import AccessibleTooltipButton from "./AccessibleTooltipButton";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -36,12 +36,12 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
selected: TagOrderStore.getSelectedTags().length === 0, selected: GroupFilterOrderStore.getSelectedTags().length === 0,
}; };
} }
public componentDidMount() { public componentDidMount() {
this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -49,7 +49,7 @@ export default class UserTagTile extends React.PureComponent<IProps, IState> {
} }
private onTagStoreUpdate = () => { private onTagStoreUpdate = () => {
const selected = TagOrderStore.getSelectedTags().length === 0; const selected = GroupFilterOrderStore.getSelectedTags().length === 0;
this.setState({selected}); this.setState({selected});
}; };

View file

@ -45,7 +45,7 @@ export default class RoomCreate extends React.Component {
render() { render() {
const predecessor = this.props.mxEvent.getContent()['predecessor']; const predecessor = this.props.mxEvent.getContent()['predecessor'];
if (predecessor === undefined) { if (predecessor === undefined) {
return <div />; // We should never have been instaniated in this case return <div />; // We should never have been instantiated in this case
} }
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']); const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']); const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);

View file

@ -31,7 +31,7 @@ interface IProps {
// The badge to display above the icon // The badge to display above the icon
badge?: React.ReactNode; badge?: React.ReactNode;
// The parameters to track the click event // The parameters to track the click event
analytics: string[]; analytics: Parameters<typeof Analytics.trackEvent>;
// Button name // Button name
name: string; name: string;

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, {useCallback, useState, useEffect, useContext} from "react"; import React, {useCallback, useState, useEffect, useContext} from "react";
import classNames from "classnames"; import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useIsEncrypted } from '../../../hooks/useIsEncrypted'; import { useIsEncrypted } from '../../../hooks/useIsEncrypted';
@ -32,17 +31,18 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ShareDialog from '../dialogs/ShareDialog'; import ShareDialog from '../dialogs/ShareDialog';
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import WidgetUtils from "../../../utils/WidgetUtils"; import WidgetUtils from "../../../utils/WidgetUtils";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip"; import TextWithTooltip from "../elements/TextWithTooltip";
import BaseAvatar from "../avatars/BaseAvatar"; import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; 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 { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
interface IProps { interface IProps {
room: Room; room: Room;
@ -68,22 +68,105 @@ const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => {
}; };
export const useWidgets = (room: Room) => { 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(() => { const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings // 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]); }, [room]);
useEffect(updateApps, [room]); useEffect(updateApps, [room]);
useEventEmitter(WidgetEchoStore, "update", updateApps);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps); useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
return apps; 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 AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
const cli = useContext(MatrixClientContext);
const apps = useWidgets(room); const apps = useWidgets(room);
const onManageIntegrations = () => { const onManageIntegrations = () => {
@ -100,65 +183,7 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
}; };
return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}> return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => { { apps.map(app => <AppRow key={app.id} app={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>
);
}) }
<AccessibleButton kind="link" onClick={onManageIntegrations}> <AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") } { apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }

View file

@ -801,6 +801,11 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
} = powerLevels; } = powerLevels;
const me = room.getMember(cli.getUserId()); const me = room.getMember(cli.getUserId());
if (!me) {
// we aren't in the room, so return no admin tooling
return <div />;
}
const isMe = me.userId === member.userId; const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe; const canAffectUser = member.powerLevel < me.powerLevel || isMe;

View file

@ -20,7 +20,6 @@ import {Room} from "matrix-js-sdk/src/models/room";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard"; import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils"; import WidgetUtils from "../../../utils/WidgetUtils";
import AccessibleButton from "../elements/AccessibleButton";
import AppTile from "../elements/AppTile"; import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard"; import {useWidgets} from "./RoomSummaryCard";
@ -30,16 +29,7 @@ import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPa
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import IconizedContextMenu, { import WidgetContextMenu from "../context_menus/WidgetContextMenu";
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";
interface IProps { interface IProps {
room: Room; room: Room;
@ -69,111 +59,22 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
// Don't render anything as we are about to transition // Don't render anything as we are about to transition
if (!app || isPinned) return null; if (!app || isPinned) return null;
const header = <React.Fragment>
<h2>{ WidgetUtils.getWidgetName(app) }</h2>
</React.Fragment>;
const canModify = WidgetUtils.canUserModifyWidgets(room.roomId);
let contextMenu; let contextMenu;
if (menuDisplayed) { 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(); const rect = handle.current.getBoundingClientRect();
contextMenu = ( contextMenu = (
<IconizedContextMenu <WidgetContextMenu
chevronFace={ChevronFace.None} chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right} right={window.innerWidth - rect.right - 12}
bottom={window.innerHeight - rect.top} top={rect.bottom + 12}
onFinished={closeMenu} onFinished={closeMenu}
> app={app}
<IconizedContextMenuOptionList> />
{ snapshotButton }
{ deleteButton }
<IconizedContextMenuOption onClick={onRevokeClick} label={_t("Remove for me")} />
</IconizedContextMenuOptionList>
</IconizedContextMenu>
); );
} }
const onPinClick = () => { const header = <React.Fragment>
WidgetStore.instance.pinWidget(app.id); <h2>{ WidgetUtils.getWidgetName(app) }</h2>
};
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 }
<ContextMenuButton <ContextMenuButton
kind="secondary" kind="secondary"
className="mx_WidgetCard_optionsButton" className="mx_WidgetCard_optionsButton"
@ -182,16 +83,12 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
label={_t("Options")} label={_t("Options")}
/> />
{ contextMenu } { contextMenu }
</React.Fragment>; </React.Fragment>;
return <BaseCard return <BaseCard
header={header} header={header}
footer={footer} className="mx_WidgetCard"
className={classNames("mx_WidgetCard", {
mx_WidgetCard_noEdit: !canModify,
})}
onClose={onClose} onClose={onClose}
previousPhase={RightPanelPhases.RoomSummary} previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer withoutScrollContainer

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useState} from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import {Resizable} from "re-resizable"; import {Resizable} from "re-resizable";
@ -24,15 +24,16 @@ import AppTile from '../elements/AppTile';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import * as ScalarMessaging from '../../../ScalarMessaging'; import * as ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetEchoStore from "../../../stores/WidgetEchoStore"; import WidgetEchoStore from "../../../stores/WidgetEchoStore";
import AccessibleButton from '../elements/AccessibleButton';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {useLocalStorageState} from "../../../hooks/useLocalStorageState"; import {useLocalStorageState} from "../../../hooks/useLocalStorageState";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import WidgetStore from "../../../stores/WidgetStore"; 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 { export default class AppsDrawer extends React.Component {
static propTypes = { static propTypes = {
@ -40,12 +41,10 @@ export default class AppsDrawer extends React.Component {
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired, resizeNotifier: PropTypes.instanceOf(ResizeNotifier).isRequired,
showApps: PropTypes.bool, // Should apps be rendered showApps: PropTypes.bool, // Should apps be rendered
hide: PropTypes.bool, // If rendered, should apps drawer be visible
}; };
static defaultProps = { static defaultProps = {
showApps: true, showApps: true,
hide: false,
}; };
constructor(props) { constructor(props) {
@ -54,6 +53,11 @@ export default class AppsDrawer extends React.Component {
this.state = { this.state = {
apps: this._getApps(), apps: this._getApps(),
}; };
this._resizeContainer = null;
this.resizer = this._createResizer();
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
} }
componentDidMount() { componentDidMount() {
@ -66,6 +70,10 @@ export default class AppsDrawer extends React.Component {
ScalarMessaging.stopListening(); ScalarMessaging.stopListening();
WidgetStore.instance.off(this.props.room.roomId, this._updateApps); WidgetStore.instance.off(this.props.room.roomId, this._updateApps);
if (this.dispatcherRef) dis.unregister(this.dispatcherRef); 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 // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -75,6 +83,95 @@ export default class AppsDrawer extends React.Component {
this._updateApps(); 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) => { onAction = (action) => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer'; const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) { switch (action.action) {
@ -93,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 = () => { _updateApps = () => {
this.setState({ this.setState({
@ -101,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() { _launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) { if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(); IntegrationManagers.sharedInstance().openAll();
@ -118,12 +206,9 @@ export default class AppsDrawer extends React.Component {
} }
} }
onClickAddWidget = (e) => {
e.preventDefault();
this._launchManageIntegrations();
};
render() { render() {
if (!this.props.showApps) return <div />;
const apps = this.state.apps.map((app, index, arr) => { const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId); const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
@ -133,7 +218,6 @@ export default class AppsDrawer extends React.Component {
fullWidth={arr.length < 2} fullWidth={arr.length < 2}
room={this.props.room} room={this.props.room}
userId={this.props.userId} userId={this.props.userId}
show={this.props.showApps}
creatorUserId={app.creatorUserId} creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad} waitForIframeLoad={app.waitForIframeLoad}
@ -145,21 +229,6 @@ export default class AppsDrawer extends React.Component {
return <div />; 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; let spinner;
if ( if (
apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets( apps.length === 0 && WidgetEchoStore.roomHasPendingWidgets(
@ -172,10 +241,11 @@ export default class AppsDrawer extends React.Component {
} }
const classes = classNames({ const classes = classNames({
"mx_AppsDrawer": true, mx_AppsDrawer: true,
"mx_AppsDrawer_hidden": this.props.hide, mx_AppsDrawer_fullWidth: apps.length < 2,
"mx_AppsDrawer_fullWidth": apps.length < 2, mx_AppsDrawer_resizing: this.state.resizing,
"mx_AppsDrawer_minimised": !this.props.showApps, mx_AppsDrawer_2apps: apps.length === 2,
mx_AppsDrawer_3apps: apps.length === 3,
}); });
return ( return (
@ -185,13 +255,20 @@ export default class AppsDrawer extends React.Component {
minHeight={100} minHeight={100}
maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined} maxHeight={this.props.maxHeight ? this.props.maxHeight - 50 : undefined}
handleClass="mx_AppsContainer_resizerHandle" handleClass="mx_AppsContainer_resizerHandle"
className="mx_AppsContainer" className="mx_AppsContainer_resizer"
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
> >
{ apps } <div className="mx_AppsContainer" ref={this._collectResizer}>
{ spinner } { 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> </PersistentVResizer>
{ this._canUserModify() && addWidget } { spinner }
</div> </div>
); );
} }
@ -208,14 +285,12 @@ const PersistentVResizer = ({
children, children,
}) => { }) => {
const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px const [height, setHeight] = useLocalStorageState("pvr_" + id, 280); // old fixed height was 273px
const [resizing, setResizing] = useState(false);
return <Resizable return <Resizable
size={{height: Math.min(height, maxHeight)}} size={{height: Math.min(height, maxHeight)}}
minHeight={minHeight} minHeight={minHeight}
maxHeight={maxHeight} maxHeight={maxHeight}
onResizeStart={() => { onResizeStart={() => {
if (!resizing) setResizing(true);
resizeNotifier.startResizing(); resizeNotifier.startResizing();
}} }}
onResize={() => { onResize={() => {
@ -223,14 +298,11 @@ const PersistentVResizer = ({
}} }}
onResizeStop={(e, dir, ref, d) => { onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height); setHeight(height + d.height);
if (resizing) setResizing(false);
resizeNotifier.stopResizing(); resizeNotifier.stopResizing();
}} }}
handleWrapperClass={handleWrapperClass} handleWrapperClass={handleWrapperClass}
handleClasses={{bottom: handleClass}} handleClasses={{bottom: handleClass}}
className={classNames(className, { className={className}
mx_AppsDrawer_resizing: resizing,
})}
enable={{bottom: true}} enable={{bottom: true}}
> >
{ children } { children }

View file

@ -37,7 +37,6 @@ export default class AuxPanel extends React.Component {
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
showApps: PropTypes.bool, // Render apps showApps: PropTypes.bool, // Render apps
hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered)
// set to true to show the file drop target // set to true to show the file drop target
draggingFile: PropTypes.bool, draggingFile: PropTypes.bool,
@ -54,7 +53,6 @@ export default class AuxPanel extends React.Component {
static defaultProps = { static defaultProps = {
showApps: true, showApps: true,
hideAppsDrawer: false,
}; };
constructor(props) { constructor(props) {
@ -170,7 +168,6 @@ export default class AuxPanel extends React.Component {
userId={this.props.userId} userId={this.props.userId}
maxHeight={this.props.maxHeight} maxHeight={this.props.maxHeight}
showApps={this.props.showApps} showApps={this.props.showApps}
hide={this.props.hideAppsDrawer}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
/>; />;
} }

View file

@ -46,6 +46,7 @@ const eventTileTypes = {
'm.call.invite': 'messages.TextualEvent', 'm.call.invite': 'messages.TextualEvent',
'm.call.answer': 'messages.TextualEvent', 'm.call.answer': 'messages.TextualEvent',
'm.call.hangup': 'messages.TextualEvent', 'm.call.hangup': 'messages.TextualEvent',
'm.call.reject': 'messages.TextualEvent',
}; };
const stateEventTileTypes = { const stateEventTileTypes = {
@ -657,8 +658,7 @@ export default class EventTile extends React.Component {
// source tile when there's no regular tile for an event and also for // source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing // replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing). // duplicate of the thing they are replacing).
const useSource = !tileHandler || this.props.mxEvent.isRelation("m.replace"); if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
if (useSource && SettingsStore.getValue("showHiddenEventsInTimeline")) {
tileHandler = "messages.ViewSourceEvent"; tileHandler = "messages.ViewSourceEvent";
// Reuse info message avatar and sender profile styling // Reuse info message avatar and sender profile styling
isInfoMessage = true; isInfoMessage = true;

View file

@ -18,7 +18,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import {Key} from '../../../Keyboard'; import {Key} from '../../../Keyboard';
@ -28,19 +27,10 @@ export default class ForwardMessage extends React.Component {
}; };
componentDidMount() { componentDidMount() {
dis.dispatch({
action: 'panel_disable',
middleDisabled: true,
});
document.addEventListener('keydown', this._onKeyDown); document.addEventListener('keydown', this._onKeyDown);
} }
componentWillUnmount() { componentWillUnmount() {
dis.dispatch({
action: 'panel_disable',
middleDisabled: false,
});
document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('keydown', this._onKeyDown);
} }

View file

@ -37,6 +37,8 @@ import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils"; import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import { PlaceCallType } from "../../../CallHandler";
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
function ComposerAvatar(props) { function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -53,7 +55,7 @@ function CallButton(props) {
const onVoiceCallClick = (ev) => { const onVoiceCallClick = (ev) => {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: "voice", type: PlaceCallType.Voice,
room_id: props.roomId, room_id: props.roomId,
}); });
}; };
@ -73,7 +75,7 @@ function VideoCallButton(props) {
const onCallClick = (ev) => { const onCallClick = (ev) => {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video,
room_id: props.roomId, room_id: props.roomId,
}); });
}; };
@ -103,8 +105,11 @@ function HangupButton(props) {
if (!call) { if (!call) {
return; return;
} }
const action = call.state === CallState.Ringing ? 'reject' : 'hangup';
dis.dispatch({ dis.dispatch({
action: 'hangup', action,
// hangup the call for this room, which may not be the room in props // hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead) // (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId, room_id: call.roomId,

View file

@ -76,7 +76,7 @@ export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState>
}; };
private viewRoom = (room: Room, index: number) => { private viewRoom = (room: Room, index: number) => {
Analytics.trackEvent("Breadcrumbs", "click_node", index); Analytics.trackEvent("Breadcrumbs", "click_node", String(index));
defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId}); defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
}; };

View file

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

View file

@ -53,7 +53,6 @@ interface IProps {
onBlur: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void;
onResize: () => void; onResize: () => void;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
collapsed: boolean;
isMinimized: boolean; isMinimized: boolean;
} }
@ -366,7 +365,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
public render() { public render() {
let explorePrompt: JSX.Element; let explorePrompt: JSX.Element;
if (RoomListStore.instance.getFirstNameFilterCondition()) { if (!this.props.isMinimized && RoomListStore.instance.getFirstNameFilterCondition()) {
explorePrompt = <div className="mx_RoomList_explorePrompt"> explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{_t("Can't see what youre looking for?")}</div> <div>{_t("Can't see what youre looking for?")}</div>
<AccessibleButton kind="link" onClick={this.onExplore}> <AccessibleButton kind="link" onClick={this.onExplore}>

View file

@ -399,6 +399,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance; const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm); await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
}; };
private onTagSortChanged = async (sort: SortAlgorithm) => { private onTagSortChanged = async (sort: SortAlgorithm) => {

Some files were not shown because too many files have changed in this diff Show more