Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/feat/modal-widgets

This commit is contained in:
Michael Telatynski 2020-10-22 21:40:05 +01:00
commit 0004dd4475
88 changed files with 2241 additions and 1305 deletions

View file

@ -1,3 +1,12 @@
Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1)
* [Release] Adjust for new widget messaging APIs
[\#5342](https://github.com/matrix-org/matrix-react-sdk/pull/5342)
* [Release] Fix Jitsi OpenIDC auth
[\#5335](https://github.com/matrix-org/matrix-react-sdk/pull/5335)
Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.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.6.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": {

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

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

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

@ -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,53 +47,100 @@ $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_AppsContainer_resizer {
margin-bottom: 8px;
}
.mx_AppsContainer { .mx_AppsContainer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
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; &:nth-child(3) {
margin: -3px auto 5px 0; flex-grow: 1;
color: $accent-color; width: 0 !important;
font-size: $font-12px; min-width: $MinWidth !important;
}
}
.mx_AppsDrawer_3apps .mx_AppTile {
width: 33%;
&:nth-child(3) {
flex-grow: 1;
width: 0 !important;
min-width: $MinWidth !important;
}
} }
.mx_AppTile { .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 {
@ -105,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 {
@ -130,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 {
@ -166,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 {
@ -231,7 +256,6 @@ $MiniAppTileHeight: 200px;
.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;
@ -296,6 +320,10 @@ $MiniAppTileHeight: 200px;
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 {
@ -323,10 +351,6 @@ $MiniAppTileHeight: 200px;
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

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

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

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

Before

Width:  |  Height:  |  Size: 655 B

View file

@ -131,6 +131,7 @@ $notice-secondary-color: $roomlist-header-color;
$panel-divider-color: transparent; $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;

View file

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

View file

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

View file

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

View file

@ -262,6 +262,12 @@ export default class CallHandler {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title, description, 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 { } else {
this.play(AudioID.CallEnd); this.play(AudioID.CallEnd);
} }

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

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

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

@ -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;
@ -142,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
@ -213,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) {
@ -388,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}
@ -426,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.
@ -205,13 +206,8 @@ class LoggedInView extends React.Component<IProps, IState> {
}; };
_createResizer() { _createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
let size; let size;
const collapseConfig = { const collapseConfig: ICollapseConfig = {
toggleSize: 260 - 50, toggleSize: 260 - 50,
onCollapsed: (collapsed) => { onCollapsed: (collapsed) => {
if (collapsed) { if (collapsed) {
@ -234,7 +230,11 @@ class LoggedInView extends React.Component<IProps, IState> {
}, },
}; };
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames(classNames); resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
});
return resizer; return resizer;
} }

View file

@ -72,6 +72,8 @@ 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 { 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) {};
@ -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),
@ -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);
@ -823,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) {
@ -1258,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>,
@ -1282,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();
@ -1352,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',
@ -1944,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" />
); );
@ -2054,6 +2080,8 @@ 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="mx_RoomView_body"> <div className="mx_RoomView_body">

View file

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

@ -0,0 +1,58 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps, useContext} from 'react';
import classNames from 'classnames';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {IApp} from "../../../stores/WidgetStore";
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp;
}
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
const cli = useContext(MatrixClientContext);
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
// heuristics for some better icons until Widgets support their own icons
if (app.type.includes("jitsi")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")];
} else if (app.type.includes("meeting") || app.type.includes("calendar")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")];
} else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")];
} else if (app.type.includes("clock")) {
iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")];
}
return (
<BaseAvatar
{...props}
name={app.id}
className={classNames("mx_WidgetAvatar", className)}
// MSC2765
url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined}
urls={iconUrls}
width={width}
height={height}
/>
)
};
export default WidgetAvatar;

View file

@ -1,142 +0,0 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import {MenuItem} from "../../structures/ContextMenu";
export default class WidgetContextMenu extends React.Component {
static propTypes = {
onFinished: PropTypes.func,
// Callback for when the revoke button is clicked. Required.
onRevokeClicked: PropTypes.func.isRequired,
// Callback for when the unpin button is clicked. If absent, unpin will be hidden.
onUnpinClicked: PropTypes.func,
// Callback for when the snapshot button is clicked. Button not shown
// without a callback.
onSnapshotClicked: PropTypes.func,
// Callback for when the reload button is clicked. Button not shown
// without a callback.
onReloadClicked: PropTypes.func,
// Callback for when the edit button is clicked. Button not shown
// without a callback.
onEditClicked: PropTypes.func,
// Callback for when the delete button is clicked. Button not shown
// without a callback.
onDeleteClicked: PropTypes.func,
};
proxyClick(fn) {
fn();
if (this.props.onFinished) this.props.onFinished();
}
// XXX: It's annoying that our context menus require us to hit onFinished() to close :(
onEditClicked = () => {
this.proxyClick(this.props.onEditClicked);
};
onReloadClicked = () => {
this.proxyClick(this.props.onReloadClicked);
};
onSnapshotClicked = () => {
this.proxyClick(this.props.onSnapshotClicked);
};
onDeleteClicked = () => {
this.proxyClick(this.props.onDeleteClicked);
};
onRevokeClicked = () => {
this.proxyClick(this.props.onRevokeClicked);
};
onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked);
render() {
const options = [];
if (this.props.onEditClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
{_t("Edit")}
</MenuItem>,
);
}
if (this.props.onUnpinClicked) {
options.push(
<MenuItem className="mx_WidgetContextMenu_option" onClick={this.onUnpinClicked} key="unpin">
{_t("Unpin")}
</MenuItem>,
);
}
if (this.props.onReloadClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked} key='reload'>
{_t("Reload")}
</MenuItem>,
);
}
if (this.props.onSnapshotClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked} key='snap'>
{_t("Take picture")}
</MenuItem>,
);
}
if (this.props.onDeleteClicked) {
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked} key='delete'>
{_t("Remove for everyone")}
</MenuItem>,
);
}
// Push this last so it appears last. It's always present.
options.push(
<MenuItem className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
{_t("Remove for me")}
</MenuItem>,
);
// Put separators between the options
if (options.length > 1) {
const length = options.length;
for (let i = 0; i < length - 1; i++) {
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
// Insert backwards so the insertions don't affect our math on where to place them.
// We also use our cached length to avoid worrying about options.length changing
options.splice(length - 1 - i, 0, sep);
}
}
return <div className="mx_WidgetContextMenu">{options}</div>;
}
}

View file

@ -0,0 +1,177 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext} from "react";
import {MatrixCapabilities} from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
userWidget?: boolean;
showUnpin?: boolean;
// override delete handler
onDeleteClick?(): void;
}
const WidgetContextMenu: React.FC<IProps> = ({
onFinished,
app,
userWidget,
onDeleteClick,
showUnpin,
...props
}) => {
const cli = useContext(MatrixClientContext);
const {room, roomId} = useContext(RoomContext);
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId);
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetStore.instance.unpinWidget(app.id);
onFinished();
};
unpinButton = <IconizedContextMenuOption onClick={onUnpinClick} label={_t("Unpin")} />;
}
let editButton;
if (canModify && WidgetUtils.isManagedByManager(app)) {
const onEditClick = () => {
WidgetUtils.editWidget(room, app);
onFinished();
};
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
}
let snapshotButton;
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
const onSnapshotClick = () => {
widgetMessaging?.takeScreenshot().then(data => {
dis.dispatch({
action: 'picture_snapshot',
file: data.screenshot,
});
}).catch(err => {
console.error("Failed to take screenshot: ", err);
});
onFinished();
};
snapshotButton = <IconizedContextMenuOption onClick={onSnapshotClick} label={_t("Take a picture")} />;
}
let deleteButton;
if (onDeleteClick || canModify) {
const onDeleteClickDefault = () => {
// Show delete confirmation dialog
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(roomId, app.id);
},
});
onFinished();
};
deleteButton = <IconizedContextMenuOption
onClick={onDeleteClick || onDeleteClickDefault}
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
/>;
}
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
if (isAllowedWidget === undefined) {
isAllowedWidget = app.creatorUserId === cli.getUserId();
}
const isLocalWidget = WidgetType.JITSI.matches(app.type);
let revokeButton;
if (!userWidget && !isLocalWidget && isAllowedWidget) {
const onRevokeClick = () => {
console.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId);
current[app.eventId] = false;
SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).catch(err => {
console.error(err);
// We don't really need to do anything about this - the user will just hit the button again.
});
onFinished();
};
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}
const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId);
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);
let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(app.id, -1);
onFinished();
};
moveLeftButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move left")} />;
}
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(app.id, 1);
onFinished();
};
moveRightButton = <IconizedContextMenuOption onClick={onClick} label={_t("Move right")} />;
}
return <IconizedContextMenu {...props} chevronFace={ChevronFace.None} onFinished={onFinished}>
<IconizedContextMenuOptionList>
{ editButton }
{ revokeButton }
{ deleteButton }
{ snapshotButton }
{ moveLeftButton }
{ moveRightButton }
{ unpinButton }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
export default WidgetContextMenu;

View file

