diff --git a/docs/img/RoomListStore2.png b/docs/img/RoomListStore2.png new file mode 100644 index 0000000000..9952d1c910 Binary files /dev/null and b/docs/img/RoomListStore2.png differ diff --git a/src/stores/room-list/README.md b/docs/room-list-store.md similarity index 63% rename from src/stores/room-list/README.md rename to docs/room-list-store.md index f4a56130ca..53f0527209 100644 --- a/src/stores/room-list/README.md +++ b/docs/room-list-store.md @@ -2,20 +2,31 @@ It's so complicated it needs its own README. +![](img/RoomListStore2.png) + +Legend: +* Orange = External event. +* Purple = Deterministic flow. +* Green = Algorithm definition. +* Red = Exit condition/point. +* Blue = Process definition. + ## Algorithms involved There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting. Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting -Algorithm respectively. The list algorithm determines the behaviour of the room list whereas the sorting -algorithm determines how rooms get ordered within tags affected by the list algorithm. +Algorithm respectively. The list algorithm determines the primary ordering of a given tag whereas the +tag sorting defines how rooms within that tag get sorted, at the discretion of the list ordering. + +Behaviour of the overall room list (sticky rooms, etc) are determined by the generically-named Algorithm +class. Here is where much of the coordination from the room list store is done to figure out which list +algorithm to call, instead of having all the logic in the room list store itself. -Behaviour of the room list takes the shape of determining what features the room list supports, as well -as determining where and when to apply the sorting algorithm in a tag. The importance algorithm, which -is described later in this doc, is an example of an algorithm which makes heavy behavioural changes -to the room list. Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm -the power to decide when and how to apply the tag sorting, if at all. +the power to decide when and how to apply the tag sorting, if at all. For example, the importance algorithm, +later described in this document, heavily uses the list ordering behaviour to break the tag into categories. +Each category then gets sorted by the appropriate tag sorting algorithm. ### Tag sorting algorithm: Alphabetical @@ -70,33 +81,33 @@ Conveniently, each tag gets ordered by those categories as presented: red rooms above bold, etc. Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm -gets applied to each category in a sub-sub-list fashion. This should result in the red rooms (for example) +gets applied to each category in a sub-list fashion. This should result in the red rooms (for example) being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but collectively the tag will be sorted into categories with red being at the top. - +## Sticky rooms -The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing. -The sticky room will remain in position on the room list regardless of other factors going on as typically -clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms -above the selected room at all times, where N is the number of rooms above the selected rooms when it was -selected. +When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm. +From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class +manages which room is sticky. This is to ensure that all algorithms handle it the same. -For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one -room above their selection at all times. If they receive another notification, and the tag ordering is -specified as Recent, they'll see the new notification go to the top position, and the one that was previously -there fall behind the sticky room. +The sticky flag is simply to say it will not move higher or lower down the list while it is active. For +example, if using the importance algorithm, the room would naturally become idle once viewed and thus +would normally fly down the list out of sight. The sticky room concept instead holds it in place, never +letting it fly down until the user moves to another room. -The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the -tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another -room, the previous sticky room gets recalculated to determine which category it needs to be in as the user -could have been scrolled up while new messages were received. +Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky +room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and +selects the middle room, they will see exactly one room above their selection at all times. If they +receive another notification which causes the room to move into the topmost position, the room that was +above the sticky room will move underneath to allow for the new room to take the top slot, maintaining +the sticky room's position. -Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what -kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user -selecting the third room (leaving 2 above it), and then having the rooms above it read on another device. -This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain -2 rooms above the sticky room. +Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries +and thus the user can see a shift in what kinds of rooms move around their selection. An example would +be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having +the rooms above it read on another device. This would result in 1 red room and 1 other kind of room +above the sticky room as it will try to maintain 2 rooms above the sticky room. An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain @@ -128,13 +139,13 @@ maintain the caching behaviour described above. ## Class breakdowns -The `RoomListStore` is the major coordinator of various `Algorithm` implementations, which take care -of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` superclass is also -responsible for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: -tags get defined on rooms and are not defined as a collection of rooms (unlike how they are presented -to the user). Various list-specific utilities are also included, though they are expected to move -somewhere more general when needed. For example, the `membership` utilities could easily be moved -elsewhere as needed. +The `RoomListStore` is the major coordinator of various algorithm implementations, which take care +of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` class is responsible +for figuring out which tags get which rooms, as Matrix specifies them as a reverse map: tags get +defined on rooms and are not defined as a collection of rooms (unlike how they are presented to the +user). Various list-specific utilities are also included, though they are expected to move somewhere +more general when needed. For example, the `membership` utilities could easily be moved elsewhere +as needed. The various bits throughout the room list store should also have jsdoc of some kind to help describe what they do and how they work. diff --git a/package.json b/package.json index 69fe175d72..966119d1eb 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "create-react-class": "^15.6.0", "diff-dom": "^4.1.3", "diff-match-patch": "^1.0.4", - "emojibase-data": "^4.0.2", - "emojibase-regex": "^3.0.0", + "emojibase-data": "^5.0.1", + "emojibase-regex": "^4.0.1", "escape-html": "^1.0.3", "file-saver": "^1.3.3", "filesize": "3.5.6", @@ -93,6 +93,8 @@ "react-beautiful-dnd": "^4.0.1", "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", @@ -125,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", diff --git a/res/css/_common.scss b/res/css/_common.scss index ebeeb381e6..e83c6aaeda 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -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; + } + } + } +} diff --git a/res/css/_components.scss b/res/css/_components.scss index 5a7630a51f..31f319e76f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; @@ -115,6 +118,7 @@ @import "./views/elements/_Slider.scss"; @import "./views/elements/_Spinner.scss"; @import "./views/elements/_StyledCheckbox.scss"; +@import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_SyntaxHighlight.scss"; @import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_ToggleSwitch.scss"; @@ -169,17 +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"; diff --git a/res/css/_font-sizes.scss b/res/css/_font-sizes.scss index 2d7ab67e40..5b876ab11d 100644 --- a/res/css/_font-sizes.scss +++ b/res/css/_font-sizes.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; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss new file mode 100644 index 0000000000..eca50bb639 --- /dev/null +++ b/res/css/structures/_LeftPanel2.scss @@ -0,0 +1,173 @@ +/* +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 + +$tagPanelWidth: 70px; // only applies in this file, used for calculations + +.mx_LeftPanel2 { + background-color: $roomlist2-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: $roomlist2-button-bg-color; + 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; + } + } + + // These styles override the defaults for the minimized (66px) layout + &.mx_LeftPanel2_minimized { + min-width: unset; + + // We have to forcefully set the width to override the resizer's style attribute. + width: calc(68px + $tagPanelWidth) !important; + + .mx_LeftPanel2_roomListContainer { + width: 68px; + + .mx_LeftPanel2_userHeader { + .mx_LeftPanel2_headerRow { + justify-content: center; + } + + .mx_LeftPanel2_userAvatarContainer { + margin-right: 0; + } + } + + .mx_LeftPanel2_filterContainer { + // Organize the flexbox into a centered column layout + flex-direction: column; + justify-content: center; + + .mx_LeftPanel2_exploreButton { + margin-left: 0; + margin-top: 8px; + background-color: transparent; + } + } + } + } +} diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index 05c703ab6d..08ed9e5559 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -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; diff --git a/res/css/structures/_RoomSearch.scss b/res/css/structures/_RoomSearch.scss new file mode 100644 index 0000000000..eddc4cd064 --- /dev/null +++ b/res/css/structures/_RoomSearch.scss @@ -0,0 +1,81 @@ +/* +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: $roomlist2-button-bg-color; + 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; + } + + &.mx_RoomSearch_minimized { + border-radius: 32px; + height: auto; + width: auto; + padding: 8px; + + .mx_RoomSearch_icon { + margin-left: 0; + } + } +} diff --git a/res/css/structures/_UserMenuButton.scss b/res/css/structures/_UserMenuButton.scss new file mode 100644 index 0000000000..1fbbbb5fd8 --- /dev/null +++ b/res/css/structures/_UserMenuButton.scss @@ -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: 247px; + + .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; + } + } +} diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index 14081f1e99..df0b8c6d94 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -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) { diff --git a/res/css/views/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss new file mode 100644 index 0000000000..a3ae823079 --- /dev/null +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -0,0 +1,98 @@ +/* +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. +*/ + +/** +* This component expects the parent to specify a positive padding and +* width +*/ + +.mx_RadioButton { + + $radio-circle-color: $muted-fg-color; + $active-radio-circle-color: $accent-color; + position: relative; + + display: flex; + align-items: center; + flex-grow: 1; + + > span { + flex-grow: 1; + + display: flex; + + 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; + appearance: none; + + + div { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + height: $font-16px; + width: $font-16px; + + border: $font-1-5px solid $radio-circle-color; + border-radius: $font-16px; + + > div { + box-sizing: border-box; + + height: $font-8px; + width: $font-8px; + + border-radius: $font-8px; + } + } + } + + > input[type=radio]:checked { + + div { + border-color: $active-radio-circle-color; + + > div { + background: $active-radio-circle-color; + } + } + } + + > input[type=radio]:disabled { + + div { + > div { + display: none; + } + } + } +} diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index de4a5538cd..44eb8c1e89 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -108,12 +108,12 @@ $left-gutter: 65px; top: 27px; } - .mx_EventTile_continuation .mx_EventTile_readAvatars, - .mx_EventTile_emote .mx_EventTile_readAvatars { + &.mx_EventTile_continuation .mx_EventTile_readAvatars, + &.mx_EventTile_emote .mx_EventTile_readAvatars { top: 5px; } - .mx_EventTile_info .mx_EventTile_readAvatars { + &.mx_EventTile_info .mx_EventTile_readAvatars { top: 4px; } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index a8eb35eeed..b4ad117573 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -43,6 +43,10 @@ $irc-line-height: $font-18px; > .mx_EventTile_msgOption { order: 5; flex-shrink: 0; + + .mx_EventTile_readAvatars { + top: 0.2rem; // ($irc-line-height - avatar height) / 2 + } } > .mx_SenderProfile { @@ -78,7 +82,7 @@ $irc-line-height: $font-18px; align-items: center; // Need to use important to override the js provided height and width values. - > .mx_BaseAvatar, .mx_BaseAvatar > * { + > .mx_BaseAvatar, > .mx_BaseAvatar > * { height: $font-14px !important; width: $font-14px !important; font-size: $font-10px !important; @@ -96,10 +100,16 @@ $irc-line-height: $font-18px; position: relative; right: unset; left: unset; + top: 0; + padding: 0; - order: 3; + flex-shrink: 0; flex-grow: 0; + + height: $font-18px; + + background-position: center; } .mx_EventTile_line { diff --git a/res/css/views/rooms/_NotificationBadge.scss b/res/css/views/rooms/_NotificationBadge.scss new file mode 100644 index 0000000000..521f1dfc20 --- /dev/null +++ b/res/css/views/rooms/_NotificationBadge.scss @@ -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-default-badge-bg-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 + } + } +} diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss new file mode 100644 index 0000000000..ac5a9fc34e --- /dev/null +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -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; + } +} diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index 50a9e7ee1f..c23c19699d 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -15,6 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RoomList.mx_RoomList2 { + overflow-y: auto; +} + .mx_RoomList { /* take up remaining space below TopLeftMenu */ flex: 1; diff --git a/res/css/views/rooms/_RoomList2.scss b/res/css/views/rooms/_RoomList2.scss new file mode 100644 index 0000000000..5b78020626 --- /dev/null +++ b/res/css/views/rooms/_RoomList2.scss @@ -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 +} diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss new file mode 100644 index 0000000000..3f5f654494 --- /dev/null +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -0,0 +1,327 @@ +/* +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_RoomSublist during replacement of old component + +.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; + border-radius: 32px; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 4px; + left: 4px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + 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: $roomlist2-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; + } + + &:not(.mx_RoomSublist2_minimized) > .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; + margin-left: 16px; + visibility: visible; + background-color: $roomlist2-button-bg-color; + } + } + } + + &.mx_RoomSublist2_minimized { + .mx_RoomSublist2_headerContainer { + height: auto; + flex-direction: column; + position: relative; + + .mx_RoomSublist2_badgeContainer { + order: 1; + align-self: flex-end; + margin-right: 0; + } + + .mx_RoomSublist2_headerText { + order: 2; + max-width: 100%; + } + + .mx_RoomSublist2_auxButton { + order: 4; + visibility: visible; + width: 32px !important; // !important to override hover styles + height: 32px !important; // !important to override hover styles + margin-left: 0 !important; // !important to override hover styles + background-color: $roomlist2-button-bg-color; + margin-top: 8px; + + &::before { + top: 8px; + left: 8px; + } + } + } + + .mx_RoomSublist2_resizeBox { + align-items: center; + + .mx_RoomSublist2_showMoreButton { + flex-direction: column; + + .mx_RoomSublist2_showMoreButtonChevron { + margin-right: 12px; // to center + } + } + } + + &:hover, &.mx_RoomSublist2_hasMenuOpen { + .mx_RoomSublist2_menuButton { + visibility: visible; + position: absolute; + bottom: 48px; // align to middle of name, 40px for aux button (with padding) and 8px for alignment + right: 0; + width: 16px; + height: 16px; + border-radius: 0; + z-index: 1; // occlude the list name + + // This is the same color as the left panel background because it needs + // to occlude the sublist title + background-color: $roomlist2-bg-color; + + &::before { + top: 0; + left: 0; + } + } + + .mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { + .mx_RoomSublist2_menuButton { + bottom: 8px; // align to the middle of name, 40px less than the `bottom` above. + } + } + } + } +} + +// We have a hover style on the room list with no specific list hovered, so account for that +.mx_RoomList2:hover .mx_RoomSublist2:not(.mx_RoomSublist2_minimized), +.mx_RoomSublist2_hasMenuOpen:not(.mx_RoomSublist2_minimized) { + .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: 4px; + } + + .mx_RadioButton, .mx_Checkbox { + margin-top: 8px; + } + + .mx_Checkbox { + margin-left: -8px; // to counteract the indent from the component + } +} diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss new file mode 100644 index 0000000000..f74d0ff5a4 --- /dev/null +++ b/res/css/views/rooms/_RoomTile2.scss @@ -0,0 +1,200 @@ +/* +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'); + } + + &:not(.mx_RoomTile2_minimized) { + &: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_minimized { + flex-direction: column; + align-items: center; + position: relative; + + .mx_RoomTile2_avatarContainer { + margin-right: 0; + } + + .mx_RoomTile2_badgeContainer { + position: absolute; + top: 0; + right: 0; + height: 18px; + } + } +} + +.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'); + } +} diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index e82ae3c575..7308bb7177 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -43,3 +43,7 @@ limitations under the License. padding-left: 20px; padding-right: 5px; } + +.mx_SettingsTab_customFontSizeField { + margin-left: calc($font-16px + 10px); +} diff --git a/res/img/feather-customised/archive.svg b/res/img/feather-customised/archive.svg new file mode 100644 index 0000000000..428882c87b --- /dev/null +++ b/res/img/feather-customised/archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/arrow-down.svg b/res/img/feather-customised/arrow-down.svg new file mode 100644 index 0000000000..4f84f627bd --- /dev/null +++ b/res/img/feather-customised/arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/compass.svg b/res/img/feather-customised/compass.svg new file mode 100644 index 0000000000..3296260803 --- /dev/null +++ b/res/img/feather-customised/compass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/more-horizontal.svg b/res/img/feather-customised/more-horizontal.svg new file mode 100644 index 0000000000..dc6a85564e --- /dev/null +++ b/res/img/feather-customised/more-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/star.svg b/res/img/feather-customised/star.svg new file mode 100644 index 0000000000..bcdc31aa47 --- /dev/null +++ b/res/img/feather-customised/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/feather-customised/sun.svg b/res/img/feather-customised/sun.svg new file mode 100644 index 0000000000..7f51b94d1c --- /dev/null +++ b/res/img/feather-customised/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 9fb36ef1a3..50f3c08782 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -103,6 +103,24 @@ $eventtile-meta-color: $roomtopic-color; $header-divider-color: $header-panel-text-primary-color; +// ******************** + +// V2 Room List +// TODO: Remove the 2 from all of these when the new list takes over + +$theme-button-bg-color: #e3e8f0; + +$roomlist2-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons +$roomlist2-bg-color: $header-panel-bg-color; + +$roomsublist2-divider-color: #e9eaeb; + +$roomtile2-preview-color: #9e9e9e; +$roomtile2-default-badge-bg-color: #61708b; +$roomtile2-selected-bg-color: #1A1D23; + +// ******************** + $roomtile-name-color: $header-panel-text-primary-color; $roomtile-selected-color: $text-primary-color; $roomtile-notified-color: $text-primary-color; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 78fe2a74c5..18a25b2663 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -172,6 +172,22 @@ $header-divider-color: #91A1C0; // ******************** +// V2 Room List +// TODO: Remove the 2 from all of these when the new list takes over + +$theme-button-bg-color: #e3e8f0; + +$roomlist2-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons +$roomlist2-bg-color: $header-panel-bg-color; + +$roomsublist2-divider-color: #e9eaeb; + +$roomtile2-preview-color: #9e9e9e; +$roomtile2-default-badge-bg-color: #61708b; +$roomtile2-selected-bg-color: #FFF; + +// ******************** + $roomtile-name-color: #61708b; $roomtile-badge-fg-color: $accent-fg-color; $roomtile-selected-color: #212121; diff --git a/src/ActiveRoomObserver.js b/src/ActiveRoomObserver.js index d6fbb460b5..b7695d401d 100644 --- a/src/ActiveRoomObserver.js +++ b/src/ActiveRoomObserver.js @@ -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); } } diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 249ad8381c..25445b1c74 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -31,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner"; // Polyfill for Canvas.toBlob API using Canvas.toDataURL import "blueimp-canvas-to-blob"; +import { Action } from "./dispatcher/actions"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -529,7 +530,7 @@ export default class ContentMessages { dis.dispatch({action: 'upload_started'}); // Focus the composer view - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); function onProgress(ev) { upload.total = ev.total; diff --git a/src/Searching.js b/src/Searching.js index 663328fe41..9631afc36b 100644 --- a/src/Searching.js +++ b/src/Searching.js @@ -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; } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 3607d7a676..09cfb67de7 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -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 ''; } diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 3b3926b724..c89a0ceeeb 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -96,6 +96,17 @@ export default class WidgetMessaging { }); } + /** + * Tells the widget that it should terminate now. + * @returns {Promise<*>} Resolves when widget has acknowledged the message. + */ + terminate() { + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: KnownWidgetActions.Terminate, + }); + } + /** * Request a screenshot from a widget * @return {Promise} To be resolved with screenshot data when it has been generated diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index ff8d35a114..209f219598 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -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 = () => { diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index a1b4f49c56..bae69b5631 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -26,7 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler'; import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import Analytics from "../../Analytics"; -import RoomList2 from "../views/rooms/RoomList2"; +import {Action} from "../../dispatcher/actions"; const LeftPanel = createReactClass({ @@ -198,7 +198,7 @@ const LeftPanel = createReactClass({ onSearchCleared: function(source) { if (source === "keyboard") { - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); } this.setState({searchExpanded: false}); }, @@ -252,7 +252,7 @@ const LeftPanel = createReactClass({ if (!this.props.collapsed) { exploreButton = (
- dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")} + dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}
); } @@ -274,28 +274,15 @@ const LeftPanel = createReactClass({ breadcrumbs = (); } - let roomList = null; - if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { - roomList = ; - } else { - roomList = ; - } + const roomList = ; return (
diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx new file mode 100644 index 0000000000..a644aa4837 --- /dev/null +++ b/src/components/structures/LeftPanel2.tsx @@ -0,0 +1,201 @@ +/* +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 TagPanel from "./TagPanel"; +import classNames from "classnames"; +import dis from "../../dispatcher/dispatcher"; +import { _t } from "../../languageHandler"; +import SearchBox from "./SearchBox"; +import RoomList2 from "../views/rooms/RoomList2"; +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 * + ******************************************************************* + * 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 { + isMinimized: boolean; +} + +interface IState { + searchFilter: string; // TODO: Move search into room list? + showBreadcrumbs: boolean; +} + +export default class LeftPanel2 extends React.Component { + // TODO: Properly support TagPanel + // TODO: Properly support searching/filtering + // TODO: Properly support breadcrumbs + // TODO: a11y + // TODO: actually make this useful in general (match design proposals) + // TODO: Fadable support (is this still needed?) + + constructor(props: IProps) { + super(props); + + this.state = { + 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 onExplore = () => { + dis.fire(Action.ViewRoomDirectory); + }; + + 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 = ( +
+ {this.props.isMinimized ? null : } +
+ ); + } + + let name = {displayName}; + let buttons = ( + + + + ); + if (this.props.isMinimized) { + name = null; + buttons = null; + } + + return ( +
+
+ + + + {name} + {buttons} +
+ {breadcrumbs} +
+ ); + } + + private renderSearchExplore(): React.ReactNode { + // TODO: Collapsed support + + return ( +
+ + +
+ ); + } + + public render(): React.ReactNode { + const tagPanel = ( +
+ +
+ ); + + // TODO: Improve props for RoomList2 + const roomList = {/*TODO*/}} + resizeNotifier={null} + collapsed={false} + searchFilter={this.state.searchFilter} + onFocus={() => {/*TODO*/}} + onBlur={() => {/*TODO*/}} + isMinimized={this.props.isMinimized} + />; + + // TODO: Conference handling / calls + + const containerClasses = classNames({ + "mx_LeftPanel2": true, + "mx_LeftPanel2_minimized": this.props.isMinimized, + }); + + return ( +
+ {tagPanel} + +
+ ); + } +} diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 7fcaadf7a5..f37f77b31b 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -51,6 +51,8 @@ import { showToast as showServerLimitToast, hideToast as hideServerLimitToast } from "../../toasts/ServerLimitToast"; +import { Action } from "../../dispatcher/actions"; +import LeftPanel2 from "./LeftPanel2"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -358,7 +360,7 @@ class LoggedInView extends React.PureComponent { // refocusing during a paste event will make the // paste end up in the newly focused element, // so dispatch synchronously before paste happens - dis.dispatch({action: 'focus_composer'}, true); + dis.fire(Action.FocusComposer, true); } }; @@ -450,9 +452,7 @@ class LoggedInView extends React.PureComponent { // composer, so CTRL+` it is if (ctrlCmdOnly) { - dis.dispatch({ - action: 'toggle_top_left_menu', - }); + dis.fire(Action.ToggleUserMenu); handled = true; } break; @@ -508,7 +508,7 @@ class LoggedInView extends React.PureComponent { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input - dis.dispatch({action: 'focus_composer'}, true); + dis.fire(Action.FocusComposer, true); ev.stopPropagation(); // we should *not* preventDefault() here as // that would prevent typing in the now-focussed composer @@ -667,6 +667,20 @@ class LoggedInView extends React.PureComponent { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } + let leftPanel = ( + + ); + if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { + // TODO: Supply props like collapsed and disabled to LeftPanel2 + leftPanel = ( + + ); + } + return (
{
- + { leftPanel } { pageElement }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index ec65fd6957..4ce29ea772 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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 { @@ -347,7 +348,7 @@ export default class MatrixChat extends React.PureComponent { Analytics.trackPageChange(durationMs); } if (this.focusComposer) { - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); this.focusComposer = false; } } @@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent { 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 { 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); @@ -1363,7 +1367,7 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(); } - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); this.setState({ ready: true, }); @@ -1607,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent { 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', diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx new file mode 100644 index 0000000000..345cf83d31 --- /dev/null +++ b/src/components/structures/RoomSearch.tsx @@ -0,0 +1,172 @@ +/* +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; + isMinimized: boolean; +} + +interface IState { + query: string; + focused: boolean; +} + +export default class RoomSearch extends React.PureComponent { + private dispatcherRef: string; + private inputRef: React.RefObject = 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 openSearch = () => { + defaultDispatcher.dispatch({action: "show_left_panel"}); + }; + + 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) => { + 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, + 'mx_RoomSearch_minimized': this.props.isMinimized, + }); + + const inputClasses = classNames({ + 'mx_RoomSearch_input': true, + 'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused, + }); + + let icon = ( +
+ ); + let input = ( + + ); + let clearButton = ( + + ); + + if (this.props.isMinimized) { + icon = ( + + ); + input = null; + clearButton = null; + } + + return ( +
+ {icon} + {input} + {clearButton} +
+ ); + } +} diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index dd4b9759d6..65d062cfaa 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -26,6 +26,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; +import {Action} from "../../dispatcher/actions"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -127,12 +128,12 @@ export default createReactClass({ _onResendAllClick: function() { Resend.resendUnsentEvents(this.props.room); - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); }, _onCancelAllClick: function() { Resend.cancelUnsentEvents(this.props.room); - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); }, _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 49d7e3c238..ab3da035c4 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -55,6 +55,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile"; import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { shieldStatusForRoom } from '../../utils/ShieldUtils'; +import {Action} from "../../dispatcher/actions"; const DEBUG = false; let debuglog = function() {}; @@ -1162,7 +1163,7 @@ export default createReactClass({ ev.dataTransfer.files, this.state.room.roomId, this.context, ); this.setState({ draggingFile: false }); - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); }, onDragLeaveOrEnd: function(ev) { @@ -1368,7 +1369,7 @@ export default createReactClass({ event: null, }); } - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); }, onLeaveClick: function() { @@ -1457,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() { @@ -1479,7 +1478,7 @@ export default createReactClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { this._messagePanel.jumpToLiveTimeline(); - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); }, // jump up to wherever our read marker is diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index c0e0e58db8..704dbf8832 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -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 { - 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, }; } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 95dc42fcee..d469a41cc8 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -798,6 +798,9 @@ const TimelinePanel = createReactClass({ readMarkerVisible: false, }); } + + // Send the updated read marker (along with read receipt) to the server + this.sendReadReceipt(); }, diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js index 234dc661f9..71e7e61406 100644 --- a/src/components/structures/TopLeftMenuButton.js +++ b/src/components/structures/TopLeftMenuButton.js @@ -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(); } }; diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx new file mode 100644 index 0000000000..41b2c3ab60 --- /dev/null +++ b/src/components/structures/UserMenuButton.tsx @@ -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 { + private dispatcherRef: string; + private themeWatcherRef: string; + private buttonRef: React.RefObject = 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 = ( +
+ {_t( + "Upgrade to your own domain", {}, + { + a: sub => ( + {sub} + ), + }, + )} +
+ ); + } + + const elementRect = this.buttonRef.current.getBoundingClientRect(); + contextMenu = ( + +
+
+
+ + {this.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
+
+ {_t("Switch +
+
+ {hostingLink} +
+
    +
  • + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> + + {_t("Notification settings")} + +
  • +
  • + this.onSettingsOpen(e, USER_SECURITY_TAB)}> + + {_t("Security & privacy")} + +
  • +
  • + this.onSettingsOpen(e, null)}> + + {_t("All settings")} + +
  • +
  • + + + {_t("Archived rooms")} + +
  • +
  • + + + {_t("Feedback")} + +
  • +
+
+
+
    +
  • + + + {_t("Sign out")} + +
  • +
+
+
+
+ ); + } + + return ( + + + ... + + {contextMenu} + + ) + } +} diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index c2b98cd9f3..7ad1001f75 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -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", , )); tabs.push(new Tab( + ROOM_SECURITY_TAB, _td("Security & Privacy"), "mx_RoomSettingsDialog_securityIcon", , )); tabs.push(new Tab( + ROOM_ROLES_TAB, _td("Roles & Permissions"), "mx_RoomSettingsDialog_rolesIcon", , )); tabs.push(new Tab( + ROOM_NOTIFICATIONS_TAB, _td("Notifications"), "mx_RoomSettingsDialog_notificationsIcon", , @@ -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", , @@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component { } tabs.push(new Tab( + ROOM_ADVANCED_TAB, _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", , diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index 4592d921a9..1f1a8d1523 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -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", , )); tabs.push(new Tab( + USER_APPEARANCE_TAB, _td("Appearance"), "mx_UserSettingsDialog_appearanceIcon", , )); tabs.push(new Tab( + USER_FLAIR_TAB, _td("Flair"), "mx_UserSettingsDialog_flairIcon", , )); tabs.push(new Tab( + USER_NOTIFICATIONS_TAB, _td("Notifications"), "mx_UserSettingsDialog_bellIcon", , )); tabs.push(new Tab( + USER_PREFERENCES_TAB, _td("Preferences"), "mx_UserSettingsDialog_preferencesIcon", , )); tabs.push(new Tab( + USER_VOICE_TAB, _td("Voice & Video"), "mx_UserSettingsDialog_voiceIcon", , )); tabs.push(new Tab( + USER_SECURITY_TAB, _td("Security & Privacy"), "mx_UserSettingsDialog_securityIcon", , )); if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) { tabs.push(new Tab( + USER_LABS_TAB, _td("Labs"), "mx_UserSettingsDialog_labsIcon", , @@ -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", , )); } tabs.push(new Tab( + USER_HELP_TAB, _td("Help & About"), "mx_UserSettingsDialog_helpIcon", , @@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
- +
); diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.tsx similarity index 67% rename from src/components/views/elements/AccessibleButton.js rename to src/components/views/elements/AccessibleButton.tsx index d708a44ab2..18dd43ad02 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.tsx @@ -15,9 +15,36 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; import {Key} from '../../../Keyboard'; +import classnames from 'classnames'; + +export type ButtonEvent = React.MouseEvent | React.KeyboardEvent + +/** + * 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 { + inputRef?: React.Ref; + 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 { + ref?: React.Ref; +} /** * AccessibleButton is a generic wrapper for any element that should be treated @@ -27,11 +54,20 @@ import {Key} from '../../../Keyboard'; * @param {Object} props react element properties * @returns {Object} rendered react */ -export default function AccessibleButton(props) { - const {element, onClick, children, kind, disabled, ...restProps} = props; +export default function AccessibleButton({ + element, + onClick, + children, + kind, + disabled, + inputRef, + className, + ...restProps +}: IProps) { + const newProps: IAccessibleButtonProps = restProps; if (!disabled) { - restProps.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 @@ -39,7 +75,7 @@ export default function AccessibleButton(props) { // And divs which we report as role button to assistive technologies. // Browsers handle space and enter keypresses differently and we are only adjusting to the // inconsistencies here - restProps.onKeyDown = function(e) { + newProps.onKeyDown = (e) => { if (e.key === Key.ENTER) { e.stopPropagation(); e.preventDefault(); @@ -50,7 +86,7 @@ export default function AccessibleButton(props) { e.preventDefault(); } }; - restProps.onKeyUp = function(e) { + newProps.onKeyUp = (e) => { if (e.key === Key.SPACE) { e.stopPropagation(); e.preventDefault(); @@ -64,53 +100,22 @@ export default function AccessibleButton(props) { } // Pass through the ref - used for keyboard shortcut access to some buttons - restProps.ref = restProps.inputRef; - delete restProps.inputRef; + newProps.ref = inputRef; - restProps.className = (restProps.className ? restProps.className + " " : "") + "mx_AccessibleButton"; - - if (kind) { - // We apply a hasKind class to maintain backwards compatibility with - // buttons which might not know about kind and break - restProps.className += " mx_AccessibleButton_hasKind mx_AccessibleButton_kind_" + kind; - } - - if (disabled) { - restProps.className += " mx_AccessibleButton_disabled"; - restProps["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. - */ -AccessibleButton.propTypes = { - children: PropTypes.node, - inputRef: PropTypes.oneOfType([ - // Either a function - PropTypes.func, - // Or the instance of a DOM native element - PropTypes.shape({ current: PropTypes.instanceOf(Element) }), - ]), - element: PropTypes.string, - onClick: PropTypes.func.isRequired, - - // The kind of button, similar to how Bootstrap works. - // See available classes for AccessibleButton for options. - kind: PropTypes.string, - // The ARIA role - role: PropTypes.string, - // The tabIndex - tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - disabled: PropTypes.bool, -}; - AccessibleButton.defaultProps = { element: 'div', role: 'button', diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 527436b0e4..9129b8fe48 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -39,6 +39,8 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; +import {Capability} from "../../../widgets/WidgetApi"; +import {sleep} from "../../../utils/promise"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -341,23 +343,37 @@ export default class AppTile extends React.Component { /** * Ends all widget interaction, such as cancelling calls and disabling webcams. * @private + * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ _endWidgetActions() { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Riot instance is located. - this._appFrame.current.src = 'about:blank'; + let terminationPromise; + + if (this._hasCapability(Capability.ReceiveTerminate)) { + // Wait for widget to terminate within a timeout + const timeout = 2000; + const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); + terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); + } else { + terminationPromise = Promise.resolve(); } - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); + return terminationPromise.finally(() => { + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 + if (this._appFrame.current) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Riot instance is located. + this._appFrame.current.src = 'about:blank'; + } + + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); + }); } /* If user has permission to modify widgets, delete the widget, @@ -381,12 +397,12 @@ export default class AppTile extends React.Component { } this.setState({deleting: true}); - this._endWidgetActions(); - - WidgetUtils.setRoomWidget( - this.props.room.roomId, - this.props.app.id, - ).catch((e) => { + this._endWidgetActions().then(() => { + return WidgetUtils.setRoomWidget( + this.props.room.roomId, + this.props.app.id, + ); + }).catch((e) => { console.error('Failed to delete widget', e); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -669,6 +685,17 @@ export default class AppTile extends React.Component { } _onPopoutWidgetClick() { + // Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them + // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). + if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { + this._endWidgetActions().then(() => { + if (this._appFrame.current) { + // Reload iframe + this._appFrame.current.src = this._getRenderedUrl(); + this.setState({}); + } + }); + } // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index e7f7196ac6..e96d9ced11 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -26,6 +26,7 @@ import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks import SettingsStore from "../../../settings/SettingsStore"; import escapeHtml from "escape-html"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {Action} from "../../../dispatcher/actions"; // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would @@ -290,7 +291,7 @@ export default class ReplyThread extends React.Component { events, }, this.loadNextEvent); - dis.dispatch({action: 'focus_composer'}); + dis.fire(Action.FocusComposer); } render() { diff --git a/src/components/views/elements/RoomDirectoryButton.js b/src/components/views/elements/RoomDirectoryButton.js index d0bff4beeb..e9de6f8d15 100644 --- a/src/components/views/elements/RoomDirectoryButton.js +++ b/src/components/views/elements/RoomDirectoryButton.js @@ -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 ( - { + constructor(props: IProps) { + super(props); + + this.state = { value: SettingsStore.getValueAt( this.props.level, this.props.name, this.props.roomId, this.props.isExplicit, ), - }; - }, - - onChange: function(checked) { - if (this.props.group && !checked) return; + } + } + private onChange = (checked: boolean): void => { this.save(checked); this.setState({ value: checked }); if (this.props.onChange) this.props.onChange(checked); - }, + } - save: function(val = undefined) { + private checkBoxOnChange = (e: React.ChangeEvent) => { + this.onChange(e.target.checked); + } + + private save = (val?: boolean): void => { return SettingsStore.setValue( this.props.name, this.props.roomId, this.props.level, val !== undefined ? val : this.state.value, ); - }, + } - render: function() { + public render() { const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level); let label = this.props.label; if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level); else label = _t(label); - return ( -
- {label} - -
- ); - }, -}); + if (this.props.useCheckbox) { + return + {label} + ; + } else { + return ( +
+ {label} + +
+ ); + } + } +} diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx new file mode 100644 index 0000000000..7d84f68c49 --- /dev/null +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -0,0 +1,41 @@ +/* +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'; + +interface IProps extends React.InputHTMLAttributes { +} + +interface IState { +} + +export default class StyledRadioButton extends React.PureComponent { + public static readonly defaultProps = { + className: '', + } + + public render() { + const { children, className, ...otherProps } = this.props; + return