mirror of https://github.com/vector-im/riot-web
Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/radio-buttons
commit
0f685eb0e9
|
@ -94,6 +94,7 @@
|
|||
"react-dom": "^16.9.0",
|
||||
"react-focus-lock": "^2.2.1",
|
||||
"react-resizable": "^1.10.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"resize-observer-polyfill": "^1.5.0",
|
||||
"sanitize-html": "^1.18.4",
|
||||
"text-encoding-utf-8": "^1.0.1",
|
||||
|
@ -126,6 +127,7 @@
|
|||
"@types/qrcode": "^1.3.4",
|
||||
"@types/react": "^16.9",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/zxcvbn": "^4.4.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
|
|
|
@ -19,7 +19,7 @@ limitations under the License.
|
|||
@import "./_font-sizes.scss";
|
||||
|
||||
:root {
|
||||
font-size: 15px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
html {
|
||||
|
@ -581,3 +581,118 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
|
|||
// So it fits in the space provided by the page
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
// A context menu that largely fits the | [icon] [label] | format.
|
||||
.mx_IconizedContextMenu {
|
||||
// Put 20px of padding around the whole menu. We do this instead of a
|
||||
// simple `padding: 20px` rule so the horizontal rules added by the
|
||||
// optionLists is rendered correctly (full width).
|
||||
> * {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_IconizedContextMenu_optionList {
|
||||
// the notFirst class is for cases where the optionList might be under a header of sorts.
|
||||
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
|
||||
margin-top: 20px;
|
||||
|
||||
// This is a bit of a hack when we could just use a simple border-top property,
|
||||
// however we have a (kinda) good reason for doing it this way: we need opacity.
|
||||
// To get the right color, we need an opacity modifier which means we have to work
|
||||
// around the problem. PostCSS doesn't support the opacity() function, and if we
|
||||
// use something like postcss-functions we quickly run into an issue where the
|
||||
// function we would define gets passed a CSS variable for custom themes, which
|
||||
// can't be converted easily even when considering https://stackoverflow.com/a/41265350/7037379
|
||||
//
|
||||
// Therefore, we just hack in a line and border the thing ourselves
|
||||
&::before {
|
||||
border-top: 1px solid $primary-fg-color;
|
||||
opacity: 0.1;
|
||||
content: '';
|
||||
|
||||
// Counteract the padding problems (width: 100% ignores the 40px padding,
|
||||
// unless we position it absolutely then it does the right thing).
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 20px 0 0;
|
||||
|
||||
.mx_AccessibleButton {
|
||||
text-decoration: none;
|
||||
color: $primary-fg-color;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
|
||||
// Create a flexbox to more easily define the list items
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img, .mx_IconizedContextMenu_icon { // icons
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
max-width: 16px;
|
||||
}
|
||||
|
||||
span:last-child { // labels
|
||||
padding-left: 14px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
|
||||
// Ellipsize any text overflow
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_IconizedContextMenu_compact {
|
||||
> * {
|
||||
padding-left: 11px;
|
||||
padding-right: 16px;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_IconizedContextMenu_optionList {
|
||||
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
|
||||
margin-top: 10px;
|
||||
|
||||
li:first-child {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
li:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,12 +12,14 @@
|
|||
@import "./structures/_HeaderButtons.scss";
|
||||
@import "./structures/_HomePage.scss";
|
||||
@import "./structures/_LeftPanel.scss";
|
||||
@import "./structures/_LeftPanel2.scss";
|
||||
@import "./structures/_MainSplit.scss";
|
||||
@import "./structures/_MatrixChat.scss";
|
||||
@import "./structures/_MyGroups.scss";
|
||||
@import "./structures/_NotificationPanel.scss";
|
||||
@import "./structures/_RightPanel.scss";
|
||||
@import "./structures/_RoomDirectory.scss";
|
||||
@import "./structures/_RoomSearch.scss";
|
||||
@import "./structures/_RoomStatusBar.scss";
|
||||
@import "./structures/_RoomSubList.scss";
|
||||
@import "./structures/_RoomView.scss";
|
||||
|
@ -28,6 +30,7 @@
|
|||
@import "./structures/_ToastContainer.scss";
|
||||
@import "./structures/_TopLeftMenuButton.scss";
|
||||
@import "./structures/_UploadBar.scss";
|
||||
@import "./structures/_UserMenuButton.scss";
|
||||
@import "./structures/_ViewSource.scss";
|
||||
@import "./structures/auth/_CompleteSecurity.scss";
|
||||
@import "./structures/auth/_Login.scss";
|
||||
|
@ -170,18 +173,22 @@
|
|||
@import "./views/rooms/_MemberList.scss";
|
||||
@import "./views/rooms/_MessageComposer.scss";
|
||||
@import "./views/rooms/_MessageComposerFormatBar.scss";
|
||||
@import "./views/rooms/_NotificationBadge.scss";
|
||||
@import "./views/rooms/_PinnedEventTile.scss";
|
||||
@import "./views/rooms/_PinnedEventsPanel.scss";
|
||||
@import "./views/rooms/_PresenceLabel.scss";
|
||||
@import "./views/rooms/_ReplyPreview.scss";
|
||||
@import "./views/rooms/_RoomBreadcrumbs.scss";
|
||||
@import "./views/rooms/_RoomBreadcrumbs2.scss";
|
||||
@import "./views/rooms/_RoomDropTarget.scss";
|
||||
@import "./views/rooms/_RoomHeader.scss";
|
||||
@import "./views/rooms/_RoomList.scss";
|
||||
@import "./views/rooms/_RoomList2.scss";
|
||||
@import "./views/rooms/_RoomPreviewBar.scss";
|
||||
@import "./views/rooms/_RoomRecoveryReminder.scss";
|
||||
@import "./views/rooms/_RoomSublist2.scss";
|
||||
@import "./views/rooms/_RoomTile.scss";
|
||||
@import "./views/rooms/_RoomTile2.scss";
|
||||
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
||||
@import "./views/rooms/_SearchBar.scss";
|
||||
@import "./views/rooms/_SendMessageComposer.scss";
|
||||
|
|
|
@ -14,59 +14,59 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
$font-1px: 0.067rem;
|
||||
$font-1-5px: 0.100rem;
|
||||
$font-2px: 0.133rem;
|
||||
$font-3px: 0.200rem;
|
||||
$font-4px: 0.267rem;
|
||||
$font-5px: 0.333rem;
|
||||
$font-6px: 0.400rem;
|
||||
$font-7px: 0.467rem;
|
||||
$font-8px: 0.533rem;
|
||||
$font-9px: 0.600rem;
|
||||
$font-10px: 0.667rem;
|
||||
$font-10-4px: 0.693rem;
|
||||
$font-11px: 0.733rem;
|
||||
$font-12px: 0.800rem;
|
||||
$font-13px: 0.867rem;
|
||||
$font-14px: 0.933rem;
|
||||
$font-15px: 1.000rem;
|
||||
$font-16px: 1.067rem;
|
||||
$font-17px: 1.133rem;
|
||||
$font-18px: 1.200rem;
|
||||
$font-19px: 1.267rem;
|
||||
$font-20px: 1.3333333rem;
|
||||
$font-21px: 1.400rem;
|
||||
$font-22px: 1.467rem;
|
||||
$font-23px: 1.533rem;
|
||||
$font-24px: 1.600rem;
|
||||
$font-25px: 1.667rem;
|
||||
$font-26px: 1.733rem;
|
||||
$font-27px: 1.800rem;
|
||||
$font-28px: 1.867rem;
|
||||
$font-29px: 1.933rem;
|
||||
$font-30px: 2.000rem;
|
||||
$font-31px: 2.067rem;
|
||||
$font-32px: 2.133rem;
|
||||
$font-33px: 2.200rem;
|
||||
$font-34px: 2.267rem;
|
||||
$font-35px: 2.333rem;
|
||||
$font-36px: 2.400rem;
|
||||
$font-37px: 2.467rem;
|
||||
$font-38px: 2.533rem;
|
||||
$font-39px: 2.600rem;
|
||||
$font-40px: 2.667rem;
|
||||
$font-41px: 2.733rem;
|
||||
$font-42px: 2.800rem;
|
||||
$font-43px: 2.867rem;
|
||||
$font-44px: 2.933rem;
|
||||
$font-45px: 3.000rem;
|
||||
$font-46px: 3.067rem;
|
||||
$font-47px: 3.133rem;
|
||||
$font-48px: 3.200rem;
|
||||
$font-49px: 3.267rem;
|
||||
$font-50px: 3.333rem;
|
||||
$font-51px: 3.400rem;
|
||||
$font-52px: 3.467rem;
|
||||
$font-88px: 5.887rem;
|
||||
$font-400px: 26.667rem;
|
||||
$font-1px: 0.1rem;
|
||||
$font-1-5px: 0.15rem;
|
||||
$font-2px: 0.2rem;
|
||||
$font-3px: 0.3rem;
|
||||
$font-4px: 0.4rem;
|
||||
$font-5px: 0.5rem;
|
||||
$font-6px: 0.6rem;
|
||||
$font-7px: 0.7rem;
|
||||
$font-8px: 0.8rem;
|
||||
$font-9px: 0.9rem;
|
||||
$font-10px: 1.0rem;
|
||||
$font-10-4px: 1.04rem;
|
||||
$font-11px: 1.1rem;
|
||||
$font-12px: 1.2rem;
|
||||
$font-13px: 1.3rem;
|
||||
$font-14px: 1.4rem;
|
||||
$font-15px: 1.5rem;
|
||||
$font-16px: 1.6rem;
|
||||
$font-17px: 1.7rem;
|
||||
$font-18px: 1.8rem;
|
||||
$font-19px: 1.9rem;
|
||||
$font-20px: 2.0rem;
|
||||
$font-21px: 2.1rem;
|
||||
$font-22px: 2.2rem;
|
||||
$font-23px: 2.3rem;
|
||||
$font-24px: 2.4rem;
|
||||
$font-25px: 2.5rem;
|
||||
$font-26px: 2.6rem;
|
||||
$font-27px: 2.7rem;
|
||||
$font-28px: 2.8rem;
|
||||
$font-29px: 2.9rem;
|
||||
$font-30px: 3.0rem;
|
||||
$font-31px: 3.1rem;
|
||||
$font-32px: 3.2rem;
|
||||
$font-33px: 3.3rem;
|
||||
$font-34px: 3.4rem;
|
||||
$font-35px: 3.5rem;
|
||||
$font-36px: 3.6rem;
|
||||
$font-37px: 3.7rem;
|
||||
$font-38px: 3.8rem;
|
||||
$font-39px: 3.9rem;
|
||||
$font-40px: 4.0rem;
|
||||
$font-41px: 4.1rem;
|
||||
$font-42px: 4.2rem;
|
||||
$font-43px: 4.3rem;
|
||||
$font-44px: 4.4rem;
|
||||
$font-45px: 4.5rem;
|
||||
$font-46px: 4.6rem;
|
||||
$font-47px: 4.7rem;
|
||||
$font-48px: 4.8rem;
|
||||
$font-49px: 4.9rem;
|
||||
$font-50px: 5.0rem;
|
||||
$font-51px: 5.1rem;
|
||||
$font-52px: 5.2rem;
|
||||
$font-88px: 8.8rem;
|
||||
$font-400px: 40rem;
|
||||
|
|
|
@ -23,14 +23,6 @@ limitations under the License.
|
|||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
// TODO: Remove temporary indicator of new room list implementation.
|
||||
// This border is meant to visually distinguish between the two components when the
|
||||
// user has turned on the new room list implementation, at least until the designs
|
||||
// themselves give it away.
|
||||
.mx_LeftPanel2 .mx_LeftPanel {
|
||||
border-left: 5px #e26dff solid;
|
||||
}
|
||||
|
||||
.mx_LeftPanel_container.collapsed {
|
||||
min-width: unset;
|
||||
/* Collapsed LeftPanel 50px */
|
||||
|
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// TODO: Rename to mx_LeftPanel during replacement of old component
|
||||
|
||||
// TODO: Put these variables in the right place, or namespace them.
|
||||
$tagPanelWidth: 70px;
|
||||
$roomListMinimizedWidth: 50px;
|
||||
|
||||
.mx_LeftPanel2 {
|
||||
background-color: $header-panel-bg-color;
|
||||
min-width: 260px;
|
||||
max-width: 50%;
|
||||
|
||||
// Create a row-based flexbox for the TagPanel and the room list
|
||||
display: flex;
|
||||
|
||||
.mx_LeftPanel2_tagPanelContainer {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: $tagPanelWidth;
|
||||
height: 100%;
|
||||
|
||||
// Create another flexbox so the TagPanel fills the container
|
||||
display: flex;
|
||||
|
||||
// TagPanel handles its own CSS
|
||||
}
|
||||
|
||||
// Note: The 'room list' in this context is actually everything that isn't the tag
|
||||
// panel, such as the menu options, breadcrumbs, filtering, etc
|
||||
.mx_LeftPanel2_roomListContainer {
|
||||
width: calc(100% - $tagPanelWidth);
|
||||
|
||||
// Create another flexbox (this time a column) for the room list components
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.mx_LeftPanel2_userHeader {
|
||||
padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom
|
||||
|
||||
// Create another flexbox column for the rows to stack within
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs
|
||||
.mx_LeftPanel2_headerRow {
|
||||
// Create yet another flexbox, this time within the row, to ensure items stay
|
||||
// aligned correctly. This is also a row-based flexbox.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_userAvatarContainer {
|
||||
position: relative; // to make default avatars work
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_userName {
|
||||
font-weight: 600;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_headerButtons {
|
||||
// No special styles: the rest of the layout happens to make it work.
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_breadcrumbsContainer {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_filterContainer {
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
|
||||
// Create a flexbox to organize the inputs
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
|
||||
// Cheaty way to return the occupied space to the filter input
|
||||
margin: 0;
|
||||
width: 0;
|
||||
|
||||
// Don't forget to hide the masked ::before icon
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_exploreButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 20px;
|
||||
background-color: #fff; // TODO: Variable and theme
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask-image: url('$(res)/img/feather-customised/compass.svg');
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $primary-fg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_actualRoomListContainer {
|
||||
flex-grow: 1; // fill the available space
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
// Create a flexbox to trick the layout engine
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -66,7 +66,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
/* not the left panel, and not the resize handle, so the roomview/groupview/... */
|
||||
.mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_ResizeHandle) {
|
||||
.mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_LeftPanel2):not(.mx_ResizeHandle) {
|
||||
background-color: $primary-bg-color;
|
||||
|
||||
flex: 1 1 0;
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Note: this component expects to be contained within a flexbox
|
||||
.mx_RoomSearch {
|
||||
flex: 1;
|
||||
border-radius: 20px;
|
||||
background-color: #fff; // TODO: Variable & theme
|
||||
height: 26px;
|
||||
padding: 2px;
|
||||
|
||||
// Create a flexbox for the icons (easier to manage)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mx_RoomSearch_icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask: url('$(res)/img/feather-customised/search-input.svg');
|
||||
mask-repeat: no-repeat;
|
||||
background: $primary-fg-color;
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.mx_RoomSearch_input {
|
||||
border: none !important; // !important to override default app-wide styles
|
||||
flex: 1 !important; // !important to override default app-wide styles
|
||||
color: $primary-fg-color !important; // !important to override default app-wide styles
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: $font-12px;
|
||||
line-height: $font-16px;
|
||||
|
||||
&:not(.mx_RoomSearch_inputExpanded)::placeholder {
|
||||
color: $primary-fg-color !important; // !important to override default app-wide styles
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_RoomSearch_expanded {
|
||||
.mx_RoomSearch_clearButton {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask-image: url('$(res)/img/feather-customised/x.svg');
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $primary-fg-color;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomSearch_clearButton {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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_UserMenuButton {
|
||||
// No special styles on the button itself
|
||||
}
|
||||
|
||||
.mx_UserMenuButton_contextMenu {
|
||||
width: 231px;
|
||||
|
||||
.mx_UserMenuButton_contextMenu_header {
|
||||
// Create a flexbox to organize the header a bit easier
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:nth-child(n + 1) {
|
||||
// The first header will have appropriate padding, subsequent ones need a margin.
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mx_UserMenuButton_contextMenu_name {
|
||||
// Create another flexbox of columns to handle large user IDs
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// fit the container
|
||||
flex: 1;
|
||||
width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button
|
||||
|
||||
* {
|
||||
// Automatically grow all subelements to fit the container
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
// Ellipsize any text overflow
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mx_UserMenuButton_contextMenu_displayName {
|
||||
font-weight: bold;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-20px;
|
||||
}
|
||||
|
||||
.mx_UserMenuButton_contextMenu_userId {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_UserMenuButton_contextMenu_themeButton {
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 8px;
|
||||
border-radius: 32px;
|
||||
background-color: $theme-button-bg-color;
|
||||
cursor: pointer;
|
||||
|
||||
// to make alignment easier, create flexbox for the image
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ limitations under the License.
|
|||
align-items: flex-start;
|
||||
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
appearance: none;
|
||||
|
||||
& + label {
|
||||
display: flex;
|
||||
|
@ -48,6 +48,8 @@ limitations under the License.
|
|||
border-radius: $border-radius;
|
||||
|
||||
img {
|
||||
display: none;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
filter: invert(100%);
|
||||
|
@ -57,6 +59,10 @@ limitations under the License.
|
|||
&:checked + label > .mx_Checkbox_background {
|
||||
background: $accent-color;
|
||||
border-color: $accent-color;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
& + label > *:not(.mx_Checkbox_background) {
|
||||
|
|
|
@ -5,7 +5,7 @@ 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
|
||||
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,
|
||||
|
@ -23,8 +23,6 @@ limitations under the License.
|
|||
|
||||
$radio-circle-color: $muted-fg-color;
|
||||
$active-radio-circle-color: $accent-color;
|
||||
// We need to make this element positioned
|
||||
// so that the radio circle can be absolute
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
|
@ -35,18 +33,28 @@ limitations under the License.
|
|||
flex-grow: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mx_RadioButton_spacer {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
height: $font-16px;
|
||||
width: $font-16px;
|
||||
}
|
||||
|
||||
> input[type=radio] {
|
||||
// Remove the OS's representation
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
appearance: none;
|
||||
|
||||
+ div {
|
||||
// Necessary to center the following span
|
||||
position: absolute;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -72,8 +80,10 @@ limitations under the License.
|
|||
|
||||
> input[type=radio]:checked {
|
||||
+ div {
|
||||
border-color: $active-radio-circle-color;
|
||||
|
||||
> div {
|
||||
background: $radio-circle-color;
|
||||
background: $active-radio-circle-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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_NotificationBadge {
|
||||
&:not(.mx_NotificationBadge_visible) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Badges are structured a bit weirdly to work around issues with non-monospace
|
||||
// font styles. The badge pill is actually a background div and the count floats
|
||||
// within that. For example:
|
||||
//
|
||||
// ( 99+ ) <-- Rounded pill is a _bg class.
|
||||
// ^- The count is an element floating within that.
|
||||
|
||||
&.mx_NotificationBadge_visible {
|
||||
background-color: $roomtile2-badge-color;
|
||||
|
||||
// Create a flexbox to order the count a bit easier
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.mx_NotificationBadge_highlighted {
|
||||
// TODO: Use a more specific variable
|
||||
background-color: $warning-color;
|
||||
}
|
||||
|
||||
// These are the 3 background types
|
||||
|
||||
&.mx_NotificationBadge_dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&.mx_NotificationBadge_2char {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
&.mx_NotificationBadge_3char {
|
||||
width: 26px;
|
||||
height: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
// The following is the floating badge
|
||||
|
||||
.mx_NotificationBadge_count {
|
||||
font-size: $font-10px;
|
||||
line-height: $font-14px;
|
||||
color: #fff; // TODO: Variable
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
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_RoomBreadcrumbs2 {
|
||||
width: 100%;
|
||||
|
||||
// Create a flexbox for the crumbs
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
.mx_RoomBreadcrumbs2_crumb {
|
||||
margin-right: 8px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
// These classes come from the CSSTransition component. There's many more classes we
|
||||
// could care about, but this is all we worried about for now. The animation works by
|
||||
// first triggering the enter state with the newest breadcrumb off screen (-40px) then
|
||||
// sliding it into view.
|
||||
&.mx_RoomBreadcrumbs2-enter {
|
||||
margin-left: -40px; // 32px for the avatar, 8px for the margin
|
||||
}
|
||||
&.mx_RoomBreadcrumbs2-enter-active {
|
||||
margin-left: 0;
|
||||
|
||||
// Timing function is as-requested by design.
|
||||
// NOTE: The transition time MUST match the value passed to CSSTransition!
|
||||
transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
|
||||
}
|
||||
|
||||
.mx_RoomBreadcrumbs2_placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $font-14px;
|
||||
line-height: 32px; // specifically to match the height this is not scaled
|
||||
height: 32px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// TODO: Rename to mx_RoomList during replacement of old component
|
||||
|
||||
.mx_RoomList2 {
|
||||
width: calc(100% - 16px); // 16px of artificial right-side margin (8px is overflowed from the sublists)
|
||||
|
||||
// Create a column-based flexbox for the sublists. That's pretty much all we have to
|
||||
// worry about in this stylesheet.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap; // let the column overflow
|
||||
}
|
|
@ -14,8 +14,232 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
@import "../../../../node_modules/react-resizable/css/styles.css";
|
||||
// TODO: Rename to mx_RoomSublist during replacement of old component
|
||||
|
||||
.mx_RoomList2 .mx_RoomSubList_labelContainer {
|
||||
z-index: 12;
|
||||
.mx_RoomSublist2 {
|
||||
// The sublist is a column of rows, essentially
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin-left: 8px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
width: 100%;
|
||||
|
||||
.mx_RoomSublist2_headerContainer {
|
||||
// Create a flexbox to make ordering easy
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
height: 24px;
|
||||
|
||||
.mx_RoomSublist2_badgeContainer {
|
||||
opacity: 0.8;
|
||||
width: 16px;
|
||||
margin-right: 5px; // aligns with the room tile's badge
|
||||
|
||||
// Create another flexbox row because it's super easy to position the badge this way.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Both of these buttons are hidden by default until the list is hovered
|
||||
.mx_RoomSublist2_auxButton,
|
||||
.mx_RoomSublist2_menuButton {
|
||||
width: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $muted-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_auxButton::before {
|
||||
mask-image: url('$(res)/img/feather-customised/plus.svg');
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_menuButton::before {
|
||||
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_headerText {
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
line-height: $font-16px;
|
||||
font-size: $font-12px;
|
||||
|
||||
flex: 1;
|
||||
max-width: calc(100% - 16px); // 16px is the badge width
|
||||
|
||||
// Ellipsize any text overflow
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_resizeBox {
|
||||
margin-bottom: 4px; // for the resize handle
|
||||
position: relative;
|
||||
|
||||
// Create another flexbox column for the tiles
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.mx_RoomSublist2_showMoreButton {
|
||||
cursor: pointer;
|
||||
font-size: $font-13px;
|
||||
line-height: $font-18px;
|
||||
color: $roomtile2-preview-color;
|
||||
|
||||
// This is the same color as the left panel background because it needs
|
||||
// to occlude the lastmost tile in the list.
|
||||
background-color: $header-panel-bg-color;
|
||||
|
||||
// Update the render() function for RoomSublist2 if these change
|
||||
// Update the ListLayout class for minVisibleTiles if these change.
|
||||
//
|
||||
// At 24px high and 8px padding on the top this equates to 0.65 of
|
||||
// a tile due to how the padding calculations work.
|
||||
height: 24px;
|
||||
padding-top: 8px;
|
||||
|
||||
// We force this to the bottom so it will overlap rooms as needed.
|
||||
// We account for the space it takes up (24px) in the code through padding.
|
||||
position: absolute;
|
||||
bottom: 4px; // the height of the resize handle
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
// We create a flexbox to cheat at alignment
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.mx_RoomSublist2_showMoreButtonChevron {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 12px;
|
||||
margin-right: 18px;
|
||||
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $roomtile2-preview-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Class name comes from the ResizableBox component
|
||||
// The hover state needs to use the whole sublist, not just the resizable box,
|
||||
// so that selector is below and one level higher.
|
||||
.react-resizable-handle {
|
||||
cursor: ns-resize;
|
||||
border-radius: 2px;
|
||||
|
||||
// This is positioned directly below the 'show more' button.
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
// This is to visually align the bar in the list. Should be 12px from
|
||||
// either side of the list. We define this after the positioning to
|
||||
// trick the browser.
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// The aforementioned selector for the hover state.
|
||||
&:hover, &.mx_RoomSublist2_hasMenuOpen {
|
||||
.react-resizable-handle {
|
||||
opacity: 0.2;
|
||||
|
||||
// Update the render() function for RoomSublist2 if this changes
|
||||
border: 2px solid $primary-fg-color;
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_headerContainer {
|
||||
// If the header doesn't have an aux button we still need to hide the badge for
|
||||
// the menu button.
|
||||
.mx_RoomSublist2_badgeContainer {
|
||||
// Completely hide the badge
|
||||
width: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:not(.mx_RoomSublist2_headerContainer_withAux) {
|
||||
// The menu button will be the rightmost button, so make it correctly aligned.
|
||||
.mx_RoomSublist2_menuButton {
|
||||
margin-right: 1px; // line it up with the badges on the room tiles
|
||||
}
|
||||
}
|
||||
|
||||
// Both of these buttons have circled backgrounds and are visible at this point,
|
||||
// so make them so.
|
||||
.mx_RoomSublist2_auxButton,
|
||||
.mx_RoomSublist2_menuButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 32px;
|
||||
margin-left: 16px;
|
||||
background-color: #fff; // TODO: Variable and theme
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have a hover style on the room list with no specific list hovered, so account for that
|
||||
.mx_RoomList2:hover .mx_RoomSublist2,
|
||||
.mx_RoomSublist2_hasMenuOpen {
|
||||
.mx_RoomSublist2_headerContainer_withAux {
|
||||
.mx_RoomSublist2_badgeContainer {
|
||||
// Completely hide the badge
|
||||
width: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_auxButton {
|
||||
// Show the aux button, but not the list button
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 1px; // line it up with the badges on the room tiles
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_contextMenu {
|
||||
padding: 20px 16px;
|
||||
width: 250px;
|
||||
|
||||
hr {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-right: 16px; // additional 16px
|
||||
border: 1px solid $roomsublist2-divider-color;
|
||||
}
|
||||
|
||||
.mx_RoomSublist2_contextMenu_title {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// TODO: Rename to mx_RoomTile during replacement of old component
|
||||
|
||||
// Note: the room tile expects to be in a flexbox column container
|
||||
.mx_RoomTile2 {
|
||||
margin-bottom: 4px;
|
||||
padding: 4px;
|
||||
|
||||
// The tile is also a flexbox row itself
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen {
|
||||
background-color: $roomtile2-selected-bg-color;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_avatarContainer {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_nameContainer {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 58px); // 32px avatar, 18px badge area, 8px margin on avatar
|
||||
|
||||
// Create a new column layout flexbox for the name parts
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.mx_RoomTile2_name,
|
||||
.mx_RoomTile2_messagePreview {
|
||||
margin: 0 2px;
|
||||
width: 100%;
|
||||
|
||||
// Ellipsize any text overflow
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_name {
|
||||
font-size: $font-14px;
|
||||
line-height: $font-18px;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_messagePreview {
|
||||
font-size: $font-13px;
|
||||
line-height: $font-18px;
|
||||
color: $roomtile2-preview-color;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_nameWithPreview {
|
||||
margin-top: -4px; // shift the name up a bit more
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomTile2_badgeContainer {
|
||||
width: 18px;
|
||||
height: 32px;
|
||||
|
||||
// Create another flexbox row because it's super easy to position the badge at
|
||||
// the end this way.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// The menu button is hidden by default
|
||||
// TODO: [Notifications] Use mx_RoomTile2_notificationsButton, similar to the following approach:
|
||||
// https://github.com/matrix-org/matrix-react-sdk/blob/2180a56074f3698fc0241c309a72ba6cad802d1c/res/css/views/rooms/_RoomSublist2.scss#L48-L76
|
||||
// You'll need to do the same down below on the &:hover selector for the tile.
|
||||
// ... also remove this 4 line TODO comment.
|
||||
.mx_RoomTile2_menuButton,
|
||||
.mx_RoomTile2_notificationsButton {
|
||||
width: 0;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $primary-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomTile2_menuButton::before {
|
||||
top: 8px;
|
||||
left: -1px; // this is off-center to align it with the badges
|
||||
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
|
||||
}
|
||||
|
||||
&:hover, &.mx_RoomTile2_hasMenuOpen {
|
||||
// Hide the badge container on hover because it'll be a menu button
|
||||
.mx_RoomTile2_badgeContainer {
|
||||
width: 0;
|
||||
height: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.mx_RoomTile2_menuButton {
|
||||
width: 18px;
|
||||
height: 32px;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomTile2_contextMenu {
|
||||
.mx_RoomTile2_contextMenu_redRow {
|
||||
.mx_AccessibleButton {
|
||||
color: $warning-color !important; // !important to override styles from context menu
|
||||
}
|
||||
|
||||
.mx_IconizedContextMenu_icon::before {
|
||||
background-color: $warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_IconizedContextMenu_icon {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
background: $primary-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomTile2_iconStar::before {
|
||||
mask-image: url('$(res)/img/feather-customised/star.svg');
|
||||
}
|
||||
|
||||
.mx_RoomTile2_iconArrowDown::before {
|
||||
mask-image: url('$(res)/img/feather-customised/arrow-down.svg');
|
||||
}
|
||||
|
||||
.mx_RoomTile2_iconUser::before {
|
||||
mask-image: url('$(res)/img/feather-customised/user.svg');
|
||||
}
|
||||
|
||||
.mx_RoomTile2_iconSettings::before {
|
||||
mask-image: url('$(res)/img/feather-customised/settings.svg');
|
||||
}
|
||||
|
||||
.mx_RoomTile2_iconSignOut::before {
|
||||
mask-image: url('$(res)/img/feather-customised/sign-out.svg');
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-archive"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
|
After Width: | Height: | Size: 361 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-down"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></svg>
|
After Width: | Height: | Size: 313 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-compass"><circle cx="12" cy="12" r="10"></circle><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon></svg>
|
After Width: | Height: | Size: 342 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-horizontal"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>
|
After Width: | Height: | Size: 343 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
After Width: | Height: | Size: 339 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-sun"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
After Width: | Height: | Size: 650 B |
|
@ -172,6 +172,14 @@ $header-divider-color: #91A1C0;
|
|||
|
||||
// ********************
|
||||
|
||||
// TODO: Update variables for new room list
|
||||
// TODO: Dark theme
|
||||
$roomtile2-preview-color: #9e9e9e;
|
||||
$roomtile2-badge-color: #61708b;
|
||||
$roomtile2-selected-bg-color: #FFF;
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
$roomsublist2-divider-color: #e9eaeb;
|
||||
|
||||
$roomtile-name-color: #61708b;
|
||||
$roomtile-badge-fg-color: $accent-fg-color;
|
||||
$roomtile-selected-color: #212121;
|
||||
|
|
|
@ -27,7 +27,7 @@ import RoomViewStore from './stores/RoomViewStore';
|
|||
*/
|
||||
class ActiveRoomObserver {
|
||||
constructor() {
|
||||
this._listeners = {};
|
||||
this._listeners = {}; // key=roomId, value=function(isActive:boolean)
|
||||
|
||||
this._activeRoomId = RoomViewStore.getRoomId();
|
||||
// TODO: We could self-destruct when the last listener goes away, or at least
|
||||
|
@ -35,6 +35,10 @@ class ActiveRoomObserver {
|
|||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
|
||||
}
|
||||
|
||||
get activeRoomId(): string {
|
||||
return this._activeRoomId;
|
||||
}
|
||||
|
||||
addListener(roomId, listener) {
|
||||
if (!this._listeners[roomId]) this._listeners[roomId] = [];
|
||||
this._listeners[roomId].push(listener);
|
||||
|
@ -51,23 +55,23 @@ class ActiveRoomObserver {
|
|||
}
|
||||
}
|
||||
|
||||
_emit(roomId) {
|
||||
_emit(roomId, isActive: boolean) {
|
||||
if (!this._listeners[roomId]) return;
|
||||
|
||||
for (const l of this._listeners[roomId]) {
|
||||
l.call();
|
||||
l.call(null, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomViewStoreUpdate() {
|
||||
// emit for the old room ID
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId);
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId, false);
|
||||
|
||||
// update our cache
|
||||
this._activeRoomId = RoomViewStore.getRoomId();
|
||||
|
||||
// and emit for the new one
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId);
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,6 +107,29 @@ async function localSearch(searchTerm, roomId = undefined) {
|
|||
const result = MatrixClientPeg.get()._processRoomEventsSearch(
|
||||
emptyResult, response);
|
||||
|
||||
// Restore our encryption info so we can properly re-verify the events.
|
||||
for (let i = 0; i < result.results.length; i++) {
|
||||
const timeline = result.results[i].context.getTimeline();
|
||||
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
const ev = timeline[j];
|
||||
if (ev.event.curve25519Key) {
|
||||
ev.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
{ algorithm: ev.event.algorithm },
|
||||
ev.event.curve25519Key,
|
||||
ev.event.ed25519Key,
|
||||
);
|
||||
ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
|
||||
|
||||
delete ev.event.curve25519Key;
|
||||
delete ev.event.ed25519Key;
|
||||
delete ev.event.algorithm;
|
||||
delete ev.event.forwardingCurve25519KeyChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
@ -265,13 +265,22 @@ function textForServerACLEvent(ev) {
|
|||
return text + changes.join(" ");
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev) {
|
||||
function textForMessageEvent(ev, skipUserPrefix) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = "* " + senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
|
||||
if (skipUserPrefix) {
|
||||
message = ev.getContent().body;
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('sent an image.');
|
||||
}
|
||||
} else {
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = "* " + senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
@ -612,8 +621,8 @@ for (const evType of ALL_RULE_TYPES) {
|
|||
stateHandlers[evType] = textForMjolnirEvent;
|
||||
}
|
||||
|
||||
export function textForEvent(ev) {
|
||||
export function textForEvent(ev, skipUserPrefix) {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
if (handler) return handler(ev);
|
||||
if (handler) return handler(ev, skipUserPrefix);
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
|
|||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
|
||||
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
|
||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||
|
||||
const HomePage = () => {
|
||||
|
|
|
@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
|
|||
if (!this.props.collapsed) {
|
||||
exploreButton = (
|
||||
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
||||
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
|
||||
<AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,12 +18,18 @@ import * as React from "react";
|
|||
import TagPanel from "./TagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import TopLeftMenuButton from "./TopLeftMenuButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import UserMenuButton from "./UserMenuButton";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
|
||||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -38,15 +44,14 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
searchExpanded: boolean;
|
||||
searchFilter: string; // TODO: Move search into room list?
|
||||
showBreadcrumbs: boolean;
|
||||
}
|
||||
|
||||
export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||
// TODO: Properly support TagPanel
|
||||
// TODO: Properly support searching/filtering
|
||||
// TODO: Properly support breadcrumbs
|
||||
// TODO: Properly support TopLeftMenu (User Settings)
|
||||
// TODO: a11y
|
||||
// TODO: actually make this useful in general (match design proposals)
|
||||
// TODO: Fadable support (is this still needed?)
|
||||
|
@ -55,61 +60,104 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchExpanded: false,
|
||||
searchFilter: "",
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
}
|
||||
|
||||
private onSearch = (term: string): void => {
|
||||
this.setState({searchFilter: term});
|
||||
};
|
||||
|
||||
private onSearchCleared = (source: string): void => {
|
||||
if (source === "keyboard") {
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
}
|
||||
|
||||
private onSearchFocus = (): void => {
|
||||
this.setState({searchExpanded: true});
|
||||
private onExplore = () => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private onSearchBlur = (event: FocusEvent): void => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value.length === 0) {
|
||||
this.setState({searchExpanded: false});
|
||||
private onBreadcrumbsUpdate = () => {
|
||||
const newVal = BreadcrumbsStore.instance.visible;
|
||||
if (newVal !== this.state.showBreadcrumbs) {
|
||||
this.setState({showBreadcrumbs: newVal});
|
||||
}
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
// TODO: Update when profile info changes
|
||||
// TODO: Presence
|
||||
// TODO: Breadcrumbs toggle
|
||||
// TODO: Menu button
|
||||
const avatarSize = 32;
|
||||
// TODO: Don't do this profile lookup in render()
|
||||
const client = MatrixClientPeg.get();
|
||||
let displayName = client.getUserId();
|
||||
let avatarUrl: string = null;
|
||||
const myUser = client.getUser(client.getUserId());
|
||||
if (myUser) {
|
||||
displayName = myUser.rawDisplayName;
|
||||
avatarUrl = myUser.avatarUrl;
|
||||
}
|
||||
|
||||
let breadcrumbs;
|
||||
if (this.state.showBreadcrumbs) {
|
||||
breadcrumbs = (
|
||||
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
|
||||
<RoomBreadcrumbs2 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_LeftPanel2_userHeader">
|
||||
<div className="mx_LeftPanel2_headerRow">
|
||||
<span className="mx_LeftPanel2_userAvatarContainer">
|
||||
<BaseAvatar
|
||||
idName={MatrixClientPeg.get().getUserId()}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
resizeMethod="crop"
|
||||
className="mx_LeftPanel2_userAvatar"
|
||||
/>
|
||||
</span>
|
||||
<span className="mx_LeftPanel2_userName">{displayName}</span>
|
||||
<span className="mx_LeftPanel2_headerButtons">
|
||||
<UserMenuButton />
|
||||
</span>
|
||||
</div>
|
||||
{breadcrumbs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
// TODO: Collapsed support
|
||||
|
||||
return (
|
||||
<div className="mx_LeftPanel2_filterContainer">
|
||||
<RoomSearch onQueryUpdate={this.onSearch} />
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_LeftPanel2_exploreButton'
|
||||
onClick={this.onExplore}
|
||||
alt={_t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const tagPanel = (
|
||||
<div className="mx_LeftPanel_tagPanelContainer">
|
||||
<div className="mx_LeftPanel2_tagPanelContainer">
|
||||
<TagPanel/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const exploreButton = (
|
||||
<div
|
||||
className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
||||
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>
|
||||
{_t("Explore")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchBox = (<SearchBox
|
||||
className="mx_LeftPanel_filterRooms"
|
||||
enableRoomSearchFocus={true}
|
||||
blurredPlaceholder={_t('Filter')}
|
||||
placeholder={_t('Filter rooms…')}
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
onSearch={this.onSearch}
|
||||
onCleared={this.onSearchCleared}
|
||||
onFocus={this.onSearchFocus}
|
||||
onBlur={this.onSearchBlur}
|
||||
collapsed={false}/>); // TODO: Collapsed support
|
||||
|
||||
// TODO: Improve props for RoomList2
|
||||
const roomList = <RoomList2
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
|
@ -120,33 +168,21 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
onBlur={() => {/*TODO*/}}
|
||||
/>;
|
||||
|
||||
// TODO: Breadcrumbs
|
||||
// TODO: Conference handling / calls
|
||||
|
||||
const containerClasses = classNames({
|
||||
"mx_LeftPanel_container": true,
|
||||
"mx_fadable": true,
|
||||
"collapsed": false, // TODO: Collapsed support
|
||||
"mx_LeftPanel_container_hasTagPanel": true, // TODO: TagPanel support
|
||||
"mx_fadable_faded": false,
|
||||
"mx_LeftPanel2": true, // TODO: Remove flag when RoomList2 ships (used as an indicator)
|
||||
"mx_LeftPanel2": true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
<aside className="mx_LeftPanel dark-panel">
|
||||
<TopLeftMenuButton collapsed={false}/>
|
||||
<div
|
||||
className="mx_LeftPanel_exploreAndFilterRow"
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
onFocus={() => {/*TODO*/}}
|
||||
onBlur={() => {/*TODO*/}}
|
||||
>
|
||||
{exploreButton}
|
||||
{searchBox}
|
||||
<aside className="mx_LeftPanel2_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
<div className="mx_LeftPanel2_actualRoomListContainer">
|
||||
{roomList}
|
||||
</div>
|
||||
{roomList}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -452,9 +452,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
|||
// composer, so CTRL+` it is
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
});
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
|
|
@ -72,6 +72,7 @@ import {
|
|||
hideToast as hideAnalyticsToast
|
||||
} from "../../toasts/AnalyticsToast";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.viewIndexedRoom(payload.roomIndex);
|
||||
break;
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||
{initialTabId: tabPayload.initialTabId},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
|
||||
);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
|
@ -620,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
break;
|
||||
}
|
||||
case 'view_room_directory': {
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
|
@ -1607,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === 'groups') {
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { throttle } from 'lodash';
|
||||
import { Key } from "../../Keyboard";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
interface IProps {
|
||||
onQueryUpdate: (newQuery: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
query: string;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private inputRef: React.RefObject<HTMLInputElement> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: "",
|
||||
focused: false,
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === 'view_room' && payload.clear_search) {
|
||||
this.clearInput();
|
||||
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private clearInput = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.inputRef.current.value = "";
|
||||
this.onChange();
|
||||
};
|
||||
|
||||
private onChange = () => {
|
||||
if (!this.inputRef.current) return;
|
||||
this.setState({query: this.inputRef.current.value});
|
||||
this.onSearchUpdated();
|
||||
};
|
||||
|
||||
// it wants this at the top of the file, but we know better
|
||||
// tslint:disable-next-line
|
||||
private onSearchUpdated = throttle(
|
||||
() => {
|
||||
// We can't use the state variable because it can lag behind the input.
|
||||
// The lag is most obvious when deleting/clearing text with the keyboard.
|
||||
this.props.onQueryUpdate(this.inputRef.current.value);
|
||||
}, 200, {trailing: true, leading: true},
|
||||
);
|
||||
|
||||
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||
this.setState({focused: true});
|
||||
ev.target.select();
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
this.setState({focused: false});
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusComposer);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const classes = classNames({
|
||||
'mx_RoomSearch': true,
|
||||
'mx_RoomSearch_expanded': this.state.query || this.state.focused,
|
||||
});
|
||||
|
||||
const inputClasses = classNames({
|
||||
'mx_RoomSearch_input': true,
|
||||
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className='mx_RoomSearch_icon'/>
|
||||
<input
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className={inputClasses}
|
||||
value={this.state.query}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
placeholder={_t("Search")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_RoomSearch_clearButton'
|
||||
onClick={this.clearInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1458,9 +1458,7 @@ export default createReactClass({
|
|||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
// https://github.com/vector-im/vector-web/issues/1134
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
},
|
||||
|
||||
onSearchClick: function() {
|
||||
|
|
|
@ -27,25 +27,20 @@ import { ReactNode } from "react";
|
|||
* Represents a tab for the TabbedView.
|
||||
*/
|
||||
export class Tab {
|
||||
public label: string;
|
||||
public icon: string;
|
||||
public body: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Creates a new tab.
|
||||
* @param {string} tabLabel The untranslated tab label.
|
||||
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} tabJsx The JSX for the tab container.
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @param {string} icon The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} body The JSX for the tab container.
|
||||
*/
|
||||
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
|
||||
this.label = tabLabel;
|
||||
this.icon = tabIconClass;
|
||||
this.body = tabJsx;
|
||||
constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -53,16 +48,17 @@ interface IState {
|
|||
}
|
||||
|
||||
export default class TabbedView extends React.Component<IProps, IState> {
|
||||
static propTypes = {
|
||||
// The tabs to show
|
||||
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
|
||||
};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
let activeTabIndex = 0;
|
||||
if (props.initialTabId) {
|
||||
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
|
||||
if (tabIndex >= 0) activeTabIndex = tabIndex;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
activeTabIndex: 0,
|
||||
activeTabIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import * as Avatar from '../../Avatar';
|
|||
import { _t } from '../../languageHandler';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
||||
|
@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
|
||||
onAction = (payload) => {
|
||||
// For accessibility
|
||||
if (payload.action === "toggle_top_left_menu") {
|
||||
if (payload.action === Action.ToggleUserMenu) {
|
||||
if (this._buttonRef) this._buttonRef.click();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {User} from "matrix-js-sdk/src/models/user";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { createRef } from "react";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import {getCustomTheme} from "../../theme";
|
||||
import {getHostingLink} from "../../utils/HostingLink";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
user: User;
|
||||
menuDisplayed: boolean;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
export default class UserMenuButton extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
menuDisplayed: false,
|
||||
user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()),
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
};
|
||||
}
|
||||
|
||||
private get displayName(): string {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return _t("Guest");
|
||||
} else if (this.state.user) {
|
||||
return this.state.user.displayName;
|
||||
} else {
|
||||
return MatrixClientPeg.get().getUserId();
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
|
||||
private onThemeChanged = () => {
|
||||
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
|
||||
};
|
||||
|
||||
private onAction = (ev: ActionPayload) => {
|
||||
if (ev.action !== Action.ToggleUserMenu) return; // not interested
|
||||
|
||||
// For accessibility
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({menuDisplayed: false});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = () => {
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
const newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme);
|
||||
};
|
||||
|
||||
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
};
|
||||
|
||||
private onShowArchived = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// TODO: Archived room view (deferred)
|
||||
console.log("TODO: Show archived rooms");
|
||||
};
|
||||
|
||||
private onProvideFeedback = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
};
|
||||
|
||||
public render() {
|
||||
let contextMenu;
|
||||
if (this.state.menuDisplayed) {
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
if (signupLink) {
|
||||
hostingLink = (
|
||||
<div className="mx_UserMenuButton_contextMenu_header">
|
||||
{_t(
|
||||
"<a>Upgrade</a> to your own domain", {},
|
||||
{
|
||||
a: sub => (
|
||||
<a
|
||||
href={signupLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
tabIndex={-1}
|
||||
>{sub}</a>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const elementRect = this.buttonRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu mx_UserMenuButton_contextMenu">
|
||||
<div className="mx_UserMenuButton_contextMenu_header">
|
||||
<div className="mx_UserMenuButton_contextMenu_name">
|
||||
<span className="mx_UserMenuButton_contextMenu_displayName">
|
||||
{this.displayName}
|
||||
</span>
|
||||
<span className="mx_UserMenuButton_contextMenu_userId">
|
||||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mx_UserMenuButton_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||
>
|
||||
<img
|
||||
src={require("../../../res/img/feather-customised/sun.svg")}
|
||||
alt={_t("Switch theme")}
|
||||
width={16}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hostingLink}
|
||||
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
|
||||
<ul>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
|
||||
<img src={require("../../../res/img/feather-customised/notifications.svg")} width={16} />
|
||||
<span>{_t("Notification settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
|
||||
<img src={require("../../../res/img/feather-customised/lock.svg")} width={16} />
|
||||
<span>{_t("Security & privacy")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
|
||||
<img src={require("../../../res/img/feather-customised/settings.svg")} width={16} />
|
||||
<span>{_t("All settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onShowArchived}>
|
||||
<img src={require("../../../res/img/feather-customised/archive.svg")} width={16} />
|
||||
<span>{_t("Archived rooms")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onProvideFeedback}>
|
||||
<img src={require("../../../res/img/feather-customised/message-circle.svg")} width={16} />
|
||||
<span>{_t("Feedback")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onSignOutClick}>
|
||||
<img src={require("../../../res/img/feather-customised/sign-out.svg")} width={16} />
|
||||
<span>{_t("Sign out")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_UserMenuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={_t("Account settings")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
>
|
||||
<img src={require("../../../res/img/feather-customised/more-horizontal.svg")} alt="..." width={14} />
|
||||
</ContextMenuButton>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -30,6 +30,13 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
|||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
|
||||
export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
|
||||
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
|
||||
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
|
||||
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
|
||||
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
|
||||
|
||||
export default class RoomSettingsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
|
@ -56,21 +63,25 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
const tabs = [];
|
||||
|
||||
tabs.push(new Tab(
|
||||
ROOM_GENERAL_TAB,
|
||||
_td("General"),
|
||||
"mx_RoomSettingsDialog_settingsIcon",
|
||||
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_SECURITY_TAB,
|
||||
_td("Security & Privacy"),
|
||||
"mx_RoomSettingsDialog_securityIcon",
|
||||
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_ROLES_TAB,
|
||||
_td("Roles & Permissions"),
|
||||
"mx_RoomSettingsDialog_rolesIcon",
|
||||
<RolesRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_NOTIFICATIONS_TAB,
|
||||
_td("Notifications"),
|
||||
"mx_RoomSettingsDialog_notificationsIcon",
|
||||
<NotificationSettingsTab roomId={this.props.roomId} />,
|
||||
|
@ -78,6 +89,7 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
|
||||
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
|
||||
tabs.push(new Tab(
|
||||
ROOM_BRIDGES_TAB,
|
||||
_td("Bridges"),
|
||||
"mx_RoomSettingsDialog_bridgesIcon",
|
||||
<BridgeSettingsTab roomId={this.props.roomId} />,
|
||||
|
@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
}
|
||||
|
||||
tabs.push(new Tab(
|
||||
ROOM_ADVANCED_TAB,
|
||||
_td("Advanced"),
|
||||
"mx_RoomSettingsDialog_warningIcon",
|
||||
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
|
||||
|
|
|
@ -33,9 +33,21 @@ import * as sdk from "../../../index";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
|
||||
|
||||
export const USER_GENERAL_TAB = "USER_GENERAL_TAB";
|
||||
export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB";
|
||||
export const USER_FLAIR_TAB = "USER_FLAIR_TAB";
|
||||
export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB";
|
||||
export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB";
|
||||
export const USER_VOICE_TAB = "USER_VOICE_TAB";
|
||||
export const USER_SECURITY_TAB = "USER_SECURITY_TAB";
|
||||
export const USER_LABS_TAB = "USER_LABS_TAB";
|
||||
export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB";
|
||||
export const USER_HELP_TAB = "USER_HELP_TAB";
|
||||
|
||||
export default class UserSettingsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
initialTabId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -63,42 +75,50 @@ export default class UserSettingsDialog extends React.Component {
|
|||
const tabs = [];
|
||||
|
||||
tabs.push(new Tab(
|
||||
USER_GENERAL_TAB,
|
||||
_td("General"),
|
||||
"mx_UserSettingsDialog_settingsIcon",
|
||||
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_APPEARANCE_TAB,
|
||||
_td("Appearance"),
|
||||
"mx_UserSettingsDialog_appearanceIcon",
|
||||
<AppearanceUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_FLAIR_TAB,
|
||||
_td("Flair"),
|
||||
"mx_UserSettingsDialog_flairIcon",
|
||||
<FlairUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_NOTIFICATIONS_TAB,
|
||||
_td("Notifications"),
|
||||
"mx_UserSettingsDialog_bellIcon",
|
||||
<NotificationUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_PREFERENCES_TAB,
|
||||
_td("Preferences"),
|
||||
"mx_UserSettingsDialog_preferencesIcon",
|
||||
<PreferencesUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_VOICE_TAB,
|
||||
_td("Voice & Video"),
|
||||
"mx_UserSettingsDialog_voiceIcon",
|
||||
<VoiceUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_SECURITY_TAB,
|
||||
_td("Security & Privacy"),
|
||||
"mx_UserSettingsDialog_securityIcon",
|
||||
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
|
||||
tabs.push(new Tab(
|
||||
USER_LABS_TAB,
|
||||
_td("Labs"),
|
||||
"mx_UserSettingsDialog_labsIcon",
|
||||
<LabsUserSettingsTab />,
|
||||
|
@ -106,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
|
|||
}
|
||||
if (this.state.mjolnirEnabled) {
|
||||
tabs.push(new Tab(
|
||||
USER_MJOLNIR_TAB,
|
||||
_td("Ignored users"),
|
||||
"mx_UserSettingsDialog_mjolnirIcon",
|
||||
<MjolnirUserSettingsTab />,
|
||||
));
|
||||
}
|
||||
tabs.push(new Tab(
|
||||
USER_HELP_TAB,
|
||||
_td("Help & About"),
|
||||
"mx_UserSettingsDialog_helpIcon",
|
||||
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
|
@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
|
|||
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished} title={_t("Settings")}>
|
||||
<div className='ms_SettingsDialog_content'>
|
||||
<TabbedView tabs={this._getTabs()} />
|
||||
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -17,6 +17,34 @@
|
|||
import React from 'react';
|
||||
|
||||
import {Key} from '../../../Keyboard';
|
||||
import classnames from 'classnames';
|
||||
|
||||
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element>
|
||||
|
||||
/**
|
||||
* children: React's magic prop. Represents all children given to the element.
|
||||
* element: (optional) The base element type. "div" by default.
|
||||
* onClick: (required) Event handler for button activation. Should be
|
||||
* implemented exactly like a normal onClick handler.
|
||||
*/
|
||||
interface IProps extends React.InputHTMLAttributes<Element> {
|
||||
inputRef?: React.Ref<Element>;
|
||||
element?: string;
|
||||
// The kind of button, similar to how Bootstrap works.
|
||||
// See available classes for AccessibleButton for options.
|
||||
kind?: string;
|
||||
// The ARIA role
|
||||
role?: string;
|
||||
// The tabIndex
|
||||
tabIndex?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?(e?: ButtonEvent): void;
|
||||
};
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
ref?: React.Ref<Element>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AccessibleButton is a generic wrapper for any element that should be treated
|
||||
|
@ -39,7 +67,7 @@ export default function AccessibleButton({
|
|||
|
||||
const newProps: IAccessibleButtonProps = restProps;
|
||||
if (!disabled) {
|
||||
newProps.onClick = onClick,
|
||||
newProps.onClick = onClick;
|
||||
// We need to consume enter onKeyDown and space onKeyUp
|
||||
// otherwise we are risking also activating other keyboard focusable elements
|
||||
// that might receive focus as a result of the AccessibleButtonClick action
|
||||
|
@ -57,7 +85,7 @@ export default function AccessibleButton({
|
|||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
};
|
||||
newProps.onKeyUp = (e) => {
|
||||
if (e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
|
@ -68,54 +96,26 @@ export default function AccessibleButton({
|
|||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pass through the ref - used for keyboard shortcut access to some buttons
|
||||
newProps.ref = inputRef;
|
||||
|
||||
newProps.className = (className ? className + " " : "") + "mx_AccessibleButton";
|
||||
|
||||
if (kind) {
|
||||
// We apply a hasKind class to maintain backwards compatibility with
|
||||
// buttons which might not know about kind and break
|
||||
newProps.className += " mx_AccessibleButton_hasKind mx_AccessibleButton_kind_" + kind;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
newProps.className += " mx_AccessibleButton_disabled";
|
||||
newProps["aria-disabled"] = true;
|
||||
}
|
||||
newProps.className = classnames(
|
||||
"mx_AccessibleButton",
|
||||
className,
|
||||
{
|
||||
"mx_AccessibleButton_hasKind": kind,
|
||||
[`mx_AccessibleButton_kind_${kind}`]: kind,
|
||||
"mx_AccessibleButton_disabled": disabled,
|
||||
},
|
||||
);
|
||||
|
||||
// React.createElement expects InputHTMLAttributes
|
||||
return React.createElement(element, restProps, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* children: React's magic prop. Represents all children given to the element.
|
||||
* element: (optional) The base element type. "div" by default.
|
||||
* onClick: (required) Event handler for button activation. Should be
|
||||
* implemented exactly like a normal onClick handler.
|
||||
*/
|
||||
interface IProps extends React.InputHTMLAttributes<Element> {
|
||||
inputRef?: React.Ref<Element>,
|
||||
element?: string;
|
||||
// The kind of button, similar to how Bootstrap works.
|
||||
// See available classes for AccessibleButton for options.
|
||||
kind?: string,
|
||||
// The ARIA role
|
||||
role?: string,
|
||||
// The tabIndex
|
||||
tabIndex?: number,
|
||||
disabled?: boolean,
|
||||
className?: string,
|
||||
onClick?(e?: React.MouseEvent<Element> | React.KeyboardEvent<Element>): void;
|
||||
};
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
ref?: React.Ref<Element>,
|
||||
}
|
||||
|
||||
AccessibleButton.defaultProps = {
|
||||
element: 'div',
|
||||
role: 'button',
|
||||
|
|
|
@ -18,11 +18,12 @@ import React from 'react';
|
|||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const RoomDirectoryButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_room_directory"
|
||||
<ActionButton action={Action.ViewRoomDirectory}
|
||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||
label={_t("Room directory")}
|
||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||
|
|
|
@ -16,31 +16,28 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { _t } from '../../../languageHandler';
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import StyledCheckbox from "./StyledCheckbox";
|
||||
|
||||
interface IProps {
|
||||
name: string,
|
||||
level: string,
|
||||
roomId?: string, // for per-room settings
|
||||
label?: string, // untranslated
|
||||
isExplicit: boolean,
|
||||
// The setting must be a boolean
|
||||
name: string;
|
||||
level: string;
|
||||
roomId?: string; // for per-room settings
|
||||
label?: string; // untranslated
|
||||
isExplicit?: boolean;
|
||||
// XXX: once design replaces all toggles make this the default
|
||||
useCheckbox?: boolean,
|
||||
onChange(checked: boolean): void,
|
||||
useCheckbox?: boolean;
|
||||
onChange?(checked: boolean): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// XXX: make this generic when the settings store is typed
|
||||
value: any;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export default class SettingsFlag extends React.Component<IProps, IState> {
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -64,7 +61,7 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
|
|||
this.onChange(e.target.checked);
|
||||
}
|
||||
|
||||
private save = (val?: any): void => {
|
||||
private save = (val?: boolean): void => {
|
||||
return SettingsStore.setValue(
|
||||
this.props.name,
|
||||
this.props.roomId,
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may 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,
|
||||
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.
|
||||
|
@ -24,7 +24,6 @@ interface IState {
|
|||
}
|
||||
|
||||
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
|
||||
|
||||
public static readonly defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
@ -36,6 +35,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
{/* Used to render the radio button circle */}
|
||||
<div><div></div></div>
|
||||
<span>{children}</span>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</label>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,21 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { EventHandler } from "react";
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import * as sdk from "../../../index";
|
||||
|
||||
interface IProps {
|
||||
// Whether or not this toggle is in the 'on' position.
|
||||
checked: boolean;
|
||||
|
||||
// Whether or not the user can interact with the switch
|
||||
disabled: boolean;
|
||||
|
||||
// Called when the checked state changes. First argument will be the new state.
|
||||
onChange(checked: boolean): void;
|
||||
};
|
||||
|
||||
// Controlled Toggle Switch element, written with Accessibility in mind
|
||||
export default ({checked, disabled = false, onChange, ...props}: IProps) => {
|
||||
const _onClick = () => {
|
||||
|
@ -45,14 +56,3 @@ export default ({checked, disabled = false, onChange, ...props}: IProps) => {
|
|||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
// Whether or not this toggle is in the 'on' position.
|
||||
checked: boolean,
|
||||
|
||||
// Whether or not the user can interact with the switch
|
||||
disabled: boolean,
|
||||
|
||||
// Called when the checked state changes. First argument will be the new state.
|
||||
onChange(checked: boolean): void,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,280 @@
|
|||
/*
|
||||
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 from "react";
|
||||
import classNames from "classnames";
|
||||
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||
import * as Unread from '../../../Unread';
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import { EventEmitter } from "events";
|
||||
import { arrayDiff } from "../../../utils/arrays";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
|
||||
export const NOTIFICATION_STATE_UPDATE = "update";
|
||||
|
||||
export enum NotificationColor {
|
||||
// Inverted (None -> Red) because we do integer comparisons on this
|
||||
None, // nothing special
|
||||
Bold, // no badge, show as unread
|
||||
Grey, // unread notified messages
|
||||
Red, // unread pings
|
||||
}
|
||||
|
||||
export interface INotificationState extends EventEmitter {
|
||||
symbol?: string;
|
||||
count: number;
|
||||
color: NotificationColor;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
notification: INotificationState;
|
||||
|
||||
/**
|
||||
* If true, the badge will conditionally display a badge without count for the user.
|
||||
*/
|
||||
allowNoCount: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
||||
if (prevProps.notification) {
|
||||
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||
}
|
||||
|
||||
private onNotificationUpdate = () => {
|
||||
this.forceUpdate(); // notification state changed - update
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// Don't show a badge if we don't need to
|
||||
if (this.props.notification.color <= NotificationColor.Bold) return null;
|
||||
|
||||
const hasNotif = this.props.notification.color >= NotificationColor.Red;
|
||||
const hasCount = this.props.notification.color >= NotificationColor.Grey;
|
||||
const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
|
||||
|
||||
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
|
||||
if (isEmptyBadge) symbol = "";
|
||||
|
||||
const classes = classNames({
|
||||
'mx_NotificationBadge': true,
|
||||
'mx_NotificationBadge_visible': hasCount,
|
||||
'mx_NotificationBadge_highlighted': hasNotif,
|
||||
'mx_NotificationBadge_dot': isEmptyBadge,
|
||||
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
|
||||
'mx_NotificationBadge_3char': symbol.length > 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomNotificationState extends EventEmitter implements IDestroyable {
|
||||
private _symbol: string;
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
|
||||
constructor(private room: Room) {
|
||||
super();
|
||||
this.room.on("Room.receipt", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
this.updateNotificationState();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return this._symbol;
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
private get roomIsInvite(): boolean {
|
||||
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
if (roomId !== this.room.roomId) return; // ignore - not for us
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private updateNotificationState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.roomIsInvite) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._symbol = "!";
|
||||
this._count = 1; // not used, technically
|
||||
} else {
|
||||
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
|
||||
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
|
||||
|
||||
// For a 'true count' we pick the grey notifications first because they include the
|
||||
// red notifications. If we don't have a grey count for some reason we use the red
|
||||
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||
|
||||
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||
// zero on badges.
|
||||
|
||||
if (redNotifs > 0) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else if (greyNotifs > 0) {
|
||||
this._color = NotificationColor.Grey;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else {
|
||||
// We don't have any notified messages, but we might have unread messages. Let's
|
||||
// find out.
|
||||
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
|
||||
if (hasUnread) {
|
||||
this._color = NotificationColor.Bold;
|
||||
} else {
|
||||
this._color = NotificationColor.None;
|
||||
}
|
||||
|
||||
// no symbol or count for this state
|
||||
this._count = 0;
|
||||
this._symbol = null;
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ListNotificationState extends EventEmitter {
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
private rooms: Room[] = [];
|
||||
private states: { [roomId: string]: RoomNotificationState } = {};
|
||||
|
||||
constructor(private byTileCount = false) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
// If we're only concerned about the tile count, don't bother setting up listeners.
|
||||
if (this.byTileCount) {
|
||||
this.rooms = rooms;
|
||||
this.calculateTotalState();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRooms = this.rooms;
|
||||
const diff = arrayDiff(oldRooms, rooms);
|
||||
for (const oldRoom of diff.removed) {
|
||||
const state = this.states[oldRoom.roomId];
|
||||
delete this.states[oldRoom.roomId];
|
||||
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
state.destroy();
|
||||
}
|
||||
for (const newRoom of diff.added) {
|
||||
const state = new RoomNotificationState(newRoom);
|
||||
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
this.states[newRoom.roomId] = state;
|
||||
}
|
||||
|
||||
this.calculateTotalState();
|
||||
}
|
||||
|
||||
private onRoomNotificationStateUpdate = () => {
|
||||
this.calculateTotalState();
|
||||
};
|
||||
|
||||
private calculateTotalState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.byTileCount) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = this.rooms.length;
|
||||
} else {
|
||||
this._count = 0;
|
||||
this._color = NotificationColor.None;
|
||||
for (const state of Object.values(this.states)) {
|
||||
this._count += state.count;
|
||||
this._color = Math.max(this.color, state.color);
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
|
|||
import {formatDate} from '../../../DateUtils';
|
||||
import Velociraptor from "../../../Velociraptor";
|
||||
import * as sdk from "../../../index";
|
||||
import {toRem} from "../../../utils/units";
|
||||
import {toPx} from "../../../utils/units";
|
||||
|
||||
let bounce = false;
|
||||
try {
|
||||
|
@ -149,7 +149,7 @@ export default createReactClass({
|
|||
// start at the old height and in the old h pos
|
||||
|
||||
startStyles.push({ top: startTopOffset+"px",
|
||||
left: toRem(oldInfo.left) });
|
||||
left: toPx(oldInfo.left) });
|
||||
|
||||
const reorderTransitionOpts = {
|
||||
duration: 100,
|
||||
|
@ -182,7 +182,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
const style = {
|
||||
left: toRem(this.props.leftOffset),
|
||||
left: toPx(this.props.leftOffset),
|
||||
top: '0px',
|
||||
visibility: this.props.hidden ? 'hidden' : 'visible',
|
||||
};
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
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 from "react";
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import Analytics from "../../../Analytics";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
*******************************************************************
|
||||
* This is a work in progress implementation and isn't complete or *
|
||||
* even useful as a component. Please avoid using it until this *
|
||||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// Both of these control the animation for the breadcrumbs. For details on the
|
||||
// actual animation, see the CSS.
|
||||
//
|
||||
// doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate
|
||||
// for info). skipFirst is used to try and reduce jerky animation - also see the
|
||||
// breadcrumb update function for info on that.
|
||||
doAnimation: boolean;
|
||||
skipFirst: boolean;
|
||||
}
|
||||
|
||||
export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState> {
|
||||
private isMounted = true;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
doAnimation: true, // technically we want animation on mount, but it won't be perfect
|
||||
skipFirst: false, // render the thing, as boring as it is
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.isMounted = false;
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
}
|
||||
|
||||
private onBreadcrumbsUpdate = () => {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
// We need to trick the CSSTransition component into updating, which means we need to
|
||||
// tell it to not animate, then to animate a moment later. This causes two updates
|
||||
// which means two renders. The skipFirst change is so that our don't-animate state
|
||||
// doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk.
|
||||
// The second update, on the next available tick, causes the "enter" animation to start
|
||||
// again and this time we want to show the newest breadcrumb because it'll be hidden
|
||||
// off screen for the animation.
|
||||
this.setState({doAnimation: false, skipFirst: true});
|
||||
setTimeout(() => this.setState({doAnimation: true, skipFirst: false}), 0);
|
||||
};
|
||||
|
||||
private viewRoom = (room: Room, index: number) => {
|
||||
Analytics.trackEvent("Breadcrumbs", "click_node", index);
|
||||
defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Decorate crumbs with icons
|
||||
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_RoomBreadcrumbs2_crumb"
|
||||
key={r.roomId}
|
||||
onClick={() => this.viewRoom(r, i)}
|
||||
aria-label={_t("Room %(name)s", {name: r.name})}
|
||||
>
|
||||
<RoomAvatar room={r} width={32} height={32}/>
|
||||
</AccessibleButton>
|
||||
)
|
||||
});
|
||||
|
||||
if (tiles.length > 0) {
|
||||
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
|
||||
return (
|
||||
<CSSTransition
|
||||
appear={true} in={this.state.doAnimation} timeout={640}
|
||||
classNames='mx_RoomBreadcrumbs2'
|
||||
>
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
{tiles.slice(this.state.skipFirst ? 1 : 0)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='mx_RoomBreadcrumbs2'>
|
||||
<div className="mx_RoomBreadcrumbs2_placeholder">
|
||||
{_t("No recently visited rooms")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -96,7 +96,7 @@ const TAG_AESTHETICS: {
|
|||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.DM]: {
|
||||
sectionLabel: _td("Direct Messages"),
|
||||
sectionLabel: _td("People"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
addRoomLabel: _td("Start chat"),
|
||||
|
@ -216,7 +216,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
className="mx_RoomList mx_RoomList2"
|
||||
className="mx_RoomList2"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
|
|
|
@ -20,15 +20,15 @@ import * as React from "react";
|
|||
import { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from 'classnames';
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
|
||||
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||
import RoomTile2 from "./RoomTile2";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||
import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -57,13 +57,22 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
menuDisplayed: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
private hasTiles(): boolean {
|
||||
return this.numTiles > 0;
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: new ListNotificationState(this.props.isInvite),
|
||||
menuDisplayed: false,
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
|
@ -71,6 +80,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
return (this.props.rooms || []).length;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
|
@ -88,38 +101,103 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({menuDisplayed: false});
|
||||
};
|
||||
|
||||
private onUnreadFirstChanged = () => {
|
||||
// TODO: Support per-list algorithm changes
|
||||
console.log("Unread first changed");
|
||||
};
|
||||
|
||||
private onMessagePreviewChanged = () => {
|
||||
this.props.layout.showPreviews = !this.props.layout.showPreviews;
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private renderTiles(): React.ReactElement[] {
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (this.props.rooms) {
|
||||
for (const room of this.props.rooms) {
|
||||
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
|
||||
tiles.push(
|
||||
<RoomTile2
|
||||
room={room}
|
||||
key={`room-${room.roomId}`}
|
||||
showMessagePreview={this.props.layout.showPreviews}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private renderHeader(): React.ReactElement {
|
||||
const notifications = !this.props.isInvite
|
||||
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
|
||||
: {count: 0, highlight: true};
|
||||
const notifCount = notifications.count;
|
||||
const notifHighlight = notifications.highlight;
|
||||
private renderMenu(): React.ReactElement {
|
||||
let contextMenu = null;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_RoomSublist2_contextMenu">
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
|
||||
TODO: Radios are blocked by https://github.com/matrix-org/matrix-react-sdk/pull/4731
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
|
||||
<StyledCheckbox
|
||||
onChange={this.onUnreadFirstChanged}
|
||||
checked={false/*TODO*/}
|
||||
>
|
||||
{_t("Always show first")}
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
|
||||
<StyledCheckbox
|
||||
onChange={this.onMessagePreviewChanged}
|
||||
checked={this.props.layout.showPreviews}
|
||||
>
|
||||
{_t("Message preview")}
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomSublist2_menuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.menuButtonRef}
|
||||
label={_t("List options")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderHeader(): React.ReactElement {
|
||||
// TODO: Title on collapsed
|
||||
// TODO: Incoming call box
|
||||
|
||||
let chevron = null;
|
||||
if (this.hasTiles()) {
|
||||
const chevronClasses = classNames({
|
||||
'mx_RoomSubList_chevron': true,
|
||||
'mx_RoomSubList_chevronRight': false, // isCollapsed
|
||||
'mx_RoomSubList_chevronDown': true, // !isCollapsed
|
||||
});
|
||||
chevron = (<div className={chevronClasses}/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
|
@ -127,68 +205,43 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
// TODO: Collapsed state
|
||||
let badge;
|
||||
if (true) { // !isCollapsed
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomSubList_badge': true,
|
||||
'mx_RoomSubList_badgeHighlight': notifHighlight,
|
||||
});
|
||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||
if (notifCount > 0) {
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
aria-label={_t("Jump to first unread room.")}
|
||||
>
|
||||
<div>
|
||||
{FormattingUtils.formatCount(notifCount)}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (this.props.isInvite && this.hasTiles()) {
|
||||
// Render the `!` badge for invites
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
aria-label={_t("Jump to first invite.")}
|
||||
>
|
||||
<div>
|
||||
{FormattingUtils.formatCount(this.numTiles)}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
|
||||
|
||||
let addRoomButton = null;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
className="mx_RoomSublist2_auxButton"
|
||||
aria-label={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist2_headerContainer': true,
|
||||
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
|
||||
});
|
||||
|
||||
// TODO: a11y (see old component)
|
||||
return (
|
||||
<div className={"mx_RoomSubList_labelContainer"}>
|
||||
<div className={classes}>
|
||||
<AccessibleButton
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSubList_label"}
|
||||
className={"mx_RoomSublist2_headerText"}
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
>
|
||||
{chevron}
|
||||
<span>{this.props.label}</span>
|
||||
</AccessibleButton>
|
||||
{badge}
|
||||
{this.renderMenu()}
|
||||
{addRoomButton}
|
||||
<div className="mx_RoomSublist2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
@ -204,53 +257,68 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
const classes = classNames({
|
||||
// TODO: Proper collapse support
|
||||
'mx_RoomSubList': true,
|
||||
'mx_RoomSubList_hidden': false, // len && isCollapsed
|
||||
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
|
||||
'mx_RoomSublist2': true,
|
||||
'mx_RoomSublist2_collapsed': false, // len && isCollapsed
|
||||
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed,
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (tiles.length > 0) {
|
||||
const layout = this.props.layout; // to shorten calls
|
||||
|
||||
// TODO: Lazy list rendering
|
||||
// TODO: Whatever scrolling magic needs to happen here
|
||||
const layout = this.props.layout; // to shorten calls
|
||||
const minTilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.minVisibleTiles));
|
||||
const maxTilesPx = layout.tilesToPixels(tiles.length);
|
||||
const tilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.visibleTiles));
|
||||
|
||||
const nVisible = Math.floor(layout.visibleTiles);
|
||||
const visibleTiles = tiles.slice(0, nVisible);
|
||||
|
||||
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||
// floats above the resize handle, if we have one present
|
||||
let showMoreButton = null;
|
||||
if (tiles.length > nVisible) {
|
||||
// we have a cutoff condition - add the button to show all
|
||||
const numMissing = tiles.length - visibleTiles.length;
|
||||
showMoreButton = (
|
||||
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showMoreButton'>
|
||||
<span className='mx_RoomSublist2_showMoreButtonChevron'>
|
||||
{/* set by CSS masking */}
|
||||
</span>
|
||||
<span className='mx_RoomSublist2_showMoreButtonText'>
|
||||
{_t("Show %(count)s more", {count: numMissing})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Figure out if we need a handle
|
||||
let handles = ['s'];
|
||||
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
|
||||
handles = []; // no handles, we're at a minimum
|
||||
}
|
||||
|
||||
// TODO: This might need adjustment, however for now it is fine as a round.
|
||||
const nVisible = Math.round(layout.visibleTiles);
|
||||
const visibleTiles = tiles.slice(0, nVisible);
|
||||
// We have to account for padding so we can accommodate a 'show more' button and
|
||||
// the resize handle, which are pinned to the bottom of the container. This is the
|
||||
// easiest way to have a resize handle below the button as otherwise we're writing
|
||||
// our own resize handling and that doesn't sound fun.
|
||||
//
|
||||
// The layout class has some helpers for dealing with padding, as we don't want to
|
||||
// apply it in all cases. If we apply it in all cases, the resizing feels like it
|
||||
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
|
||||
// only mathematically 7 possible).
|
||||
|
||||
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||
// replaces the last visible tile, so will always show 2+ rooms. We do this
|
||||
// because if it said "show 1 more room" we had might as well show that room
|
||||
// instead. We also replace the last item so we don't have to adjust our math
|
||||
// on pixel heights, etc. It's much easier to pretend the button is a tile.
|
||||
if (tiles.length > nVisible) {
|
||||
// we have a cutoff condition - add the button to show all
|
||||
const showMoreHeight = 32; // As defined by CSS
|
||||
const resizeHandleHeight = 4; // As defined by CSS
|
||||
|
||||
// we +1 to account for the room we're about to hide with our 'show more' button
|
||||
// this results in the button always being 1+, and not needing an i18n `count`.
|
||||
const numMissing = (tiles.length - visibleTiles.length) + 1;
|
||||
// The padding is variable though, so figure out what we need padding for.
|
||||
let padding = 0;
|
||||
if (showMoreButton) padding += showMoreHeight;
|
||||
if (handles.length > 0) padding += resizeHandleHeight;
|
||||
|
||||
const minTilesPx = layout.calculateTilesToPixelsMin(tiles.length, layout.minVisibleTiles, padding);
|
||||
const maxTilesPx = layout.tilesToPixelsWithPadding(tiles.length, padding);
|
||||
const tilesWithoutPadding = Math.min(tiles.length, layout.visibleTiles);
|
||||
const tilesPx = layout.calculateTilesToPixelsMin(tiles.length, tilesWithoutPadding, padding);
|
||||
|
||||
// TODO: CSS TBD
|
||||
// TODO: Make this an actual tile
|
||||
// TODO: This is likely to pop out of the list, consider that.
|
||||
visibleTiles.splice(visibleTiles.length - 1, 1, (
|
||||
<div
|
||||
onClick={this.onShowAllClick}
|
||||
style={{height: '34px', lineHeight: '34px', cursor: 'pointer'}}
|
||||
key='showall'
|
||||
>
|
||||
{_t("Show %(n)s more", {n: numMissing})}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
content = (
|
||||
<ResizableBox
|
||||
width={-1}
|
||||
|
@ -263,6 +331,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
className="mx_RoomSublist2_resizeBox"
|
||||
>
|
||||
{visibleTiles}
|
||||
{showMoreButton}
|
||||
</ResizableBox>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -21,17 +21,16 @@ import React, { createRef } from "react";
|
|||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import AccessibleButton, {ButtonEvent} from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import Tooltip from "../../views/elements/Tooltip";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||
import * as Unread from '../../../Unread';
|
||||
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { MessagePreviewStore } from "../../../stores/MessagePreviewStore";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -41,34 +40,25 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
* warning disappears. *
|
||||
*******************************************************************/
|
||||
|
||||
enum NotificationColor {
|
||||
// Inverted (None -> Red) because we do integer comparisons on this
|
||||
None, // nothing special
|
||||
Bold, // no badge, show as unread
|
||||
Grey, // unread notified messages
|
||||
Red, // unread pings
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
showMessagePreview: boolean;
|
||||
|
||||
// TODO: Allow falsifying counts (for invites and stuff)
|
||||
// TODO: Transparency? Was this ever used?
|
||||
// TODO: Incoming call boxes?
|
||||
}
|
||||
|
||||
interface INotificationState {
|
||||
symbol: string;
|
||||
color: NotificationColor;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: INotificationState;
|
||||
selected: boolean;
|
||||
generalMenuDisplayed: boolean;
|
||||
}
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
private roomTile = createRef();
|
||||
private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
// TODO: Custom status
|
||||
// TODO: Lock icon
|
||||
|
@ -86,86 +76,18 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
hover: false,
|
||||
notificationState: this.getNotificationState(),
|
||||
notificationState: new RoomNotificationState(this.props.room),
|
||||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
generalMenuDisplayed: false,
|
||||
};
|
||||
|
||||
this.props.room.on("Room.receipt", this.handleRoomEventUpdate);
|
||||
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
|
||||
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
|
||||
// this, but instead we're kinda forced to either duplicate the code or thread a variable
|
||||
// through the code paths. This feels like the least evil option.
|
||||
private get roomIsInvite(): boolean {
|
||||
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||
}
|
||||
|
||||
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
// Sanity check: should never happen
|
||||
if (roomId !== this.props.room.roomId) return;
|
||||
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private updateNotificationState() {
|
||||
this.setState({notificationState: this.getNotificationState()});
|
||||
}
|
||||
|
||||
private getNotificationState(): INotificationState {
|
||||
const state: INotificationState = {
|
||||
color: NotificationColor.None,
|
||||
symbol: null,
|
||||
};
|
||||
|
||||
if (this.roomIsInvite) {
|
||||
state.color = NotificationColor.Red;
|
||||
state.symbol = "!";
|
||||
} else {
|
||||
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
|
||||
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
|
||||
|
||||
// For a 'true count' we pick the grey notifications first because they include the
|
||||
// red notifications. If we don't have a grey count for some reason we use the red
|
||||
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||
|
||||
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||
// zero on badges.
|
||||
|
||||
if (redNotifs > 0) {
|
||||
state.color = NotificationColor.Red;
|
||||
state.symbol = FormattingUtils.formatCount(trueCount);
|
||||
} else if (greyNotifs > 0) {
|
||||
state.color = NotificationColor.Grey;
|
||||
state.symbol = FormattingUtils.formatCount(trueCount);
|
||||
} else {
|
||||
// We don't have any notified messages, but we might have unread messages. Let's
|
||||
// find out.
|
||||
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
|
||||
if (hasUnread) {
|
||||
state.color = NotificationColor.Bold;
|
||||
// no symbol for this state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
|
@ -186,63 +108,175 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onActiveRoomUpdate = (isActive: boolean) => {
|
||||
this.setState({selected: isActive});
|
||||
};
|
||||
|
||||
private onGeneralMenuOpenClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: true});
|
||||
};
|
||||
|
||||
private onCloseGeneralMenu = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: false});
|
||||
};
|
||||
|
||||
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (tagId === DefaultTagID.DM) {
|
||||
// TODO: DM Flagging
|
||||
} else {
|
||||
// TODO: XOR favourites and low priority
|
||||
}
|
||||
};
|
||||
|
||||
private onLeaveRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
};
|
||||
|
||||
private onOpenRoomSettings = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'open_room_settings',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
};
|
||||
|
||||
private renderGeneralMenu(): React.ReactElement {
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuDisplayed) {
|
||||
// The context menu appears within the list, so use the room tile as a reference point
|
||||
const elementRect = this.roomTileRef.current.getBoundingClientRect();
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height + 8}
|
||||
onFinished={this.onCloseGeneralMenu}
|
||||
>
|
||||
<div
|
||||
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
|
||||
style={{width: elementRect.width}}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
|
||||
<span>{_t("Favourite")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
|
||||
<span>{_t("Low Priority")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.DM)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconUser" />
|
||||
<span>{_t("Direct Chat")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onOpenRoomSettings}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
|
||||
<span>{_t("Settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li className="mx_RoomTile2_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onLeaveRoomClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span>{_t("Leave Room")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className="mx_RoomTile2_menuButton"
|
||||
onClick={this.onGeneralMenuOpenClick}
|
||||
inputRef={this.generalMenuButtonRef}
|
||||
label={_t("Room options")}
|
||||
isExpanded={this.state.generalMenuDisplayed}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Collapsed state
|
||||
// TODO: Invites
|
||||
// TODO: a11y proper
|
||||
// TODO: Render more than bare minimum
|
||||
|
||||
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
|
||||
const isUnread = this.state.notificationState.color > NotificationColor.None;
|
||||
const classes = classNames({
|
||||
'mx_RoomTile': true,
|
||||
// 'mx_RoomTile_selected': this.state.selected,
|
||||
'mx_RoomTile_unread': isUnread,
|
||||
'mx_RoomTile_unreadNotify': this.state.notificationState.color >= NotificationColor.Grey,
|
||||
'mx_RoomTile_highlight': this.state.notificationState.color >= NotificationColor.Red,
|
||||
'mx_RoomTile_invited': this.roomIsInvite,
|
||||
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
|
||||
'mx_RoomTile_noBadges': !hasBadge,
|
||||
// 'mx_RoomTile_transparent': this.props.transparent,
|
||||
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile2_selected': this.state.selected,
|
||||
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed,
|
||||
});
|
||||
|
||||
const avatarClasses = classNames({
|
||||
'mx_RoomTile_avatar': true,
|
||||
});
|
||||
|
||||
|
||||
let badge;
|
||||
if (hasBadge) {
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomTile_badge': true,
|
||||
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
|
||||
});
|
||||
badge = <div className={badgeClasses}>{this.state.notificationState.symbol}</div>;
|
||||
}
|
||||
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
'mx_RoomTile_name': true,
|
||||
'mx_RoomTile_invite': this.roomIsInvite,
|
||||
'mx_RoomTile_badgeShown': hasBadge,
|
||||
});
|
||||
|
||||
// TODO: Support collapsed state properly
|
||||
let tooltip = null;
|
||||
if (false) { // isCollapsed
|
||||
if (this.state.hover) {
|
||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
|
||||
// TODO: Tooltip?
|
||||
|
||||
let messagePreview = null;
|
||||
if (this.props.showMessagePreview) {
|
||||
// The preview store heavily caches this info, so should be safe to hammer.
|
||||
const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room);
|
||||
|
||||
// Only show the preview if there is one to show.
|
||||
if (text) {
|
||||
messagePreview = (
|
||||
<div className="mx_RoomTile2_messagePreview">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
|
||||
});
|
||||
|
||||
const avatarSize = 32;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
|
@ -254,20 +288,19 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
onClick={this.onTileClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className={avatarClasses}>
|
||||
<div className="mx_RoomTile_avatar_container">
|
||||
<RoomAvatar room={this.props.room} width={24} height={24}/>
|
||||
</div>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
|
||||
</div>
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div className="mx_RoomTile_labelContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
{messagePreview}
|
||||
</div>
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
{tooltip}
|
||||
{this.renderGeneralMenu()}
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
|
|
|
@ -427,7 +427,9 @@ export default class SendMessageComposer extends React.Component {
|
|||
|
||||
_onPaste = (event) => {
|
||||
const {clipboardData} = event;
|
||||
if (clipboardData.files.length) {
|
||||
// Prioritize text on the clipboard over files as Office on macOS puts a bitmap
|
||||
// in the clipboard as well as the content being copied.
|
||||
if (clipboardData.files.length && !clipboardData.types.some(t => t === "text/plain")) {
|
||||
// This actually not so much for 'files' as such (at time of writing
|
||||
// neither chrome nor firefox let you paste a plain file copied
|
||||
// from Finder) but more images copied from a different website
|
||||
|
|
|
@ -62,7 +62,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
fontSize: SettingsStore.getValue("fontSize", null).toString(),
|
||||
fontSize: (SettingsStore.getValue("baseFontSize", null) + FontWatcher.SIZE_DIFF).toString(),
|
||||
...this.calculateThemeState(),
|
||||
customThemeUrl: "",
|
||||
customThemeMessage: {isError: false, text: ""},
|
||||
|
@ -132,13 +132,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
|
||||
private onFontSizeChanged = (size: number): void => {
|
||||
this.setState({fontSize: size.toString()});
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size);
|
||||
SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, size - FontWatcher.SIZE_DIFF);
|
||||
};
|
||||
|
||||
private onValidateFontSize = async ({value}: Pick<IFieldState, "value">): Promise<IValidationResult> => {
|
||||
const parsedSize = parseFloat(value);
|
||||
const min = FontWatcher.MIN_SIZE;
|
||||
const max = FontWatcher.MAX_SIZE;
|
||||
const min = FontWatcher.MIN_SIZE + FontWatcher.SIZE_DIFF;
|
||||
const max = FontWatcher.MAX_SIZE + FontWatcher.SIZE_DIFF;
|
||||
|
||||
if (isNaN(parsedSize)) {
|
||||
return {valid: false, feedback: _t("Size must be a number")};
|
||||
|
@ -151,7 +151,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
};
|
||||
}
|
||||
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value);
|
||||
SettingsStore.setValue(
|
||||
"baseFontSize",
|
||||
null,
|
||||
SettingLevel.DEVICE,
|
||||
parseInt(value, 10) - FontWatcher.SIZE_DIFF
|
||||
);
|
||||
|
||||
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
|
||||
}
|
||||
|
||||
|
@ -282,7 +288,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
values={[13, 15, 16, 18, 20]}
|
||||
value={parseInt(this.state.fontSize, 10)}
|
||||
onSelectionChange={this.onFontSizeChanged}
|
||||
displayFunc={value => ""}
|
||||
displayFunc={_ => ""}
|
||||
disabled={this.state.useCustomFontSize}
|
||||
/>
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
||||
|
@ -291,9 +297,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
name="useCustomFontSize"
|
||||
level={SettingLevel.ACCOUNT}
|
||||
onChange={(checked) => this.setState({useCustomFontSize: checked})}
|
||||
useCheckbox={true}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
type="number"
|
||||
label={_t("Font size")}
|
||||
autoComplete="off"
|
||||
placeholder={this.state.fontSize.toString()}
|
||||
|
@ -302,6 +309,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
onValidate={this.onValidateFontSize}
|
||||
onChange={(value) => this.setState({fontSize: value.target.value})}
|
||||
disabled={!this.state.useCustomFontSize}
|
||||
className="mx_SettingsTab_customFontSizeField"
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -36,9 +36,15 @@ export enum Action {
|
|||
|
||||
/**
|
||||
* Open the user settings. No additional payload information required.
|
||||
* Optionally can include an OpenToTabPayload.
|
||||
*/
|
||||
ViewUserSettings = "view_user_settings",
|
||||
|
||||
/**
|
||||
* Opens the room directory. No additional payload information required.
|
||||
*/
|
||||
ViewRoomDirectory = "view_room_directory",
|
||||
|
||||
/**
|
||||
* Sets the current tooltip. Should be use with ViewTooltipPayload.
|
||||
*/
|
||||
|
@ -58,4 +64,9 @@ export enum Action {
|
|||
* Focuses the user's cursor to the composer. No additional payload information required.
|
||||
*/
|
||||
FocusComposer = "focus_composer",
|
||||
|
||||
/**
|
||||
* Opens the user menu (previously known as the top left menu). No additional payload information required.
|
||||
*/
|
||||
ToggleUserMenu = "toggle_user_menu",
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
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 { ActionPayload } from "../payloads";
|
||||
import { Action } from "../actions";
|
||||
|
||||
export interface OpenToTabPayload extends ActionPayload {
|
||||
action: Action.ViewUserSettings | string, // TODO: Add room settings action
|
||||
|
||||
/**
|
||||
* The tab ID to open in the settings view to start, if possible.
|
||||
*/
|
||||
initialTabId?: string;
|
||||
}
|
|
@ -246,6 +246,7 @@
|
|||
"%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.",
|
||||
"%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.",
|
||||
"%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.",
|
||||
"sent an image.": "sent an image.",
|
||||
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
|
||||
"%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.",
|
||||
"%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.",
|
||||
|
@ -419,6 +420,7 @@
|
|||
"Restart": "Restart",
|
||||
"Upgrade your Riot": "Upgrade your Riot",
|
||||
"A new version of Riot is available!": "A new version of Riot is available!",
|
||||
"You: %(message)s": "You: %(message)s",
|
||||
"There was an error joining the room": "There was an error joining the room",
|
||||
"Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.",
|
||||
"Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
|
||||
|
@ -1070,6 +1072,7 @@
|
|||
"Replying": "Replying",
|
||||
"Room %(name)s": "Room %(name)s",
|
||||
"Recent rooms": "Recent rooms",
|
||||
"No recently visited rooms": "No recently visited rooms",
|
||||
"No rooms to show": "No rooms to show",
|
||||
"Unnamed room": "Unnamed room",
|
||||
"World readable": "World readable",
|
||||
|
@ -1091,6 +1094,7 @@
|
|||
"Low priority": "Low priority",
|
||||
"Historical": "Historical",
|
||||
"System Alerts": "System Alerts",
|
||||
"People": "People",
|
||||
"This room": "This room",
|
||||
"Joining room …": "Joining room …",
|
||||
"Loading …": "Loading …",
|
||||
|
@ -1134,10 +1138,15 @@
|
|||
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
||||
"Not now": "Not now",
|
||||
"Don't ask me again": "Don't ask me again",
|
||||
"Jump to first unread room.": "Jump to first unread room.",
|
||||
"Jump to first invite.": "Jump to first invite.",
|
||||
"Sort by": "Sort by",
|
||||
"Unread rooms": "Unread rooms",
|
||||
"Always show first": "Always show first",
|
||||
"Show": "Show",
|
||||
"Message preview": "Message preview",
|
||||
"List options": "List options",
|
||||
"Add room": "Add room",
|
||||
"Show %(n)s more": "Show %(n)s more",
|
||||
"Show %(count)s more|other": "Show %(count)s more",
|
||||
"Show %(count)s more|one": "Show %(count)s more",
|
||||
"Options": "Options",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
||||
|
@ -1145,6 +1154,11 @@
|
|||
"%(count)s unread messages.|one": "1 unread message.",
|
||||
"Unread mentions.": "Unread mentions.",
|
||||
"Unread messages.": "Unread messages.",
|
||||
"Favourite": "Favourite",
|
||||
"Low Priority": "Low Priority",
|
||||
"Direct Chat": "Direct Chat",
|
||||
"Leave Room": "Leave Room",
|
||||
"Room options": "Room options",
|
||||
"Add a topic": "Add a topic",
|
||||
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
|
||||
"This room has already been upgraded.": "This room has already been upgraded.",
|
||||
|
@ -1821,9 +1835,6 @@
|
|||
"Mentions only": "Mentions only",
|
||||
"Leave": "Leave",
|
||||
"Forget": "Forget",
|
||||
"Favourite": "Favourite",
|
||||
"Low Priority": "Low Priority",
|
||||
"Direct Chat": "Direct Chat",
|
||||
"Clear status": "Clear status",
|
||||
"Update status": "Update status",
|
||||
"Set status": "Set status",
|
||||
|
@ -1962,6 +1973,7 @@
|
|||
"Explore": "Explore",
|
||||
"Filter": "Filter",
|
||||
"Filter rooms…": "Filter rooms…",
|
||||
"Explore rooms": "Explore rooms",
|
||||
"Failed to reject invitation": "Failed to reject invitation",
|
||||
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
|
||||
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
|
||||
|
@ -2007,7 +2019,6 @@
|
|||
"Find a room…": "Find a room…",
|
||||
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
|
||||
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
|
||||
"Explore rooms": "Explore rooms",
|
||||
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</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.": "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.",
|
||||
"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.": "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.",
|
||||
|
@ -2019,6 +2030,8 @@
|
|||
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
||||
"Active call": "Active call",
|
||||
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||
"Jump to first unread room.": "Jump to first unread room.",
|
||||
"Jump to first invite.": "Jump to first invite.",
|
||||
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
||||
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
||||
"Search failed": "Search failed",
|
||||
|
@ -2043,6 +2056,14 @@
|
|||
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
|
||||
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
|
||||
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
|
||||
"Switch to light mode": "Switch to light mode",
|
||||
"Switch to dark mode": "Switch to dark mode",
|
||||
"Switch theme": "Switch theme",
|
||||
"Security & privacy": "Security & privacy",
|
||||
"All settings": "All settings",
|
||||
"Archived rooms": "Archived rooms",
|
||||
"Feedback": "Feedback",
|
||||
"Account settings": "Account settings",
|
||||
"Could not load user profile": "Could not load user profile",
|
||||
"Verify this login": "Verify this login",
|
||||
"Session verified": "Session verified",
|
||||
|
|
|
@ -290,6 +290,33 @@ export default class EventIndex extends EventEmitter {
|
|||
return validEventType && validMsgType && hasContentValue;
|
||||
}
|
||||
|
||||
eventToJson(ev) {
|
||||
const jsonEvent = ev.toJSON();
|
||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
|
||||
if (ev.isEncrypted()) {
|
||||
// Let us store some additional data so we can re-verify the event.
|
||||
// The js-sdk checks if an event is encrypted using the algorithm,
|
||||
// the sender key and ed25519 signing key are used to find the
|
||||
// correct device that sent the event which allows us to check the
|
||||
// verification state of the event, either directly or using cross
|
||||
// signing.
|
||||
e.curve25519Key = ev.getSenderKey();
|
||||
e.ed25519Key = ev.getClaimedEd25519Key();
|
||||
e.algorithm = ev.getWireContent().algorithm;
|
||||
e.forwardingCurve25519KeyChain = ev.getForwardingCurve25519KeyChain();
|
||||
} else {
|
||||
// Make sure that unencrypted events don't contain any of that data,
|
||||
// despite what the server might give to us.
|
||||
delete e.curve25519Key;
|
||||
delete e.ed25519Key;
|
||||
delete e.algorithm;
|
||||
delete e.forwardingCurve25519KeyChain;
|
||||
}
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue up live events to be added to the event index.
|
||||
*
|
||||
|
@ -300,8 +327,7 @@ export default class EventIndex extends EventEmitter {
|
|||
|
||||
if (!this.isValidEvent(ev)) return;
|
||||
|
||||
const jsonEvent = ev.toJSON();
|
||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
const e = this.eventToJson(ev);
|
||||
|
||||
const profile = {
|
||||
displayname: ev.sender.rawDisplayName,
|
||||
|
@ -477,8 +503,7 @@ export default class EventIndex extends EventEmitter {
|
|||
// Let us convert the events back into a format that EventIndex can
|
||||
// consume.
|
||||
const events = filteredEvents.map((ev) => {
|
||||
const jsonEvent = ev.toJSON();
|
||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
const e = this.eventToJson(ev);
|
||||
|
||||
let profile = {};
|
||||
if (e.sender in profiles) profile = profiles[e.sender];
|
||||
|
|
|
@ -145,6 +145,10 @@ export default async function sendBugReport(bugReportEndpoint: string, opts: IOp
|
|||
if (enabledLabs.length) {
|
||||
body.append('enabled_labs', enabledLabs.join(', '));
|
||||
}
|
||||
// if low bandwidth mode is enabled, say so over rageshake, it causes many issues
|
||||
if (SettingsStore.getValue("lowBandwidth")) {
|
||||
body.append("lowBandwidth", "enabled");
|
||||
}
|
||||
|
||||
// add storage persistence/quota information
|
||||
if (navigator.storage && navigator.storage.persisted) {
|
||||
|
|
|
@ -170,10 +170,10 @@ export const SETTINGS = {
|
|||
displayName: _td("Show info about bridges in room settings"),
|
||||
default: false,
|
||||
},
|
||||
"fontSize": {
|
||||
"baseFontSize": {
|
||||
displayName: _td("Font size"),
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: 15,
|
||||
default: 10,
|
||||
controller: new FontSizeController(),
|
||||
},
|
||||
"useCustomFontSize": {
|
||||
|
|
|
@ -181,6 +181,8 @@ export default class SettingsStore {
|
|||
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
|
||||
*/
|
||||
static monitorSetting(settingName, roomId) {
|
||||
roomId = roomId || null; // the thing wants null specifically to work, so appease it.
|
||||
|
||||
if (!this._monitors[settingName]) this._monitors[settingName] = {};
|
||||
|
||||
const registerWatcher = () => {
|
||||
|
|
|
@ -20,8 +20,10 @@ import IWatcher from "./Watcher";
|
|||
import { toPx } from '../../utils/units';
|
||||
|
||||
export class FontWatcher implements IWatcher {
|
||||
public static readonly MIN_SIZE = 13;
|
||||
public static readonly MAX_SIZE = 20;
|
||||
public static readonly MIN_SIZE = 8;
|
||||
public static readonly MAX_SIZE = 15;
|
||||
// Externally we tell the user the font is size 15. Internally we use 10.
|
||||
public static readonly SIZE_DIFF = 5;
|
||||
|
||||
private dispatcherRef: string;
|
||||
|
||||
|
@ -30,7 +32,7 @@ export class FontWatcher implements IWatcher {
|
|||
}
|
||||
|
||||
public start() {
|
||||
this.setRootFontSize(SettingsStore.getValue("fontSize"));
|
||||
this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
|
@ -48,7 +50,7 @@ export class FontWatcher implements IWatcher {
|
|||
const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE);
|
||||
|
||||
if (fontSize !== size) {
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.Device, fontSize);
|
||||
SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, fontSize);
|
||||
}
|
||||
(<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize);
|
||||
};
|
||||
|
|
|
@ -42,18 +42,20 @@ export const UPDATE_EVENT = "update";
|
|||
* help prevent lock conflicts.
|
||||
*/
|
||||
export abstract class AsyncStore<T extends Object> extends EventEmitter {
|
||||
private storeState: T = <T>{};
|
||||
private storeState: T;
|
||||
private lock = new AwaitLock();
|
||||
private readonly dispatcherRef: string;
|
||||
|
||||
/**
|
||||
* Creates a new AsyncStore using the given dispatcher.
|
||||
* @param {Dispatcher<ActionPayload>} dispatcher The dispatcher to rely upon.
|
||||
* @param {T} initialState The initial state for the store.
|
||||
*/
|
||||
protected constructor(private dispatcher: Dispatcher<ActionPayload>) {
|
||||
protected constructor(private dispatcher: Dispatcher<ActionPayload>, initialState: T = <T>{}) {
|
||||
super();
|
||||
|
||||
this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this));
|
||||
this.storeState = initialState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { AsyncStore } from "./AsyncStore";
|
||||
import { ActionPayload } from "../dispatcher/payloads";
|
||||
|
||||
|
||||
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
|
||||
protected matrixClient: MatrixClient;
|
||||
|
||||
protected abstract async onAction(payload: ActionPayload);
|
||||
|
||||
protected async onReady() {
|
||||
// Default implementation is to do nothing.
|
||||
}
|
||||
|
||||
protected async onNotReady() {
|
||||
// Default implementation is to do nothing.
|
||||
}
|
||||
|
||||
protected async onDispatch(payload: ActionPayload) {
|
||||
await this.onAction(payload);
|
||||
|
||||
if (payload.action === 'MatrixActions.sync') {
|
||||
// Filter out anything that isn't the first PREPARED sync.
|
||||
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.matrixClient = payload.matrixClient;
|
||||
await this.onReady();
|
||||
} else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
|
||||
if (this.matrixClient) {
|
||||
await this.onNotReady();
|
||||
this.matrixClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
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 SettingsStore, { SettingLevel } from "../settings/SettingsStore";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ActionPayload } from "../dispatcher/payloads";
|
||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import { arrayHasDiff } from "../utils/arrays";
|
||||
import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy";
|
||||
|
||||
const MAX_ROOMS = 20; // arbitrary
|
||||
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
|
||||
|
||||
interface IState {
|
||||
enabled?: boolean;
|
||||
rooms?: Room[];
|
||||
}
|
||||
|
||||
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
|
||||
private static internalInstance = new BreadcrumbsStore();
|
||||
|
||||
private waitingRooms: { roomId: string, addedTs: number }[] = [];
|
||||
|
||||
private constructor() {
|
||||
super(defaultDispatcher);
|
||||
|
||||
SettingsStore.monitorSetting("breadcrumb_rooms", null);
|
||||
SettingsStore.monitorSetting("breadcrumbs", null);
|
||||
}
|
||||
|
||||
public static get instance(): BreadcrumbsStore {
|
||||
return BreadcrumbsStore.internalInstance;
|
||||
}
|
||||
|
||||
public get rooms(): Room[] {
|
||||
return this.state.rooms || [];
|
||||
}
|
||||
|
||||
public get visible(): boolean {
|
||||
return this.state.enabled;
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload) {
|
||||
if (!this.matrixClient) return;
|
||||
|
||||
// TODO: Remove when new room list is made the default
|
||||
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
|
||||
|
||||
if (payload.action === 'setting_updated') {
|
||||
if (payload.settingName === 'breadcrumb_rooms') {
|
||||
await this.updateRooms();
|
||||
} else if (payload.settingName === 'breadcrumbs') {
|
||||
await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
|
||||
}
|
||||
} else if (payload.action === 'view_room') {
|
||||
if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) {
|
||||
// Queue the room instead of pushing it immediately. We're probably just
|
||||
// waiting for a room join to complete.
|
||||
this.waitingRooms.push({roomId: payload.room_id, addedTs: Date.now()});
|
||||
} else {
|
||||
// The tests might not result in a valid room object.
|
||||
const room = this.matrixClient.getRoom(payload.room_id);
|
||||
if (room) await this.appendRoom(room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async onReady() {
|
||||
// TODO: Remove when new room list is made the default
|
||||
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
|
||||
|
||||
await this.updateRooms();
|
||||
await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
|
||||
|
||||
this.matrixClient.on("Room.myMembership", this.onMyMembership);
|
||||
this.matrixClient.on("Room", this.onRoom);
|
||||
}
|
||||
|
||||
protected async onNotReady() {
|
||||
// TODO: Remove when new room list is made the default
|
||||
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
|
||||
|
||||
this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
|
||||
this.matrixClient.removeListener("Room", this.onRoom);
|
||||
}
|
||||
|
||||
private onMyMembership = async (room: Room) => {
|
||||
// We turn on breadcrumbs by default once the user has at least 1 room to show.
|
||||
if (!this.state.enabled) {
|
||||
await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
|
||||
}
|
||||
};
|
||||
|
||||
private onRoom = async (room: Room) => {
|
||||
const waitingRoom = this.waitingRooms.find(r => r.roomId === room.roomId);
|
||||
if (!waitingRoom) return;
|
||||
this.waitingRooms.splice(this.waitingRooms.indexOf(waitingRoom), 1);
|
||||
|
||||
if ((Date.now() - waitingRoom.addedTs) > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
|
||||
await this.appendRoom(room);
|
||||
};
|
||||
|
||||
private async updateRooms() {
|
||||
let roomIds = SettingsStore.getValue("breadcrumb_rooms");
|
||||
if (!roomIds || roomIds.length === 0) roomIds = [];
|
||||
|
||||
const rooms = roomIds.map(r => this.matrixClient.getRoom(r)).filter(r => !!r);
|
||||
const currentRooms = this.state.rooms || [];
|
||||
if (!arrayHasDiff(rooms, currentRooms)) return; // no change (probably echo)
|
||||
await this.updateState({rooms});
|
||||
}
|
||||
|
||||
private async appendRoom(room: Room) {
|
||||
const rooms = (this.state.rooms || []).slice(); // cheap clone
|
||||
|
||||
// If the room is upgraded, use that room instead. We'll also splice out
|
||||
// any children of the room.
|
||||
const history = this.matrixClient.getRoomUpgradeHistory(room.roomId);
|
||||
if (history.length > 1) {
|
||||
room = history[history.length - 1]; // Last room is most recent in history
|
||||
|
||||
// Take out any room that isn't the most recent room
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
|
||||
if (idx !== -1) rooms.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the existing room, if it is present
|
||||
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
|
||||
if (existingIdx !== -1) {
|
||||
rooms.splice(existingIdx, 1);
|
||||
}
|
||||
|
||||
// Splice the room to the start of the list
|
||||
rooms.splice(0, 0, room);
|
||||
|
||||
if (rooms.length > MAX_ROOMS) {
|
||||
// This looks weird, but it's saying to start at the MAX_ROOMS point in the
|
||||
// list and delete everything after it.
|
||||
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
|
||||
}
|
||||
|
||||
// Update the breadcrumbs
|
||||
await this.updateState({rooms});
|
||||
const roomIds = rooms.map(r => r.roomId);
|
||||
if (roomIds.length > 0) {
|
||||
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ActionPayload } from "../dispatcher/payloads";
|
||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy";
|
||||
import { textForEvent } from "../TextForEvent";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t } from "../languageHandler";
|
||||
|
||||
const PREVIEWABLE_EVENTS = [
|
||||
// This is the same list from RiotX
|
||||
{type: "m.room.message", isState: false},
|
||||
{type: "m.room.name", isState: true},
|
||||
{type: "m.room.topic", isState: true},
|
||||
{type: "m.room.member", isState: true},
|
||||
{type: "m.room.history_visibility", isState: true},
|
||||
{type: "m.call.invite", isState: false},
|
||||
{type: "m.call.hangup", isState: false},
|
||||
{type: "m.call.answer", isState: false},
|
||||
{type: "m.room.encrypted", isState: false},
|
||||
{type: "m.room.encryption", isState: true},
|
||||
{type: "m.room.third_party_invite", isState: true},
|
||||
{type: "m.sticker", isState: false},
|
||||
{type: "m.room.create", isState: true},
|
||||
];
|
||||
|
||||
// The maximum number of events we're willing to look back on to get a preview.
|
||||
const MAX_EVENTS_BACKWARDS = 50;
|
||||
|
||||
interface IState {
|
||||
[roomId: string]: string | null; // null indicates the preview is empty
|
||||
}
|
||||
|
||||
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
|
||||
private static internalInstance = new MessagePreviewStore();
|
||||
|
||||
private constructor() {
|
||||
super(defaultDispatcher, {});
|
||||
}
|
||||
|
||||
public static get instance(): MessagePreviewStore {
|
||||
return MessagePreviewStore.internalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the pre-translated preview for a given room
|
||||
* @param room The room to get the preview for.
|
||||
* @returns The preview, or null if none present.
|
||||
*/
|
||||
public getPreviewForRoom(room: Room): string {
|
||||
if (!room) return null; // invalid room, just return nothing
|
||||
|
||||
// It's faster to do a lookup this way than it is to use Object.keys().includes()
|
||||
// We only want to generate a preview if there's one actually missing and not explicitly
|
||||
// set as 'none'.
|
||||
const val = this.state[room.roomId];
|
||||
if (val !== null && typeof(val) !== "string") {
|
||||
this.generatePreview(room);
|
||||
}
|
||||
|
||||
return this.state[room.roomId];
|
||||
}
|
||||
|
||||
private generatePreview(room: Room) {
|
||||
const timeline = room.getLiveTimeline();
|
||||
if (!timeline) return; // usually only happens in tests
|
||||
const events = timeline.getEvents();
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached
|
||||
|
||||
const event = events[i];
|
||||
const preview = this.generatePreviewForEvent(event);
|
||||
if (preview.isPreviewable) {
|
||||
// noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
|
||||
this.updateState({[room.roomId]: preview.preview});
|
||||
return; // break - we found some text
|
||||
}
|
||||
}
|
||||
|
||||
// if we didn't find anything, subscribe ourselves to an update
|
||||
// noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
|
||||
this.updateState({[room.roomId]: null});
|
||||
}
|
||||
|
||||
protected async onAction(payload: ActionPayload) {
|
||||
if (!this.matrixClient) return;
|
||||
|
||||
// TODO: Remove when new room list is made the default
|
||||
if (!RoomListStoreTempProxy.isUsingNewStore()) return;
|
||||
|
||||
if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
|
||||
const event = payload.event; // TODO: Type out the dispatcher
|
||||
if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important
|
||||
|
||||
const preview = this.generatePreviewForEvent(event);
|
||||
if (preview.isPreviewable) {
|
||||
await this.updateState({[event.getRoomId()]: preview.preview});
|
||||
return; // break - we found some text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } {
|
||||
if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) {
|
||||
const isSelf = event.getSender() === this.matrixClient.getUserId();
|
||||
let text = textForEvent(event, /*skipUserPrefix=*/isSelf);
|
||||
if (!text || text.trim().length === 0) text = null; // force null if useless to us
|
||||
if (text && isSelf) {
|
||||
// XXX: i18n doesn't really work here if the language doesn't support prefixing.
|
||||
// We'd ideally somehow route the `You:` bit to the textForEvent call, however
|
||||
// threading that through is non-trivial.
|
||||
text = _t("You: %(message)s", {message: text});
|
||||
}
|
||||
return {isPreviewable: true, preview: text};
|
||||
}
|
||||
return {isPreviewable: false, preview: null};
|
||||
}
|
||||
}
|
|
@ -16,14 +16,16 @@ limitations under the License.
|
|||
|
||||
import { TagID } from "./models";
|
||||
|
||||
const TILE_HEIGHT_PX = 34;
|
||||
const TILE_HEIGHT_PX = 44;
|
||||
|
||||
interface ISerializedListLayout {
|
||||
numTiles: number;
|
||||
showPreviews: boolean;
|
||||
}
|
||||
|
||||
export class ListLayout {
|
||||
private _n = 0;
|
||||
private _previews = false;
|
||||
|
||||
constructor(public readonly tagId: TagID) {
|
||||
const serialized = localStorage.getItem(this.key);
|
||||
|
@ -31,9 +33,19 @@ export class ListLayout {
|
|||
// We don't use the setters as they cause writes.
|
||||
const parsed = <ISerializedListLayout>JSON.parse(serialized);
|
||||
this._n = parsed.numTiles;
|
||||
this._previews = parsed.showPreviews;
|
||||
}
|
||||
}
|
||||
|
||||
public get showPreviews(): boolean {
|
||||
return this._previews;
|
||||
}
|
||||
|
||||
public set showPreviews(v: boolean) {
|
||||
this._previews = v;
|
||||
this.save();
|
||||
}
|
||||
|
||||
public get tileHeight(): number {
|
||||
return TILE_HEIGHT_PX;
|
||||
}
|
||||
|
@ -48,11 +60,28 @@ export class ListLayout {
|
|||
|
||||
public set visibleTiles(v: number) {
|
||||
this._n = v;
|
||||
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
|
||||
this.save();
|
||||
}
|
||||
|
||||
public get minVisibleTiles(): number {
|
||||
return 3;
|
||||
// the .65 comes from the CSS where the show more button is
|
||||
// mathematically 65% of a tile when floating.
|
||||
return 4.65;
|
||||
}
|
||||
|
||||
public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
|
||||
// Only apply the padding if we're about to use maxTiles as we need to
|
||||
// plan for the padding. If we're using n, the padding is already accounted
|
||||
// for by the resizing stuff.
|
||||
let padding = 0;
|
||||
if (maxTiles < n) {
|
||||
padding = possiblePadding;
|
||||
}
|
||||
return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
|
||||
}
|
||||
|
||||
public tilesToPixelsWithPadding(n: number, padding: number): number {
|
||||
return this.tilesToPixels(n) + padding;
|
||||
}
|
||||
|
||||
public tilesToPixels(n: number): number {
|
||||
|
@ -63,9 +92,14 @@ export class ListLayout {
|
|||
return px / this.tileHeight;
|
||||
}
|
||||
|
||||
private save() {
|
||||
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
|
||||
}
|
||||
|
||||
private serialize(): ISerializedListLayout {
|
||||
return {
|
||||
numTiles: this.visibleTiles,
|
||||
showPreviews: this.showPreviews,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,11 +31,14 @@ export class RoomListStoreTempProxy {
|
|||
return SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||
}
|
||||
|
||||
public static addListener(handler: () => void) {
|
||||
public static addListener(handler: () => void): RoomListStoreTempToken {
|
||||
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||
return RoomListStore.instance.on(UPDATE_EVENT, handler);
|
||||
const offFn = () => RoomListStore.instance.off(UPDATE_EVENT, handler);
|
||||
RoomListStore.instance.on(UPDATE_EVENT, handler);
|
||||
return new RoomListStoreTempToken(offFn);
|
||||
} else {
|
||||
return OldRoomListStore.addListener(handler);
|
||||
const token = OldRoomListStore.addListener(handler);
|
||||
return new RoomListStoreTempToken(() => token.remove());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,3 +50,12 @@ export class RoomListStoreTempProxy {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomListStoreTempToken {
|
||||
constructor(private offFn: () => void) {
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
this.offFn();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,6 +74,11 @@ export class TagWatcher {
|
|||
this.store.removeFilter(filter);
|
||||
}
|
||||
|
||||
// Destroy any and all old filter conditions to prevent resource leaks
|
||||
for (const filter of this.filters.values()) {
|
||||
filter.destroy();
|
||||
}
|
||||
|
||||
this.filters = newFilters;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -20,9 +20,11 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
|||
import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
|
||||
import { ITagMap, ITagSortingMap } from "../models";
|
||||
import DMRoomMap from "../../../../utils/DMRoomMap";
|
||||
import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition";
|
||||
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../../filters/IFilterCondition";
|
||||
import { EventEmitter } from "events";
|
||||
import { UPDATE_EVENT } from "../../../AsyncStore";
|
||||
import { ArrayUtil } from "../../../../utils/arrays";
|
||||
import { getEnumValues } from "../../../../utils/enums";
|
||||
|
||||
// TODO: Add locking support to avoid concurrent writes?
|
||||
|
||||
|
@ -184,22 +186,33 @@ export abstract class Algorithm extends EventEmitter {
|
|||
}
|
||||
|
||||
console.warn("Recalculating filtered room list");
|
||||
const allowedByFilters = new Set<Room>();
|
||||
const filters = Array.from(this.allowedByFilter.keys());
|
||||
const orderedFilters = new ArrayUtil(filters)
|
||||
.groupBy(f => f.relativePriority)
|
||||
.orderBy(getEnumValues(FilterPriority))
|
||||
.value;
|
||||
const newMap: ITagMap = {};
|
||||
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||
// Cheaply clone the rooms so we can more easily do operations on the list.
|
||||
// We optimize our lookups by trying to reduce sample size as much as possible
|
||||
// to the rooms we know will be deduped by the Set.
|
||||
const rooms = this.cachedRooms[tagId];
|
||||
const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r));
|
||||
const allowedRoomsInThisTag = [];
|
||||
for (const filter of filters) {
|
||||
let remainingRooms = rooms.map(r => r);
|
||||
let allowedRoomsInThisTag = [];
|
||||
let lastFilterPriority = orderedFilters[0].relativePriority;
|
||||
for (const filter of orderedFilters) {
|
||||
if (filter.relativePriority !== lastFilterPriority) {
|
||||
// Every time the filter changes priority, we want more specific filtering.
|
||||
// To accomplish that, reset the variables to make it look like the process
|
||||
// has started over, but using the filtered rooms as the seed.
|
||||
remainingRooms = allowedRoomsInThisTag;
|
||||
allowedRoomsInThisTag = [];
|
||||
lastFilterPriority = filter.relativePriority;
|
||||
}
|
||||
const filteredRooms = remainingRooms.filter(r => filter.isVisible(r));
|
||||
for (const room of filteredRooms) {
|
||||
const idx = remainingRooms.indexOf(room);
|
||||
if (idx >= 0) remainingRooms.splice(idx, 1);
|
||||
allowedByFilters.add(room);
|
||||
allowedRoomsInThisTag.push(room);
|
||||
}
|
||||
}
|
||||
|
@ -207,7 +220,8 @@ export abstract class Algorithm extends EventEmitter {
|
|||
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
|
||||
}
|
||||
|
||||
this.allowedRoomsByFilters = allowedByFilters;
|
||||
const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
|
||||
this.allowedRoomsByFilters = new Set(allowedRooms);
|
||||
this.filteredRooms = newMap;
|
||||
this.emit(LIST_UPDATED_EVENT);
|
||||
}
|
||||
|
|
|
@ -15,18 +15,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
|
||||
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { EventEmitter } from "events";
|
||||
import GroupStore from "../../GroupStore";
|
||||
import { arrayHasDiff } from "../../../utils/arrays";
|
||||
import { IDisposable } from "../../../utils/IDisposable";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
|
||||
/**
|
||||
* A filter condition for the room list which reveals rooms which
|
||||
* are a member of a given community.
|
||||
*/
|
||||
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDisposable {
|
||||
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
|
||||
private roomIds: string[] = [];
|
||||
|
||||
constructor(private community: Group) {
|
||||
|
@ -37,6 +37,11 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
|
|||
this.onStoreUpdate(); // trigger a false update to seed the store
|
||||
}
|
||||
|
||||
public get relativePriority(): FilterPriority {
|
||||
// Lowest priority so we can coarsely find rooms.
|
||||
return FilterPriority.Lowest;
|
||||
}
|
||||
|
||||
public isVisible(room: Room): boolean {
|
||||
return this.roomIds.includes(room.roomId);
|
||||
}
|
||||
|
@ -52,7 +57,7 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
|
|||
}
|
||||
};
|
||||
|
||||
public dispose(): void {
|
||||
public destroy(): void {
|
||||
GroupStore.off("update", this.onStoreUpdate);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,12 @@ import { EventEmitter } from "events";
|
|||
|
||||
export const FILTER_CHANGED = "filter_changed";
|
||||
|
||||
export enum FilterPriority {
|
||||
Lowest,
|
||||
// in the middle would be Low, Normal, and High if we had a need
|
||||
Highest,
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter condition for the room list, determining if a room
|
||||
* should be shown or not.
|
||||
|
@ -32,6 +38,12 @@ export const FILTER_CHANGED = "filter_changed";
|
|||
* as a change in the user's input), this emits FILTER_CHANGED.
|
||||
*/
|
||||
export interface IFilterCondition extends EventEmitter {
|
||||
/**
|
||||
* The relative priority that this filter should be applied with.
|
||||
* Lower priorities get applied first.
|
||||
*/
|
||||
relativePriority: FilterPriority;
|
||||
|
||||
/**
|
||||
* Determines if a given room should be visible under this
|
||||
* condition.
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
|
||||
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
/**
|
||||
|
@ -29,6 +29,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
|
|||
super();
|
||||
}
|
||||
|
||||
public get relativePriority(): FilterPriority {
|
||||
// We want this one to be at the highest priority so it can search within other filters.
|
||||
return FilterPriority.Highest;
|
||||
}
|
||||
|
||||
public get search(): string {
|
||||
return this._search;
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ function setCustomThemeVars(customTheme) {
|
|||
}
|
||||
}
|
||||
|
||||
function getCustomTheme(themeName) {
|
||||
export function getCustomTheme(themeName) {
|
||||
// set css variables
|
||||
const customThemes = SettingsStore.getValue("custom_themes");
|
||||
if (!customThemes) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -21,8 +21,8 @@ import { _t } from '../languageHandler';
|
|||
* formats numbers to fit into ~3 characters, suitable for badge counts
|
||||
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
|
||||
*/
|
||||
export function formatCount(count) {
|
||||
if (count < 1000) return count;
|
||||
export function formatCount(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return (count / 1000).toFixed(1) + "K";
|
||||
if (count < 100000) return (count / 1000).toFixed(0) + "K";
|
||||
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
|
||||
|
@ -34,7 +34,7 @@ export function formatCount(count) {
|
|||
* Format a count showing the whole number but making it a bit more readable.
|
||||
* e.g: 1000 => 1,000
|
||||
*/
|
||||
export function formatCountLong(count) {
|
||||
export function formatCountLong(count: number): string {
|
||||
const formatter = new Intl.NumberFormat();
|
||||
return formatter.format(count)
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ export function formatCountLong(count) {
|
|||
* format a size in bytes into a human readable form
|
||||
* e.g: 1024 -> 1.00 KB
|
||||
*/
|
||||
export function formatBytes(bytes, decimals = 2) {
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
|
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
|
|||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function formatCryptoKey(key) {
|
||||
export function formatCryptoKey(key: string): string {
|
||||
return key.match(/.{1,4}/g).join(" ");
|
||||
}
|
||||
/**
|
||||
|
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
|
|||
*
|
||||
* @return {number}
|
||||
*/
|
||||
export function hashCode(str) {
|
||||
export function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
let i;
|
||||
let chr;
|
||||
|
@ -87,7 +87,7 @@ export function hashCode(str) {
|
|||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getUserNameColorClass(userId) {
|
||||
export function getUserNameColorClass(userId: string): string {
|
||||
const colorNumber = (hashCode(userId) % 8) + 1;
|
||||
return `mx_Username_color${colorNumber}`;
|
||||
}
|
||||
|
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
|
|||
* @returns {string} a string constructed by joining `items` with a comma
|
||||
* between each item, but with the last item appended as " and [lastItem]".
|
||||
*/
|
||||
export function formatCommaSeparatedList(items, itemLimit) {
|
||||
export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
|
||||
const remaining = itemLimit === undefined ? 0 : Math.max(
|
||||
items.length - itemLimit, 0,
|
||||
);
|
||||
|
@ -119,3 +119,14 @@ export function formatCommaSeparatedList(items, itemLimit) {
|
|||
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number into a 'minimal' badge count (9, 98, 99+).
|
||||
* @param count The number to convert
|
||||
* @returns The badge count, stringified.
|
||||
*/
|
||||
export function formatMinimalBadgeCount(count: number): string {
|
||||
// we specifically go from "98" to "99+"
|
||||
if (count < 99) return count.toString();
|
||||
return "99+";
|
||||
}
|
|
@ -14,6 +14,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
export interface IDestroyable {
|
||||
destroy(): void;
|
||||
}
|
|
@ -45,3 +45,63 @@ export function arrayDiff<T>(a: T[], b: T[]): { added: T[], removed: T[] } {
|
|||
removed: a.filter(i => !b.includes(i)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions to perform LINQ-like queries on arrays.
|
||||
*/
|
||||
export class ArrayUtil<T> {
|
||||
/**
|
||||
* Create a new array helper.
|
||||
* @param a The array to help. Can be modified in-place.
|
||||
*/
|
||||
constructor(private a: T[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of this array, after all appropriate alterations.
|
||||
*/
|
||||
public get value(): T[] {
|
||||
return this.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an array by keys.
|
||||
* @param fn The key-finding function.
|
||||
* @returns This.
|
||||
*/
|
||||
public groupBy<K>(fn: (a: T) => K): GroupedArray<K, T> {
|
||||
const obj = this.a.reduce((rv: Map<K, T[]>, val: T) => {
|
||||
const k = fn(val);
|
||||
if (!rv.has(k)) rv.set(k, []);
|
||||
rv.get(k).push(val);
|
||||
return rv;
|
||||
}, new Map<K, T[]>());
|
||||
return new GroupedArray(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions to perform LINQ-like queries on groups (maps).
|
||||
*/
|
||||
export class GroupedArray<K, T> {
|
||||
/**
|
||||
* Creates a new group helper.
|
||||
* @param val The group to help. Can be modified in-place.
|
||||
*/
|
||||
constructor(private val: Map<K, T[]>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the grouping into an array using the provided key order.
|
||||
* @param keyOrder The key order.
|
||||
* @returns An array helper of the result.
|
||||
*/
|
||||
public orderBy(keyOrder: K[]): ArrayUtil<T> {
|
||||
const a: T[] = [];
|
||||
for (const k of keyOrder) {
|
||||
if (!this.val.has(k)) continue;
|
||||
a.push(...this.val.get(k));
|
||||
}
|
||||
return new ArrayUtil(a);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the values for an enum.
|
||||
* @param e The enum.
|
||||
* @returns The enum values.
|
||||
*/
|
||||
export function getEnumValues<T>(e: any): T[] {
|
||||
const keys = Object.keys(e);
|
||||
return keys
|
||||
.filter(k => ['string', 'number'].includes(typeof(e[k])))
|
||||
.map(k => e[k]);
|
||||
}
|
|
@ -15,7 +15,8 @@
|
|||
"types": [
|
||||
"node",
|
||||
"react",
|
||||
"flux"
|
||||
"flux",
|
||||
"react-transition-group"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
29
yarn.lock
29
yarn.lock
|
@ -968,7 +968,7 @@
|
|||
core-js-pure "^3.0.0"
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4":
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
|
||||
version "7.10.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839"
|
||||
integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg==
|
||||
|
@ -1352,6 +1352,13 @@
|
|||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-transition-group@^4.4.0":
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d"
|
||||
integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.9":
|
||||
version "16.9.35"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368"
|
||||
|
@ -2835,7 +2842,7 @@ cssstyle@^1.0.0:
|
|||
dependencies:
|
||||
cssom "0.3.x"
|
||||
|
||||
csstype@^2.2.0:
|
||||
csstype@^2.2.0, csstype@^2.6.7:
|
||||
version "2.6.10"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b"
|
||||
integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==
|
||||
|
@ -3054,6 +3061,14 @@ doctrine@^3.0.0:
|
|||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
dom-helpers@^5.0.1:
|
||||
version "5.1.4"
|
||||
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b"
|
||||
integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
csstype "^2.6.7"
|
||||
|
||||
dom-serializer@0, dom-serializer@^0.2.1:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
|
||||
|
@ -7136,6 +7151,16 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0:
|
|||
react-is "^16.8.6"
|
||||
scheduler "^0.19.1"
|
||||
|
||||
react-transition-group@^4.4.1:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
|
||||
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.5.5"
|
||||
dom-helpers "^5.0.1"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react@^16.9.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
|
||||
|
|
Loading…
Reference in New Issue