@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
tooltip?: React.ReactNode; 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
@ -250,57 +212,6 @@ export default class AppTile extends React.Component {
this._sgWidget.stop({forceDestroy: true}); 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 = () => {
this.setState({loading: false}); this.setState({loading: false});
}; };
@ -311,7 +222,7 @@ export default class AppTile extends React.Component {
} }
}; };
_onAction(payload) { _onAction = payload => {
if (payload.widgetId === this.props.app.id) { if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
@ -321,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);
@ -347,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";
@ -376,32 +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();
} else {
// restart the widget actions
this._resetWidget(this.props);
}
dis.dispatch({
action: 'appsDrawer',
show: !this.props.show,
});
}
}
/** /**
* Whether we're using a local version of the widget rather than loading the * Whether we're using a local version of the widget rather than loading the
* actual widget URL * actual widget URL
@ -421,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
@ -449,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 });
@ -468,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
@ -487,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};
@ -560,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}
@ -645,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,
@ -657,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)
@ -667,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.
@ -692,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

@ -79,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

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

@ -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 = {
@ -52,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() {
@ -64,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
@ -73,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) {
@ -91,7 +190,7 @@ export default class AppsDrawer extends React.Component {
} }
}; };
_getApps = () => WidgetStore.instance.getApps(this.props.room, true); _getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId);
_updateApps = () => { _updateApps = () => {
this.setState({ this.setState({
@ -99,15 +198,6 @@ export default class AppsDrawer extends React.Component {
}); });
}; };
_canUserModify() {
try {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
} catch (err) {
console.error(err);
return false;
}
}
_launchManageIntegrations() { _launchManageIntegrations() {
if (SettingsStore.getValue("feature_many_integration_managers")) { if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(); IntegrationManagers.sharedInstance().openAll();
@ -116,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);
@ -131,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}
@ -143,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(
@ -170,9 +241,11 @@ export default class AppsDrawer extends React.Component {
} }
const classes = classNames({ const classes = classNames({
"mx_AppsDrawer": true, mx_AppsDrawer: true,
"mx_AppsDrawer_fullWidth": apps.length < 2, mx_AppsDrawer_fullWidth: apps.length < 2,
"mx_AppsDrawer_minimised": !this.props.showApps, mx_AppsDrawer_resizing: this.state.resizing,
mx_AppsDrawer_2apps: apps.length === 2,
mx_AppsDrawer_3apps: apps.length === 3,
}); });
return ( return (
@ -182,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>
); );
} }
@ -205,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={() => {
@ -220,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

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

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

View file

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

View file

@ -26,7 +26,7 @@ const getValue = <T>(key: string, initialValue: T): T => {
}; };
// Hook behaving like useState but persisting the value to localStorage. Returns same as useState // Hook behaving like useState but persisting the value to localStorage. Returns same as useState
export const useLocalStorageState = <T>(key: string, initialValue: T) => { export const useLocalStorageState = <T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] => {
const lsKey = "mx_" + key; const lsKey = "mx_" + key;
const [value, setValue] = useState<T>(getValue(lsKey, initialValue)); const [value, setValue] = useState<T>(getValue(lsKey, initialValue));

View file

@ -2305,5 +2305,22 @@
"You were uninvited": "Поканата към вас беше премахната", "You were uninvited": "Поканата към вас беше премахната",
"%(targetName)s was uninvited": "Поканата към %(targetName)s беше премахната", "%(targetName)s was uninvited": "Поканата към %(targetName)s беше премахната",
"You were banned (%(reason)s)": "Бяхте блокирани (%(reason)s)", "You were banned (%(reason)s)": "Бяхте блокирани (%(reason)s)",
"%(targetName)s was banned (%(reason)s)": "%(targetName)s беше блокиран(а) (%(reason)s)" "%(targetName)s was banned (%(reason)s)": "%(targetName)s беше блокиран(а) (%(reason)s)",
"%(senderName)s: %(message)s": "%(senderName)s: %(message)s",
"* %(senderName)s %(emote)s": "%(senderName)s%(emote)s",
"The person who invited you already left the room, or their server is offline.": "Участникът който ви е поканил вече е напуснал стаята или техният сървър не е на линия.",
"The person who invited you already left the room.": "Участникът който ви покани вече напусна стаята.",
"Safeguard against losing access to encrypted messages & data": "Защитете се срещу загуба на достъп до криптирани съобшения и информация",
"Set up Secure Backup": "Конфигуриране на Защитен Архив",
"Unknown App": "Неизвестно приложение",
"Error leaving room": "Грешка при напускане на стаята",
"%(senderName)s declined the call.": "%(senderName)s отказа разговора.",
"(their device couldn't start the camera / microphone)": "(тяхното устройство не може да стартира камерата / микрофонът)",
"(connection failed)": "(връзката се разпадна)",
"Are you sure you want to cancel entering passphrase?": "Сигурни ли сте че желате да прекратите въвеждането на паролата?",
"This will end the conference for everyone. Continue?": "Това ще прекрати конферентният разговор за всички. Продължи?",
"End conference": "Прекрати конфетентният разговор",
"Call Declined": "Обаждането е отказано",
"The call could not be established": "Обаждането не може да бъде осъществено",
"The other party declined the call.": "Другата страна отказа обаждането."
} }

View file

@ -2519,5 +2519,11 @@
"Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert", "Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert",
"Video conference started by %(senderName)s": "Videokonferenz wurde von %(senderName)s gestartet", "Video conference started by %(senderName)s": "Videokonferenz wurde von %(senderName)s gestartet",
"Ignored attempt to disable encryption": "Versuch, die Verschlüsselung zu deaktivieren, wurde ignoriert", "Ignored attempt to disable encryption": "Versuch, die Verschlüsselung zu deaktivieren, wurde ignoriert",
"Failed to save your profile": "Profil speichern fehlgeschlagen" "Failed to save your profile": "Profil speichern fehlgeschlagen",
"The operation could not be completed": "Die Operation konnte nicht abgeschlossen werden",
"Remove messages sent by others": "Nachrichten von anderen entfernen",
"Starting camera...": "Starte Kamera...",
"Call connecting...": "Verbinde den Anruf...",
"Calling...": "Rufe an...",
"Starting microphone...": "Starte Mikrofon..."
} }

View file

@ -39,6 +39,8 @@
"The other party declined the call.": "The other party declined the call.", "The other party declined the call.": "The other party declined the call.",
"The remote side failed to pick up": "The remote side failed to pick up", "The remote side failed to pick up": "The remote side failed to pick up",
"The call could not be established": "The call could not be established", "The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server", "Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.", "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.", "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
@ -1031,7 +1033,6 @@
"Remove %(phone)s?": "Remove %(phone)s?", "Remove %(phone)s?": "Remove %(phone)s?",
"A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"Phone Number": "Phone Number", "Phone Number": "Phone Number",
"Add a widget": "Add a widget",
"Drop File Here": "Drop File Here", "Drop File Here": "Drop File Here",
"Drop file here to upload": "Drop file here to upload", "Drop file here to upload": "Drop file here to upload",
"This user has not verified all of their sessions.": "This user has not verified all of their sessions.", "This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
@ -1113,6 +1114,8 @@
"(~%(count)s results)|one": "(~%(count)s result)", "(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room", "Join Room": "Join Room",
"Forget room": "Forget room", "Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search", "Search": "Search",
"Invites": "Invites", "Invites": "Invites",
"Favourites": "Favourites", "Favourites": "Favourites",
@ -1278,8 +1281,11 @@
"Yours, or the other users session": "Yours, or the other users session", "Yours, or the other users session": "Yours, or the other users session",
"Members": "Members", "Members": "Members",
"Room Info": "Room Info", "Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin": "Unpin",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Widgets": "Widgets", "Widgets": "Widgets",
"Unpin app": "Unpin app",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
"Add widgets, bridges & bots": "Add widgets, bridges & bots", "Add widgets, bridges & bots": "Add widgets, bridges & bots",
"Not encrypted": "Not encrypted", "Not encrypted": "Not encrypted",
@ -1302,7 +1308,6 @@
"Invite": "Invite", "Invite": "Invite",
"Share Link to User": "Share Link to User", "Share Link to User": "Share Link to User",
"Direct message": "Direct message", "Direct message": "Direct message",
"Options": "Options",
"Demote yourself?": "Demote yourself?", "Demote yourself?": "Demote yourself?",
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.",
"Demote": "Demote", "Demote": "Demote",
@ -1366,12 +1371,6 @@
"You cancelled verification.": "You cancelled verification.", "You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled", "Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji", "Compare emoji": "Compare emoji",
"Take a picture": "Take a picture",
"Remove for everyone": "Remove for everyone",
"Remove for me": "Remove for me",
"Edit": "Edit",
"Pin to room": "Pin to room",
"You can only pin 2 widgets at a time": "You can only pin 2 widgets at a time",
"Sunday": "Sunday", "Sunday": "Sunday",
"Monday": "Monday", "Monday": "Monday",
"Tuesday": "Tuesday", "Tuesday": "Tuesday",
@ -1390,6 +1389,7 @@
"Error decrypting audio": "Error decrypting audio", "Error decrypting audio": "Error decrypting audio",
"React": "React", "React": "React",
"Reply": "Reply", "Reply": "Reply",
"Edit": "Edit",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Attachment": "Attachment", "Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
@ -1482,15 +1482,7 @@
"Widgets do not use message encryption.": "Widgets do not use message encryption.", "Widgets do not use message encryption.": "Widgets do not use message encryption.",
"Widget added by": "Widget added by", "Widget added by": "Widget added by",
"This widget may use cookies.": "This widget may use cookies.", "This widget may use cookies.": "This widget may use cookies.",
"Delete Widget": "Delete Widget",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Failed to remove widget": "Failed to remove widget",
"An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room",
"Minimize widget": "Minimize widget",
"Maximize widget": "Maximize widget",
"Popout widget": "Popout widget", "Popout widget": "Popout widget",
"More options": "More options",
"Use the <a>Desktop app</a> to see all encrypted files": "Use the <a>Desktop app</a> to see all encrypted files", "Use the <a>Desktop app</a> to see all encrypted files": "Use the <a>Desktop app</a> to see all encrypted files",
"Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages", "Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files", "This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
@ -1925,9 +1917,14 @@
"Set status": "Set status", "Set status": "Set status",
"Set a new status...": "Set a new status...", "Set a new status...": "Set a new status...",
"View Community": "View Community", "View Community": "View Community",
"Unpin": "Unpin", "Take a picture": "Take a picture",
"Reload": "Reload", "Delete Widget": "Delete Widget",
"Take picture": "Take picture", "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?",
"Delete widget": "Delete widget",
"Remove for everyone": "Remove for everyone",
"Revoke permissions": "Revoke permissions",
"Move left": "Move left",
"Move right": "Move right",
"This room is public": "This room is public", "This room is public": "This room is public",
"Away": "Away", "Away": "Away",
"User Status": "User Status", "User Status": "User Status",

View file

@ -2524,5 +2524,19 @@
"Offline encrypted messaging using dehydrated devices": "Võrguühenduseta kasutamiseks mõeldud krüptitud sõnumid dehydrated teenuse abil", "Offline encrypted messaging using dehydrated devices": "Võrguühenduseta kasutamiseks mõeldud krüptitud sõnumid dehydrated teenuse abil",
"Remove messages sent by others": "Kustuta teiste saadetud sõnumid", "Remove messages sent by others": "Kustuta teiste saadetud sõnumid",
"Failed to save your profile": "Sinu profiili salvestamine ei õnnestunud", "Failed to save your profile": "Sinu profiili salvestamine ei õnnestunud",
"The operation could not be completed": "Toimingut ei õnnestunud lõpetada" "The operation could not be completed": "Toimingut ei õnnestunud lõpetada",
"Calling...": "Helistan...",
"Call connecting...": "Kõne on ühendamisel...",
"Starting camera...": "Käivitan kaamerat...",
"Starting microphone...": "Lülitan mikrofoni sisse...",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s muutis seda jututuba teenindavate koduserverite loendit.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s seadistas seda jututuba teenindavate koduserverite loendi.",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Kõikidel serveritel on keeld seda jututuba teenindada! Seega seda jututuba ei saa enam kasutada.",
"(an error occurred)": "(tekkis viga)",
"(their device couldn't start the camera / microphone)": "(teise osapoole seadmes ei õnnestunud sisse lülitada kaamerat või mikrofoni)",
"(connection failed)": "(ühendus ebaõnnestus)",
"The call could not be established": "Kõnet ei saa korraldada",
"%(senderName)s declined the call.": "%(senderName)s ei võtnud kõnet vastu.",
"The other party declined the call.": "Teine osapool ei võtnud kõnet vastu.",
"Call Declined": "Kõne on tagasilükatud"
} }

View file

@ -36,7 +36,7 @@
"Unavailable": "غیرقابل‌دسترسی", "Unavailable": "غیرقابل‌دسترسی",
"View Decrypted Source": "دیدن منبع رمزگشایی شده", "View Decrypted Source": "دیدن منبع رمزگشایی شده",
"Failed to update keywords": "به‌روزرسانی کلیدواژه‌ها موفقیت‌آمیز نبود", "Failed to update keywords": "به‌روزرسانی کلیدواژه‌ها موفقیت‌آمیز نبود",
"remove %(name)s from the directory.": "%(name)s را از فهرست گپ‌ها حذف کن", "remove %(name)s from the directory.": "برداشتن %(name)s از فهرست گپ‌ها.",
"Please set a password!": "لطفا یک پسورد اختیار کنید!", "Please set a password!": "لطفا یک پسورد اختیار کنید!",
"powered by Matrix": "قدرت‌یافته از ماتریکس", "powered by Matrix": "قدرت‌یافته از ماتریکس",
"You have successfully set a password!": "شما با موفقیت رمزتان را انتخاب کردید!", "You have successfully set a password!": "شما با موفقیت رمزتان را انتخاب کردید!",
@ -149,5 +149,7 @@
"Restart": "شروع دوباره", "Restart": "شروع دوباره",
"Upgrade your %(brand)s": "ارتقای %(brand)s تان", "Upgrade your %(brand)s": "ارتقای %(brand)s تان",
"A new version of %(brand)s is available!": "نگارشی جدید از %(brand)s موجود است!", "A new version of %(brand)s is available!": "نگارشی جدید از %(brand)s موجود است!",
"Guest": "مهمان" "Guest": "مهمان",
"Confirm adding this email address by using Single Sign On to prove your identity.": "برای تأیید هویتتان، این نشانی رایانامه را با ورود یکپارچه تأیید کنید.",
"Click the button below to confirm adding this email address.": "برای تأیید افزودن این نشانی رایانامه، دکمهٔ زیر را بزنید."
} }

View file

@ -525,9 +525,9 @@
"Unignored user": "Sallittu käyttäjä", "Unignored user": "Sallittu käyttäjä",
"%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s tasolta %(fromPowerLevel)s tasolle %(toPowerLevel)s", "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s tasolta %(fromPowerLevel)s tasolle %(toPowerLevel)s",
"%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s muutti %(powerLevelDiffText)s:n oikeustasoa.", "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s muutti %(powerLevelDiffText)s:n oikeustasoa.",
"%(widgetName)s widget modified by %(senderName)s": "%(senderName)s muutti pienoisohjelmaa %(widgetName)s", "%(widgetName)s widget modified by %(senderName)s": "%(senderName)s muokkasi sovelmaa %(widgetName)s",
"%(widgetName)s widget added by %(senderName)s": "%(senderName)s lisäsi pienoisohjelman %(widgetName)s", "%(widgetName)s widget added by %(senderName)s": "%(senderName)s lisäsi sovelman %(widgetName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(senderName)s poisti pienoisohjelman %(widgetName)s", "%(widgetName)s widget removed by %(senderName)s": "%(senderName)s poisti sovelman %(widgetName)s",
"Send": "Lähetä", "Send": "Lähetä",
"Ongoing conference call%(supportedText)s.": "Menossa oleva ryhmäpuhelu %(supportedText)s.", "Ongoing conference call%(supportedText)s.": "Menossa oleva ryhmäpuhelu %(supportedText)s.",
"%(duration)ss": "%(duration)s s", "%(duration)ss": "%(duration)s s",
@ -549,7 +549,7 @@
"URL previews are disabled by default for participants in this room.": "URL-esikatselut ovat oletuksena pois päältä tämän huoneen jäsenillä.", "URL previews are disabled by default for participants in this room.": "URL-esikatselut ovat oletuksena pois päältä tämän huoneen jäsenillä.",
"Token incorrect": "Väärä tunniste", "Token incorrect": "Väärä tunniste",
"Something went wrong when trying to get your communities.": "Jokin meni pieleen yhteisöjäsi haettaessa.", "Something went wrong when trying to get your communities.": "Jokin meni pieleen yhteisöjäsi haettaessa.",
"Delete Widget": "Poista pienoisohjelma", "Delete Widget": "Poista sovelma",
"%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s liittyivät", "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s liittyivät",
"%(oneUser)sjoined %(count)s times|other": "%(oneUser)s liittyi %(count)s kertaa", "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s liittyi %(count)s kertaa",
"%(oneUser)sjoined %(count)s times|one": "%(oneUser)s liittyi", "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s liittyi",
@ -591,7 +591,7 @@
"expand": "laajenna", "expand": "laajenna",
"collapse": "supista", "collapse": "supista",
"Display your community flair in rooms configured to show it.": "Näytä yhteisötyylisi huoneissa joissa ominaisuus on päällä.", "Display your community flair in rooms configured to show it.": "Näytä yhteisötyylisi huoneissa joissa ominaisuus on päällä.",
"Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Pienoisohjelman poistaminen poistaa sen kaikilta huoneen käyttäjiltä. Oletko varma että haluat poistaa pienoisohjelman?", "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Sovelman poistaminen poistaa sen kaikilta huoneen käyttäjiltä. Haluatko varmasti poistaa tämän sovelman?",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s liittyivät %(count)s kertaa", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s liittyivät %(count)s kertaa",
"%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s vaihtoivat nimensä %(count)s kertaa", "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s vaihtoivat nimensä %(count)s kertaa",
"%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s vaihtoivat nimensä", "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s vaihtoivat nimensä",
@ -743,7 +743,7 @@
"Every page you use in the app": "Jokainen sivu, jota käytät sovelluksessa", "Every page you use in the app": "Jokainen sivu, jota käytät sovelluksessa",
"e.g. <CurrentPageURL>": "esim. <CurrentPageURL>", "e.g. <CurrentPageURL>": "esim. <CurrentPageURL>",
"Your device resolution": "Laitteesi näytön tarkkuus", "Your device resolution": "Laitteesi näytön tarkkuus",
"You do not have permission to start a conference call in this room": "Sinulla ei ole oikeutta aloittaa konferenssipuhelua tässä huoneessa", "You do not have permission to start a conference call in this room": "Sinulla ei ole oikeutta aloittaa ryhmäpuhelua tässä huoneessa",
"Upgrades a room to a new version": "Päivittää huoneen uuteen versioon", "Upgrades a room to a new version": "Päivittää huoneen uuteen versioon",
"Gets or sets the room topic": "Hakee tai asettaa huoneen aiheen", "Gets or sets the room topic": "Hakee tai asettaa huoneen aiheen",
"This room has no topic.": "Tässä huoneessa ei ole aihetta.", "This room has no topic.": "Tässä huoneessa ei ole aihetta.",
@ -1096,7 +1096,7 @@
"There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "Huoneen tyylin päivittämisessä tapahtui virhe. Palvelin ei välttämättä salli sitä tai kyseessä on tilapäinen virhe.", "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "Huoneen tyylin päivittämisessä tapahtui virhe. Palvelin ei välttämättä salli sitä tai kyseessä on tilapäinen virhe.",
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Salatuissa huoneissa, kuten tässä, osoitteiden esikatselut ovat oletuksena pois käytöstä, jotta kotipalvelimesi (missä osoitteiden esikatselut luodaan) ei voi kerätä tietoa siitä, mitä linkkejä näet tässä huoneessa.", "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Salatuissa huoneissa, kuten tässä, osoitteiden esikatselut ovat oletuksena pois käytöstä, jotta kotipalvelimesi (missä osoitteiden esikatselut luodaan) ei voi kerätä tietoa siitä, mitä linkkejä näet tässä huoneessa.",
"Failed to remove widget": "Sovelman poisto epäonnistui", "Failed to remove widget": "Sovelman poisto epäonnistui",
"An error ocurred whilst trying to remove the widget from the room": "Sovelman poistossa huoneesta tapahtui virhe", "An error ocurred whilst trying to remove the widget from the room": "Poistaessa sovelmaa huoneesta tapahtui virhe",
"Minimize apps": "Pienennä sovellukset", "Minimize apps": "Pienennä sovellukset",
"Popout widget": "Avaa sovelma omassa ikkunassaan", "Popout widget": "Avaa sovelma omassa ikkunassaan",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Lisää ”¯\\_(ツ)_/¯” viestin alkuun", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Lisää ”¯\\_(ツ)_/¯” viestin alkuun",
@ -2222,5 +2222,27 @@
"Were excited to announce Riot is now Element!": "Meillä on ilo ilmoittaa, että Riot on nyt Element!", "Were excited to announce Riot is now Element!": "Meillä on ilo ilmoittaa, että Riot on nyt Element!",
"Learn more at <a>element.io/previously-riot</a>": "Lue lisää osoitteessa <a>element.io/previously-riot</a>", "Learn more at <a>element.io/previously-riot</a>": "Lue lisää osoitteessa <a>element.io/previously-riot</a>",
"Security & privacy": "Tietoturva ja -suoja", "Security & privacy": "Tietoturva ja -suoja",
"User menu": "Käyttäjän valikko" "User menu": "Käyttäjän valikko",
"Video conference started by %(senderName)s": "%(senderName)s aloitti videopuhelun",
"Video conference updated by %(senderName)s": "%(senderName)s muokkasi videopuhelua",
"Video conference ended by %(senderName)s": "%(senderName)s päätti videopuhelun",
"Join the conference from the room information card on the right": "Liity ryhmäpuheluun oikealla olevasta huoneen tiedoista",
"Join the conference at the top of this room": "Liity ryhmäpuheluun huoneen ylälaidassa",
"This will end the conference for everyone. Continue?": "Tämä päättää ryhmäpuhelun kaikilta. Jatka?",
"End conference": "Päätä ryhmäpuhelu",
"Wrong Recovery Key": "Väärä palautusavain",
"Wrong file type": "Väärä tiedostotyyppi",
"Please provide a room address": "Anna huoneen osoite",
"Room address": "Huoneen osoite",
"Message deleted on %(date)s": "Viesti poistettu %(date)s",
"Show %(count)s more|one": "Näytä %(count)s lisää",
"Show %(count)s more|other": "Näytä %(count)s lisää",
"Mod": "Moderaattori",
"Read Marker off-screen lifetime (ms)": "Viestin luetuksi merkkaamisen kesto, kun Element ei ole näkyvissä (ms)",
"Maximize widget": "Suurenna sovelma",
"Minimize widget": "Pienennä sovelma",
"You can only pin 2 widgets at a time": "Vain kaksi sovelmaa voi olla kiinnitettynä samaan aikaan",
"Add widgets, bridges & bots": "Lisää sovelmia, siltoja ja botteja",
"Edit widgets, bridges & bots": "Muokkaa sovelmia, siltoja ja botteja",
"Widgets": "Sovelmat"
} }

View file

@ -163,7 +163,7 @@
"Current password": "Contrasinal actual", "Current password": "Contrasinal actual",
"Password": "Contrasinal", "Password": "Contrasinal",
"New Password": "Novo contrasinal", "New Password": "Novo contrasinal",
"Confirm password": "Confirme o contrasinal", "Confirm password": "Confirma o contrasinal",
"Change Password": "Cambiar contrasinal", "Change Password": "Cambiar contrasinal",
"Authentication": "Autenticación", "Authentication": "Autenticación",
"Last seen": "Visto por última vez", "Last seen": "Visto por última vez",
@ -424,7 +424,7 @@
"email address": "enderezo de correo", "email address": "enderezo de correo",
"Try using one of the following valid address types: %(validTypesList)s.": "Intentar utilizar algún dos seguintes tipos de enderezo válidos: %(validTypesList)s.", "Try using one of the following valid address types: %(validTypesList)s.": "Intentar utilizar algún dos seguintes tipos de enderezo válidos: %(validTypesList)s.",
"You have entered an invalid address.": "Introduciu un enderezo non válido.", "You have entered an invalid address.": "Introduciu un enderezo non válido.",
"Confirm Removal": "Confirme a retirada", "Confirm Removal": "Confirma a retirada",
"Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Estás certa de que queres quitar (eliminar) este evento? Debes saber que se eliminas un nome de sala ou cambias o asunto, poderías desfacer o cambio.", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Estás certa de que queres quitar (eliminar) este evento? Debes saber que se eliminas un nome de sala ou cambias o asunto, poderías desfacer o cambio.",
"Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Os ID de comunidade só poden conter caracteres a-z, 0-9, or '=_-./'", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Os ID de comunidade só poden conter caracteres a-z, 0-9, or '=_-./'",
"Community IDs cannot be empty.": "O ID de comunidade non pode quedar baldeiro.", "Community IDs cannot be empty.": "O ID de comunidade non pode quedar baldeiro.",
@ -614,7 +614,7 @@
"This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "Este proceso permíteche exportar a un ficheiro local as chaves para as mensaxes que recibiches en salas cifradas. Após poderás importar as chaves noutro cliente Matrix no futuro, así o cliente poderá descifrar esas mensaxes.", "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "Este proceso permíteche exportar a un ficheiro local as chaves para as mensaxes que recibiches en salas cifradas. Após poderás importar as chaves noutro cliente Matrix no futuro, así o cliente poderá descifrar esas mensaxes.",
"The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "O ficheiro exportado permitiralle a calquera que poida lelo descifrar e cifrar mensaxes que ti ves, así que deberías ter coidado e gardalo de xeito seguro. Para axudarche, deberías escribir unha frase de paso aquí abaixo que será usada para cifrar os datos exportados. Só será posible importar os datos utilizando a mesma frase de paso.", "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "O ficheiro exportado permitiralle a calquera que poida lelo descifrar e cifrar mensaxes que ti ves, así que deberías ter coidado e gardalo de xeito seguro. Para axudarche, deberías escribir unha frase de paso aquí abaixo que será usada para cifrar os datos exportados. Só será posible importar os datos utilizando a mesma frase de paso.",
"Enter passphrase": "Introduza a frase de paso", "Enter passphrase": "Introduza a frase de paso",
"Confirm passphrase": "Confirme a frase de paso", "Confirm passphrase": "Confirma a frase de paso",
"Export": "Exportar", "Export": "Exportar",
"Import room keys": "Importar chaves de sala", "Import room keys": "Importar chaves de sala",
"This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "Este proceso permíteche importar chaves de cifrado que exportaches doutro cliente Matrix. Así poderás descifrar calquera mensaxe que o outro cliente puidese cifrar.", "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "Este proceso permíteche importar chaves de cifrado que exportaches doutro cliente Matrix. Así poderás descifrar calquera mensaxe que o outro cliente puidese cifrar.",
@ -1887,7 +1887,7 @@
"Power level": "Nivel de permisos", "Power level": "Nivel de permisos",
"Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifica este dispositivo para marcalo como confiable. Confiando neste dispositivo permite que ti e outras usuarias estedes máis tranquilas ao utilizar mensaxes cifradas.", "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifica este dispositivo para marcalo como confiable. Confiando neste dispositivo permite que ti e outras usuarias estedes máis tranquilas ao utilizar mensaxes cifradas.",
"Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Ao verificar este dispositivo marcaralo como confiable, e as usuarias que confiaron en ti tamén confiarán nel.", "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Ao verificar este dispositivo marcaralo como confiable, e as usuarias que confiaron en ti tamén confiarán nel.",
"Waiting for partner to confirm...": "Agardando a que o compañeiro confirme...", "Waiting for partner to confirm...": "Agardando a que o contacto confirme...",
"Incoming Verification Request": "Solicitude entrante de verificación", "Incoming Verification Request": "Solicitude entrante de verificación",
"Integrations are disabled": "As Integracións están desactivadas", "Integrations are disabled": "As Integracións están desactivadas",
"Enable 'Manage Integrations' in Settings to do this.": "Activa 'Xestionar Integracións' nos Axustes para facer esto.", "Enable 'Manage Integrations' in Settings to do this.": "Activa 'Xestionar Integracións' nos Axustes para facer esto.",
@ -2523,5 +2523,19 @@
"Ignored attempt to disable encryption": "Intento ignorado de desactivar o cifrado", "Ignored attempt to disable encryption": "Intento ignorado de desactivar o cifrado",
"Failed to save your profile": "Non se gardaron os cambios", "Failed to save your profile": "Non se gardaron os cambios",
"The operation could not be completed": "Non se puido realizar a acción", "The operation could not be completed": "Non se puido realizar a acción",
"Remove messages sent by others": "Eliminar mensaxes enviadas por outras" "Remove messages sent by others": "Eliminar mensaxes enviadas por outras",
"Calling...": "Chamando...",
"Call connecting...": "Conectando a chamada...",
"Starting camera...": "Iniciando a cámara...",
"Starting microphone...": "Iniciando o micrófono...",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Tódolos servidores están prohibidos! Esta sala xa non pode ser utilizada.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s cambiou ACLs de servidor para esta sala.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s estableceu ACLs de servidor para esta sala.",
"%(senderName)s declined the call.": "%(senderName)s rexeitou a chamada.",
"(an error occurred)": "(algo fallou)",
"(their device couldn't start the camera / microphone)": "(o dispositivo deles non puido iniciar a cámara / micrófono)",
"(connection failed)": "(fallou a conexión)",
"The call could not be established": "Non se puido establecer a chamada",
"The other party declined the call.": "A outra persoa rexeitou a chamada.",
"Call Declined": "Chamada rexeitada"
} }

View file

@ -2524,5 +2524,19 @@
"Video conference started by %(senderName)s": "A videókonferenciát elindította: %(senderName)s", "Video conference started by %(senderName)s": "A videókonferenciát elindította: %(senderName)s",
"Failed to save your profile": "A profilodat nem sikerült elmenteni", "Failed to save your profile": "A profilodat nem sikerült elmenteni",
"The operation could not be completed": "A műveletet nem lehetett befejezni", "The operation could not be completed": "A műveletet nem lehetett befejezni",
"Remove messages sent by others": "Mások által küldött üzenetek törlése" "Remove messages sent by others": "Mások által küldött üzenetek törlése",
"Starting microphone...": "Mikrofon bekapcsolása…",
"Starting camera...": "Kamera bekapcsolása…",
"Call connecting...": "Híváshoz csatlakozás…",
"Calling...": "Hívás…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Minden szerver ki van tiltva! Ezt a szobát nem lehet többet használni.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s megváltoztatta a jogosultságokat a szobában.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s beállította a jogosultságokat a szobában.",
"%(senderName)s declined the call.": "%(senderName)s visszautasította a hívást.",
"(an error occurred)": "(hiba történt)",
"(their device couldn't start the camera / microphone)": "(az ő eszköze nem tudja a kamerát / mikrofont használni)",
"(connection failed)": "(kapcsolódás sikertelen)",
"The call could not be established": "A hívás kapcsolatot nem lehet felépíteni",
"The other party declined the call.": "A másik fél elutasította a hívást.",
"Call Declined": "Hívás elutasítva"
} }

View file

@ -805,7 +805,7 @@
"To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Per continuare a usare l'homeserver %(homeserverDomain)s devi leggere e accettare i nostri termini e condizioni.", "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Per continuare a usare l'homeserver %(homeserverDomain)s devi leggere e accettare i nostri termini e condizioni.",
"Review terms and conditions": "Leggi i termini e condizioni", "Review terms and conditions": "Leggi i termini e condizioni",
"Muted Users": "Utenti silenziati", "Muted Users": "Utenti silenziati",
"Message Pinning": "Messaggi appuntati", "Message Pinning": "Ancoraggio messaggi",
"Mirror local video feed": "Feed video dai ripetitori locali", "Mirror local video feed": "Feed video dai ripetitori locali",
"Replying": "Rispondere", "Replying": "Rispondere",
"Popout widget": "Oggetto a comparsa", "Popout widget": "Oggetto a comparsa",
@ -2523,5 +2523,22 @@
"Video conference started by %(senderName)s": "Conferenza video iniziata da %(senderName)s", "Video conference started by %(senderName)s": "Conferenza video iniziata da %(senderName)s",
"End conference": "Termina conferenza", "End conference": "Termina conferenza",
"This will end the conference for everyone. Continue?": "Verrà terminata la conferenza per tutti. Continuare?", "This will end the conference for everyone. Continue?": "Verrà terminata la conferenza per tutti. Continuare?",
"Ignored attempt to disable encryption": "Tentativo di disattivare la crittografia ignorato" "Ignored attempt to disable encryption": "Tentativo di disattivare la crittografia ignorato",
"Failed to save your profile": "Salvataggio del profilo fallito",
"The operation could not be completed": "Impossibile completare l'operazione",
"Remove messages sent by others": "Rimuovi i messaggi inviati dagli altri",
"Calling...": "Chiamata in corso...",
"Call connecting...": "In connessione...",
"Starting camera...": "Avvio fotocamera...",
"Starting microphone...": "Avvio microfono...",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Tutti i server sono banditi dalla partecipazione! Questa stanza non può più essere usata.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ha cambiato le ACL del server per questa stanza.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s ha impostato le ACL del server per questa stanza.",
"%(senderName)s declined the call.": "%(senderName)s ha rifiutato la chiamata.",
"(an error occurred)": "(si è verificato un errore)",
"(their device couldn't start the camera / microphone)": "(il suo dispositivo non ha potuto avviare la fotocamera / il microfono)",
"(connection failed)": "(connessione fallita)",
"The call could not be established": "Impossibile stabilire la chiamata",
"The other party declined the call.": "Il destinatario ha rifiutato la chiamata.",
"Call Declined": "Chiamata rifiutata"
} }

View file

@ -1423,5 +1423,15 @@
"Upload files": "ファイルのアップロード", "Upload files": "ファイルのアップロード",
"Upload all": "全てアップロード", "Upload all": "全てアップロード",
"No files visible in this room": "この部屋にファイルはありません", "No files visible in this room": "この部屋にファイルはありません",
"Attach files from chat or just drag and drop them anywhere in a room.": "チャットでファイルを添付するか、部屋のどこかにドラッグ&ドロップするとファイルを追加できます。" "Attach files from chat or just drag and drop them anywhere in a room.": "チャットでファイルを添付するか、部屋のどこかにドラッグ&ドロップするとファイルを追加できます。",
"Add widgets, bridges & bots": "ウィジェット、ブリッジ、ボットの追加",
"Widgets": "ウィジェット",
"Cross-signing is ready for use.": "クロス署名の使用準備が完了しています。",
"Secure Backup": "セキュアバックアップ",
"Set up Secure Backup": "セキュアバックアップのセットアップ",
"Restart": "再起動",
"Go back": "戻る",
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "重複した issue の報告が発生しないようにするため、まず<existingIssuesLink>既存の issue を確認</existingIssuesLink>してあなたが行おうとしているのと同様の報告が見つかった場合はその issue を +1 してください。見つからなかった場合は、<newIssueLink>新しい issue を作成</newIssueLink>して報告を行ってください。",
"If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "バグが発生したり、共有したいフィードバックがある場合は、GitHub でお知らせください。",
"Report bugs & give feedback": "バグ報告とフィードバック"
} }

View file

@ -2438,5 +2438,16 @@
"There was an error updating your community. The server is unable to process your request.": "Tella-d tuccḍa deg uleqqem n temɣiwent-ik•im. Aqeddac ur izmir ara ad isesfer asuter.", "There was an error updating your community. The server is unable to process your request.": "Tella-d tuccḍa deg uleqqem n temɣiwent-ik•im. Aqeddac ur izmir ara ad isesfer asuter.",
"Update community": "Leqqem tamɣiwent", "Update community": "Leqqem tamɣiwent",
"May include members not in %(communityName)s": "Yezmer ad d-isseddu iɛeggalen ur nelli deg %(communityName)s", "May include members not in %(communityName)s": "Yezmer ad d-isseddu iɛeggalen ur nelli deg %(communityName)s",
"Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.": "Bdu adiwenni akked ḥedd s useqdec n yisem-is, isem uffir (am <userId/>) neɣ tansa imayl. Aya ur ten-iecced ara ɣer %(communityName)s. Akked ad d-tnecdeḍ yiwen ɣer %(communityName)s sit ɣef <a>da</a>." "Start a conversation with someone using their name, username (like <userId/>) or email address. This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>.": "Bdu adiwenni akked ḥedd s useqdec n yisem-is, isem uffir (am <userId/>) neɣ tansa imayl. Aya ur ten-iecced ara ɣer %(communityName)s. Akked ad d-tnecdeḍ yiwen ɣer %(communityName)s sit ɣef <a>da</a>.",
"not found in storage": "Ulac-it deg uklas",
"Failed to save your profile": "Yecceḍ usekles n umaɣnu-ik•im",
"The operation could not be completed": "Tamahilt ur tezmir ara ad tettwasmed",
"Backup key cached:": "Tasarut n ukles tettwaffer:",
"Secret storage:": "Aklas uffir:",
"Remove messages sent by others": "Kkes iznan i uznen wiyaḍ",
"%(count)s results|one": "%(count)s n ugmuḍ",
"Widgets": "Iwiǧiten",
"Unpin app": "Serreḥ i usnas",
"Pin to room": "Sentu deg texxamt",
"You can only pin 2 widgets at a time": "Tzemreḍ ad tsentuḍ 2 kan n yiwiǧiten ɣef tikkelt"
} }

View file

@ -10,7 +10,7 @@
"Banned users": "Usuários banidos", "Banned users": "Usuários banidos",
"Bans user with given id": "Bane o usuário com o ID indicado", "Bans user with given id": "Bane o usuário com o ID indicado",
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s alterou a descrição para \"%(topic)s\".", "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s alterou a descrição para \"%(topic)s\".",
"Changes your display nickname": "Alterar seu nome e sobrenome", "Changes your display nickname": "Altera o seu nome e sobrenome",
"Click here to fix": "Clique aqui para resolver isso", "Click here to fix": "Clique aqui para resolver isso",
"Commands": "Comandos", "Commands": "Comandos",
"Confirm password": "Confirme a nova senha", "Confirm password": "Confirme a nova senha",
@ -20,7 +20,7 @@
"Current password": "Senha atual", "Current password": "Senha atual",
"Deactivate Account": "Desativar minha conta", "Deactivate Account": "Desativar minha conta",
"Default": "Padrão", "Default": "Padrão",
"Deops user with given id": "Retirar nível de moderador do usuário com o identificador informado", "Deops user with given id": "Retira o nível de moderador do usuário com o ID informado",
"Displays action": "Visualizar atividades", "Displays action": "Visualizar atividades",
"Emoji": "Emoji", "Emoji": "Emoji",
"Error": "Erro", "Error": "Erro",
@ -70,7 +70,7 @@
"Return to login screen": "Retornar à tela de login", "Return to login screen": "Retornar à tela de login",
"Room Colour": "Cores da sala", "Room Colour": "Cores da sala",
"Rooms": "Salas", "Rooms": "Salas",
"Searches DuckDuckGo for results": "Buscar por resultados no buscador DuckDuckGo", "Searches DuckDuckGo for results": "Buscar resultados no DuckDuckGo",
"Send Reset Email": "Enviar e-mail para redefinição de senha", "Send Reset Email": "Enviar e-mail para redefinição de senha",
"Server may be unavailable, overloaded, or you hit a bug.": "O servidor pode estar indisponível ou sobrecarregado, ou então você encontrou uma falha no sistema.", "Server may be unavailable, overloaded, or you hit a bug.": "O servidor pode estar indisponível ou sobrecarregado, ou então você encontrou uma falha no sistema.",
"Session ID": "Identificador de sessão", "Session ID": "Identificador de sessão",
@ -168,7 +168,7 @@
"The remote side failed to pick up": "A pessoa não atendeu a chamada", "The remote side failed to pick up": "A pessoa não atendeu a chamada",
"This room is not recognised.": "Esta sala não é reconhecida.", "This room is not recognised.": "Esta sala não é reconhecida.",
"This phone number is already in use": "Este número de telefone já está em uso", "This phone number is already in use": "Este número de telefone já está em uso",
"To use it, just wait for autocomplete results to load and tab through them.": "Para usar este recurso, aguarde o carregamento dos resultados de autocompletar e então escolha entre as opções.", "To use it, just wait for autocomplete results to load and tab through them.": "Para usar este recurso, aguarde o carregamento dos resultados de preenchimento automático, e então escolha dentre as opções.",
"%(senderName)s unbanned %(targetName)s.": "%(senderName)s removeu o banimento de %(targetName)s.", "%(senderName)s unbanned %(targetName)s.": "%(senderName)s removeu o banimento de %(targetName)s.",
"Unable to capture screen": "Não foi possível capturar a imagem da tela", "Unable to capture screen": "Não foi possível capturar a imagem da tela",
"Unable to enable Notifications": "Não foi possível ativar as notificações", "Unable to enable Notifications": "Não foi possível ativar as notificações",
@ -414,7 +414,7 @@
"%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(day)s de %(monthName)s de %(fullYear)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(day)s de %(monthName)s de %(fullYear)s",
"Who would you like to add to this community?": "Quem você gostaria de adicionar a esta comunidade?", "Who would you like to add to this community?": "Quem você gostaria de adicionar a esta comunidade?",
"Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Atenção: qualquer pessoa que você adicionar a esta comunidade estará publicamente visível para todas as pessoas que conheçam o ID da comunidade", "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Atenção: qualquer pessoa que você adicionar a esta comunidade estará publicamente visível para todas as pessoas que conheçam o ID da comunidade",
"Invite new community members": "Convidar novos participantes para a comunidade", "Invite new community members": "Convidar novos integrantes para a comunidade",
"Which rooms would you like to add to this community?": "Quais salas você quer adicionar a esta comunidade?", "Which rooms would you like to add to this community?": "Quais salas você quer adicionar a esta comunidade?",
"Show these rooms to non-members on the community page and room list?": "Exibir estas salas para não participantes na página da comunidade e na lista de salas?", "Show these rooms to non-members on the community page and room list?": "Exibir estas salas para não participantes na página da comunidade e na lista de salas?",
"Unable to create widget.": "Não foi possível criar o widget.", "Unable to create widget.": "Não foi possível criar o widget.",
@ -632,9 +632,9 @@
"An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "Um e-mail foi enviado para %(emailAddress)s. Após clicar no link contido no e-mail, clique abaixo.", "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "Um e-mail foi enviado para %(emailAddress)s. Após clicar no link contido no e-mail, clique abaixo.",
"Please note you are logging into the %(hs)s server, not matrix.org.": "Note que você está se conectando ao servidor %(hs)s, e não ao servidor matrix.org.", "Please note you are logging into the %(hs)s server, not matrix.org.": "Note que você está se conectando ao servidor %(hs)s, e não ao servidor matrix.org.",
"This homeserver doesn't offer any login flows which are supported by this client.": "Este servidor de base (homeserver) não oferece fluxos de login que funcionem neste cliente.", "This homeserver doesn't offer any login flows which are supported by this client.": "Este servidor de base (homeserver) não oferece fluxos de login que funcionem neste cliente.",
"Define the power level of a user": "Definir o nível de permissões de um(a) usuário(a)", "Define the power level of a user": "Define o nível de permissões de um usuário",
"Ignores a user, hiding their messages from you": "Bloquear um usuário, esconderá as mensagens dele de você", "Ignores a user, hiding their messages from you": "Bloqueia um usuário, escondendo as mensagens dele de você",
"Stops ignoring a user, showing their messages going forward": "Desbloquear um usuário, exibe suas mensagens daqui para frente", "Stops ignoring a user, showing their messages going forward": "Desbloqueia um usuário, exibindo as mensagens dele daqui para frente",
"Notify the whole room": "Notifica a sala inteira", "Notify the whole room": "Notifica a sala inteira",
"Room Notification": "Notificação da sala", "Room Notification": "Notificação da sala",
"Failed to set direct chat tag": "Falha ao definir esta conversa como direta", "Failed to set direct chat tag": "Falha ao definir esta conversa como direta",
@ -708,13 +708,13 @@
"Failed to set Direct Message status of room": "Falha em definir a descrição da conversa", "Failed to set Direct Message status of room": "Falha em definir a descrição da conversa",
"Monday": "Segunda-feira", "Monday": "Segunda-feira",
"All messages (noisy)": "Todas as mensagens (com som)", "All messages (noisy)": "Todas as mensagens (com som)",
"Enable them now": "Ativá-los agora", "Enable them now": "Ativar agora",
"Toolbox": "Ferramentas", "Toolbox": "Ferramentas",
"Collecting logs": "Coletando logs", "Collecting logs": "Coletando logs",
"You must specify an event type!": "Você precisa especificar um tipo do evento!", "You must specify an event type!": "Você precisa especificar um tipo do evento!",
"(HTTP status %(httpStatus)s)": "(Status HTTP %(httpStatus)s)", "(HTTP status %(httpStatus)s)": "(Status HTTP %(httpStatus)s)",
"Invite to this room": "Convidar para esta sala", "Invite to this room": "Convidar para esta sala",
"Send logs": "Enviar registros", "Send logs": "Enviar relatórios",
"All messages": "Todas as mensagens novas", "All messages": "Todas as mensagens novas",
"Call invitation": "Recebendo chamada", "Call invitation": "Recebendo chamada",
"Downloading update...": "Baixando atualização...", "Downloading update...": "Baixando atualização...",
@ -821,8 +821,8 @@
"Messages containing @room": "Mensagens contendo @room", "Messages containing @room": "Mensagens contendo @room",
"Encrypted messages in one-to-one chats": "Mensagens criptografadas em conversas individuais", "Encrypted messages in one-to-one chats": "Mensagens criptografadas em conversas individuais",
"Encrypted messages in group chats": "Mensagens criptografadas em salas", "Encrypted messages in group chats": "Mensagens criptografadas em salas",
"Delete Backup": "Deletar Backup", "Delete Backup": "Remover backup",
"Unable to load key backup status": "Não é possível carregar o status da chave de backup", "Unable to load key backup status": "Não foi possível carregar o status do backup da chave",
"Backup version: ": "Versão do Backup: ", "Backup version: ": "Versão do Backup: ",
"Algorithm: ": "Algoritmo: ", "Algorithm: ": "Algoritmo: ",
"This event could not be displayed": "Este evento não pôde ser exibido", "This event could not be displayed": "Este evento não pôde ser exibido",
@ -860,12 +860,12 @@
"An error ocurred whilst trying to remove the widget from the room": "Ocorreu um erro ao tentar remover o widget da sala", "An error ocurred whilst trying to remove the widget from the room": "Ocorreu um erro ao tentar remover o widget da sala",
"Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Não é possível carregar o evento que foi respondido, ele não existe ou você não tem permissão para visualizá-lo.", "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Não é possível carregar o evento que foi respondido, ele não existe ou você não tem permissão para visualizá-lo.",
"That doesn't look like a valid email address": "Este não parece ser um endereço de e-mail válido", "That doesn't look like a valid email address": "Este não parece ser um endereço de e-mail válido",
"Preparing to send logs": "Preparando para enviar registros", "Preparing to send logs": "Preparando para enviar relatórios",
"Logs sent": "Registros enviados", "Logs sent": "Relatórios enviados",
"Failed to send logs: ": "Falha ao enviar registros:· ", "Failed to send logs: ": "Falha ao enviar os relatórios:· ",
"Submit debug logs": "Submeter registros de depuração", "Submit debug logs": "Enviar relatórios de erros",
"Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Os registros de depuração contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou aliases das salas ou comunidades que você visitou e os nomes de usuários de outros usuários. Eles não contêm mensagens.", "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Os relatórios de erros contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou nomes das salas ou comunidades que você visitou e os nomes de usuários de seus contatos. Eles não contêm mensagens.",
"Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Antes de enviar os registros, você deve <a>criar um bilhete de erro no GitHub</a> para descrever seu problema.", "Before submitting logs, you must <a>create a GitHub issue</a> to describe your problem.": "Antes de enviar os relatórios, você deve <a>criar um bilhete de erro no GitHub</a> para descrever seu problema.",
"Unable to load commit detail: %(msg)s": "Não foi possível carregar os detalhes do envio: %(msg)s", "Unable to load commit detail: %(msg)s": "Não foi possível carregar os detalhes do envio: %(msg)s",
"To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Para evitar perder seu histórico de bate-papo, você precisa exportar as chaves da sua sala antes de se desconectar. Quando entrar novamente, você precisará usar a versão mais atual do %(brand)s", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Para evitar perder seu histórico de bate-papo, você precisa exportar as chaves da sua sala antes de se desconectar. Quando entrar novamente, você precisará usar a versão mais atual do %(brand)s",
"Incompatible Database": "Banco de dados incompatível", "Incompatible Database": "Banco de dados incompatível",
@ -888,7 +888,7 @@
"Put a link back to the old room at the start of the new room so people can see old messages": "Colocar um link para a sala antiga no começo da sala nova de modo que as pessoas possam ver mensagens antigas", "Put a link back to the old room at the start of the new room so people can see old messages": "Colocar um link para a sala antiga no começo da sala nova de modo que as pessoas possam ver mensagens antigas",
"You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "Você já usou o %(brand)s em %(host)s com o carregamento Lazy de participantes ativado. Nesta versão, o carregamento Lazy está desativado. Como o cache local não é compatível entre essas duas configurações, o %(brand)s precisa ressincronizar sua conta.", "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "Você já usou o %(brand)s em %(host)s com o carregamento Lazy de participantes ativado. Nesta versão, o carregamento Lazy está desativado. Como o cache local não é compatível entre essas duas configurações, o %(brand)s precisa ressincronizar sua conta.",
"If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Se a outra versão do %(brand)s ainda estiver aberta em outra aba, por favor, feche-a pois usar o %(brand)s no mesmo host com o carregamento Lazy ativado e desativado simultaneamente causará problemas.", "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Se a outra versão do %(brand)s ainda estiver aberta em outra aba, por favor, feche-a pois usar o %(brand)s no mesmo host com o carregamento Lazy ativado e desativado simultaneamente causará problemas.",
"Update any local room aliases to point to the new room": "Atualize todos os aliases da sala local para apontar para a nova sala", "Update any local room aliases to point to the new room": "Atualize todos os nomes locais da sala para apontar para a nova sala",
"Clear Storage and Sign Out": "Limpar armazenamento e sair", "Clear Storage and Sign Out": "Limpar armazenamento e sair",
"Refresh": "Recarregar", "Refresh": "Recarregar",
"We encountered an error trying to restore your previous session.": "Encontramos um erro ao tentar restaurar sua sessão anterior.", "We encountered an error trying to restore your previous session.": "Encontramos um erro ao tentar restaurar sua sessão anterior.",
@ -901,8 +901,8 @@
"Share Room Message": "Compartilhar Mensagem da Sala", "Share Room Message": "Compartilhar Mensagem da Sala",
"Link to selected message": "Link da mensagem selecionada", "Link to selected message": "Link da mensagem selecionada",
"COPY": "COPIAR", "COPY": "COPIAR",
"Unable to load backup status": "Não é possível carregar o status do backup", "Unable to load backup status": "Não foi possível carregar o status do backup",
"Unable to restore backup": "Não é possível restaurar o backup", "Unable to restore backup": "Não foi possível restaurar o backup",
"No backup found!": "Nenhum backup encontrado!", "No backup found!": "Nenhum backup encontrado!",
"Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Acesse seu histórico de mensagens seguras e configure mensagens seguras digitando sua frase secreta de recuperação.", "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Acesse seu histórico de mensagens seguras e configure mensagens seguras digitando sua frase secreta de recuperação.",
"Next": "Próximo", "Next": "Próximo",
@ -912,7 +912,7 @@
"Access your secure message history and set up secure messaging by entering your recovery key.": "Acesse seu histórico seguro de mensagens e configure mensagens seguras inserindo sua chave de recuperação.", "Access your secure message history and set up secure messaging by entering your recovery key.": "Acesse seu histórico seguro de mensagens e configure mensagens seguras inserindo sua chave de recuperação.",
"Share Message": "Compartilhar Mensagem", "Share Message": "Compartilhar Mensagem",
"Popout widget": "Widget Popout", "Popout widget": "Widget Popout",
"Send Logs": "Enviar registros", "Send Logs": "Enviar relatórios",
"Failed to decrypt %(failedCount)s sessions!": "Falha ao descriptografar as sessões de %(failedCount)s!", "Failed to decrypt %(failedCount)s sessions!": "Falha ao descriptografar as sessões de %(failedCount)s!",
"Set a new status...": "Definir um novo status ...", "Set a new status...": "Definir um novo status ...",
"Collapse Reply Thread": "Recolher grupo de respostas", "Collapse Reply Thread": "Recolher grupo de respostas",
@ -933,7 +933,7 @@
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "Você não pode enviar nenhuma mensagem até revisar e concordar com <consentLink>nossos termos e condições</consentLink>.", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "Você não pode enviar nenhuma mensagem até revisar e concordar com <consentLink>nossos termos e condições</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este homeserver atingiu seu Limite de usuário ativo mensal. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este homeserver atingiu seu Limite de usuário ativo mensal. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este servidor local excedeu o limite de recursos. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Sua mensagem não foi enviada porque este servidor local excedeu o limite de recursos. Por favor, <a>entre em contato com o seu administrador de serviços</a> para continuar usando o serviço.",
"If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Se você enviou um bug por meio do GitHub, os registros de depuração podem nos ajudar a rastrear o problema. Os registros de depuração contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou apelidos das salas ou comunidades que você visitou e os nomes de usuários de outros usuários. Eles não contêm mensagens.", "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Se você informou um erro por meio do GitHub, os relatórios de erros podem nos ajudar a rastrear o problema. Os relatórios de erros contêm dados de uso do aplicativo, incluindo seu nome de usuário, os IDs ou apelidos das salas ou comunidades que você visitou e os nomes de usuários de seus contatos. Eles não contêm mensagens.",
"Legal": "Legal", "Legal": "Legal",
"No Audio Outputs detected": "Nenhuma caixa de som detectada", "No Audio Outputs detected": "Nenhuma caixa de som detectada",
"Audio Output": "Caixa de som", "Audio Output": "Caixa de som",
@ -951,12 +951,12 @@
"<b>Save it</b> on a USB key or backup drive": "<b>Salve isto</ b> em uma chave USB ou unidade de backup", "<b>Save it</b> on a USB key or backup drive": "<b>Salve isto</ b> em uma chave USB ou unidade de backup",
"<b>Copy it</b> to your personal cloud storage": "<b>Copie isto</ b> para seu armazenamento em nuvem pessoal", "<b>Copy it</b> to your personal cloud storage": "<b>Copie isto</ b> para seu armazenamento em nuvem pessoal",
"Set up Secure Message Recovery": "Configurar Recuperação Segura de Mensagens", "Set up Secure Message Recovery": "Configurar Recuperação Segura de Mensagens",
"Unable to create key backup": "Não é possível criar backup de chave", "Unable to create key backup": "Não foi possível criar backup da chave",
"Retry": "Tentar novamente", "Retry": "Tentar novamente",
"Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Sem configurar a Recuperação Segura de Mensagens, você perderá seu histórico de mensagens seguras quando fizer logout.", "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Sem configurar a Recuperação Segura de Mensagens, você perderá seu histórico de mensagens seguras quando fizer logout.",
"If you don't want to set this up now, you can later in Settings.": "Se você não quiser configurá-lo agora, poderá fazê-lo posteriormente em Configurações.", "If you don't want to set this up now, you can later in Settings.": "Se você não quiser configurá-lo agora, poderá fazê-lo posteriormente em Configurações.",
"New Recovery Method": "Novo método de recuperação", "New Recovery Method": "Nova opção de recuperação",
"If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não definiu o novo método de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina um novo método de recuperação imediatamente nas Configurações.", "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não definiu a nova opção de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina uma nova opção de recuperação imediatamente nas Configurações.",
"Set up Secure Messages": "Configurar mensagens seguras", "Set up Secure Messages": "Configurar mensagens seguras",
"Go to Settings": "Ir para as configurações", "Go to Settings": "Ir para as configurações",
"Unrecognised address": "Endereço não reconhecido", "Unrecognised address": "Endereço não reconhecido",
@ -967,10 +967,10 @@
"Invite anyway and never warn me again": "Convide mesmo assim e nunca mais me avise", "Invite anyway and never warn me again": "Convide mesmo assim e nunca mais me avise",
"Invite anyway": "Convide mesmo assim", "Invite anyway": "Convide mesmo assim",
"Whether or not you're logged in (we don't record your username)": "Se você está logado ou não (não gravamos seu nome de usuário)", "Whether or not you're logged in (we don't record your username)": "Se você está logado ou não (não gravamos seu nome de usuário)",
"Upgrades a room to a new version": "Atualiza uma sala para uma nova versão", "Upgrades a room to a new version": "Atualiza a sala para uma nova versão",
"Gets or sets the room topic": "Consultar ou definir a descrição da sala", "Gets or sets the room topic": "Consulta ou altera a descrição da sala",
"This room has no topic.": "Esta sala não tem descrição.", "This room has no topic.": "Esta sala não tem descrição.",
"Sets the room name": "Define o nome da sala", "Sets the room name": "Altera o nome da sala",
"Group & filter rooms by custom tags (refresh to apply changes)": "Agrupar e filtrar salas por tags personalizadas (recarregue para aplicar as alterações)", "Group & filter rooms by custom tags (refresh to apply changes)": "Agrupar e filtrar salas por tags personalizadas (recarregue para aplicar as alterações)",
"Render simple counters in room header": "Renderizar contadores simples no cabeçalho da sala", "Render simple counters in room header": "Renderizar contadores simples no cabeçalho da sala",
"Enable Emoji suggestions while typing": "Ativar sugestões de emojis ao digitar", "Enable Emoji suggestions while typing": "Ativar sugestões de emojis ao digitar",
@ -978,7 +978,7 @@
"Show join/leave messages (invites/kicks/bans unaffected)": "Mostrar mensagens de entrar/sair (não considera convites/remoções/banimentos)", "Show join/leave messages (invites/kicks/bans unaffected)": "Mostrar mensagens de entrar/sair (não considera convites/remoções/banimentos)",
"Show avatar changes": "Mostrar alterações de foto de perfil", "Show avatar changes": "Mostrar alterações de foto de perfil",
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "O arquivo '%(fileName)s' excede o limite de tamanho deste homeserver para uploads", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "O arquivo '%(fileName)s' excede o limite de tamanho deste homeserver para uploads",
"Changes your display nickname in the current room only": "Altera o seu nome e sobrenome apenas na sala atual", "Changes your display nickname in the current room only": "Altera o seu nome e sobrenome apenas nesta sala",
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s atualizou esta sala.", "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s atualizou esta sala.",
"%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s tornou a sala pública para quem conhece o link.", "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s tornou a sala pública para quem conhece o link.",
"%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s tornou a sala disponível apenas por convite.", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s tornou a sala disponível apenas por convite.",
@ -1005,7 +1005,7 @@
"You've successfully verified this user.": "Você confirmou este usuário com sucesso.", "You've successfully verified this user.": "Você confirmou este usuário com sucesso.",
"Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "As mensagens com este usuário estão protegidas com a criptografia de ponta a ponta e não podem ser lidas por terceiros.", "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "As mensagens com este usuário estão protegidas com a criptografia de ponta a ponta e não podem ser lidas por terceiros.",
"Got It": "Ok, entendi", "Got It": "Ok, entendi",
"Unable to find a supported verification method.": "Nenhum método de confirmação é suportado.", "Unable to find a supported verification method.": "Nenhuma opção de confirmação é suportada.",
"Dog": "Cachorro", "Dog": "Cachorro",
"Cat": "Gato", "Cat": "Gato",
"Lion": "Leão", "Lion": "Leão",
@ -1042,7 +1042,7 @@
"Glasses": "Óculos", "Glasses": "Óculos",
"Spanner": "Chave inglesa", "Spanner": "Chave inglesa",
"Santa": "Papai-noel", "Santa": "Papai-noel",
"Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Adiciona ¯ \\ _ (ツ) _ / ¯ no início de uma mensagem de texto simples", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Adiciona ¯ \\ _ (ツ) _ / ¯ a uma mensagem de texto",
"User %(userId)s is already in the room": "O usuário %(userId)s já está na sala", "User %(userId)s is already in the room": "O usuário %(userId)s já está na sala",
"The user must be unbanned before they can be invited.": "O banimento do usuário precisa ser removido antes de ser convidado.", "The user must be unbanned before they can be invited.": "O banimento do usuário precisa ser removido antes de ser convidado.",
"Show display name changes": "Mostrar alterações de nome e sobrenome", "Show display name changes": "Mostrar alterações de nome e sobrenome",
@ -1079,13 +1079,13 @@
"No": "Não", "No": "Não",
"We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "Enviamos um e-mail para você confirmar seu endereço. Por favor, siga as instruções e clique no botão abaixo.", "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "Enviamos um e-mail para você confirmar seu endereço. Por favor, siga as instruções e clique no botão abaixo.",
"Email Address": "Endereço de e-mail", "Email Address": "Endereço de e-mail",
"Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Você tem certeza? Você perderá suas mensagens criptografadas se não for feito o backup correto de suas chaves.", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Tem certeza? Você perderá suas mensagens criptografadas se não tiver feito o backup de suas chaves.",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "As mensagens estão protegidas com a criptografia de ponta a ponta. Somente você e o(s) destinatário(s) têm as chaves para ler essas mensagens.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "As mensagens estão protegidas com a criptografia de ponta a ponta. Somente você e o(s) destinatário(s) têm as chaves para ler essas mensagens.",
"Restore from Backup": "Restaurar do Backup", "Restore from Backup": "Restaurar do backup",
"Back up your keys before signing out to avoid losing them.": "Faça o backup das suas chaves antes de sair, para evitar perdê-las.", "Back up your keys before signing out to avoid losing them.": "Faça o backup das suas chaves antes de sair, para evitar perdê-las.",
"Backing up %(sessionsRemaining)s keys...": "Fazendo o backup das chaves de %(sessionsRemaining)s...", "Backing up %(sessionsRemaining)s keys...": "Fazendo o backup das chaves de %(sessionsRemaining)s...",
"All keys backed up": "O Backup de todas as chaves foi realizado", "All keys backed up": "O backup de todas as chaves foi realizado",
"Start using Key Backup": "Comece a usar o Backup de chave", "Start using Key Backup": "Comece a usar backup de chave",
"Add an email address to configure email notifications": "Adicione um endereço de e-mail para configurar notificações por e-mail", "Add an email address to configure email notifications": "Adicione um endereço de e-mail para configurar notificações por e-mail",
"Unable to verify phone number.": "Não foi possível confirmar o número de telefone.", "Unable to verify phone number.": "Não foi possível confirmar o número de telefone.",
"Verification code": "Código de confirmação", "Verification code": "Código de confirmação",
@ -1181,12 +1181,12 @@
"Messages": "Mensagens", "Messages": "Mensagens",
"Actions": "Ações", "Actions": "Ações",
"Other": "Outros", "Other": "Outros",
"Sends a message as plain text, without interpreting it as markdown": "Envia uma mensagem como texto simples, sem formatar o texto", "Sends a message as plain text, without interpreting it as markdown": "Envia uma mensagem de texto sem formatação",
"Sends a message as html, without interpreting it as markdown": "Envia uma mensagem como HTML, sem formatar o texto", "Sends a message as html, without interpreting it as markdown": "Envia uma mensagem como HTML, sem formatação",
"You do not have the required permissions to use this command.": "Você não tem as permissões necessárias para usar este comando.", "You do not have the required permissions to use this command.": "Você não tem as permissões necessárias para usar este comando.",
"Error upgrading room": "Erro atualizando a sala", "Error upgrading room": "Erro atualizando a sala",
"Double check that your server supports the room version chosen and try again.": "Verifique se seu servidor suporta a versão de sala escolhida e tente novamente.", "Double check that your server supports the room version chosen and try again.": "Verifique se seu servidor suporta a versão de sala escolhida e tente novamente.",
"Changes the avatar of the current room": "Altera a foto da sala atual", "Changes the avatar of the current room": "Altera a foto da sala",
"Changes your avatar in this current room only": "Altera a sua foto de perfil apenas nesta sala", "Changes your avatar in this current room only": "Altera a sua foto de perfil apenas nesta sala",
"Changes your avatar in all rooms": "Altera a sua foto de perfil em todas as salas", "Changes your avatar in all rooms": "Altera a sua foto de perfil em todas as salas",
"Failed to set topic": "Não foi possível definir a descrição", "Failed to set topic": "Não foi possível definir a descrição",
@ -1211,9 +1211,9 @@
"Sends the given emote coloured as a rainbow": "Envia o emoji colorido como um arco-íris", "Sends the given emote coloured as a rainbow": "Envia o emoji colorido como um arco-íris",
"Displays list of commands with usages and descriptions": "Exibe a lista de comandos com usos e descrições", "Displays list of commands with usages and descriptions": "Exibe a lista de comandos com usos e descrições",
"Displays information about a user": "Exibe informação sobre um usuário", "Displays information about a user": "Exibe informação sobre um usuário",
"Send a bug report with logs": "Envia um relatório de erros com os logs", "Send a bug report with logs": "Envia um relatório de erro",
"Opens chat with the given user": "Abre um chat com determinada pessoa", "Opens chat with the given user": "Abre um chat com determinada pessoa",
"Sends a message to the given user": "Envia uma mensagem com determinada pessoa", "Sends a message to the given user": "Envia uma mensagem para determinada pessoa",
"%(senderName)s made no change.": "%(senderName)s não fez nenhuma alteração.", "%(senderName)s made no change.": "%(senderName)s não fez nenhuma alteração.",
"%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s alterou o nome da sala de %(oldRoomName)s para %(newRoomName)s.", "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s alterou o nome da sala de %(oldRoomName)s para %(newRoomName)s.",
"%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s adicionou os endereços alternativos %(addresses)s desta sala.", "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s adicionou os endereços alternativos %(addresses)s desta sala.",
@ -1328,7 +1328,7 @@
"Try out new ways to ignore people (experimental)": "Tente novas maneiras de bloquear pessoas (experimental)", "Try out new ways to ignore people (experimental)": "Tente novas maneiras de bloquear pessoas (experimental)",
"Support adding custom themes": "Permite adicionar temas personalizados", "Support adding custom themes": "Permite adicionar temas personalizados",
"Enable advanced debugging for the room list": "Ativar a depuração avançada para a lista de salas", "Enable advanced debugging for the room list": "Ativar a depuração avançada para a lista de salas",
"Show info about bridges in room settings": "Exibir informações sobre bridges nas configurações das salas", "Show info about bridges in room settings": "Exibir informações sobre integrações nas configurações das salas",
"Font size": "Tamanho da fonte", "Font size": "Tamanho da fonte",
"Use custom size": "Usar tamanho personalizado", "Use custom size": "Usar tamanho personalizado",
"Use a more compact Modern layout": "Usar um layout mais compacto 'Moderno'", "Use a more compact Modern layout": "Usar um layout mais compacto 'Moderno'",
@ -1378,8 +1378,8 @@
"Decline (%(counter)s)": "Recusar (%(counter)s)", "Decline (%(counter)s)": "Recusar (%(counter)s)",
"Accept <policyLink /> to continue:": "Aceitar <policyLink /> para continuar:", "Accept <policyLink /> to continue:": "Aceitar <policyLink /> para continuar:",
"Upload": "Enviar", "Upload": "Enviar",
"This bridge was provisioned by <user />.": "Esta ponte foi disponibilizada por <user />.", "This bridge was provisioned by <user />.": "Esta integração foi disponibilizada por <user />.",
"This bridge is managed by <user />.": "Esta ponte é gerida por <user />.", "This bridge is managed by <user />.": "Esta integração é desenvolvida por <user />.",
"Workspace: %(networkName)s": "Espaço de trabalho: %(networkName)s", "Workspace: %(networkName)s": "Espaço de trabalho: %(networkName)s",
"Channel: %(channelName)s": "Canal: %(channelName)s", "Channel: %(channelName)s": "Canal: %(channelName)s",
"Show less": "Mostrar menos", "Show less": "Mostrar menos",
@ -1432,23 +1432,23 @@
"Connecting to integration manager...": "Conectando ao gestor de integrações...", "Connecting to integration manager...": "Conectando ao gestor de integrações...",
"Cannot connect to integration manager": "Não foi possível conectar ao gerenciador de integrações", "Cannot connect to integration manager": "Não foi possível conectar ao gerenciador de integrações",
"The integration manager is offline or it cannot reach your homeserver.": "Ou o gerenciador de integrações está desconectado, ou ele não conseguiu acessar o seu servidor.", "The integration manager is offline or it cannot reach your homeserver.": "Ou o gerenciador de integrações está desconectado, ou ele não conseguiu acessar o seu servidor.",
"This session is backing up your keys. ": "Esta sessão está fazendo a cópia (backup) das suas chaves. ", "This session is backing up your keys. ": "Esta sessão está fazendo backup das suas chaves. ",
"This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.": "Esta sessão <b>não está fazendo cópia (backup) de suas chaves</b>, mas você tem uma cópia existente que pode restaurar e adicionar para continuar.", "This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.": "Esta sessão <b>não está fazendo backup de suas chaves</b>, mas você tem um backup existente que pode restaurar para continuar.",
"Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Conecte esta sessão à cópia de segurança (backup) das chaves antes de fazer logout para evitar perder quaisquer chaves que possam estar apenas nesta sessão.", "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Autorize esta sessão a fazer o backup de chaves antes de se desconectar, para evitar perder chaves que possam estar apenas nesta sessão.",
"Connect this session to Key Backup": "Conecte esta sessão à Cópia de Segurança (Backup) da Chave", "Connect this session to Key Backup": "Autorize esta sessão a fazer o backup de chaves",
"not stored": "não armazenado", "not stored": "não armazenado",
"Backup has a <validity>valid</validity> signature from this user": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> deste(a) usuário(a)", "Backup has a <validity>valid</validity> signature from this user": "O backup tem uma assinatura <validity>válida</validity> deste usuário",
"Backup has a <validity>invalid</validity> signature from this user": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> deste(a) usuário(a)", "Backup has a <validity>invalid</validity> signature from this user": "O backup tem uma assinatura <validity>inválida</validity> deste usuário",
"Backup has a signature from <verify>unknown</verify> user with ID %(deviceId)s": "A cópia de segurança tem uma assinatura de um usuário <verify>desconhecido</verify> com ID %(deviceId)s", "Backup has a signature from <verify>unknown</verify> user with ID %(deviceId)s": "O backup tem uma assinatura de um usuário <verify>desconhecido</verify> com ID %(deviceId)s",
"Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "A cópia de segurança tem uma assinatura de uma sessão <verify>desconhecida</verify> com ID %(deviceId)s", "Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "O backup tem uma assinatura de uma sessão <verify>desconhecida</verify> com ID %(deviceId)s",
"Backup has a <validity>valid</validity> signature from this session": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> desta sessão", "Backup has a <validity>valid</validity> signature from this session": "O backup tem uma assinatura <validity>válida</validity> desta sessão",
"Backup has an <validity>invalid</validity> signature from this session": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> desta sessão", "Backup has an <validity>invalid</validity> signature from this session": "O backup tem uma assinatura <validity>inválida</validity> desta sessão",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "A cópia de segurança (backup) tem uma assinatura <validity>válida</validity> da sessão <verify>confirmada</verify> <device></device>", "Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "O backup tem uma assinatura <validity>válida</validity> da sessão <verify>confirmada</verify> <device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "A cópia de segurança tem uma assinatura <validity>válida</validity> de uma sessão <verify>não confirmada</verify> <device></device>", "Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "O backup tem uma assinatura <validity>válida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "A cópia de segurança tem uma assinatura <validity>inválida</validity> de uma sessão <verify>confirmada</verify> <device></device>", "Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "O backup tem uma assinatura <validity>inválida</validity> de uma sessão <verify>confirmada</verify> <device></device>",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "A cópia de segurança (backup) tem uma assinatura <validity>inválida</validity> de uma sessão <verify>não confirmada</verify> <device></device>", "Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "O backup tem uma assinatura <validity>inválida</validity> de uma sessão <verify>não confirmada</verify> <device></device>",
"Backup is not signed by any of your sessions": "A cópia de segurança (backup) não foi assinada por nenhuma de suas sessões", "Backup is not signed by any of your sessions": "O backup não foi assinado por nenhuma de suas sessões",
"This backup is trusted because it has been restored on this session": "Esta cópia de segurança (backup) é confiável, pois foi restaurada nesta sessão", "This backup is trusted because it has been restored on this session": "Este backup é confiável, pois foi restaurado nesta sessão",
"Backup key stored: ": "Chave de segurança (backup) armazenada: ", "Backup key stored: ": "Chave de segurança (backup) armazenada: ",
"Your keys are <b>not being backed up from this session</b>.": "Suas chaves <b>não estão sendo copiadas desta sessão</b>.", "Your keys are <b>not being backed up from this session</b>.": "Suas chaves <b>não estão sendo copiadas desta sessão</b>.",
"wait and try again later": "aguarde e tente novamente mais tarde", "wait and try again later": "aguarde e tente novamente mais tarde",
@ -1460,7 +1460,7 @@
"A session's public name is visible to people you communicate with": "O nome público de uma sessão é visível para as pessoas com quem você se comunica", "A session's public name is visible to people you communicate with": "O nome público de uma sessão é visível para as pessoas com quem você se comunica",
"Enable room encryption": "Ativar criptografia nesta sala", "Enable room encryption": "Ativar criptografia nesta sala",
"Enable encryption?": "Ativar criptografia?", "Enable encryption?": "Ativar criptografia?",
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Uma vez ativada, a criptografia da sala não poderá ser desativada. Mensagens enviadas em uma sala criptografada não podem ser lidas pelo servidor, apenas pelos participantes da sala. Ativar a criptografia poderá impedir que vários bots e bridges funcionem corretamente. <a>Saiba mais sobre criptografia.</a>", "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Uma vez ativada, a criptografia da sala não poderá ser desativada. Mensagens enviadas em uma sala criptografada não podem ser lidas pelo servidor, apenas pelos participantes da sala. Ativar a criptografia poderá impedir que vários bots e integrações funcionem corretamente. <a>Saiba mais sobre criptografia.</a>",
"Encryption": "Criptografia", "Encryption": "Criptografia",
"Once enabled, encryption cannot be disabled.": "Uma vez ativada, a criptografia não poderá ser desativada.", "Once enabled, encryption cannot be disabled.": "Uma vez ativada, a criptografia não poderá ser desativada.",
"Encrypted": "Criptografada", "Encrypted": "Criptografada",
@ -1514,7 +1514,7 @@
"Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Por favor, diga-nos o que aconteceu de errado ou, ainda melhor, crie um bilhete de erro no GitHub que descreva o problema.", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Por favor, diga-nos o que aconteceu de errado ou, ainda melhor, crie um bilhete de erro no GitHub que descreva o problema.",
"Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Apagar todos os dados desta sessão é uma ação permanente. Mensagens criptografadas serão perdidas, a não ser que as chaves delas tenham sido copiadas para o backup.", "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Apagar todos os dados desta sessão é uma ação permanente. Mensagens criptografadas serão perdidas, a não ser que as chaves delas tenham sido copiadas para o backup.",
"Set a room address to easily share your room with other people.": "Defina um endereço de sala para facilmente compartilhar sua sala com outras pessoas.", "Set a room address to easily share your room with other people.": "Defina um endereço de sala para facilmente compartilhar sua sala com outras pessoas.",
"You cant disable this later. Bridges & most bots wont work yet.": "Você não poderá desativar isso mais tarde. Pontes e a maioria dos bots não funcionarão.", "You cant disable this later. Bridges & most bots wont work yet.": "Você não poderá desativar isso mais tarde. Integrações e a maioria dos bots não funcionarão.",
"Enable end-to-end encryption": "Ativar a criptografia de ponta a ponta", "Enable end-to-end encryption": "Ativar a criptografia de ponta a ponta",
"Create a public room": "Criar uma sala pública", "Create a public room": "Criar uma sala pública",
"Create a private room": "Criar uma sala privada", "Create a private room": "Criar uma sala privada",
@ -1540,7 +1540,7 @@
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "Para evitar a duplicação de registro de problemas, por favor <existingIssuesLink>veja os problemas existentes</existingIssuesLink> antes e adicione um +1, ou então <newIssueLink>crie um novo item</newIssueLink> se seu problema ainda não foi reportado.", "To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "Para evitar a duplicação de registro de problemas, por favor <existingIssuesLink>veja os problemas existentes</existingIssuesLink> antes e adicione um +1, ou então <newIssueLink>crie um novo item</newIssueLink> se seu problema ainda não foi reportado.",
"Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reportar esta mensagem enviará o seu 'event ID' único para o/a administrador/a do seu Homeserver. Se as mensagens nesta sala são criptografadas, o/a administrador/a não conseguirá ler o texto da mensagem nem ver nenhuma imagem ou arquivo.", "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Reportar esta mensagem enviará o seu 'event ID' único para o/a administrador/a do seu Homeserver. Se as mensagens nesta sala são criptografadas, o/a administrador/a não conseguirá ler o texto da mensagem nem ver nenhuma imagem ou arquivo.",
"Sign out and remove encryption keys?": "Fazer logout e remover as chaves de criptografia?", "Sign out and remove encryption keys?": "Fazer logout e remover as chaves de criptografia?",
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Alguns dados de sessão, incluindo chaves de mensagens criptografadas, estão faltando. Faça logout e entre novamente para resolver isso, restaurando as chaves do backup.", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Alguns dados de sessão, incluindo chaves de mensagens criptografadas, estão faltando. Desconecte-se e entre novamente para resolver isso, o que restaurará as chaves do backup.",
"Verify other session": "Confirmar outra sessão", "Verify other session": "Confirmar outra sessão",
"A widget would like to verify your identity": "Um widget deseja confirmar sua identidade", "A widget would like to verify your identity": "Um widget deseja confirmar sua identidade",
"A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "Um widget localizado em %(widgetUrl)s deseja confirmar sua identidade. Permitindo isso, o widget poderá verificar sua ID de usuário, mas não poderá realizar nenhuma ação em seu nome.", "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "Um widget localizado em %(widgetUrl)s deseja confirmar sua identidade. Permitindo isso, o widget poderá verificar sua ID de usuário, mas não poderá realizar nenhuma ação em seu nome.",
@ -1553,15 +1553,15 @@
"Recovery key mismatch": "Chave de recuperação incorreta", "Recovery key mismatch": "Chave de recuperação incorreta",
"Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.": "O backup não pôde ser descriptografado com esta chave de recuperação: por favor, verifique se você digitou a chave de recuperação correta.", "Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.": "O backup não pôde ser descriptografado com esta chave de recuperação: por favor, verifique se você digitou a chave de recuperação correta.",
"Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.": "O backup não pôde ser descriptografado com esta frase de recuperação: por favor, verifique se você digitou a frase de recuperação correta.", "Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.": "O backup não pôde ser descriptografado com esta frase de recuperação: por favor, verifique se você digitou a frase de recuperação correta.",
"<b>Warning</b>: you should only set up key backup from a trusted computer.": "<b>Atenção</b>: você só deve configurar a cópia de segurança (backup) das chaves em um computador de sua confiança.", "<b>Warning</b>: you should only set up key backup from a trusted computer.": "<b>Atenção</b>: você só deve configurar o backup de chave em um computador de sua confiança.",
"Enter recovery key": "Digite a chave de recuperação", "Enter recovery key": "Digite a chave de recuperação",
"<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Atenção</b>: Você só deve configurar a cópia de segurança (backup) das chaves em um computador de sua confiança.", "<b>Warning</b>: You should only set up key backup from a trusted computer.": "<b>Atenção</b>: Você só deve configurar o backup de chave em um computador de sua confiança.",
"If you've forgotten your recovery key you can <button>set up new recovery options</button>": "Se você esqueceu sua chave de recuperação, pode <button>configurar novas opções de recuperação</button>", "If you've forgotten your recovery key you can <button>set up new recovery options</button>": "Se você esqueceu sua chave de recuperação, pode <button>configurar novas opções de recuperação</button>",
"Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Está faltando a chave pública do captcha no Servidor (homeserver). Por favor, reporte isso aos(às) administradores(as) do servidor.", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Está faltando a chave pública do captcha no Servidor (homeserver). Por favor, reporte isso aos(às) administradores(as) do servidor.",
"Enter the location of your Element Matrix Services homeserver. It may use your own domain name or be a subdomain of <a>element.io</a>.": "Entre com a localização do seu Servidor Matrix. Pode ser seu próprio domínio ou ser um subdomínio de <a>element.io</a>.", "Enter the location of your Element Matrix Services homeserver. It may use your own domain name or be a subdomain of <a>element.io</a>.": "Entre com a localização do seu Servidor Matrix. Pode ser seu próprio domínio ou ser um subdomínio de <a>element.io</a>.",
"Create your Matrix account on %(serverName)s": "Criar sua conta Matrix em %(serverName)s", "Create your Matrix account on %(serverName)s": "Criar sua conta Matrix em %(serverName)s",
"Create your Matrix account on <underlinedServerName />": "Crie sua conta Matrix em <underlinedServerName />", "Create your Matrix account on <underlinedServerName />": "Crie sua conta Matrix em <underlinedServerName />",
"Welcome to %(appName)s": "Bem-vinda/o ao %(appName)s", "Welcome to %(appName)s": "Boas-vindas ao %(appName)s",
"Liberate your communication": "Liberte sua comunicação", "Liberate your communication": "Liberte sua comunicação",
"Send a Direct Message": "Enviar uma mensagem", "Send a Direct Message": "Enviar uma mensagem",
"Explore Public Rooms": "Explorar salas públicas", "Explore Public Rooms": "Explorar salas públicas",
@ -1571,7 +1571,7 @@
"%(creator)s created and configured the room.": "%(creator)s criou e configurou esta sala.", "%(creator)s created and configured the room.": "%(creator)s criou e configurou esta sala.",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "Se você não conseguir encontrar a sala que está procurando, peça um convite para a sala ou <a>Crie uma nova sala</a>.", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "Se você não conseguir encontrar a sala que está procurando, peça um convite para a sala ou <a>Crie uma nova sala</a>.",
"Verify this login": "Confirmar este login", "Verify this login": "Confirmar este login",
"Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Alterar a sua senha redefinirá todas as chaves de criptografia de ponta a ponta existentes em todas as suas sessões, tornando o histórico de mensagens criptografadas ilegível. Faça uma cópia (backup) das suas chaves, ou exporte as chaves de outra sessão antes de alterar a sua senha.", "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Alterar a sua senha redefinirá todas as chaves de criptografia de ponta a ponta existentes em todas as suas sessões, tornando o histórico de mensagens criptografadas ilegível. Faça um backup das suas chaves, ou exporte as chaves de outra sessão antes de alterar a sua senha.",
"Create account": "Criar conta", "Create account": "Criar conta",
"Create your account": "Criar sua conta", "Create your account": "Criar sua conta",
"Use Recovery Key or Passphrase": "Use a chave de recuperação, ou a frase de recuperação", "Use Recovery Key or Passphrase": "Use a chave de recuperação, ou a frase de recuperação",
@ -1583,17 +1583,17 @@
"Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Atenção: Seus dados pessoais (incluindo chaves de criptografia) ainda estão armazenados nesta sessão. Apague-os quando tiver finalizado esta sessão, ou se quer entrar com outra conta.", "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Atenção: Seus dados pessoais (incluindo chaves de criptografia) ainda estão armazenados nesta sessão. Apague-os quando tiver finalizado esta sessão, ou se quer entrar com outra conta.",
"Confirm encryption setup": "Confirmar a configuração de criptografia", "Confirm encryption setup": "Confirmar a configuração de criptografia",
"Click the button below to confirm setting up encryption.": "Clique no botão abaixo para confirmar a configuração da criptografia.", "Click the button below to confirm setting up encryption.": "Clique no botão abaixo para confirmar a configuração da criptografia.",
"Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Proteja-se contra a perda de acesso a mensagens e dados criptografados fazendo a cópia segura (backup) das chaves de criptografia no seu servidor.", "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Proteja-se contra a perda de acesso a mensagens e dados criptografados fazendo backup das chaves de criptografia no seu servidor.",
"Generate a Security Key": "Gerar uma Chave de Segurança", "Generate a Security Key": "Gerar uma Chave de Segurança",
"Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Nós geramos uma Chave de Segurança para você. Por favor, guarde-a em um lugar seguro, como um gerenciador de senhas ou um cofre.", "Well generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Nós geramos uma Chave de Segurança para você. Por favor, guarde-a em um lugar seguro, como um gerenciador de senhas ou um cofre.",
"Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use uma frase secreta que apenas você conhece, e opcionalmente salve uma Chave de Segurança para usar como cópia de segurança (backup).", "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Use uma frase secreta que apenas você conhece, e opcionalmente salve uma Chave de Segurança para acessar o backup.",
"Restore your key backup to upgrade your encryption": "Restaurar a sua cópia segura (backup) de chaves para atualizar a sua criptografia", "Restore your key backup to upgrade your encryption": "Restaurar o backup das suas chaves para atualizar a sua criptografia",
"Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Atualize esta sessão para permitir que ela confirme outras sessões, dando a elas acesso às mensagens criptografadas e marcando-as como confiáveis para os seus contatos.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Atualize esta sessão para permitir que ela confirme outras sessões, dando a elas acesso às mensagens criptografadas e marcando-as como confiáveis para os seus contatos.",
"Store your Security Key somewhere safe, like a password manager or a safe, as its used to safeguard your encrypted data.": "Guarde sua Chave de Segurança em algum lugar seguro, como por exemplo um gestor de senhas ou um cofre, já que esta chave é a proteção para seus dados criptografados.", "Store your Security Key somewhere safe, like a password manager or a safe, as its used to safeguard your encrypted data.": "Guarde sua Chave de Segurança em algum lugar seguro, como por exemplo um gestor de senhas ou um cofre, já que esta chave é a proteção para seus dados criptografados.",
"If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Se você cancelar agora, poderá perder mensagens e dados criptografados se você perder acesso aos seus logins atuais.", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Se você cancelar agora, poderá perder mensagens e dados criptografados se você perder acesso aos seus logins atuais.",
"Upgrade your encryption": "Atualizar sua criptografia", "Upgrade your encryption": "Atualizar sua criptografia",
"Save your Security Key": "Salve sua Chave de Segurança", "Save your Security Key": "Salve sua Chave de Segurança",
"We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Nós vamos armazenar uma cópia criptografada de suas chaves no nosso servidor. Por favor, proteja esta cópia (backup) com uma frase de recuperação.", "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Nós armazenaremos uma cópia criptografada de suas chaves no nosso servidor. Por favor, proteja este backup com uma frase de recuperação.",
"Set up with a recovery key": "Configurar com uma chave de recuperação", "Set up with a recovery key": "Configurar com uma chave de recuperação",
"Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "Sua chave de recuperação é uma rede de proteção - você pode usá-la para restaurar o acesso às suas mensagens criptografadas se você esquecer sua frase de recuperação.", "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "Sua chave de recuperação é uma rede de proteção - você pode usá-la para restaurar o acesso às suas mensagens criptografadas se você esquecer sua frase de recuperação.",
"Your recovery key": "Sua chave de recuperação", "Your recovery key": "Sua chave de recuperação",
@ -1601,12 +1601,12 @@
"Your recovery key is in your <b>Downloads</b> folder.": "Sua chave de recuperação está na sua pasta de <b>Downloads</b>.", "Your recovery key is in your <b>Downloads</b> folder.": "Sua chave de recuperação está na sua pasta de <b>Downloads</b>.",
"Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Sem configurar a Recuperação Segura de Mensagens, você não será capaz de restaurar seu histórico de mensagens criptografadas e fizer logout ou usar outra sessão.", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Sem configurar a Recuperação Segura de Mensagens, você não será capaz de restaurar seu histórico de mensagens criptografadas e fizer logout ou usar outra sessão.",
"Make a copy of your recovery key": "Fazer uma cópia de sua chave de recuperação", "Make a copy of your recovery key": "Fazer uma cópia de sua chave de recuperação",
"Starting backup...": "Iniciando cópia de segurança (backup)...", "Starting backup...": "Começando o backup...",
"Create key backup": "Criar cópia de segurança (backup) da chave", "Create key backup": "Criar backup de chave",
"A new recovery passphrase and key for Secure Messages have been detected.": "Uma nova frase e chave de recuperação para Mensagens Seguras foram detectadas.", "A new recovery passphrase and key for Secure Messages have been detected.": "Uma nova frase e chave de recuperação para Mensagens Seguras foram detectadas.",
"This session is encrypting history using the new recovery method.": "Esta sessão está criptografando o histórico de mensagens usando o novo método de restauração.", "This session is encrypting history using the new recovery method.": "Esta sessão está criptografando o histórico de mensagens usando a nova opção de recuperação.",
"This session has detected that your recovery passphrase and key for Secure Messages have been removed.": "Esta sessão detectou que sua frase e chave de recuperação para Mensagens Seguras foram removidas.", "This session has detected that your recovery passphrase and key for Secure Messages have been removed.": "Esta sessão detectou que sua frase e chave de recuperação para Mensagens Seguras foram removidas.",
"If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Se você fez isso acidentalmente, você pode configurar Mensagens Seguras nesta sessão, o que vai re-criptografar o histórico de mensagens desta sessão com um novo método de recuperação.", "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Se você fez isso acidentalmente, você pode configurar Mensagens Seguras nesta sessão, o que vai re-criptografar o histórico de mensagens desta sessão com uma nova opção de recuperação.",
"If disabled, messages from encrypted rooms won't appear in search results.": "Se desativado, as mensagens de salas criptografadas não aparecerão em resultados de buscas.", "If disabled, messages from encrypted rooms won't appear in search results.": "Se desativado, as mensagens de salas criptografadas não aparecerão em resultados de buscas.",
"%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s está armazenando de forma segura as mensagens criptografadas localmente, para que possam aparecer nos resultados das buscas:", "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s está armazenando de forma segura as mensagens criptografadas localmente, para que possam aparecer nos resultados das buscas:",
"%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s de %(totalRooms)s", "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s de %(totalRooms)s",
@ -1642,7 +1642,7 @@
"Edited at %(date)s. Click to view edits.": "Editado em %(date)s. Clique para ver edições.", "Edited at %(date)s. Click to view edits.": "Editado em %(date)s. Clique para ver edições.",
"edited": "editado", "edited": "editado",
"Can't load this message": "Não foi possível carregar esta mensagem", "Can't load this message": "Não foi possível carregar esta mensagem",
"Submit logs": "Enviar registros", "Submit logs": "Enviar relatórios",
"Frequently Used": "Mais usados", "Frequently Used": "Mais usados",
"Animals & Nature": "Animais e natureza", "Animals & Nature": "Animais e natureza",
"Food & Drink": "Comidas e bebidas", "Food & Drink": "Comidas e bebidas",
@ -1722,7 +1722,7 @@
"Published Addresses": "Endereços publicados", "Published Addresses": "Endereços publicados",
"Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "Os endereços publicados podem ser usados por qualquer pessoa em qualquer servidor para entrar na sala. Para publicar um endereço, primeiramente ele precisa ser definido como um endereço local.", "Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "Os endereços publicados podem ser usados por qualquer pessoa em qualquer servidor para entrar na sala. Para publicar um endereço, primeiramente ele precisa ser definido como um endereço local.",
"Other published addresses:": "Outros endereços publicados:", "Other published addresses:": "Outros endereços publicados:",
"New published address (e.g. #alias:server)": "Novo endereço publicado (por exemplo, #apelido:server)", "New published address (e.g. #alias:server)": "Novo endereço publicado (por exemplo, #nome:server)",
"Local Addresses": "Endereços locais", "Local Addresses": "Endereços locais",
"%(name)s cancelled verifying": "%(name)s cancelou a confirmação", "%(name)s cancelled verifying": "%(name)s cancelou a confirmação",
"Your display name": "Seu nome e sobrenome", "Your display name": "Seu nome e sobrenome",
@ -1889,12 +1889,12 @@
"All settings": "Todas as configurações", "All settings": "Todas as configurações",
"You're signed out": "Você está desconectada/o", "You're signed out": "Você está desconectada/o",
"Clear personal data": "Limpar dados pessoais", "Clear personal data": "Limpar dados pessoais",
"Command Autocomplete": "Preenchimento automático de comandos", "Command Autocomplete": "Preenchimento automático do comando",
"Community Autocomplete": "Preenchimento automático da comunidade", "Community Autocomplete": "Preenchimento automático da comunidade",
"DuckDuckGo Results": "Resultados no DuckDuckGo", "DuckDuckGo Results": "Resultados no DuckDuckGo",
"If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não excluiu o método de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina imediatamente um novo método de recuperação nas Configurações.", "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Se você não excluiu a opção de recuperação, um invasor pode estar tentando acessar sua conta. Altere a senha da sua conta e defina imediatamente uma nova opção de recuperação nas Configurações.",
"Room List": "Lista de salas", "Room List": "Lista de salas",
"Autocomplete": "Autocompletar", "Autocomplete": "Preencher automaticamente",
"Alt": "Alt", "Alt": "Alt",
"Alt Gr": "Alt Gr", "Alt Gr": "Alt Gr",
"Shift": "Shift", "Shift": "Shift",
@ -2056,11 +2056,11 @@
"You'll upgrade this room from <oldVersion /> to <newVersion />.": "Você atualizará esta sala de <oldVersion /> para <newVersion />.", "You'll upgrade this room from <oldVersion /> to <newVersion />.": "Você atualizará esta sala de <oldVersion /> para <newVersion />.",
"A username can only contain lower case letters, numbers and '=_-./'": "Um nome de usuário só pode ter letras minúsculas, números e '=_-./'", "A username can only contain lower case letters, numbers and '=_-./'": "Um nome de usuário só pode ter letras minúsculas, números e '=_-./'",
"Command Help": "Ajuda com Comandos", "Command Help": "Ajuda com Comandos",
"To help us prevent this in future, please <a>send us logs</a>.": "Para nos ajudar a evitar isso no futuro, <a>envie-nos os registros</a>.", "To help us prevent this in future, please <a>send us logs</a>.": "Para nos ajudar a evitar isso no futuro, <a>envie-nos os relatórios</a>.",
"Your browser likely removed this data when running low on disk space.": "O seu navegador provavelmente removeu esses dados quando o espaço de armazenamento ficou insuficiente.", "Your browser likely removed this data when running low on disk space.": "O seu navegador provavelmente removeu esses dados quando o espaço de armazenamento ficou insuficiente.",
"Integration Manager": "Gerenciador de Integrações", "Integration Manager": "Gerenciador de Integrações",
"Find others by phone or email": "Encontre outras pessoas por telefone ou e-mail", "Find others by phone or email": "Encontre outras pessoas por telefone ou e-mail",
"Use bots, bridges, widgets and sticker packs": "Use bots, pontes, widgets e pacotes de figurinhas", "Use bots, bridges, widgets and sticker packs": "Use bots, integrações, widgets e pacotes de figurinhas",
"Terms of Service": "Termos de serviço", "Terms of Service": "Termos de serviço",
"To continue you need to accept the terms of this service.": "Para continuar, você precisa aceitar os termos deste serviço.", "To continue you need to accept the terms of this service.": "Para continuar, você precisa aceitar os termos deste serviço.",
"Service": "Serviço", "Service": "Serviço",
@ -2308,8 +2308,8 @@
"Room Info": "Informações da sala", "Room Info": "Informações da sala",
"Widgets": "Widgets", "Widgets": "Widgets",
"Unpin app": "Desafixar app", "Unpin app": "Desafixar app",
"Edit widgets, bridges & bots": "Editar widgets, pontes & bots", "Edit widgets, bridges & bots": "Editar widgets, integrações & bots",
"Add widgets, bridges & bots": "Adicionar widgets, pontes & bots", "Add widgets, bridges & bots": "Adicionar widgets, integrações & bots",
"%(count)s people|other": "%(count)s pessoas", "%(count)s people|other": "%(count)s pessoas",
"%(count)s people|one": "%(count)s pessoa", "%(count)s people|one": "%(count)s pessoa",
"Show files": "Mostrar arquivos", "Show files": "Mostrar arquivos",
@ -2329,5 +2329,145 @@
"What's the name of your community or team?": "Qual é o nome da sua comunidade ou equipe?", "What's the name of your community or team?": "Qual é o nome da sua comunidade ou equipe?",
"Add image (optional)": "Adicionar foto (opcional)", "Add image (optional)": "Adicionar foto (opcional)",
"An image will help people identify your community.": "Uma foto ajudará as pessoas identificarem a sua comunidade.", "An image will help people identify your community.": "Uma foto ajudará as pessoas identificarem a sua comunidade.",
"Preview": "Visualizar" "Preview": "Visualizar",
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Adiciona ( ͡° ͜ʖ ͡°) a uma mensagem de texto",
"Set up Secure Backup": "Configurar o backup online",
"Safeguard against losing access to encrypted messages & data": "Proteja-se contra a perda de acesso a mensagens & dados criptografados",
"Show message previews for reactions in DMs": "Mostrar pré-visualizações para reações em mensagens privadas",
"Show message previews for reactions in all rooms": "Mostrar pré-visualizações para reações em todas as salas",
"Uploading logs": "Enviando relatórios",
"Downloading logs": "Baixando relatórios",
"Backup version:": "Versão do backup:",
"Backup key stored:": "Backup da chave armazenada:",
"Backup key cached:": "Backup da chave em cache:",
"Secure Backup": "Backup online",
"Your keys are being backed up (the first backup could take a few minutes).": "O backup de suas chaves está sendo feito (o primeiro backup pode demorar alguns minutos).",
"Secure your backup with a recovery passphrase": "Proteja o seu backup com uma frase de recuperação",
"You can also set up Secure Backup & manage your keys in Settings.": "Você também pode configurar o Backup online & configurar as suas senhas nas Configurações.",
"End conference": "Terminar conferência",
"This will end the conference for everyone. Continue?": "Isso encerrará a chamada para todos. Prosseguir?",
"Cross-signing is ready for use.": "A autoverificação está pronta para uso.",
"Cross-signing is not set up.": "A autoverificação não está configurada.",
"Reset": "Redefinir",
"not found in storage": "não encontrado no armazenamento",
"Master private key:": "Chave privada principal:",
"Failed to save your profile": "Houve uma falha ao salvar o seu perfil",
"The operation could not be completed": "A operação não foi concluída",
"Algorithm:": "Algoritmo:",
"Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.": "Faça backup de suas chaves de criptografia com os dados da sua conta, para se prevenir a perder o acesso às suas sessões. Suas chaves serão protegidas com uma chave de recuperação exclusiva.",
"Secret storage:": "Armazenamento secreto:",
"ready": "pronto",
"not ready": "não está pronto",
"Subscribed lists": "Listas inscritas",
"This room is bridging messages to the following platforms. <a>Learn more.</a>": "Esta sala está integrando mensagens com as seguintes plataformas. <a>Saiba mais.</a>",
"This room isnt bridging messages to any platforms. <a>Learn more.</a>": "Esta sala não está integrando mensagens com nenhuma plataforma. <a>Saiba mais.</a>",
"Bridges": "Integrações",
"Error changing power level requirement": "Houve um erro ao alterar o nível de permissão do contato",
"An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "Ocorreu um erro ao alterar os níveis de permissão da sala. Certifique-se de que você tem o nível suficiente e tente novamente.",
"An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "Ocorreu um erro ao alterar o nível de permissão de um contato. Certifique-se de que você tem o nível suficiente e tente novamente.",
"Remove messages sent by others": "Remover mensagens enviadas por outros",
"To link to this room, please add an address.": "Para criar um link para esta sala, antes adicione um endereço.",
"Explore community rooms": "Explorar salas da comunidade",
"Explore public rooms": "Explorar salas públicas",
"Can't see what youre looking for?": "Não consegue encontrar o que está procurando?",
"Explore all public rooms": "Explorar todas as salas públicas",
"%(count)s results|other": "%(count)s resultados",
"%(count)s results|one": "%(count)s resultado",
"%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.": "%(errcode)s apareceu ao tentar entrar na sala. Se você recebeu essa mensagem por engano, <issueLink>envie um relatório de erro</issueLink>.",
"Not encrypted": "Não criptografada",
"About": "Sobre a sala",
"Pin to room": "Fixar na sala",
"You can only pin 2 widgets at a time": "Você só pode fixar 2 widgets ao mesmo tempo",
"Ignored attempt to disable encryption": "A tentativa de desativar a criptografia foi ignorada",
"Message Actions": "Ações da mensagem",
"Join the conference at the top of this room": "Entre na chamada em grupo no topo desta sala",
"Join the conference from the room information card on the right": "Participe da chamada em grupo, clicando no botão de informações da sala, à direita da tela",
"Video conference ended by %(senderName)s": "Chamada de vídeo em grupo encerrada por %(senderName)s",
"Video conference updated by %(senderName)s": "Chamada de vídeo em grupo atualizada por %(senderName)s",
"Video conference started by %(senderName)s": "Chamada de vídeo em grupo iniciada por %(senderName)s",
"Preparing to download logs": "Preparando os relatórios para download",
"Download logs": "Baixar relatórios",
"Use this when referencing your community to others. The community ID cannot be changed.": "Use esta informação para indicar a sua comunidade para outras pessoas. O ID da comunidade não pode ser alterado.",
"You can change this later if needed.": "Você poderá alterar esta informação posteriormente, se for necessário.",
"Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Salas privadas são encontradas e acessadas apenas por meio de convite. Por sua vez, salas públicas são encontradas e acessadas por qualquer pessoa.",
"Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Salas privadas são encontradas e acessadas apenas por meio de convite. Por sua vez, salas públicas são encontradas e acessadas por qualquer integrante desta comunidade.",
"Your server requires encryption to be enabled in private rooms.": "O seu servidor demanda que a criptografia esteja ativada em salas privadas.",
"You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Você pode ativar essa opção se a sala for usada apenas para colaboração dentre equipes internas em seu servidor local. Essa opção não poderá ser alterado mais tarde.",
"You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Você pode desativar essa opção se a sala for usada para colaboração dentre equipes externas que possuem seu próprio servidor local. Isso não poderá ser alterado mais tarde.",
"Create a room in %(communityName)s": "Criar uma sala em %(communityName)s",
"Block anyone not part of %(serverName)s from ever joining this room.": "Bloquear pessoas externas ao servidor %(serverName)s de conseguirem entrar nesta sala.",
"Confirm your account deactivation by using Single Sign On to prove your identity.": "Prove a sua identidade por meio do seu Acesso único, para confirmar a desativação da sua conta.",
"There was an error updating your community. The server is unable to process your request.": "Houve um erro ao atualizar a sua comunidade. O servidor não conseguiu processar a sua solicitação.",
"Update community": "Atualizar a comunidade",
"To continue, use Single Sign On to prove your identity.": "Para continuar, use o Acesso único para provar a sua identidade.",
"May include members not in %(communityName)s": "Pode incluir integrantes externos à %(communityName)s",
"Start a conversation with someone using their name or username (like <userId/>).": "Comece uma conversa, a partir do nome ou nome de usuário de alguém (por exemplo: <userId/>).",
"This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>": "Ninguém será convidado para %(communityName)s. Para convidar alguém para %(communityName)s, clique <a>aqui</a>",
"Go": "Próximo",
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.": "Convide alguém a partir do nome ou nome de usuário (por exemplo: <userId/>) ou <a>compartilhe esta sala</a>.",
"Confirm by comparing the following with the User Settings in your other session:": "Para confirmar, compare a seguinte informação com aquela apresentada em sua outra sessão:",
"Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Atualizar esta sala irá fechar a instância atual da sala e, em seu lugar, criar uma sala atualizada com o mesmo nome. Para oferecer a melhor experiência possível aos integrantes da sala, nós iremos:",
"You're all caught up.": "Tudo em dia.",
"Your area is experiencing difficulties connecting to the internet.": "A sua região está com dificuldade de acesso à internet.",
"A connection error occurred while trying to contact the server.": "Um erro ocorreu na conexão do Element com o servidor.",
"Unable to set up keys": "Não foi possível configurar as chaves",
"Unpin": "Desafixar",
"Cannot create rooms in this community": "Não foi possível criar salas nesta comunidade",
"You do not have permission to create rooms in this community.": "Você não tem permissão para criar salas nesta comunidade.",
"Youre all caught up": "Tudo em dia",
"Explore rooms in %(communityName)s": "Explore as salas em %(communityName)s",
"Create community": "Criar comunidade",
"Failed to find the general chat for this community": "Houve uma falha para encontrar a conversa principal desta comunidade",
"Community settings": "Configurações da comunidade",
"User settings": "Configurações do usuário",
"Community and user menu": "Comunidade e menu de usuário",
"Sign in instead": "Fazer login",
"Failed to get autodiscovery configuration from server": "Houve uma falha para obter do servidor a configuração de encontrar contatos",
"If you've joined lots of rooms, this might take a while": "Se você participa em muitas salas, isso pode demorar um pouco",
"Unable to query for supported registration methods.": "Não foi possível consultar as opções de registro suportadas.",
"Registration has been disabled on this homeserver.": "O registro de contas foi desativado neste servidor local.",
"Continue with previous account": "Continuar com a conta anterior",
"This requires the latest %(brand)s on your other devices:": "Esta funcionalidade requer o %(brand)s mais recente em seus outros aparelhos:",
"Emoji Autocomplete": "Preenchimento automático de emoji",
"Room Autocomplete": "Preenchimento automático de sala",
"User Autocomplete": "Preenchimento automático de usuário",
"Enter a recovery passphrase": "Digite uma frase de recuperação",
"Great! This recovery passphrase looks strong enough.": "Ótimo! Essa frase de recuperação é forte o suficiente.",
"Please enter your recovery passphrase a second time to confirm.": "Digite a sua frase de recuperação uma segunda vez para confirmar, por favor.",
"Repeat your recovery passphrase...": "Digite a sua frase de recuperação novamente...",
"Keep a copy of it somewhere secure, like a password manager or even a safe.": "Mantenha uma cópia em algum lugar seguro, como em um gerenciador de senhas ou até mesmo em um cofre.",
"Unable to query secret storage status": "Não foi possível obter o status do armazenamento secreto",
"Set a Security Phrase": "Defina uma frase de segurança",
"Confirm Security Phrase": "Confirme a frase de segurança",
"Unable to set up secret storage": "Não foi possível definir o armazenamento secreto",
"Recovery Method Removed": "Opção de recuperação removida",
"Not currently indexing messages for any room.": "Atualmente, mensagens de nenhuma sala estão sendo armazenadas.",
"Currently indexing: %(currentRoom)s": "Armazenando no momento: %(currentRoom)s",
"Indexed messages:": "Mensagens armazenadas:",
"Indexed rooms:": "Salas armazenadas:",
"Message downloading sleep time(ms)": "Tempo de espera entre o download de mensagens (ms)",
"Clear room list filter field": "Limpar o campo de busca de salas",
"Previous/next unread room or DM": "Anterior/próxima mensagem ou sala não lida",
"Previous/next room or DM": "Anterior/próxima mensagem ou sala",
"Toggle the top left menu": "Alternar o menu superior esquerdo",
"Activate selected button": "Apertar no botão selecionado",
"Toggle right panel": "Alternar o painel na direita",
"Toggle this dialog": "Alternar esta janela",
"Move autocomplete selection up/down": "Alternar para cima/baixo a opção do preenchimento automático",
"Cancel autocomplete": "Cancelar o preenchimento automático",
"Offline encrypted messaging using dehydrated devices": "Envio de mensagens criptografadas offline, usando dispositivos específicos",
"Calling...": "Chamando...",
"Call connecting...": "Iniciando chamada...",
"Starting camera...": "Iniciando a câmera...",
"Starting microphone...": "Iniciando o microfone...",
"(their device couldn't start the camera / microphone)": "(o aparelho não conseguiu iniciar a câmera/microfone)",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s alterou a lista de controle de acesso do servidor para esta sala.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s definiu a lista de controle de acesso do servidor para esta sala.",
"The call could not be established": "Não foi possível iniciar a chamada",
"The other party declined the call.": "O contato recusou a chamada.",
"%(senderName)s declined the call.": "%(senderName)s recusou a chamada.",
"(an error occurred)": "(ocorreu um erro)",
"(connection failed)": "(a conexão falhou)",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Todos os servidores foram banidos desta sala! Esta sala não pode mais ser utilizada.",
"Call Declined": "Chamada recusada"
} }

View file

@ -2517,5 +2517,10 @@
"End conference": "Завершить конференцию", "End conference": "Завершить конференцию",
"This will end the conference for everyone. Continue?": "Это завершит конференцию для всех. Продолжить?", "This will end the conference for everyone. Continue?": "Это завершит конференцию для всех. Продолжить?",
"Failed to save your profile": "Не удалось сохранить ваш профиль", "Failed to save your profile": "Не удалось сохранить ваш профиль",
"The operation could not be completed": "Операция не может быть выполнена" "The operation could not be completed": "Операция не может быть выполнена",
"Calling...": "Звонок…",
"Call connecting...": "Устанавливается соединение…",
"Starting camera...": "Запуск камеры…",
"Starting microphone...": "Запуск микрофона…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Все серверы запрещены к участию! Эта комната больше не может быть использована."
} }

View file

@ -2518,5 +2518,21 @@
"This version of %(brand)s does not support viewing some encrypted files": "Ky version i %(brand)s nuk mbulon parjen për disa kartela të fshehtëzuara", "This version of %(brand)s does not support viewing some encrypted files": "Ky version i %(brand)s nuk mbulon parjen për disa kartela të fshehtëzuara",
"This version of %(brand)s does not support searching encrypted messages": "Ky version i %(brand)s nuk mbulon kërkimin në mesazhe të fshehtëzuar", "This version of %(brand)s does not support searching encrypted messages": "Ky version i %(brand)s nuk mbulon kërkimin në mesazhe të fshehtëzuar",
"Cannot create rooms in this community": "Smund të krijohen dhoma në këtë bashkësi", "Cannot create rooms in this community": "Smund të krijohen dhoma në këtë bashkësi",
"You do not have permission to create rooms in this community.": "Skeni leje të krijoni dhoma në këtë bashkësi." "You do not have permission to create rooms in this community.": "Skeni leje të krijoni dhoma në këtë bashkësi.",
"Failed to save your profile": "Su arrit të ruhej profili juaj",
"The operation could not be completed": "Veprimi su plotësua dot",
"Starting microphone...": "Po vihet mikrofoni në punë…",
"Starting camera...": "Po vihet kamera në punë…",
"Call connecting...": "Po bëhet lidhja për thirrje…",
"Calling...": "Po thirret…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Janë dëbuar nga pjesëmarrja krejt shërbyesit! Kjo dhomë smund të përdoret më.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ndryshoi ACL-ra shërbyesi për këtë dhomë.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s caktoi ACL-ra shërbyesi për këtë dhomë.",
"%(senderName)s declined the call.": "%(senderName)s hodhi poshtë thirrjen.",
"(an error occurred)": "(ndodhi një gabim)",
"(their device couldn't start the camera / microphone)": "(pajisja e tyre svuri dot në punë kamerën / mikrofonin)",
"(connection failed)": "(dështoi lidhja)",
"The call could not be established": "Thirrja su nis dot",
"The other party declined the call.": "Pala tjetër hodhi poshtë thirrjen.",
"Call Declined": "Thirrja u Hodh Poshtë"
} }

View file

@ -2457,5 +2457,19 @@
"This version of %(brand)s does not support viewing some encrypted files": "Den här versionen av %(brand)s stöder inte visning av vissa krypterade filer", "This version of %(brand)s does not support viewing some encrypted files": "Den här versionen av %(brand)s stöder inte visning av vissa krypterade filer",
"This version of %(brand)s does not support searching encrypted messages": "Den här versionen av %(brand)s stöder inte sökning bland krypterade meddelanden", "This version of %(brand)s does not support searching encrypted messages": "Den här versionen av %(brand)s stöder inte sökning bland krypterade meddelanden",
"Cannot create rooms in this community": "Kan inte skapa rum i den här gemenskapen", "Cannot create rooms in this community": "Kan inte skapa rum i den här gemenskapen",
"You do not have permission to create rooms in this community.": "Du har inte behörighet att skapa rum i den här gemenskapen." "You do not have permission to create rooms in this community.": "Du har inte behörighet att skapa rum i den här gemenskapen.",
"Calling...": "Ringer…",
"Call connecting...": "Samtal ansluts…",
"Starting camera...": "Startar kamera…",
"Starting microphone...": "Startar mikrofon…",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Alla servrar har bannats från att delta! Det här rummet kan inte längre användas.",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ändrade server-ACL:erna för det här rummet.",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s ställde in server-ACL:er för det här rummet.",
"%(senderName)s declined the call.": "%(senderName)s avböjde samtalet.",
"(an error occurred)": "(ett fel inträffade)",
"(their device couldn't start the camera / microphone)": "(deras enhet kunde inte starta kameran/mikrofonen)",
"(connection failed)": "(anslutning misslyckad)",
"The call could not be established": "Samtalet kunde inte etableras",
"The other party declined the call.": "Den andra parten avböjde samtalet.",
"Call Declined": "Samtal avböjt"
} }

View file

@ -222,7 +222,7 @@
"Whether or not you're using the Richtext mode of the Rich Text Editor": "Чи використовуєте ви режим форматованого тексту у редакторі Rich Text Editor", "Whether or not you're using the Richtext mode of the Rich Text Editor": "Чи використовуєте ви режим форматованого тексту у редакторі Rich Text Editor",
"Your homeserver's URL": "URL адреса вашого домашнього сервера", "Your homeserver's URL": "URL адреса вашого домашнього сервера",
"Failed to verify email address: make sure you clicked the link in the email": "Не вдалось перевірити адресу електронної пошти: переконайтесь, що ви перейшли за посиланням у листі", "Failed to verify email address: make sure you clicked the link in the email": "Не вдалось перевірити адресу електронної пошти: переконайтесь, що ви перейшли за посиланням у листі",
"The platform you're on": "Використовувана платформа", "The platform you're on": "Платформа, на якій ви працюєте",
"e.g. %(exampleValue)s": "напр. %(exampleValue)s", "e.g. %(exampleValue)s": "напр. %(exampleValue)s",
"Every page you use in the app": "Кожна сторінка, яку ви використовуєте в програмі", "Every page you use in the app": "Кожна сторінка, яку ви використовуєте в програмі",
"e.g. <CurrentPageURL>": "напр. <CurrentPageURL>", "e.g. <CurrentPageURL>": "напр. <CurrentPageURL>",
@ -1229,5 +1229,40 @@
"Cancel autocomplete": "Скасувати самодоповнення", "Cancel autocomplete": "Скасувати самодоповнення",
"Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Журнали зневадження містять дані використання застосунку, включно з вашим користувацьким ім’ям, ідентифікаторами або псевдонімами відвіданих вами кімнат або груп, а також іменами інших користувачів. Вони не містять повідомлень.", "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Журнали зневадження містять дані використання застосунку, включно з вашим користувацьким ім’ям, ідентифікаторами або псевдонімами відвіданих вами кімнат або груп, а також іменами інших користувачів. Вони не містять повідомлень.",
"Confirm your account deactivation by using Single Sign On to prove your identity.": "Підтвердьте знедіяння вашого облікового запису через Single Sign On щоб підтвердити вашу особу.", "Confirm your account deactivation by using Single Sign On to prove your identity.": "Підтвердьте знедіяння вашого облікового запису через Single Sign On щоб підтвердити вашу особу.",
"This account has been deactivated.": "Цей обліковий запис було знедіяно." "This account has been deactivated.": "Цей обліковий запис було знедіяно.",
"End conference": "Завершити конференцію",
"This will end the conference for everyone. Continue?": "Це завершить конференцію для всіх. Продовжити?",
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Додає ( ͡° ͜ʖ ͡°) на початку текстового повідомлення",
"about a day ago": "близько доби тому",
"%(name)s (%(userId)s)": "%(name)s (%(userId)s)",
"Unexpected server error trying to leave the room": "Виникла неочікувана помилка серверу під час спроби залишити кімнату",
"Unknown App": "Невідомий додаток",
"Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. This will use a <PolicyLink>cookie</PolicyLink>.": "Відправляти <UsageDataLink>анонімну статистику користування</UsageDataLink>, що дозволяє нам покращувати %(brand)s. Це використовує <PolicyLink>кукі</PolicyLink>.",
"Set up Secure Backup": "Налаштувати захищене резервне копіювання",
"Safeguard against losing access to encrypted messages & data": "Захист від втрати доступу до зашифрованих повідомлень та даних",
"The person who invited you already left the room.": "Особа, що вас запросила, вже залишила кімнату.",
"The person who invited you already left the room, or their server is offline.": "Особа, що вас запросила вже залишила кімнату, або її сервер відімкнено.",
"Change notification settings": "Змінити налаштування сповіщень",
"Render simple counters in room header": "Показувати звичайні лічильники у заголовку кімнати",
"Send typing notifications": "Надсилати сповіщення про набирання тексту",
"Use a system font": "Використовувати системний шрифт",
"System font name": "Ім’я системного шрифту",
"Allow Peer-to-Peer for 1:1 calls": "Дозволити Peer-to-Peer для дзвінків 1:1",
"Enable widget screenshots on supported widgets": "Увімкнути скріншоти віджетів для віджетів, що підтримуються",
"Prompt before sending invites to potentially invalid matrix IDs": "Запитувати перед надсиланням запрошень на потенційно недійсні matrix ID",
"Order rooms by name": "Сортувати кімнати за назвою",
"Low bandwidth mode": "Режим для низької пропускної здатності",
"Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Дозволити резервний сервер допоміжних викликів turn.matrix.org якщо ваш домашній сервер не пропонує такого (ваша IP-адреса буде розкрита для здійснення дзвінка)",
"Send read receipts for messages (requires compatible homeserver to disable)": "Надсилати мітки прочитання повідомлень (необхідний сумісний домашній сервер для відімкнення)",
"How fast should messages be downloaded.": "Як швидко повідомлення повинні завантажуватися.",
"Enable experimental, compact IRC style layout": "Увімкнути експериментальне, компактне компонування IRC",
"Uploading logs": "Відвантаження журналів",
"Downloading logs": "Завантаження журналів",
"My Ban List": "Мій список блокувань",
"This is your list of users/servers you have blocked - don't leave the room!": "Це ваш список користувачів/серверів, які ви заблокували не залишайте кімнату!",
"Incoming call": "Вхідний виклик",
"The other party cancelled the verification.": "Друга сторона скасувала звірення.",
"Verified!": "Звірено!",
"You've successfully verified this user.": "Ви успішно звірили цього користувача.",
"Got It": "Зрозуміло"
} }

View file

@ -1529,10 +1529,10 @@
"Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "备份有来自 ID 为 %(deviceId)s 的<verify>未知</verify>会话的签名", "Backup has a signature from <verify>unknown</verify> session with ID %(deviceId)s": "备份有来自 ID 为 %(deviceId)s 的<verify>未知</verify>会话的签名",
"Backup has a <validity>valid</validity> signature from this session": "备份有来自此会话的<validity>有效</validity>签名", "Backup has a <validity>valid</validity> signature from this session": "备份有来自此会话的<validity>有效</validity>签名",
"Backup has an <validity>invalid</validity> signature from this session": "备份有来自此会话的<validity>无效</validity>签名", "Backup has an <validity>invalid</validity> signature from this session": "备份有来自此会话的<validity>无效</validity>签名",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "备份有来自<verify>已验证</verify>会话 <device></device> 的<validity>有效</validity>签名", "Backup has a <validity>valid</validity> signature from <verify>verified</verify> session <device></device>": "备份有一个<validity>有效的签名</validity>,它来自<verify>已验证</verify>会话<device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有来自<verify>未验证</verify>会话 <device></device> 的<validity>无效</validity>签名", "Backup has a <validity>valid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有一个<validity>有效的</validity>签名,它来自<verify>未验证</verify>会话<device>\n</device>",
"Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "备份有来自<verify>已验证</verify>会话 <device></device> 的<validity>无效</validity>签名", "Backup has an <validity>invalid</validity> signature from <verify>verified</verify> session <device></device>": "备份有一个<validity>无效的</validity>签名,它来自<verify>已验证</verify>会话<device>\n</device>",
"Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有来自<verify>未验证的</verify>会话 <device></device> 的 <validity>无效</validity>签名", "Backup has an <validity>invalid</validity> signature from <verify>unverified</verify> session <device></device>": "备份有一个<validity>无效的</validity>签名,它来自<verify>未验证的</verify>会话<device>\n</device>",
"Backup is not signed by any of your sessions": "备份没有被您的任何一个会话签名", "Backup is not signed by any of your sessions": "备份没有被您的任何一个会话签名",
"This backup is trusted because it has been restored on this session": "此备份是受信任的因为它被恢复到了此会话上", "This backup is trusted because it has been restored on this session": "此备份是受信任的因为它被恢复到了此会话上",
"Backup key stored: ": "存储的备份密钥: ", "Backup key stored: ": "存储的备份密钥: ",
@ -1553,12 +1553,12 @@
"Disconnect identity server": "断开身份服务器连接", "Disconnect identity server": "断开身份服务器连接",
"Disconnect from the identity server <idserver />?": "从身份服务器 <idserver /> 断开连接吗?", "Disconnect from the identity server <idserver />?": "从身份服务器 <idserver /> 断开连接吗?",
"Disconnect": "断开连接", "Disconnect": "断开连接",
"You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> is currently offline or cannot be reached.": "在断开连接之前,您应该从身份服务器 <idserver /> <b>删除您的个人信息</b>。不幸的是,身份服务器 <idserver /> 现在为离线状态或不能到达。", "You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> is currently offline or cannot be reached.": "断开连接前,你应当<b>删除你的个人信息</b>从身份服务器<idserver />。\n不幸的是身份服务器<idserver />当前处于离线状态或无法访问。",
"You should:": "您应该:", "You should:": "您应该:",
"contact the administrators of identity server <idserver />": "联系身份服务器 <idserver /> 的管理员", "contact the administrators of identity server <idserver />": "联系身份服务器 <idserver /> 的管理员",
"wait and try again later": "等待并稍后重试", "wait and try again later": "等待并稍后重试",
"Disconnect anyway": "仍然断开连接", "Disconnect anyway": "仍然断开连接",
"You are still <b>sharing your personal data</b> on the identity server <idserver />.": "您仍然在身份服务器 <idserver /> 上<b>共享您的个人信息</b>。", "You are still <b>sharing your personal data</b> on the identity server <idserver />.": "您仍然在<b>分享您的个人信息</b>在身份服务器上<idserver />。",
"We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐您在断开连接前从身份服务器上删除您的邮箱地址和电话号码。", "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "我们推荐您在断开连接前从身份服务器上删除您的邮箱地址和电话号码。",
"Identity Server (%(server)s)": "身份服务器(%(server)s", "Identity Server (%(server)s)": "身份服务器(%(server)s",
"not stored": "未存储", "not stored": "未存储",
@ -2287,7 +2287,7 @@
"Alt": "Alt", "Alt": "Alt",
"Alt Gr": "Alt Gr", "Alt Gr": "Alt Gr",
"Shift": "Shift", "Shift": "Shift",
"Super": "Super", "Super": "",
"Ctrl": "Ctrl", "Ctrl": "Ctrl",
"New line": "换行", "New line": "换行",
"Jump to start/end of the composer": "跳转到编辑器的开始/结束", "Jump to start/end of the composer": "跳转到编辑器的开始/结束",

View file

@ -2527,5 +2527,19 @@
"Offline encrypted messaging using dehydrated devices": "使用乾淨裝置的離線加密訊息", "Offline encrypted messaging using dehydrated devices": "使用乾淨裝置的離線加密訊息",
"Failed to save your profile": "儲存您的設定檔失敗", "Failed to save your profile": "儲存您的設定檔失敗",
"The operation could not be completed": "無法完成操作", "The operation could not be completed": "無法完成操作",
"Remove messages sent by others": "移除其他人傳送的訊息" "Remove messages sent by others": "移除其他人傳送的訊息",
"Calling...": "正在通話……",
"Call connecting...": "正在連線通話……",
"Starting camera...": "正在開啟攝影機……",
"Starting microphone...": "正在開啟麥克風……",
"🎉 All servers are banned from participating! This room can no longer be used.": "🎉 所有伺服器都被禁止加入! 這間聊天室無法使用。",
"%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s 為此房間更改了伺服器的存取控制列表。",
"%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s 為此房間設置了伺服器的存取控制列表。",
"%(senderName)s declined the call.": "%(senderName)s 拒絕了通話。",
"(an error occurred)": "(遇到錯誤)",
"(their device couldn't start the camera / microphone)": "(他們的裝置無法開啟攝影機/麥克風)",
"(connection failed)": "(連線失敗)",
"The call could not be established": "無法建立通話",
"The other party declined the call.": "對方拒絕了電話。",
"Call Declined": "通話已拒絕"
} }

View file

@ -120,7 +120,7 @@ export class IntegrationManagers {
if (!data) return; if (!data) return;
const uiUrl = w.content['url']; const uiUrl = w.content['url'];
const apiUrl = data['api_url']; const apiUrl = data['api_url'] as string;
if (!apiUrl || !uiUrl) return; if (!apiUrl || !uiUrl) return;
const manager = new IntegrationManagerInstance( const manager = new IntegrationManagerInstance(

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 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.
@ -16,9 +16,16 @@ limitations under the License.
import FixedDistributor from "./fixed"; import FixedDistributor from "./fixed";
import ResizeItem from "../item"; import ResizeItem from "../item";
import Resizer, {IConfig} from "../resizer";
import Sizer from "../sizer";
class CollapseItem extends ResizeItem { export interface ICollapseConfig extends IConfig {
notifyCollapsed(collapsed) { toggleSize: number;
onCollapsed?(collapsed: boolean, id: string, element: HTMLElement): void;
}
class CollapseItem extends ResizeItem<ICollapseConfig> {
notifyCollapsed(collapsed: boolean) {
const callback = this.resizer.config.onCollapsed; const callback = this.resizer.config.onCollapsed;
if (callback) { if (callback) {
callback(collapsed, this.id, this.domNode); callback(collapsed, this.id, this.domNode);
@ -26,18 +33,20 @@ class CollapseItem extends ResizeItem {
} }
} }
export default class CollapseDistributor extends FixedDistributor { export default class CollapseDistributor extends FixedDistributor<ICollapseConfig, CollapseItem> {
static createItem(resizeHandle, resizer, sizer) { static createItem(resizeHandle: HTMLDivElement, resizer: Resizer<ICollapseConfig>, sizer: Sizer) {
return new CollapseItem(resizeHandle, resizer, sizer); return new CollapseItem(resizeHandle, resizer, sizer);
} }
constructor(item, config) { private readonly toggleSize: number;
private isCollapsed = false;
constructor(item: CollapseItem) {
super(item); super(item);
this.toggleSize = config && config.toggleSize; this.toggleSize = item.resizer?.config?.toggleSize;
this.isCollapsed = false;
} }
resize(newSize) { public resize(newSize: number) {
const isCollapsedSize = newSize < this.toggleSize; const isCollapsedSize = newSize < this.toggleSize;
if (isCollapsedSize && !this.isCollapsed) { if (isCollapsedSize && !this.isCollapsed) {
this.isCollapsed = true; this.isCollapsed = true;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 - 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.
@ -16,6 +16,7 @@ limitations under the License.
import ResizeItem from "../item"; import ResizeItem from "../item";
import Sizer from "../sizer"; import Sizer from "../sizer";
import Resizer, {IConfig} from "../resizer";
/** /**
distributors translate a moving cursor into distributors translate a moving cursor into
@ -27,29 +28,42 @@ they have two methods:
within the container bounding box. For internal use. within the container bounding box. For internal use.
This method usually ends up calling `resize` once the start offset is subtracted. This method usually ends up calling `resize` once the start offset is subtracted.
*/ */
export default class FixedDistributor { export default class FixedDistributor<C extends IConfig, I extends ResizeItem<any> = ResizeItem<C>> {
static createItem(resizeHandle, resizer, sizer) { static createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem {
return new ResizeItem(resizeHandle, resizer, sizer); return new ResizeItem(resizeHandle, resizer, sizer);
} }
static createSizer(containerElement, vertical, reverse) { static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer {
return new Sizer(containerElement, vertical, reverse); return new Sizer(containerElement, vertical, reverse);
} }
constructor(item) { private readonly beforeOffset: number;
this.item = item;
constructor(public readonly item: I) {
this.beforeOffset = item.offset(); this.beforeOffset = item.offset();
} }
resize(size) { public get size() {
return this.item.getSize();
}
public set size(size: string) {
this.item.setRawSize(size);
}
public resize(size: number) {
this.item.setSize(size); this.item.setSize(size);
} }
resizeFromContainerOffset(offset) { public resizeFromContainerOffset(offset: number) {
this.resize(offset - this.beforeOffset); this.resize(offset - this.beforeOffset);
} }
start() {} public start() {
this.item.start();
}
finish() {} public finish() {
this.item.finish();
}
} }

View file

@ -0,0 +1,49 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Sizer from "../sizer";
import FixedDistributor from "./fixed";
import {IConfig} from "../resizer";
class PercentageSizer extends Sizer {
public start(item: HTMLElement) {
if (this.vertical) {
item.style.minHeight = null;
} else {
item.style.minWidth = null;
}
}
public finish(item: HTMLElement) {
const parent = item.offsetParent as HTMLElement;
if (!parent) return;
if (this.vertical) {
const p = ((item.offsetHeight / parent.offsetHeight) * 100).toFixed(2) + "%";
item.style.minHeight = p;
item.style.height = p;
} else {
const p = ((item.offsetWidth / parent.offsetWidth) * 100).toFixed(2) + "%";
item.style.minWidth = p;
item.style.width = p;
}
}
}
export default class PercentageDistributor extends FixedDistributor<IConfig> {
static createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean) {
return new PercentageSizer(containerElement, vertical, reverse);
}
}

View file

@ -1,5 +1,4 @@
/* /*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. 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");
@ -15,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export FixedDistributor from "./distributors/fixed"; export {default as FixedDistributor} from "./distributors/fixed";
export CollapseDistributor from "./distributors/collapse"; export {default as PercentageDistributor} from "./distributors/percentage";
export Resizer from "./resizer"; export {default as CollapseDistributor} from "./distributors/collapse";
export {default as Resizer} from "./resizer";

View file

@ -1,107 +0,0 @@
/*
Copyright 2019 New Vector Ltd
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.
*/
export default class ResizeItem {
constructor(handle, resizer, sizer) {
const id = handle.getAttribute("data-id");
const reverse = resizer.isReverseResizeHandle(handle);
const domNode = reverse ? handle.nextElementSibling : handle.previousElementSibling;
this.domNode = domNode;
this.id = id;
this.reverse = reverse;
this.resizer = resizer;
this.sizer = sizer;
}
_copyWith(handle, resizer, sizer) {
const Ctor = this.constructor;
return new Ctor(handle, resizer, sizer);
}
_advance(forwards) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ?
this.domNode.previousElementSibling :
this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
if (moveNext) {
handle = handle.nextElementSibling;
} else {
handle = handle.previousElementSibling;
}
} while (handle && !this.resizer.isResizeHandle(handle));
if (handle) {
const nextHandle = this._copyWith(handle, this.resizer, this.sizer);
nextHandle.reverse = this.reverse;
return nextHandle;
}
}
next() {
return this._advance(true);
}
previous() {
return this._advance(false);
}
size() {
return this.sizer.getItemSize(this.domNode);
}
offset() {
return this.sizer.getItemOffset(this.domNode);
}
setSize(size) {
this.sizer.setItemSize(this.domNode, size);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
}
}
clearSize() {
this.sizer.clearItemSize(this.domNode);
const callback = this.resizer.config.onResized;
if (callback) {
callback(null, this.id, this.domNode);
}
}
first() {
const firstHandle = Array.from(this.domNode.parentElement.children).find(el => {
return this.resizer.isResizeHandle(el);
});
if (firstHandle) {
return this._copyWith(firstHandle, this.resizer, this.sizer);
}
}
last() {
const lastHandle = Array.from(this.domNode.parentElement.children).reverse().find(el => {
return this.resizer.isResizeHandle(el);
});
if (lastHandle) {
return this._copyWith(lastHandle, this.resizer, this.sizer);
}
}
}

125
src/resizer/item.ts Normal file
View file

@ -0,0 +1,125 @@
/*
Copyright 2019 - 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 Resizer, {IConfig} from "./resizer";
import Sizer from "./sizer";
export default class ResizeItem<C extends IConfig = IConfig> {
public readonly domNode: HTMLElement;
protected readonly id: string;
protected reverse: boolean;
constructor(
handle: HTMLElement,
public readonly resizer: Resizer<C>,
public readonly sizer: Sizer,
) {
this.reverse = resizer.isReverseResizeHandle(handle);
this.domNode = <HTMLElement>(this.reverse ? handle.nextElementSibling : handle.previousElementSibling);
this.id = handle.getAttribute("data-id");
}
private copyWith(handle: HTMLElement, resizer: Resizer, sizer: Sizer) {
const Ctor = this.constructor as typeof ResizeItem;
return new Ctor(handle, resizer, sizer);
}
private advance(forwards: boolean) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ? this.domNode.previousElementSibling : this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
if (moveNext) {
handle = handle.nextElementSibling;
} else {
handle = handle.previousElementSibling;
}
} while (handle && !this.resizer.isResizeHandle(<HTMLElement>handle));
if (handle) {
const nextHandle = this.copyWith(<HTMLElement>handle, this.resizer, this.sizer);
nextHandle.reverse = this.reverse;
return nextHandle;
}
}
public next() {
return this.advance(true);
}
public previous() {
return this.advance(false);
}
public size() {
return this.sizer.getItemSize(this.domNode);
}
public offset() {
return this.sizer.getItemOffset(this.domNode);
}
public start() {
this.sizer.start(this.domNode);
}
public finish() {
this.sizer.finish(this.domNode);
}
public getSize() {
return this.sizer.getDesiredItemSize(this.domNode);
}
public setRawSize(size: string) {
this.sizer.setItemSize(this.domNode, size);
}
public setSize(size: number) {
this.setRawSize(`${Math.round(size)}px`);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
}
}
public clearSize() {
this.sizer.clearItemSize(this.domNode);
const callback = this.resizer.config.onResized;
if (callback) {
callback(null, this.id, this.domNode);
}
}
public first() {
const firstHandle = Array.from(this.domNode.parentElement.children).find(el => {
return this.resizer.isResizeHandle(<HTMLElement>el);
});
if (firstHandle) {
return this.copyWith(<HTMLElement>firstHandle, this.resizer, this.sizer);
}
}
public last() {
const lastHandle = Array.from(this.domNode.parentElement.children).reverse().find(el => {
return this.resizer.isResizeHandle(<HTMLElement>el);
});
if (lastHandle) {
return this.copyWith(<HTMLElement>lastHandle, this.resizer, this.sizer);
}
}
}

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018 - 2020 The Matrix.org Foundation C.I.C.
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.
@ -15,86 +14,105 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
/* import {throttle} from "lodash";
classNames:
import FixedDistributor from "./distributors/fixed";
import ResizeItem from "./item";
import Sizer from "./sizer";
interface IClassNames {
// class on resize-handle // class on resize-handle
handle: string handle?: string;
// class on resize-handle // class on resize-handle
reverse: string reverse?: string;
// class on resize-handle // class on resize-handle
vertical: string vertical?: string;
// class on container // class on container
resizing: string resizing?: string;
*/ }
export interface IConfig {
onResizeStart?(): void;
onResizeStop?(): void;
onResized?(size: number, id: string, element: HTMLElement): void;
}
export default class Resizer<C extends IConfig = IConfig> {
private classNames: IClassNames;
export default class Resizer {
// TODO move vertical/horizontal to config option/container class // TODO move vertical/horizontal to config option/container class
// as it doesn't make sense to mix them within one container/Resizer // as it doesn't make sense to mix them within one container/Resizer
constructor(container, distributorCtor, config) { constructor(
public container: HTMLElement,
private readonly distributorCtor: {
new(item: ResizeItem): FixedDistributor<C, any>;
createItem(resizeHandle: HTMLDivElement, resizer: Resizer, sizer: Sizer): ResizeItem;
createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer;
},
public readonly config?: C,
) {
if (!container) { if (!container) {
throw new Error("Resizer requires a non-null `container` arg"); throw new Error("Resizer requires a non-null `container` arg");
} }
this.container = container;
this.distributorCtor = distributorCtor;
this.config = config;
this.classNames = { this.classNames = {
handle: "resizer-handle", handle: "resizer-handle",
reverse: "resizer-reverse", reverse: "resizer-reverse",
vertical: "resizer-vertical", vertical: "resizer-vertical",
resizing: "resizer-resizing", resizing: "resizer-resizing",
}; };
this._onMouseDown = this._onMouseDown.bind(this);
} }
setClassNames(classNames) { public setClassNames(classNames: IClassNames) {
this.classNames = classNames; this.classNames = classNames;
} }
attach() { public attach() {
this.container.addEventListener("mousedown", this._onMouseDown, false); this.container.addEventListener("mousedown", this.onMouseDown, false);
window.addEventListener("resize", this.onResize);
} }
detach() { public detach() {
this.container.removeEventListener("mousedown", this._onMouseDown, false); this.container.removeEventListener("mousedown", this.onMouseDown, false);
window.removeEventListener("resize", this.onResize);
} }
/** /**
Gives the distributor for a specific resize handle, as if you would have started Gives the distributor for a specific resize handle, as if you would have started
to drag that handle. Can be used to manipulate the size of an item programmatically. to drag that handle. Can be used to manipulate the size of an item programmatically.
@param {number} handleIndex the index of the resize handle in the container @param {number} handleIndex the index of the resize handle in the container
@return {Distributor} a new distributor for the given handle @return {FixedDistributor} a new distributor for the given handle
*/ */
forHandleAt(handleIndex) { public forHandleAt(handleIndex: number): FixedDistributor<C> {
const handles = this._getResizeHandles(); const handles = this.getResizeHandles();
const handle = handles[handleIndex]; const handle = handles[handleIndex];
if (handle) { if (handle) {
const {distributor} = this._createSizerAndDistributor(handle); const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor; return distributor;
} }
} }
forHandleWithId(id) { public forHandleWithId(id: string): FixedDistributor<C> {
const handles = this._getResizeHandles(); const handles = this.getResizeHandles();
const handle = handles.find((h) => h.getAttribute("data-id") === id); const handle = handles.find((h) => h.getAttribute("data-id") === id);
if (handle) { if (handle) {
const {distributor} = this._createSizerAndDistributor(handle); const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor; return distributor;
} }
} }
isReverseResizeHandle(el) { public isReverseResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.reverse); return el && el.classList.contains(this.classNames.reverse);
} }
isResizeHandle(el) { public isResizeHandle(el: HTMLElement): boolean {
return el && el.classList.contains(this.classNames.handle); return el && el.classList.contains(this.classNames.handle);
} }
_onMouseDown(event) { private onMouseDown = (event: MouseEvent) => {
// use closest in case the resize handle contains // use closest in case the resize handle contains
// child dom nodes that can be the target // child dom nodes that can be the target
const resizeHandle = event.target && event.target.closest(`.${this.classNames.handle}`); const resizeHandle = event.target && (<HTMLDivElement>event.target).closest(`.${this.classNames.handle}`);
if (!resizeHandle || resizeHandle.parentElement !== this.container) { if (!resizeHandle || resizeHandle.parentElement !== this.container) {
return; return;
} }
@ -109,7 +127,7 @@ export default class Resizer {
this.config.onResizeStart(); this.config.onResizeStart();
} }
const {sizer, distributor} = this._createSizerAndDistributor(resizeHandle); const {sizer, distributor} = this.createSizerAndDistributor(<HTMLDivElement>resizeHandle);
distributor.start(); distributor.start();
const onMouseMove = (event) => { const onMouseMove = (event) => {
@ -122,10 +140,10 @@ export default class Resizer {
if (this.classNames.resizing) { if (this.classNames.resizing) {
this.container.classList.remove(this.classNames.resizing); this.container.classList.remove(this.classNames.resizing);
} }
distributor.finish();
if (this.config.onResizeStop) { if (this.config.onResizeStop) {
this.config.onResizeStop(); this.config.onResizeStop();
} }
distributor.finish();
body.removeEventListener("mouseup", finishResize, false); body.removeEventListener("mouseup", finishResize, false);
document.removeEventListener("mouseleave", finishResize, false); document.removeEventListener("mouseleave", finishResize, false);
body.removeEventListener("mousemove", onMouseMove, false); body.removeEventListener("mousemove", onMouseMove, false);
@ -133,21 +151,39 @@ export default class Resizer {
body.addEventListener("mouseup", finishResize, false); body.addEventListener("mouseup", finishResize, false);
document.addEventListener("mouseleave", finishResize, false); document.addEventListener("mouseleave", finishResize, false);
body.addEventListener("mousemove", onMouseMove, false); body.addEventListener("mousemove", onMouseMove, false);
} };
_createSizerAndDistributor(resizeHandle) { private onResize = throttle(() => {
const distributors = this.getDistributors();
// relax all items if they had any overconstrained flexboxes
distributors.forEach(d => d.start());
distributors.forEach(d => d.finish());
}, 100, {trailing: true, leading: true});
public getDistributors = () => {
return this.getResizeHandles().map(handle => {
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
});
};
private createSizerAndDistributor(
resizeHandle: HTMLDivElement,
): {sizer: Sizer, distributor: FixedDistributor<any>} {
const vertical = resizeHandle.classList.contains(this.classNames.vertical); const vertical = resizeHandle.classList.contains(this.classNames.vertical);
const reverse = this.isReverseResizeHandle(resizeHandle); const reverse = this.isReverseResizeHandle(resizeHandle);
const Distributor = this.distributorCtor; const Distributor = this.distributorCtor;
const sizer = Distributor.createSizer(this.container, vertical, reverse); const sizer = Distributor.createSizer(this.container, vertical, reverse);
const item = Distributor.createItem(resizeHandle, this, sizer); const item = Distributor.createItem(resizeHandle, this, sizer);
const distributor = new Distributor(item, this.config); const distributor = new Distributor(item);
return {sizer, distributor}; return {sizer, distributor};
} }
_getResizeHandles() { private getResizeHandles() {
if (!this.container.children) return [];
return Array.from(this.container.children).filter(el => { return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(el); return this.isResizeHandle(<HTMLElement>el);
}); }) as HTMLElement[];
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018 - 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.
@ -19,18 +19,18 @@ implements DOM/CSS operations for resizing.
The sizer determines what CSS mechanism is used for sizing items, like flexbox, ... The sizer determines what CSS mechanism is used for sizing items, like flexbox, ...
*/ */
export default class Sizer { export default class Sizer {
constructor(container, vertical, reverse) { constructor(
this.container = container; protected readonly container: HTMLElement,
this.reverse = reverse; protected readonly vertical: boolean,
this.vertical = vertical; protected readonly reverse: boolean,
} ) {}
/** /**
@param {Element} item the dom element being resized @param {Element} item the dom element being resized
@return {number} how far the edge of the item is from the edge of the container @return {number} how far the edge of the item is from the edge of the container
*/ */
getItemOffset(item) { public getItemOffset(item: HTMLElement): number {
const offset = (this.vertical ? item.offsetTop : item.offsetLeft) - this._getOffset(); const offset = (this.vertical ? item.offsetTop : item.offsetLeft) - this.getOffset();
if (this.reverse) { if (this.reverse) {
return this.getTotalSize() - (offset + this.getItemSize(item)); return this.getTotalSize() - (offset + this.getItemSize(item));
} else { } else {
@ -42,41 +42,49 @@ export default class Sizer {
@param {Element} item the dom element being resized @param {Element} item the dom element being resized
@return {number} the width/height of an item in the container @return {number} the width/height of an item in the container
*/ */
getItemSize(item) { public getItemSize(item: HTMLElement): number {
return this.vertical ? item.offsetHeight : item.offsetWidth; return this.vertical ? item.offsetHeight : item.offsetWidth;
} }
/** @return {number} the width/height of the container */ /** @return {number} the width/height of the container */
getTotalSize() { public getTotalSize(): number {
return this.vertical ? this.container.offsetHeight : this.container.offsetWidth; return this.vertical ? this.container.offsetHeight : this.container.offsetWidth;
} }
/** @return {number} container offset to offsetParent */ /** @return {number} container offset to offsetParent */
_getOffset() { private getOffset(): number {
return this.vertical ? this.container.offsetTop : this.container.offsetLeft; return this.vertical ? this.container.offsetTop : this.container.offsetLeft;
} }
/** @return {number} container offset to document */ /** @return {number} container offset to document */
_getPageOffset() { private getPageOffset(): number {
let element = this.container; let element = this.container;
let offset = 0; let offset = 0;
while (element) { while (element) {
const pos = this.vertical ? element.offsetTop : element.offsetLeft; const pos = this.vertical ? element.offsetTop : element.offsetLeft;
offset = offset + pos; offset = offset + pos;
element = element.offsetParent; element = <HTMLElement>element.offsetParent;
} }
return offset; return offset;
} }
setItemSize(item, size) { public getDesiredItemSize(item: HTMLElement) {
if (this.vertical) { if (this.vertical) {
item.style.height = `${Math.round(size)}px`; return item.style.height;
} else { } else {
item.style.width = `${Math.round(size)}px`; return item.style.width;
} }
} }
clearItemSize(item) { public setItemSize(item: HTMLElement, size: string) {
if (this.vertical) {
item.style.height = size;
} else {
item.style.width = size;
}
}
public clearItemSize(item: HTMLElement) {
if (this.vertical) { if (this.vertical) {
item.style.height = null; item.style.height = null;
} else { } else {
@ -84,17 +92,21 @@ export default class Sizer {
} }
} }
public start(item: HTMLElement) {}
public finish(item: HTMLElement) {}
/** /**
@param {MouseEvent} event the mouse event @param {MouseEvent} event the mouse event
@return {number} the distance between the cursor and the edge of the container, @return {number} the distance between the cursor and the edge of the container,
along the applicable axis (vertical or horizontal) along the applicable axis (vertical or horizontal)
*/ */
offsetFromEvent(event) { public offsetFromEvent(event: MouseEvent) {
const pos = this.vertical ? event.pageY : event.pageX; const pos = this.vertical ? event.pageY : event.pageX;
if (this.reverse) { if (this.reverse) {
return (this._getPageOffset() + this.getTotalSize()) - pos; return (this.getPageOffset() + this.getTotalSize()) - pos;
} else { } else {
return pos - this._getPageOffset(); return pos - this.getPageOffset();
} }
} }
} }

View file

@ -626,6 +626,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT, supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: {}, default: {},
}, },
"Widgets.leftPanel": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null,
},
[UIFeature.AdvancedEncryption]: { [UIFeature.AdvancedEncryption]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,

View file

@ -55,7 +55,7 @@ class WidgetEchoStore extends EventEmitter {
const widgetId = w.getStateKey(); const widgetId = w.getStateKey();
// If there's no echo, or the echo still has a widget present, show the *old* widget // If there's no echo, or the echo still has a widget present, show the *old* widget
// we don't include widgets that have changed for the same reason we don't include new ones, // we don't include widgets that have changed for the same reason we don't include new ones,
// ie. we'd need to fake matrix events to do so and therte's currently no need. // ie. we'd need to fake matrix events to do so and there's currently no need.
if (!roomEchoState[widgetId] || Object.keys(roomEchoState[widgetId]).length !== 0) { if (!roomEchoState[widgetId] || Object.keys(roomEchoState[widgetId]).length !== 0) {
echoedWidgets.push(w); echoedWidgets.push(w);
} }

View file

@ -16,12 +16,14 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IWidget } from "matrix-widget-api";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import WidgetEchoStore from "../stores/WidgetEchoStore"; import WidgetEchoStore from "../stores/WidgetEchoStore";
import RoomViewStore from "../stores/RoomViewStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import {SettingLevel} from "../settings/SettingLevel"; import {SettingLevel} from "../settings/SettingLevel";
@ -30,13 +32,9 @@ import {UPDATE_EVENT} from "./AsyncStore";
interface IState {} interface IState {}
export interface IApp { export interface IApp extends IWidget {
id: string;
type: string;
roomId: string; roomId: string;
eventId: string; eventId: string;
creatorUserId: string;
waitForIframeLoad?: boolean;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
} }
@ -46,6 +44,8 @@ interface IRoomWidgets {
pinned: Record<string, boolean>; pinned: Record<string, boolean>;
} }
export const MAX_PINNED = 3;
// TODO consolidate WidgetEchoStore into this // TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this // TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> { export default class WidgetStore extends AsyncStoreWithClient<IState> {
@ -68,7 +68,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private initRoom(roomId: string) { private initRoom(roomId: string) {
if (!this.roomMap.has(roomId)) { if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, { this.roomMap.set(roomId, {
pinned: {}, pinned: {}, // ordered
widgets: [], widgets: [],
}); });
} }
@ -122,6 +122,15 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
if (!room) return; if (!room) return;
const roomInfo = this.roomMap.get(room.roomId); const roomInfo = this.roomMap.get(room.roomId);
roomInfo.widgets = []; roomInfo.widgets = [];
// first clean out old widgets from the map which originate from this room
// otherwise we are out of sync with the rest of the app with stale widget events during removal
Array.from(this.widgetMap.values()).forEach(app => {
if (app.roomId === room.roomId) {
this.widgetMap.delete(app.id);
}
});
this.generateApps(room).forEach(app => { this.generateApps(room).forEach(app => {
this.widgetMap.set(app.id, app); this.widgetMap.set(app.id, app);
roomInfo.widgets.push(app); roomInfo.widgets.push(app);
@ -156,27 +165,34 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
public isPinned(widgetId: string) { public isPinned(widgetId: string) {
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); return !!this.getPinnedApps(roomId).find(w => w.id === widgetId);
let pinned = roomInfo && roomInfo.pinned[widgetId];
// Jitsi widgets should be pinned by default
const widget = this.widgetMap.get(widgetId);
if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true;
return pinned;
} }
public canPin(widgetId: string) { public canPin(widgetId: string) {
// only allow pinning up to a max of two as we do not yet have grid splits
// the only case it will go to three is if you have two and then a Jitsi gets added
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); return this.getPinnedApps(roomId).length < MAX_PINNED;
return roomInfo && Object.keys(roomInfo.pinned).filter(k => {
return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k);
}).length < 2;
} }
public pinWidget(widgetId: string) { public pinWidget(widgetId: string) {
const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return;
// When pinning, first confirm all the widgets (Jitsi) which were autopinned so that the order is correct
const autoPinned = this.getPinnedApps(roomId).filter(app => !roomInfo.pinned[app.id]);
autoPinned.forEach(app => {
this.setPinned(app.id, true);
});
this.setPinned(widgetId, true); this.setPinned(widgetId, true);
// Show the apps drawer upon the user pinning a widget
if (RoomViewStore.getRoomId() === this.getRoomId(widgetId)) {
defaultDispatcher.dispatch({
action: "appsDrawer",
show: true,
})
}
} }
public unpinWidget(widgetId: string) { public unpinWidget(widgetId: string) {
@ -187,6 +203,10 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); const roomInfo = this.getRoom(roomId);
if (!roomInfo) return; if (!roomInfo) return;
if (roomInfo.pinned[widgetId] === false && value) {
// delete this before write to maintain the correct object insertion order
delete roomInfo.pinned[widgetId];
}
roomInfo.pinned[widgetId] = value; roomInfo.pinned[widgetId] = value;
// Clean up the pinned record // Clean up the pinned record
@ -201,13 +221,61 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.emit(UPDATE_EVENT); this.emit(UPDATE_EVENT);
} }
public getApps(room: Room, pinned?: boolean): IApp[] { public movePinnedWidget(widgetId: string, delta: 1 | -1) {
const roomInfo = this.getRoom(room.roomId); // TODO simplify this by changing the storage medium of pinned to an array once the Jitsi default-on goes away
if (!roomInfo) return []; const roomId = this.getRoomId(widgetId);
if (pinned) { const roomInfo = this.getRoom(roomId);
return roomInfo.widgets.filter(app => this.isPinned(app.id)); if (!roomInfo || roomInfo.pinned[widgetId] === false) return;
const pinnedApps = this.getPinnedApps(roomId).map(app => app.id);
const i = pinnedApps.findIndex(id => id === widgetId);
if (delta > 0) {
pinnedApps.splice(i, 2, pinnedApps[i + 1], pinnedApps[i]);
} else {
pinnedApps.splice(i - 1, 2, pinnedApps[i], pinnedApps[i - 1]);
} }
return roomInfo.widgets;
const reorderedPinned: IRoomWidgets["pinned"] = {};
pinnedApps.forEach(id => {
reorderedPinned[id] = true;
});
Object.keys(roomInfo.pinned).forEach(id => {
if (reorderedPinned[id] === undefined) {
reorderedPinned[id] = roomInfo.pinned[id];
}
});
roomInfo.pinned = reorderedPinned;
SettingsStore.setValue("Widgets.pinned", roomId, SettingLevel.ROOM_ACCOUNT, roomInfo.pinned);
this.emit(roomId);
this.emit(UPDATE_EVENT);
}
public getPinnedApps(roomId: string): IApp[] {
// returns the apps in the order they were pinned with, up to the maximum
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return [];
// Show Jitsi widgets even if the user already had the maximum pinned, instead of their latest pinned,
// except if the user already explicitly unpinned the Jitsi widget
const priorityWidget = roomInfo.widgets.find(widget => {
return roomInfo.pinned[widget.id] === undefined && WidgetType.JITSI.matches(widget.type);
});
const order = Object.keys(roomInfo.pinned).filter(k => roomInfo.pinned[k]);
let apps = order.map(wId => this.widgetMap.get(wId)).filter(Boolean);
apps = apps.slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED);
if (priorityWidget) {
apps.push(priorityWidget);
}
return apps;
}
public getApps(roomId: string): IApp[] {
const roomInfo = this.getRoom(roomId);
return roomInfo?.widgets || [];
} }
public doesRoomHaveConference(room: Room): boolean { public doesRoomHaveConference(room: Room): boolean {

View file

@ -76,7 +76,7 @@ class ElementWidget extends Widget {
if (WidgetType.JITSI.matches(this.type)) { if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({ return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: true, forLocalRender: true,
auth: super.rawData?.auth, // this.rawData can call templateUrl, do this to prevent looping auth: super.rawData?.auth as string, // this.rawData can call templateUrl, do this to prevent looping
}); });
} }
return super.templateUrl; return super.templateUrl;
@ -86,7 +86,7 @@ class ElementWidget extends Widget {
if (WidgetType.JITSI.matches(this.type)) { if (WidgetType.JITSI.matches(this.type)) {
return WidgetUtils.getLocalJitsiWrapperUrl({ return WidgetUtils.getLocalJitsiWrapperUrl({
forLocalRender: false, // The only important difference between this and templateUrl() forLocalRender: false, // The only important difference between this and templateUrl()
auth: super.rawData?.auth, auth: super.rawData?.auth as string,
}); });
} }
return this.templateUrl; // use this instead of super to ensure we get appropriate templating return this.templateUrl; // use this instead of super to ensure we get appropriate templating

View file

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

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Travis Ralston Copyright 2019 Travis Ralston
Copyright 2017 - 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.
@ -16,15 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as url from "url";
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
import dis from '../dispatcher/dispatcher'; import dis from '../dispatcher/dispatcher';
import * as url from "url";
import WidgetEchoStore from '../stores/WidgetEchoStore'; import WidgetEchoStore from '../stores/WidgetEchoStore';
// How long we wait for the state event echo to come back from the server
// before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000;
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {IntegrationManagers} from "../integrations/IntegrationManagers";
@ -32,7 +28,21 @@ import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
import {objectClone} from "./objects"; import {objectClone} from "./objects";
import {_t} from "../languageHandler"; import {_t} from "../languageHandler";
import {MatrixCapabilities} from "matrix-widget-api"; import {Capability, IWidgetData, MatrixCapabilities} from "matrix-widget-api";
import {IApp} from "../stores/WidgetStore"; // TODO @@
// How long we wait for the state event echo to come back from the server
// before waitFor[Room/User]Widget rejects its promise
const WIDGET_WAIT_TIME = 20000;
export interface IWidgetEvent {
id: string;
type: string;
sender: string;
// eslint-disable-next-line camelcase
state_key: string;
content: Partial<IApp>;
}
export default class WidgetUtils { export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room /* Returns true if user is able to send state events to modify widgets in this room
@ -41,7 +51,7 @@ export default class WidgetUtils {
* @return Boolean -- true if the user can modify widgets in this room * @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason * @throws Error -- specifies the error reason
*/ */
static canUserModifyWidgets(roomId) { static canUserModifyWidgets(roomId: string): boolean {
if (!roomId) { if (!roomId) {
console.warn('No room ID specified'); console.warn('No room ID specified');
return false; return false;
@ -80,7 +90,7 @@ export default class WidgetUtils {
* @param {[type]} testUrlString URL to check * @param {[type]} testUrlString URL to check
* @return {Boolean} True if specified URL is a scalar URL * @return {Boolean} True if specified URL is a scalar URL
*/ */
static isScalarUrl(testUrlString) { static isScalarUrl(testUrlString: string): boolean {
if (!testUrlString) { if (!testUrlString) {
console.error('Scalar URL check failed. No URL specified'); console.error('Scalar URL check failed. No URL specified');
return false; return false;
@ -123,7 +133,7 @@ export default class WidgetUtils {
* @returns {Promise} that resolves when the widget is in the * @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param * requested state according to the `add` param
*/ */
static waitForUserWidget(widgetId, add) { static waitForUserWidget(widgetId: string, add: boolean): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Tests an account data event, returning true if it's in the state // Tests an account data event, returning true if it's in the state
// we're waiting for it to be in // we're waiting for it to be in
@ -170,7 +180,7 @@ export default class WidgetUtils {
* @returns {Promise} that resolves when the widget is in the * @returns {Promise} that resolves when the widget is in the
* requested state according to the `add` param * requested state according to the `add` param
*/ */
static waitForRoomWidget(widgetId, roomId, add) { static waitForRoomWidget(widgetId: string, roomId: string, add: boolean): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Tests a list of state events, returning true if it's in the state // Tests a list of state events, returning true if it's in the state
// we're waiting for it to be in // we're waiting for it to be in
@ -213,7 +223,13 @@ export default class WidgetUtils {
}); });
} }
static setUserWidget(widgetId, widgetType: WidgetType, widgetUrl, widgetName, widgetData) { static setUserWidget(
widgetId: string,
widgetType: WidgetType,
widgetUrl: string,
widgetName: string,
widgetData: IWidgetData,
) {
const content = { const content = {
type: widgetType.preferred, type: widgetType.preferred,
url: widgetUrl, url: widgetUrl,
@ -257,7 +273,14 @@ export default class WidgetUtils {
}); });
} }
static setRoomWidget(roomId, widgetId, widgetType: WidgetType, widgetUrl, widgetName, widgetData) { static setRoomWidget(
roomId: string,
widgetId: string,
widgetType?: WidgetType,
widgetUrl?: string,
widgetName?: string,
widgetData?: object,
) {
let content; let content;
const addingWidget = Boolean(widgetUrl); const addingWidget = Boolean(widgetUrl);
@ -307,7 +330,7 @@ export default class WidgetUtils {
* Get user specific widgets (not linked to a specific room) * Get user specific widgets (not linked to a specific room)
* @return {object} Event content object containing current / active user widgets * @return {object} Event content object containing current / active user widgets
*/ */
static getUserWidgets() { static getUserWidgets(): Record<string, IWidgetEvent> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
throw new Error('User not logged in'); throw new Error('User not logged in');
@ -323,7 +346,7 @@ export default class WidgetUtils {
* Get user specific widgets (not linked to a specific room) as an array * Get user specific widgets (not linked to a specific room) as an array
* @return {[object]} Array containing current / active user widgets * @return {[object]} Array containing current / active user widgets
*/ */
static getUserWidgetsArray() { static getUserWidgetsArray(): IWidgetEvent[] {
return Object.values(WidgetUtils.getUserWidgets()); return Object.values(WidgetUtils.getUserWidgets());
} }
@ -331,7 +354,7 @@ export default class WidgetUtils {
* Get active stickerpicker widgets (stickerpickers are user widgets by nature) * Get active stickerpicker widgets (stickerpickers are user widgets by nature)
* @return {[object]} Array containing current / active stickerpicker widgets * @return {[object]} Array containing current / active stickerpicker widgets
*/ */
static getStickerpickerWidgets() { static getStickerpickerWidgets(): IWidgetEvent[] {
const widgets = WidgetUtils.getUserWidgetsArray(); const widgets = WidgetUtils.getUserWidgetsArray();
return widgets.filter((widget) => widget.content && widget.content.type === "m.stickerpicker"); return widgets.filter((widget) => widget.content && widget.content.type === "m.stickerpicker");
} }
@ -340,12 +363,12 @@ export default class WidgetUtils {
* Get all integration manager widgets for this user. * Get all integration manager widgets for this user.
* @returns {Object[]} An array of integration manager user widgets. * @returns {Object[]} An array of integration manager user widgets.
*/ */
static getIntegrationManagerWidgets() { static getIntegrationManagerWidgets(): IWidgetEvent[] {
const widgets = WidgetUtils.getUserWidgetsArray(); const widgets = WidgetUtils.getUserWidgetsArray();
return widgets.filter(w => w.content && w.content.type === "m.integration_manager"); return widgets.filter(w => w.content && w.content.type === "m.integration_manager");
} }
static getRoomWidgetsOfType(room: Room, type: WidgetType) { static getRoomWidgetsOfType(room: Room, type: WidgetType): IWidgetEvent[] {
const widgets = WidgetUtils.getRoomWidgets(room); const widgets = WidgetUtils.getRoomWidgets(room);
return (widgets || []).filter(w => { return (widgets || []).filter(w => {
const content = w.getContent(); const content = w.getContent();
@ -353,14 +376,14 @@ export default class WidgetUtils {
}); });
} }
static removeIntegrationManagerWidgets() { static removeIntegrationManagerWidgets(): Promise<void> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
throw new Error('User not logged in'); throw new Error('User not logged in');
} }
const widgets = client.getAccountData('m.widgets'); const widgets = client.getAccountData('m.widgets');
if (!widgets) return; if (!widgets) return;
const userWidgets = widgets.getContent() || {}; const userWidgets: IWidgetEvent[] = widgets.getContent() || {};
Object.entries(userWidgets).forEach(([key, widget]) => { Object.entries(userWidgets).forEach(([key, widget]) => {
if (widget.content && widget.content.type === "m.integration_manager") { if (widget.content && widget.content.type === "m.integration_manager") {
delete userWidgets[key]; delete userWidgets[key];
@ -369,7 +392,7 @@ export default class WidgetUtils {
return client.setAccountData('m.widgets', userWidgets); return client.setAccountData('m.widgets', userWidgets);
} }
static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string) { static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string): Promise<void> {
return WidgetUtils.setUserWidget( return WidgetUtils.setUserWidget(
"integration_manager_" + (new Date().getTime()), "integration_manager_" + (new Date().getTime()),
WidgetType.INTEGRATION_MANAGER, WidgetType.INTEGRATION_MANAGER,
@ -383,14 +406,14 @@ export default class WidgetUtils {
* Remove all stickerpicker widgets (stickerpickers are user widgets by nature) * Remove all stickerpicker widgets (stickerpickers are user widgets by nature)
* @return {Promise} Resolves on account data updated * @return {Promise} Resolves on account data updated
*/ */
static removeStickerpickerWidgets() { static removeStickerpickerWidgets(): Promise<void> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
throw new Error('User not logged in'); throw new Error('User not logged in');
} }
const widgets = client.getAccountData('m.widgets'); const widgets = client.getAccountData('m.widgets');
if (!widgets) return; if (!widgets) return;
const userWidgets = widgets.getContent() || {}; const userWidgets: Record<string, IWidgetEvent> = widgets.getContent() || {};
Object.entries(userWidgets).forEach(([key, widget]) => { Object.entries(userWidgets).forEach(([key, widget]) => {
if (widget.content && widget.content.type === 'm.stickerpicker') { if (widget.content && widget.content.type === 'm.stickerpicker') {
delete userWidgets[key]; delete userWidgets[key];
@ -399,7 +422,13 @@ export default class WidgetUtils {
return client.setAccountData('m.widgets', userWidgets); return client.setAccountData('m.widgets', userWidgets);
} }
static makeAppConfig(appId, app, senderUserId, roomId, eventId) { static makeAppConfig(
appId: string,
app: Partial<IApp>,
senderUserId: string,
roomId: string | null,
eventId: string,
): IApp {
if (!senderUserId) { if (!senderUserId) {
throw new Error("Widgets must be created by someone - provide a senderUserId"); throw new Error("Widgets must be created by someone - provide a senderUserId");
} }
@ -410,10 +439,10 @@ export default class WidgetUtils {
app.eventId = eventId; app.eventId = eventId;
app.name = app.name || app.type; app.name = app.name || app.type;
return app; return app as IApp;
} }
static getCapWhitelistForAppTypeInRoomId(appType, roomId) { static getCapWhitelistForAppTypeInRoomId(appType: string, roomId: string): Capability[] {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId); const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : []; const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
@ -428,7 +457,7 @@ export default class WidgetUtils {
return capWhitelist; return capWhitelist;
} }
static getWidgetSecurityKey(widgetId, widgetUrl, isUserWidget) { static getWidgetSecurityKey(widgetId: string, widgetUrl: string, isUserWidget: boolean): string {
let widgetLocation = ActiveWidgetStore.getRoomId(widgetId); let widgetLocation = ActiveWidgetStore.getRoomId(widgetId);
if (isUserWidget) { if (isUserWidget) {
@ -449,7 +478,7 @@ export default class WidgetUtils {
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
} }
static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string}={}) { static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean, auth?: string} = {}) {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there // NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryStringParts = [ const queryStringParts = [
'conferenceDomain=$domain', 'conferenceDomain=$domain',
@ -459,13 +488,14 @@ export default class WidgetUtils {
'avatarUrl=$matrix_avatar_url', 'avatarUrl=$matrix_avatar_url',
'userId=$matrix_user_id', 'userId=$matrix_user_id',
'roomId=$matrix_room_id', 'roomId=$matrix_room_id',
'theme=$theme',
]; ];
if (opts.auth) { if (opts.auth) {
queryStringParts.push(`auth=${opts.auth}`); queryStringParts.push(`auth=${opts.auth}`);
} }
const queryString = queryStringParts.join('&'); const queryString = queryStringParts.join('&');
let baseUrl = window.location; let baseUrl = window.location.href;
if (window.location.protocol !== "https:" && !opts.forLocalRender) { if (window.location.protocol !== "https:" && !opts.forLocalRender) {
// Use an external wrapper if we're not locally rendering the widget. This is usually // Use an external wrapper if we're not locally rendering the widget. This is usually
// the URL that will end up in the widget event, so we want to make sure it's relatively // the URL that will end up in the widget event, so we want to make sure it's relatively
@ -478,15 +508,15 @@ export default class WidgetUtils {
return url.href; return url.href;
} }
static getWidgetName(app) { static getWidgetName(app?: IApp): string {
return app?.name?.trim() || _t("Unknown App"); return app?.name?.trim() || _t("Unknown App");
} }
static getWidgetDataTitle(app) { static getWidgetDataTitle(app?: IApp): string {
return app?.data?.title?.trim() || ""; return app?.data?.title?.trim() || "";
} }
static editWidget(room, app) { static editWidget(room: Room, app: IApp): void {
// TODO: Open the right manager for the widget // TODO: Open the right manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) { if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id); IntegrationManagers.sharedInstance().openAll(room, 'type_' + app.type, app.id);
@ -494,4 +524,16 @@ export default class WidgetUtils {
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id); IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
} }
} }
static isManagedByManager(app) {
if (WidgetUtils.isScalarUrl(app.url)) {
const managers = IntegrationManagers.sharedInstance();
if (managers.hasManager()) {
// TODO: Pick the right manager for the widget
const defaultManager = managers.getPrimaryManager();
return WidgetUtils.isScalarUrl(defaultManager.apiUrl);
}
}
return false;
}
} }

View file

@ -6507,7 +6507,7 @@ mathml-tag-names@^2.0.1:
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "8.5.0" version "8.5.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d8c4101fdd521e189f4755c6f02a8971b991ef5f" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/9f713781cdfea2349115ffaac2d665e8b07fd5dc"
dependencies: dependencies:
"@babel/runtime" "^7.11.2" "@babel/runtime" "^7.11.2"
another-json "^0.2.0" another-json "^0.2.0"