diff --git a/CHANGELOG.md b/CHANGELOG.md index 089bfa73e0..2cc850822e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,200 @@ +Changes in [2.8.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.1) (2020-06-29) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0...v2.8.1) + + * Support accounts with cross signing but no SSSS + [\#4852](https://github.com/matrix-org/matrix-react-sdk/pull/4852) + +Changes in [2.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.0) (2020-06-23) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0-rc.1...v2.8.0) + + * Upgrade to JS SDK 7.0.0 + * Update read receipt remainder for internal font size change + [\#4807](https://github.com/matrix-org/matrix-react-sdk/pull/4807) + * Revert "Use recovery keys over passphrases" + [\#4793](https://github.com/matrix-org/matrix-react-sdk/pull/4793) + +Changes in [2.8.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.0-rc.1) (2020-06-17) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.2...v2.8.0-rc.1) + + * Upgrade to JS SDK 7.0.0-rc.1 + * Fix Styled Checkbox and Radio Button disabled state + [\#4778](https://github.com/matrix-org/matrix-react-sdk/pull/4778) + * clean up and fix the isMasterRuleEnabled logic + [\#4782](https://github.com/matrix-org/matrix-react-sdk/pull/4782) + * Fix case-sensitivity of /me to match rest of slash commands + [\#4763](https://github.com/matrix-org/matrix-react-sdk/pull/4763) + * Add a 'show less' button to the new room list + [\#4765](https://github.com/matrix-org/matrix-react-sdk/pull/4765) + * Update from Weblate + [\#4781](https://github.com/matrix-org/matrix-react-sdk/pull/4781) + * Sticky and collapsing headers for new room list + [\#4758](https://github.com/matrix-org/matrix-react-sdk/pull/4758) + * Make the room list labs setting reload on change + [\#4780](https://github.com/matrix-org/matrix-react-sdk/pull/4780) + * Handle/hide old rooms in the room list + [\#4767](https://github.com/matrix-org/matrix-react-sdk/pull/4767) + * Add some media queries to improve UI on mobile (#3991) + [\#4656](https://github.com/matrix-org/matrix-react-sdk/pull/4656) + * Match fuzzy filtering a bit more reliably in the new room list + [\#4769](https://github.com/matrix-org/matrix-react-sdk/pull/4769) + * Improve Field ts definitions some more + [\#4777](https://github.com/matrix-org/matrix-react-sdk/pull/4777) + * Fix alignment of checkboxes in new room list's context menu + [\#4776](https://github.com/matrix-org/matrix-react-sdk/pull/4776) + * Fix Field ts def, fix LocalEchoWrapper and NotificationsEnabledController + [\#4775](https://github.com/matrix-org/matrix-react-sdk/pull/4775) + * Add presence indicators and globes to new room list + [\#4774](https://github.com/matrix-org/matrix-react-sdk/pull/4774) + * Include the sticky room when filtering in the new room list + [\#4772](https://github.com/matrix-org/matrix-react-sdk/pull/4772) + * Add a home button to the new room list menu when available + [\#4771](https://github.com/matrix-org/matrix-react-sdk/pull/4771) + * use group layout for search results + [\#4764](https://github.com/matrix-org/matrix-react-sdk/pull/4764) + * Fix m.id.phone spec compliance + [\#4757](https://github.com/matrix-org/matrix-react-sdk/pull/4757) + * User Info default power levels for ban/kick/redact to 50 as per spec + [\#4759](https://github.com/matrix-org/matrix-react-sdk/pull/4759) + * Match new room list's text search to old room list + [\#4768](https://github.com/matrix-org/matrix-react-sdk/pull/4768) + * Fix ordering of recent rooms in the new room list + [\#4766](https://github.com/matrix-org/matrix-react-sdk/pull/4766) + * Change theme selector to use new styled radio buttons + [\#4731](https://github.com/matrix-org/matrix-react-sdk/pull/4731) + * Use recovery keys over passphrases + [\#4686](https://github.com/matrix-org/matrix-react-sdk/pull/4686) + * Update from Weblate + [\#4760](https://github.com/matrix-org/matrix-react-sdk/pull/4760) + * Initial dark theme support for new room list + [\#4756](https://github.com/matrix-org/matrix-react-sdk/pull/4756) + * Support per-list options and algorithms on the new room list + [\#4754](https://github.com/matrix-org/matrix-react-sdk/pull/4754) + * Send read marker updates immediately after moving visually + [\#4755](https://github.com/matrix-org/matrix-react-sdk/pull/4755) + * Add a minimized view to the new room list + [\#4753](https://github.com/matrix-org/matrix-react-sdk/pull/4753) + * Fix e2e icon alignment in irc-layout + [\#4752](https://github.com/matrix-org/matrix-react-sdk/pull/4752) + * Add some resource leak protection to new room list badges + [\#4750](https://github.com/matrix-org/matrix-react-sdk/pull/4750) + * Fix read-receipt alignment + [\#4747](https://github.com/matrix-org/matrix-react-sdk/pull/4747) + * Show message previews on the new room list tiles + [\#4751](https://github.com/matrix-org/matrix-react-sdk/pull/4751) + * Fix various layout concerns with the new room list + [\#4749](https://github.com/matrix-org/matrix-react-sdk/pull/4749) + * Prioritize text on the clipboard over file + [\#4748](https://github.com/matrix-org/matrix-react-sdk/pull/4748) + * Move Settings flag to ts + [\#4729](https://github.com/matrix-org/matrix-react-sdk/pull/4729) + * Add a context menu to rooms in the new room list + [\#4743](https://github.com/matrix-org/matrix-react-sdk/pull/4743) + * Add hover states and basic context menu to new room list + [\#4742](https://github.com/matrix-org/matrix-react-sdk/pull/4742) + * Update resize handle for new designs in new room list + [\#4741](https://github.com/matrix-org/matrix-react-sdk/pull/4741) + * Improve general stability in the new room list + [\#4740](https://github.com/matrix-org/matrix-react-sdk/pull/4740) + * Reimplement breadcrumbs for new room list + [\#4735](https://github.com/matrix-org/matrix-react-sdk/pull/4735) + * Add styled radio buttons + [\#4744](https://github.com/matrix-org/matrix-react-sdk/pull/4744) + * Hide checkbox tick on dark backgrounds + [\#4730](https://github.com/matrix-org/matrix-react-sdk/pull/4730) + * Make checkboxes a11y friendly + [\#4746](https://github.com/matrix-org/matrix-react-sdk/pull/4746) + * EventIndex: Store and restore the encryption info for encrypted events. + [\#4738](https://github.com/matrix-org/matrix-react-sdk/pull/4738) + * Use IDestroyable instead of IDisposable + [\#4739](https://github.com/matrix-org/matrix-react-sdk/pull/4739) + * Add/improve badge counts in new room list + [\#4734](https://github.com/matrix-org/matrix-react-sdk/pull/4734) + * Convert FormattingUtils to TypeScript and add badge utility function + [\#4732](https://github.com/matrix-org/matrix-react-sdk/pull/4732) + * Add filtering and exploring to the new room list + [\#4736](https://github.com/matrix-org/matrix-react-sdk/pull/4736) + * Support prioritized room list filters + [\#4737](https://github.com/matrix-org/matrix-react-sdk/pull/4737) + * Clean up font scaling appearance + [\#4733](https://github.com/matrix-org/matrix-react-sdk/pull/4733) + * Add user menu to new room list + [\#4722](https://github.com/matrix-org/matrix-react-sdk/pull/4722) + * New room list basic styling and layout + [\#4711](https://github.com/matrix-org/matrix-react-sdk/pull/4711) + * Fix read receipt overlap + [\#4727](https://github.com/matrix-org/matrix-react-sdk/pull/4727) + * Load correct default font size + [\#4726](https://github.com/matrix-org/matrix-react-sdk/pull/4726) + * send state of lowBandwidth in rageshakes + [\#4724](https://github.com/matrix-org/matrix-react-sdk/pull/4724) + * Change internal font size from from 15 to 10 + [\#4725](https://github.com/matrix-org/matrix-react-sdk/pull/4725) + * Upgrade deps + [\#4723](https://github.com/matrix-org/matrix-react-sdk/pull/4723) + * Ensure active Jitsi conference is closed on widget pop-out + [\#4444](https://github.com/matrix-org/matrix-react-sdk/pull/4444) + * Introduce sticky rooms to the new room list + [\#4720](https://github.com/matrix-org/matrix-react-sdk/pull/4720) + * Handle remaining cases for room updates in new room list + [\#4721](https://github.com/matrix-org/matrix-react-sdk/pull/4721) + * Allow searching the emoji picker using other emoji + [\#4719](https://github.com/matrix-org/matrix-react-sdk/pull/4719) + * New room list scrolling and resizing + [\#4697](https://github.com/matrix-org/matrix-react-sdk/pull/4697) + * Don't show FormatBar if composer is empty + [\#4696](https://github.com/matrix-org/matrix-react-sdk/pull/4696) + * Split the left panel into new and old for new room list designs + [\#4687](https://github.com/matrix-org/matrix-react-sdk/pull/4687) + * Fix compact layout regression + [\#4712](https://github.com/matrix-org/matrix-react-sdk/pull/4712) + * fix emoji in safari + [\#4710](https://github.com/matrix-org/matrix-react-sdk/pull/4710) + * Fix not being able to dismiss new login toasts + [\#4709](https://github.com/matrix-org/matrix-react-sdk/pull/4709) + * Fix exceptions from Tooltip + [\#4708](https://github.com/matrix-org/matrix-react-sdk/pull/4708) + * Stop removing variation selector from quick reactions + [\#4707](https://github.com/matrix-org/matrix-react-sdk/pull/4707) + * Tidy up continuation algorithm and make it work for hidden profile changes + [\#4704](https://github.com/matrix-org/matrix-react-sdk/pull/4704) + * Profile settings should never show a disambiguated display name + [\#4699](https://github.com/matrix-org/matrix-react-sdk/pull/4699) + * Prevent (double) 4S bootstrap from RestoreKeyBackupDialog + [\#4701](https://github.com/matrix-org/matrix-react-sdk/pull/4701) + * Stop checkbox styling bleeding through room address selector + [\#4691](https://github.com/matrix-org/matrix-react-sdk/pull/4691) + * Center HeaderButtons + [\#4695](https://github.com/matrix-org/matrix-react-sdk/pull/4695) + * Add .well-known option to control default e2ee behaviour + [\#4605](https://github.com/matrix-org/matrix-react-sdk/pull/4605) + * Add max-width to right and left panels + [\#4692](https://github.com/matrix-org/matrix-react-sdk/pull/4692) + * Fix login loop where the sso flow returns to `#/login` + [\#4685](https://github.com/matrix-org/matrix-react-sdk/pull/4685) + * Don't clear MAU toasts when a successful sync comes in + [\#4690](https://github.com/matrix-org/matrix-react-sdk/pull/4690) + * Add initial filtering support to new room list + [\#4681](https://github.com/matrix-org/matrix-react-sdk/pull/4681) + * Bubble up a decline-to-render of verification events to outside wrapper + [\#4664](https://github.com/matrix-org/matrix-react-sdk/pull/4664) + * upgrade to twemoji 13.0.0 + [\#4672](https://github.com/matrix-org/matrix-react-sdk/pull/4672) + * Apply FocusLock to ImageView to capture Escape handling + [\#4666](https://github.com/matrix-org/matrix-react-sdk/pull/4666) + * Fix the 'complete security' screen + [\#4689](https://github.com/matrix-org/matrix-react-sdk/pull/4689) + * add null-guard for Autocomplete containerRef + [\#4688](https://github.com/matrix-org/matrix-react-sdk/pull/4688) + * Remove legacy codepaths for Unknown Device Error (UDE/UDD) handling + [\#4660](https://github.com/matrix-org/matrix-react-sdk/pull/4660) + * Remove feature_cross_signing + [\#4655](https://github.com/matrix-org/matrix-react-sdk/pull/4655) + * Autocomplete: use scrollIntoView for auto-scroll to fix it + [\#4670](https://github.com/matrix-org/matrix-react-sdk/pull/4670) + Changes in [2.7.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.2) (2020-06-16) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.1...v2.7.2) diff --git a/package.json b/package.json index dbde4a3cb7..37cc3d6c4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "2.7.2", + "version": "2.8.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_common.scss b/res/css/_common.scss index e83c6aaeda..c087df04cb 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -428,6 +428,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border-radius: 8px; padding: 0px; box-shadow: none; + + /* Don't show scroll-bars on spinner dialogs */ + overflow-x: hidden; + overflow-y: hidden; } // TODO: Review mx_GeneralButton usage to see if it can use a different class @@ -596,14 +600,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } &:last-child { - padding-bottom: 20px; + padding-bottom: 16px; } } .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; + margin-top: 12px; // 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. @@ -634,7 +638,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { li { margin: 0; - padding: 20px 0 0; + padding: 12px 0 0; .mx_AccessibleButton { text-decoration: none; diff --git a/res/css/_components.scss b/res/css/_components.scss index 66eb98ea9d..afc40ca0d6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -30,7 +30,7 @@ @import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; -@import "./structures/_UserMenuButton.scss"; +@import "./structures/_UserMenu.scss"; @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 5cdefa0324..67fa9ba557 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -38,6 +38,12 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations // TagPanel handles its own CSS } + &:not(.mx_LeftPanel2_hasTagPanel) { + .mx_LeftPanel2_roomListContainer { + width: 100%; + } + } + // 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 { @@ -48,13 +54,13 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations flex-direction: column; .mx_LeftPanel2_userHeader { - padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom + padding: 12px 12px 20px; // 12px 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 + // This is basically just breadcrumbs. The row above that is handled by the UserMenu .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. @@ -62,22 +68,6 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations 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; @@ -143,21 +133,16 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel2_hasTagPanel { + width: calc(68px + $tagPanelWidth) !important; + } + &:not(.mx_LeftPanel2_hasTagPanel) { + width: 68px !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; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss new file mode 100644 index 0000000000..bbb1e1cc7b --- /dev/null +++ b/res/css/structures/_UserMenu.scss @@ -0,0 +1,192 @@ +/* +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_UserMenu { + .mx_UserMenu_headerButtons { + width: 16px; + height: 16px; + position: relative; + display: block; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 0; + left: 0; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/feather-customised/more-horizontal.svg'); + } + } + + .mx_UserMenu_row { + // Create a row-based flexbox to ensure items stay aligned correctly. + display: flex; + align-items: center; + + .mx_UserMenu_userAvatarContainer { + position: relative; // to make default avatars work + margin-right: 8px; + height: 32px; // to remove the unknown 4px gap the browser puts below it + + .mx_UserMenu_userAvatar { + border-radius: 32px; // should match avatar size + } + } + + .mx_UserMenu_userName { + font-weight: 600; + font-size: $font-15px; + line-height: $font-20px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_UserMenu_headerButtons { + // No special styles: the rest of the layout happens to make it work. + } + } + + &.mx_UserMenu_minimized { + .mx_UserMenu_userHeader { + .mx_UserMenu_row { + justify-content: center; + } + + .mx_UserMenu_userAvatarContainer { + margin-right: 0; + } + } + } +} + +.mx_UserMenu_contextMenu { + width: 247px; + + .mx_UserMenu_contextMenu_redRow { + .mx_AccessibleButton { + color: $warning-color !important; // !important to override styles from context menu + } + + .mx_IconizedContextMenu_icon::before { + background-color: $warning-color; + } + } + + .mx_UserMenu_contextMenu_header { + // Create a flexbox to organize the header a bit easier + display: flex; + align-items: center; + + .mx_UserMenu_contextMenu_name { + // Create another flexbox of columns to handle large user IDs + display: flex; + flex-direction: column; + 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_UserMenu_contextMenu_displayName { + font-weight: bold; + font-size: $font-15px; + line-height: $font-20px; + } + + .mx_UserMenu_contextMenu_userId { + font-size: $font-15px; + line-height: $font-24px; + } + } + + .mx_UserMenu_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; + } + } + + .mx_IconizedContextMenu_icon { + width: 16px; + height: 16px; + display: block; + + &::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + } + } + + .mx_UserMenu_iconHome::before { + mask-image: url('$(res)/img/feather-customised/home.svg'); + } + + .mx_UserMenu_iconBell::before { + mask-image: url('$(res)/img/feather-customised/notifications.svg'); + } + + .mx_UserMenu_iconLock::before { + mask-image: url('$(res)/img/feather-customised/lock.svg'); + } + + .mx_UserMenu_iconSettings::before { + mask-image: url('$(res)/img/feather-customised/settings.svg'); + } + + .mx_UserMenu_iconArchive::before { + mask-image: url('$(res)/img/feather-customised/archive.svg'); + } + + .mx_UserMenu_iconMessage::before { + mask-image: url('$(res)/img/feather-customised/message-circle.svg'); + } + + .mx_UserMenu_iconSignOut::before { + mask-image: url('$(res)/img/feather-customised/sign-out.svg'); + } +} diff --git a/res/css/structures/_UserMenuButton.scss b/res/css/structures/_UserMenuButton.scss deleted file mode 100644 index 1fbbbb5fd8..0000000000 --- a/res/css/structures/_UserMenuButton.scss +++ /dev/null @@ -1,82 +0,0 @@ -/* -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/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 612b6209c6..6b91e45923 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,7 @@ limitations under the License. display: inline; } -.mx_InlineSpinner img { +.mx_InlineSpinner_spin img { margin: 0px 6px; vertical-align: -3px; } diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..6966a60e52 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -23,6 +23,16 @@ limitations under the License. flex: 1; } +.mx_Spinner_spin img { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index aab448605c..60f1bf0277 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -77,8 +77,8 @@ limitations under the License. } &:checked:disabled + label > .mx_Checkbox_background { - background-color: $muted-fg-color; - border-color: rgba($muted-fg-color, 0.5); + background-color: $accent-color; + border-color: $accent-color; } } } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 2b204955d8..c168699b70 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -354,6 +354,11 @@ limitations under the License. opacity: 1; } +.mx_EventTile_e2eIcon_unauthenticated { + background-image: url('$(res)/img/e2e/normal.svg'); + opacity: 1; +} + .mx_EventTile_e2eIcon_hidden { display: none; } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 814a614007..94753f9473 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -121,11 +121,6 @@ $irc-line-height: $font-18px; } } - .mx_EventTile_line .mx_MessageActionBar, - .mx_EventTile_line .mx_ReplyThread_wrapper { - display: block; - } - .mx_EventTile_reply { order: 4; } diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 66615fb6a8..f859aba623 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -22,10 +22,12 @@ limitations under the License. flex-direction: column; margin-left: 8px; - margin-top: 12px; - margin-bottom: 12px; width: 100%; + &:first-child { + margin-top: 12px; // so we're not up against the search/filter + } + .mx_RoomSublist2_headerContainer { // Create a flexbox to make alignment easy display: flex; @@ -83,23 +85,30 @@ limitations under the License. // *************************** .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; + + // Apply the width and margin to the badge so the container doesn't occupy dead space + .mx_NotificationBadge { + width: 16px; + margin-left: 8px; // same as menu+aux buttons + } + } + + &:not(.mx_RoomSublist2_headerContainer_withAux) { + .mx_NotificationBadge { + margin-right: 4px; // just to push it over a bit, aligning it with the other elements + } } - // 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; + margin-left: 8px; // should be the same as the notification badge position: relative; + width: 24px; + height: 24px; border-radius: 32px; &::before { @@ -116,6 +125,13 @@ limitations under the License. } } + // Hide the menu button by default + .mx_RoomSublist2_menuButton { + visibility: hidden; + width: 0; + margin: 0; + } + .mx_RoomSublist2_auxButton::before { mask-image: url('$(res)/img/feather-customised/plus.svg'); } @@ -128,9 +144,9 @@ limitations under the License. flex: 1; max-width: calc(100% - 16px); // 16px is the badge width text-transform: uppercase; - opacity: 0.5; line-height: $font-16px; font-size: $font-12px; + font-weight: 600; // Ellipsize any text overflow text-overflow: ellipsis; @@ -140,11 +156,9 @@ limitations under the License. .mx_RoomSublist2_collapseBtn { display: inline-block; position: relative; - - // Default hidden - visibility: hidden; - width: 0; - height: 0; + width: 12px; + height: 12px; + margin-right: 8px; &::before { content: ''; @@ -156,7 +170,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; + background-color: $primary-fg-color; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } @@ -224,6 +238,16 @@ limitations under the License. .mx_RoomSublist2_showLessButtonChevron { mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); } + + &.mx_RoomSublist2_isCutting::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08); + } } // Class name comes from the ResizableBox component @@ -231,69 +255,34 @@ limitations under the License. // so that selector is below and one level higher. .react-resizable-handle { cursor: ns-resize; - border-radius: 2px; + border-radius: 3px; + + // Update RESIZE_HANDLE_HEIGHT if this changes + height: 4px; // 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; + // Together, these make the bar 64px wide + left: calc(50% - 32px); + right: calc(50% - 32px); + } + + &:hover, &.mx_RoomSublist2_hasMenuOpen { + .react-resizable-handle { + opacity: 0.8; + background-color: $primary-fg-color; + } } } - // 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_headerContainer { - .mx_RoomSublist2_headerText { - .mx_RoomSublist2_collapseBtn { - visibility: visible; - width: 12px; - height: 12px; - margin-right: 4px; - } - } + &.mx_RoomSublist2_hasMenuOpen, + &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { + .mx_RoomSublist2_menuButton { + visibility: visible; + width: 24px; + margin-left: 8px; } } @@ -304,18 +293,18 @@ limitations under the License. position: relative; .mx_RoomSublist2_badgeContainer { - order: 1; + order: 0; align-self: flex-end; margin-right: 0; } - .mx_RoomSublist2_headerText { - order: 2; + .mx_RoomSublist2_stickable { + order: 1; max-width: 100%; } .mx_RoomSublist2_auxButton { - order: 4; + order: 2; visibility: visible; width: 32px !important; // !important to override hover styles height: 32px !important; // !important to override hover styles @@ -342,7 +331,12 @@ limitations under the License. } } - &:hover, &.mx_RoomSublist2_hasMenuOpen { + .mx_RoomSublist2_menuButton { + height: 16px; + } + + &.mx_RoomSublist2_hasMenuOpen, + & > .mx_RoomSublist2_headerContainer:hover { .mx_RoomSublist2_menuButton { visibility: visible; position: absolute; @@ -363,7 +357,7 @@ limitations under the License. } } - .mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { + &.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. } @@ -372,27 +366,6 @@ limitations under the License. } } -// 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; @@ -402,6 +375,7 @@ limitations under the License. margin-bottom: 16px; margin-right: 16px; // additional 16px border: 1px solid $roomsublist2-divider-color; + opacity: 0.1; } .mx_RoomSublist2_contextMenu_title { diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 001499fea5..2b659a0c8e 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -67,7 +67,7 @@ limitations under the License. } .mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents { - font-weight: 600; + font-weight: 700; } .mx_RoomTile2_messagePreview { @@ -193,10 +193,6 @@ limitations under the License. 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'); } diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index e2454336b8..d724b164e5 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -16,11 +16,14 @@ limitations under the License. .mx_AppearanceUserSettingsTab_fontSlider, .mx_AppearanceUserSettingsTab_fontSlider_preview, -.mx_AppearanceUserSettingsTab_Layout, -.mx_AppearanceUserSettingsTab .mx_Field { +.mx_AppearanceUserSettingsTab_Layout { @mixin mx_Settings_fullWidthField; } +.mx_AppearanceUserSettingsTab .mx_Field { + width: 256px; +} + .mx_AppearanceUserSettingsTab_fontScaling { color: $primary-fg-color; } @@ -204,3 +207,20 @@ limitations under the License. background-color: rgba($accent-color, 0.08); } } + +.mx_AppearanceUserSettingsTab_Advanced { + color: $primary-fg-color; + + > * { + margin-bottom: 16px; + } + + .mx_AppearanceUserSettingsTab_AdvancedToggle { + color: $accent-color; + cursor: pointer; + } + + .mx_AppearanceUserSettingsTab_systemFont { + margin-left: calc($font-16px + 10px); + } +} diff --git a/res/img/spinner.svg b/res/img/spinner.svg new file mode 100644 index 0000000000..a18140c7e2 --- /dev/null +++ b/res/img/spinner.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 69fc91f222..1546e7a400 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -113,7 +113,7 @@ $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; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 6206496150..e7912e3cb0 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +$font-family: var(--font-family, $font-family); +$monospace-font-family: var(--font-family-monospace, $monospace-font-family); // // --accent-color $accent-color: var(--accent-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 57dc1fa5e0..c4b4262642 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -180,7 +180,7 @@ $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; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index d54dc7dd23..1d11495e61 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -25,8 +25,8 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; -export const HOMESERVER_URL_KEY = "mx_hs_url"; -export const ID_SERVER_URL_KEY = "mx_is_url"; +export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; +export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; export enum UpdateCheckStatus { Checking = "CHECKING", @@ -221,7 +221,7 @@ export default abstract class BasePlatform { setLanguage(preferredLangs: string[]) {} - getSSOCallbackUrl(fragmentAfterLogin: string): URL { + protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { const url = new URL(window.location.href); url.hash = fragmentAfterLogin || ""; return url; @@ -235,9 +235,9 @@ export default abstract class BasePlatform { */ startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) { // persist hs url and is url for when the user is returned to the app with the login token - localStorage.setItem(HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); + localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); if (mxClient.getIdentityServerUrl()) { - localStorage.setItem(ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); + localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); } const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 102afa6bf1..1b4aa19ebf 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -25,6 +25,7 @@ import RoomViewStore from "./stores/RoomViewStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import SettingsStore from "./settings/SettingsStore"; import {Capability} from "./widgets/WidgetApi"; +import {objectClone} from "./utils/objects"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -247,7 +248,7 @@ export default class FromWidgetPostMessageApi { * @param {Object} res Response data */ sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = res; event.source.postMessage(data, event.origin); } @@ -260,7 +261,7 @@ export default class FromWidgetPostMessageApi { */ sendError(event, msg, nestedError) { console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = { error: { message: msg, diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 96cefaf593..9ae4ae7e03 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -41,7 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "./BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; + +const HOMESERVER_URL_KEY = "mx_hs_url"; +const ID_SERVER_URL_KEY = "mx_is_url"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -164,8 +167,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { return Promise.resolve(false); } - const homeserver = localStorage.getItem(HOMESERVER_URL_KEY); - const identityServer = localStorage.getItem(ID_SERVER_URL_KEY); + const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY); if (!homeserver) { console.warn("Cannot log in with token: can't determine HS URL to use"); return Promise.resolve(false); diff --git a/src/Notifier.js b/src/Notifier.js index cd328ba565..b6690959d2 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -122,7 +122,7 @@ const Notifier = { } }, - getSoundForRoom: async function(roomId) { + getSoundForRoom: function(roomId) { // We do no caching here because the SDK caches setting // and the browser will cache the sound. const content = SettingsStore.getValue("notificationSound", roomId); @@ -151,7 +151,7 @@ const Notifier = { }, _playAudioNotification: async function(ev, room) { - const sound = await this.getSoundForRoom(room.roomId); + const sound = this.getSoundForRoom(room.roomId); console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); try { diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index c67acaf314..4614bef378 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -56,10 +56,11 @@ export function countRoomsWithNotif(rooms) { } export function aggregateNotificationCount(rooms) { - return rooms.reduce((result, room, index) => { + return rooms.reduce((result, room) => { const roomNotifState = getRoomNotifsState(room.roomId); const highlight = room.getUnreadNotificationCount('highlight') > 0; - const notificationCount = room.getUnreadNotificationCount(); + // use helper method to include highlights in the previous version of the room + const notificationCount = getUnreadNotificationCount(room); const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 315c2d86f4..b33aa57359 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -244,16 +244,17 @@ import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {WidgetType} from "./widgets/WidgetType"; +import {objectClone} from "./utils/objects"; function sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = res; event.source.postMessage(data, event.origin); } function sendError(event, msg, nestedError) { console.error("Action:" + event.data.action + " failed with message: " + msg); - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = { error: { message: msg, diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 09cfb67de7..3607d7a676 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -265,22 +265,13 @@ function textForServerACLEvent(ev) { return text + changes.join(" "); } -function textForMessageEvent(ev, skipUserPrefix) { +function textForMessageEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; - 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}); - } + 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; } @@ -621,8 +612,8 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } -export function textForEvent(ev, skipUserPrefix) { +export function textForEvent(ev) { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev, skipUserPrefix); + if (handler) return handler(ev); return ''; } diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index b5da44caef..6e0faff57f 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -15,21 +15,21 @@ limitations under the License. */ import * as React from "react"; +import { createRef } 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 UserMenu from "./UserMenu"; 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"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import SettingsStore from "../../settings/SettingsStore"; /******************************************************************* * CAUTION * @@ -41,14 +41,19 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; interface IProps { isMinimized: boolean; + resizeNotifier: ResizeNotifier; } interface IState { searchFilter: string; // TODO: Move search into room list? showBreadcrumbs: boolean; + showTagPanel: boolean; } export default class LeftPanel2 extends React.Component { + private listContainerRef: React.RefObject = createRef(); + private tagPanelWatcherRef: string; + // TODO: Properly support TagPanel // TODO: Properly support searching/filtering // TODO: Properly support breadcrumbs @@ -62,13 +67,23 @@ export default class LeftPanel2 extends React.Component { this.state = { searchFilter: "", showBreadcrumbs: BreadcrumbsStore.instance.visible, + showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); + this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + }); + + // We watch the middle panel because we don't actually get resized, the middle panel does. + // We listen to the noisy channel to avoid choppy reaction times. + this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } public componentWillUnmount() { + SettingsStore.unwatchSetting(this.tagPanelWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); + this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); } private onSearch = (term: string): void => { @@ -86,9 +101,7 @@ export default class LeftPanel2 extends React.Component { } }; - // TODO: Apply this on resize, init, etc for reliability - private onScroll = (ev: React.MouseEvent) => { - const list = ev.target as HTMLDivElement; + private handleStickyHeaders(list: HTMLDivElement) { const rlRect = list.getBoundingClientRect(); const bottom = rlRect.bottom; const top = rlRect.top; @@ -123,6 +136,17 @@ export default class LeftPanel2 extends React.Component { header.style.top = `unset`; } } + } + + // TODO: Apply this on resize, init, etc for reliability + private onScroll = (ev: React.MouseEvent) => { + const list = ev.target as HTMLDivElement; + this.handleStickyHeaders(list); + }; + + private onResize = () => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); }; private renderHeader(): React.ReactNode { @@ -130,16 +154,6 @@ export default class LeftPanel2 extends React.Component { // 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) { @@ -150,34 +164,9 @@ export default class LeftPanel2 extends React.Component { ); } - let name = {displayName}; - let buttons = ( - - - - ); - if (this.props.isMinimized) { - name = null; - buttons = null; - } - return (
-
- - - - {name} - {buttons} -
+ {breadcrumbs}
); @@ -200,7 +189,7 @@ export default class LeftPanel2 extends React.Component { } public render(): React.ReactNode { - const tagPanel = ( + const tagPanel = !this.state.showTagPanel ? null : (
@@ -221,6 +210,7 @@ export default class LeftPanel2 extends React.Component { const containerClasses = classNames({ "mx_LeftPanel2": true, + "mx_LeftPanel2_hasTagPanel": !!tagPanel, "mx_LeftPanel2_minimized": this.props.isMinimized, }); @@ -230,9 +220,11 @@ export default class LeftPanel2 extends React.Component { ); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f37f77b31b..9c01480df2 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -123,7 +123,7 @@ interface IState { * * Components mounted below us can access the matrix client via the react context. */ -class LoggedInView extends React.PureComponent { +class LoggedInView extends React.Component { static displayName = 'LoggedInView'; static propTypes = { @@ -677,7 +677,10 @@ class LoggedInView extends React.PureComponent { if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { // TODO: Supply props like collapsed and disabled to LeftPanel2 leftPanel = ( - + ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index fa6cd8a4d8..79bdf743ce 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -18,6 +18,8 @@ limitations under the License. */ import React, { createRef } from 'react'; +// @ts-ignore - XXX: no idea why this import fails +import * as Matrix from "matrix-js-sdk"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -1612,6 +1614,19 @@ export default class MatrixChat extends React.PureComponent { }); } else if (screen === 'directory') { dis.fire(Action.ViewRoomDirectory); + } else if (screen === "start_sso" || screen === "start_cas") { + // TODO if logged in, skip SSO + let cli = MatrixClientPeg.get(); + if (!cli) { + const {hsUrl, isUrl} = this.props.serverConfig; + cli = Matrix.createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + } + + const type = screen === "start_sso" ? "sso" : "cas"; + PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { dis.dispatch({ action: 'view_my_groups', @@ -1828,7 +1843,9 @@ export default class MatrixChat extends React.PureComponent { } updateStatusIndicator(state: string, prevState: string) { - const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count; + // only count visible rooms to not torment the user with notification counts in rooms they can't see + // it will include highlights from the previous version of the room internally + const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getVisibleRooms()).count; if (PlatformPeg.get()) { PlatformPeg.get().setErrorStatus(state === 'ERROR'); @@ -1913,9 +1930,7 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedIn(); }; - render() { - // console.log(`Rendering MatrixChat with view ${this.state.view}`); - + getFragmentAfterLogin() { let fragmentAfterLogin = ""; if (this.props.initialScreenAfterLogin && // XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop @@ -1923,7 +1938,11 @@ export default class MatrixChat extends React.PureComponent { ) { fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`; } + return fragmentAfterLogin; + } + render() { + const fragmentAfterLogin = this.getFragmentAfterLogin(); let view; if (this.state.view === Views.LOADING) { @@ -2002,7 +2021,7 @@ export default class MatrixChat extends React.PureComponent { } } else if (this.state.view === Views.WELCOME) { const Welcome = sdk.getComponent('auth.Welcome'); - view = ; + view = ; } else if (this.state.view === Views.REGISTER) { const Registration = sdk.getComponent('structures.auth.Registration'); view = ( diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx new file mode 100644 index 0000000000..19e57ac51b --- /dev/null +++ b/src/components/structures/UserMenu.tsx @@ -0,0 +1,332 @@ +/* +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 { 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"; +import SdkConfig from "../../SdkConfig"; +import {getHomePageUrl} from "../../utils/pages"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import BaseAvatar from '../views/avatars/BaseAvatar'; +import classNames from "classnames"; + +interface IProps { + isMinimized: boolean; +} + +interface IState { + menuDisplayed: boolean; + isDarkTheme: boolean; +} + +export default class UserMenu extends React.Component { + private dispatcherRef: string; + private themeWatcherRef: string; + private buttonRef: React.RefObject = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + menuDisplayed: false, + isDarkTheme: this.isUserOnDarkTheme(), + }; + + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); + } + + private get hasHomePage(): boolean { + return !!getHomePageUrl(SdkConfig.get()); + } + + 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); + OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + } + + private isUserOnDarkTheme(): boolean { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return getCustomTheme(theme.substring("custom-".length)).is_dark; + } + return theme === "dark"; + } + + private onProfileUpdate = async () => { + // the store triggered an update, so force a layout update. We don't + // have any state to store here for that to magically happen. + this.forceUpdate(); + }; + + 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 = (ev: InputEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + 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.DEVICE, newTheme); // set at same level as Appearance tab + }; + + 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 + }; + + private onHomeClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({action: 'view_home_page'}); + }; + + private renderContextMenu = (): React.ReactNode => { + if (!this.state.menuDisplayed) return null; + + let hostingLink; + const signupLink = getHostingLink("user-context-menu"); + if (signupLink) { + hostingLink = ( +
+ {_t( + "Upgrade to your own domain", {}, + { + a: sub => ( + {sub} + ), + }, + )} +
+ ); + } + + let homeButton = null; + if (this.hasHomePage) { + homeButton = ( +
  • + + + {_t("Home")} + +
  • + ); + } + + const elementRect = this.buttonRef.current.getBoundingClientRect(); + return ( + +
    +
    +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    +
    + {_t("Switch +
    +
    + {hostingLink} +
    +
      + {homeButton} +
    • + 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")} + +
    • +
    +
    +
    +
    + ); + }; + + public render() { + const avatarSize = 32; // should match border-radius of the avatar + + let name = {OwnProfileStore.instance.displayName}; + let buttons = ( + + {/* masked image in CSS */} + + ); + if (this.props.isMinimized) { + name = null; + buttons = null; + } + + const classes = classNames({ + 'mx_UserMenu': true, + 'mx_UserMenu_minimized': this.props.isMinimized, + }); + + return ( + + +
    + + + + {name} + {buttons} +
    + {this.renderContextMenu()} +
    +
    + ); + } +} diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx deleted file mode 100644 index 6607fffdd1..0000000000 --- a/src/components/structures/UserMenuButton.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* -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"; -import SdkConfig from "../../SdkConfig"; -import {getHomePageUrl} from "../../utils/pages"; - -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(); - } - } - - private get hasHomePage(): boolean { - return !!getHomePageUrl(SdkConfig.get()); - } - - 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 - }; - - private onHomeClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({action: 'view_home_page'}); - }; - - 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} - ), - }, - )} -
    - ); - } - - let homeButton = null; - if (this.hasHomePage) { - homeButton = ( -
  • - - - {_t("Home")} - -
  • - ); - } - - const elementRect = this.buttonRef.current.getBoundingClientRect(); - contextMenu = ( - -
    -
    -
    - - {this.displayName} - - - {MatrixClientPeg.get().getUserId()} - -
    -
    - {_t("Switch -
    -
    - {hostingLink} -
    -
      - {homeButton} -
    • - 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/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a2824b63a3..6577386fae 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -25,7 +25,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import SSOButton from "../../views/elements/SSOButton"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; const LOGIN_VIEW = { LOADING: 1, @@ -158,8 +158,8 @@ export default class SoftLogout extends React.Component { async trySsoLogin() { this.setState({busy: true}); - const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); - const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); + const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); const loginType = "m.login.token"; const loginParams = { token: this.props.realQueryParams['loginToken'], diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 91ba368f70..5a30a02490 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -18,9 +18,7 @@ import React from 'react'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; -import * as Matrix from "matrix-js-sdk"; import {_td} from "../../../languageHandler"; -import PlatformPeg from "../../../PlatformPeg"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent { pageUrl = 'welcome.html'; } - const {hsUrl, isUrl} = this.props.serverConfig; - const tmpClient = Matrix.createClient({ - baseUrl: hsUrl, - idBaseUrl: isUrl, - }); - const plaf = PlatformPeg.get(); - const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(), - this.props.fragmentAfterLogin); - return (
    @@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent { className="mx_WelcomePage" url={pageUrl} replaceMap={{ - "$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"), - "$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"), + "$riot:ssoUrl": "#/start_sso", + "$riot:casUrl": "#/start_cas", }} /> diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index ef0fa83fb6..3e4418f945 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; -import MessageSpinner from './MessageSpinner'; +import Spinner from './Spinner'; import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; @@ -741,7 +741,7 @@ export default class AppTile extends React.Component { if (this.props.show) { const loadingElement = (
    - +
    ); if (!this.state.hasPermissionToLoad) { diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index fbee431d6e..834edff7df 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -54,6 +54,8 @@ interface IProps { // If specified, contents will appear as a tooltip on the element and // validation feedback tooltips will be suppressed. tooltipContent?: React.ReactNode; + // If specified the tooltip will be shown regardless of feedback + forceTooltipVisible?: boolean; // If specified alongside tooltipContent, the class name to apply to the // tooltip itself. tooltipClassName?: string; @@ -242,10 +244,9 @@ export default class Field extends React.PureComponent { const Tooltip = sdk.getComponent("elements.Tooltip"); let fieldTooltip; if (tooltipContent || this.state.feedback) { - const addlClassName = tooltipClassName ? tooltipClassName : ''; fieldTooltip = ; } diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js index ad70471d89..ad88868790 100644 --- a/src/components/views/elements/InlineSpinner.js +++ b/src/components/views/elements/InlineSpinner.js @@ -16,6 +16,8 @@ limitations under the License. import React from "react"; import createReactClass from 'create-react-class'; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; export default createReactClass({ displayName: 'InlineSpinner', @@ -25,9 +27,25 @@ export default createReactClass({ const h = this.props.h || 16; const imgClass = this.props.imgClassName || ""; + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_InlineSpinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_InlineSpinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } + return ( -
    - +
    +
    ); }, diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js deleted file mode 100644 index 1775fdd4d7..0000000000 --- a/src/components/views/elements/MessageSpinner.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import createReactClass from 'create-react-class'; - -export default createReactClass({ - displayName: 'MessageSpinner', - - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - const msg = this.props.msg || "Loading..."; - return ( -
    -
    { msg }
      - -
    - ); - }, -}); diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index 9bdd04d803..4f41db51e2 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -30,6 +30,7 @@ interface IProps { isExplicit?: boolean; // XXX: once design replaces all toggles make this the default useCheckbox?: boolean; + disabled?: boolean; onChange?(checked: boolean): void; } @@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component { else label = _t(label); if (this.props.useCheckbox) { - return + return {label} ; } else { return (
    {label} - +
    ); } diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js index b1fe97d5d2..08ba0cf921 100644 --- a/src/components/views/elements/Spinner.js +++ b/src/components/views/elements/Spinner.js @@ -16,19 +16,39 @@ limitations under the License. */ import React from "react"; -import createReactClass from 'create-react-class'; +import PropTypes from "prop-types"; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; -export default createReactClass({ - displayName: 'Spinner', +const Spinner = ({w = 32, h = 32, imgClassName, message}) => { + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_Spinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_Spinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - return ( -
    - -
    - ); - }, -}); + return ( +
    + { message &&
    { message}
     
    } + +
    + ); +}; +Spinner.propTypes = { + w: PropTypes.number, + h: PropTypes.number, + imgClassName: PropTypes.string, + message: PropTypes.node, +}; + +export default Spinner; diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx new file mode 100644 index 0000000000..050a8b7adb --- /dev/null +++ b/src/components/views/elements/StyledRadioGroup.tsx @@ -0,0 +1,61 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import classNames from "classnames"; + +import StyledRadioButton from "./StyledRadioButton"; + +interface IDefinition { + value: T; + className?: string; + disabled?: boolean; + label: React.ReactChild; + description?: React.ReactChild; +} + +interface IProps { + name: string; + className?: string; + definitions: IDefinition[]; + value?: T; // if not provided no options will be selected + onChange(newValue: T); +} + +function StyledRadioGroup({name, definitions, value, className, onChange}: IProps) { + const _onChange = e => { + onChange(e.target.value); + }; + + return + {definitions.map(d => + + {d.label} + + {d.description} + )} + ; +} + +export default StyledRadioGroup; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index a642936fec..37f85a108f 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -22,6 +22,7 @@ import MFileBody from './MFileBody'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; +import InlineSpinner from '../elements/InlineSpinner'; export default class MAudioBody extends React.Component { constructor(props) { @@ -94,7 +95,7 @@ export default class MAudioBody extends React.Component { // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + ); } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ad238a728e..c92ae475bf 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import InlineSpinner from '../elements/InlineSpinner'; export default class MImageBody extends React.Component { static propTypes = { @@ -365,12 +366,7 @@ export default class MImageBody extends React.Component { // e2e image hasn't been decrypted yet if (content.file !== undefined && this.state.decryptedUrl === null) { - placeholder = {content.body}; + placeholder = ; } else if (!this.state.imgLoaded) { // Deliberately, getSpinner is left unimplemented here, MStickerBody overides placeholder = this.getPlaceholder(); diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 03f345e042..fdc04deffc 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +import InlineSpinner from '../elements/InlineSpinner'; export default createReactClass({ displayName: 'MVideoBody', @@ -147,7 +148,7 @@ export default createReactClass({ return (
    - {content.body} +
    ); diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 5dada64b52..5f80460d03 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -19,6 +19,8 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {formatFullDate} from "../../../DateUtils"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { mxEvent: MatrixEvent; @@ -36,8 +38,12 @@ const RedactedBody = React.forwardRef(({mxEvent}, ref) => { text = _t("Message deleted by %(name)s", { name: sender ? sender.name : redactedBecauseUserId }); } + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + const fullDate = formatFullDate(new Date(unsigned.redacted_because.origin_server_ts), showTwelveHour); + const titleText = _t("Message deleted on %(date)s", { date: fullDate }); + return ( - + { text } ); diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index bf65c7fb7c..254e28dffa 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -28,6 +28,7 @@ export const E2E_STATE = { WARNING: "warning", UNKNOWN: "unknown", NORMAL: "normal", + UNAUTHENTICATED: "unauthenticated", }; const crossSigningUserTitles = { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 7508cf3372..88c4ed2e7d 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -313,35 +313,52 @@ export default createReactClass({ return; } - // If we directly trust the device, short-circuit here - const verified = await this.context.isEventSenderVerified(mxEvent); - if (verified) { + const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent); + const senderId = mxEvent.getSender(); + const userTrust = this.context.checkUserTrust(senderId); + + if (encryptionInfo.mismatchedSender) { + // something definitely wrong is going on here this.setState({ - verified: E2E_STATE.VERIFIED, - }, () => { - // Decryption may have caused a change in size - this.props.onHeightChanged(); - }); + verified: E2E_STATE.WARNING, + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } - if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) { + if (!userTrust.isCrossSigningVerified()) { + // user is not verified, so default to everything is normal this.setState({ verified: E2E_STATE.NORMAL, - }, this.props.onHeightChanged); + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } - const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent); + const eventSenderTrust = this.context.checkDeviceTrust( + senderId, encryptionInfo.sender.deviceId, + ); if (!eventSenderTrust) { this.setState({ verified: E2E_STATE.UNKNOWN, - }, this.props.onHeightChanged); // Decryption may have cause a change in size + }, this.props.onHeightChanged); // Decryption may have caused a change in size + return; + } + + if (!eventSenderTrust.isVerified()) { + this.setState({ + verified: E2E_STATE.WARNING, + }, this.props.onHeightChanged); // Decryption may have caused a change in size + return; + } + + if (!encryptionInfo.authenticated) { + this.setState({ + verified: E2E_STATE.UNAUTHENTICATED, + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } this.setState({ - verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING, + verified: E2E_STATE.VERIFIED, }, this.props.onHeightChanged); // Decryption may have caused a change in size }, @@ -526,6 +543,8 @@ export default createReactClass({ return; // no icon if we've not even cross-signed the user } else if (this.state.verified === E2E_STATE.VERIFIED) { return; // no icon for verified + } else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) { + return (); } else if (this.state.verified === E2E_STATE.UNKNOWN) { return (); } else { @@ -976,6 +995,12 @@ function E2ePadlockUnknown(props) { ); } +function E2ePadlockUnauthenticated(props) { + return ( + + ); +} + class E2ePadlock extends React.Component { static propTypes = { icon: PropTypes.string.isRequired, diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index b742f8e8e7..6929341845 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -18,27 +18,24 @@ import React from "react"; import classNames from "classnames"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; import { Room } from "matrix-js-sdk/src/models/room"; -import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; -import AccessibleButton from "../../views/elements/AccessibleButton"; -import RoomAvatar from "../../views/avatars/RoomAvatar"; -import dis from '../../../dispatcher/dispatcher'; -import { Key } from "../../../Keyboard"; import * as RoomNotifs from '../../../RoomNotifs'; import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; import * as Unread from '../../../Unread'; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { EventEmitter } from "events"; import { arrayDiff } from "../../../utils/arrays"; import { IDestroyable } from "../../../utils/IDestroyable"; +import SettingsStore from "../../../settings/SettingsStore"; +import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; export const NOTIFICATION_STATE_UPDATE = "update"; export enum NotificationColor { // Inverted (None -> Red) because we do integer comparisons on this None, // nothing special - Bold, // no badge, show as unread + Bold, // no badge, show as unread // TODO: This goes away with new notification structures Grey, // unread notified messages Red, // unread pings } @@ -53,18 +50,45 @@ interface IProps { notification: INotificationState; /** - * If true, the badge will conditionally display a badge without count for the user. + * If true, the badge will show a count if at all possible. This is typically + * used to override the user's preference for things like room sublists. */ - allowNoCount: boolean; + forceCount: boolean; + + /** + * The room ID, if any, the badge represents. + */ + roomId?: string; } interface IState { + showCounts: boolean; // whether or not to show counts. Independent of props.forceCount } export default class NotificationBadge extends React.PureComponent { + private countWatcherRef: string; + constructor(props: IProps) { super(props); this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + + this.state = { + showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId), + }; + + this.countWatcherRef = SettingsStore.watchSetting( + "Notifications.alwaysShowBadgeCounts", this.roomId, + this.countPreferenceChanged, + ); + } + + private get roomId(): string { + // We should convert this to null for safety with the SettingsStore + return this.props.roomId || null; + } + + public componentWillUnmount() { + SettingsStore.unwatchSetting(this.countWatcherRef); } public componentDidUpdate(prevProps: Readonly) { @@ -75,24 +99,34 @@ export default class NotificationBadge extends React.PureComponent { + this.setState({showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId)}); + }; + private onNotificationUpdate = () => { this.forceUpdate(); // notification state changed - update }; public render(): React.ReactElement { // Don't show a badge if we don't need to - if (this.props.notification.color <= NotificationColor.Bold) return null; + if (this.props.notification.color <= NotificationColor.None) return null; const hasNotif = this.props.notification.color >= NotificationColor.Red; const hasCount = this.props.notification.color >= NotificationColor.Grey; - const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount"); + const hasUnread = this.props.notification.color >= NotificationColor.Bold; + const couldBeEmpty = (!this.state.showCounts || hasUnread) && !hasNotif; + let isEmptyBadge = couldBeEmpty && (!this.state.showCounts || !hasCount); + if (this.props.forceCount) { + isEmptyBadge = false; + if (!hasCount) return null; // Can't render a badge + } let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count); if (isEmptyBadge) symbol = ""; const classes = classNames({ 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': hasCount, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount, 'mx_NotificationBadge_highlighted': hasNotif, 'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, @@ -107,14 +141,28 @@ export default class NotificationBadge extends React.PureComponent { + if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore + if (room.roomId !== this.room.roomId) return; // not for us - ignore + this.updateNotificationState(); + }; + private handleRoomEventUpdate = (event: MatrixEvent) => { const roomId = event.getRoomId(); @@ -205,13 +259,38 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable } } -export class ListNotificationState extends EventEmitter implements IDestroyable { +export class TagSpecificNotificationState extends RoomNotificationState { + private static TAG_TO_COLOR: { + // @ts-ignore - TS wants this to be a string key, but we know better + [tagId: TagID]: NotificationColor, + } = { + [DefaultTagID.DM]: NotificationColor.Red, + }; + + private readonly colorWhenNotIdle?: NotificationColor; + + constructor(room: Room, tagId: TagID) { + super(room); + + const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId]; + if (specificColor) this.colorWhenNotIdle = specificColor; + } + + public get color(): NotificationColor { + if (!this.colorWhenNotIdle) return super.color; + + if (super.color !== NotificationColor.None) return this.colorWhenNotIdle; + return super.color; + } +} + +export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { private _count: number; private _color: NotificationColor; private rooms: Room[] = []; private states: { [roomId: string]: RoomNotificationState } = {}; - constructor(private byTileCount = false) { + constructor(private byTileCount = false, private tagId: TagID) { super(); } @@ -246,7 +325,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable state.destroy(); } for (const newRoom of diff.added) { - const state = new RoomNotificationState(newRoom); + const state = new TagSpecificNotificationState(newRoom, this.tagId); state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); if (this.states[newRoom.roomId]) { // "Should never happen" disclaimer. @@ -259,6 +338,12 @@ export class ListNotificationState extends EventEmitter implements IDestroyable this.calculateTotalState(); } + public getForRoom(room: Room) { + const state = this.states[room.roomId]; + if (!state) throw new Error("Unknown room for notification state"); + return state; + } + public destroy() { for (const state of Object.values(this.states)) { state.destroy(); diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index 4fdce360c1..be3a7c4fdd 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -193,6 +193,7 @@ export default class RoomList2 extends React.Component { components.push( { @@ -78,8 +81,9 @@ export default class RoomSublist2 extends React.Component { super(props); this.state = { - notificationState: new ListNotificationState(this.props.isInvite), + notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), menuDisplayed: false, + isResizing: false, }; this.state.notificationState.setRooms(this.props.rooms); } @@ -109,13 +113,21 @@ export default class RoomSublist2 extends React.Component { this.forceUpdate(); // because the layout doesn't trigger a re-render }; + private onResizeStart = () => { + this.setState({isResizing: true}); + }; + + private onResizeStop = () => { + this.setState({isResizing: false}); + }; + private onShowAllClick = () => { this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onShowLessClick = () => { - this.props.layout.visibleTiles = this.props.layout.minVisibleTiles; + this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render }; @@ -130,13 +142,13 @@ export default class RoomSublist2 extends React.Component { }; private onUnreadFirstChanged = async () => { - const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance; + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance; - await RoomListStore.instance.setListOrder(this.props.layout.tagId, newAlgorithm); + await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm); }; private onTagSortChanged = async (sort: SortAlgorithm) => { - await RoomListStore.instance.setTagSorting(this.props.layout.tagId, sort); + await RoomListStore.instance.setTagSorting(this.props.tagId, sort); }; private onMessagePreviewChanged = () => { @@ -176,7 +188,7 @@ export default class RoomSublist2 extends React.Component { key={`room-${room.roomId}`} showMessagePreview={this.props.layout.showPreviews} isMinimized={this.props.isMinimized} - tag={this.props.layout.tagId} + tag={this.props.tagId} /> ); } @@ -189,8 +201,8 @@ export default class RoomSublist2 extends React.Component { let contextMenu = null; if (this.state.menuDisplayed) { const elementRect = this.menuButtonRef.current.getBoundingClientRect(); - const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.layout.tagId) === SortAlgorithm.Alphabetic; - const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance; + const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; contextMenu = ( { this.onTagSortChanged(SortAlgorithm.Recent)} checked={!isAlphabetical} - name={`mx_${this.props.layout.tagId}_sortBy`} + name={`mx_${this.props.tagId}_sortBy`} > {_t("Activity")} this.onTagSortChanged(SortAlgorithm.Alphabetic)} checked={isAlphabetical} - name={`mx_${this.props.layout.tagId}_sortBy`} + name={`mx_${this.props.tagId}_sortBy`} > {_t("A-Z")} @@ -267,7 +279,7 @@ export default class RoomSublist2 extends React.Component { // TODO: Collapsed state - const badge = ; + const badge = ; let addRoomButton = null; if (!!this.props.onAddRoom) { @@ -291,7 +303,18 @@ export default class RoomSublist2 extends React.Component { 'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton, }); + const badgeContainer = ( +
    + {badge} +
    + ); + // TODO: a11y (see old component) + // Note: the addRoomButton conditionally gets moved around + // the DOM depending on whether or not the list is minimized. + // If we're minimized, we want it below the header so it + // doesn't become sticky. + // The same applies to the notification badge. return (
    @@ -307,11 +330,11 @@ export default class RoomSublist2 extends React.Component { {this.props.label} {this.renderMenu()} - {addRoomButton} -
    - {badge} -
    + {this.props.isMinimized ? null : badgeContainer} + {this.props.isMinimized ? null : addRoomButton}
    + {this.props.isMinimized ? badgeContainer : null} + {this.props.isMinimized ? addRoomButton : null}
    ); }} @@ -343,6 +366,12 @@ export default class RoomSublist2 extends React.Component { const nVisible = Math.floor(layout.visibleTiles); const visibleTiles = tiles.slice(0, nVisible); + const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length); + const showMoreBtnClasses = classNames({ + 'mx_RoomSublist2_showNButton': true, + 'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored, + }); + // If we're hiding rooms, show a 'show more' button to the user. This button // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. @@ -357,14 +386,14 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; showNButton = ( -
    +
    {/* set by CSS masking */} {showMoreText}
    ); - } else if (tiles.length <= nVisible && tiles.length > this.props.layout.minVisibleTiles) { + } else if (tiles.length <= nVisible && tiles.length > this.props.layout.defaultVisibleTiles) { // we have all tiles visible - add a button to show less let showLessText = ( @@ -373,7 +402,7 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showLessText = null; showNButton = ( -
    +
    {/* set by CSS masking */} @@ -419,6 +448,8 @@ export default class RoomSublist2 extends React.Component { resizeHandles={handles} onResize={this.onResize} className="mx_RoomSublist2_resizeBox" + onResizeStart={this.onResizeStart} + onResizeStop={this.onResizeStop} > {visibleTiles} {showNButton} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 9f4870d437..2791bd9730 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -26,11 +26,15 @@ import RoomAvatar from "../../views/avatars/RoomAvatar"; import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; -import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge"; +import NotificationBadge, { + INotificationState, + NotificationColor, + TagSpecificNotificationState +} from "./NotificationBadge"; import { _t } from "../../../languageHandler"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomTileIcon from "./RoomTileIcon"; /******************************************************************* @@ -79,7 +83,7 @@ export default class RoomTile2 extends React.Component { this.state = { hover: false, - notificationState: new RoomNotificationState(this.props.room), + notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, generalMenuDisplayed: false, }; @@ -131,11 +135,8 @@ export default class RoomTile2 extends React.Component { ev.preventDefault(); ev.stopPropagation(); - if (tagId === DefaultTagID.DM) { - // TODO: DM Flagging - } else { - // TODO: XOR favourites and low priority - } + // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 + // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 }; private onLeaveRoomClick = (ev: ButtonEvent) => { @@ -192,12 +193,6 @@ export default class RoomTile2 extends React.Component { {_t("Low Priority")} -
  • - this.onTagRoom(e, DefaultTagID.DM)}> - - {_t("Direct Chat")} - -
  • @@ -248,7 +243,13 @@ export default class RoomTile2 extends React.Component { 'mx_RoomTile2_minimized': this.props.isMinimized, }); - const badge = ; + const badge = ( + + ); // TODO: the original RoomTile uses state for the room name. Do we need to? let name = this.props.room.name; @@ -261,7 +262,7 @@ export default class RoomTile2 extends React.Component { let messagePreview = null; if (this.props.showMessagePreview && !this.props.isMinimized) { // The preview store heavily caches this info, so should be safe to hammer. - const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room); + const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); // Only show the preview if there is one to show. if (text) { diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 7eb239cbca..aa512d4365 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -154,13 +154,6 @@ export default class CrossSigningPanel extends React.PureComponent { errorSection =
    {error.toString()}
    ; } - // Whether the various keys exist on your account (but not necessarily - // on this device). - const enabledForAccount = ( - crossSigningPrivateKeysInStorage && - secretStorageKeyInAccount - ); - let summarisedStatus; if (homeserverSupportsCrossSigning === undefined) { const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); @@ -184,8 +177,19 @@ export default class CrossSigningPanel extends React.PureComponent { )}

    ; } + const keysExistAnywhere = ( + secretStorageKeyInAccount || + crossSigningPrivateKeysInStorage || + crossSigningPublicKeysOnDevice + ); + const keysExistEverywhere = ( + secretStorageKeyInAccount && + crossSigningPrivateKeysInStorage && + crossSigningPublicKeysOnDevice + ); + let resetButton; - if (enabledForAccount) { + if (keysExistAnywhere) { resetButton = (
    @@ -197,10 +201,7 @@ export default class CrossSigningPanel extends React.PureComponent { // TODO: determine how better to expose this to users in addition to prompts at login/toast let bootstrapButton; - if ( - (!enabledForAccount || !crossSigningPublicKeysOnDevice) && - homeserverSupportsCrossSigning - ) { + if (!keysExistEverywhere && homeserverSupportsCrossSigning) { bootstrapButton = (
    diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.js b/src/components/views/settings/tabs/room/NotificationSettingsTab.js index 96e6b3d354..c521e228e0 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.js +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.js @@ -39,12 +39,11 @@ export default class NotificationsSettingsTab extends React.Component { // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs UNSAFE_componentWillMount() { // eslint-disable-line camelcase - Notifier.getSoundForRoom(this.props.roomId).then((soundData) => { - if (!soundData) { - return; - } - this.setState({currentSound: soundData.name || soundData.url}); - }); + const soundData = Notifier.getSoundForRoom(this.props.roomId); + if (!soundData) { + return; + } + this.setState({currentSound: soundData.name || soundData.url}); this._soundUpload = createRef(); } diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 46723ec7cd..f02147608d 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -21,7 +21,6 @@ import {_t} from "../../../../../languageHandler"; import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore"; import { enumerateThemes } from "../../../../../theme"; import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher"; -import Field from "../../../elements/Field"; import Slider from "../../../elements/Slider"; import AccessibleButton from "../../../elements/AccessibleButton"; import dis from "../../../../../dispatcher/dispatcher"; @@ -32,7 +31,9 @@ import { IValidationResult, IFieldState } from '../../../elements/Validation'; import StyledRadioButton from '../../../elements/StyledRadioButton'; import StyledCheckbox from '../../../elements/StyledCheckbox'; import SettingsFlag from '../../../elements/SettingsFlag'; +import Field from '../../../elements/Field'; import EventTilePreview from '../../../elements/EventTilePreview'; +import StyledRadioGroup from "../../../elements/StyledRadioGroup"; interface IProps { } @@ -55,6 +56,9 @@ interface IState extends IThemeState { customThemeUrl: string; customThemeMessage: CustomThemeMessage; useCustomFontSize: boolean; + useSystemFont: boolean; + systemFont: string; + showAdvanced: boolean; useIRCLayout: boolean; } @@ -73,6 +77,9 @@ export default class AppearanceUserSettingsTab extends React.Component): void => { - const newTheme = e.target.value; + private onThemeChange = (newTheme: string): void => { if (this.state.theme === newTheme) return; // doing getValue in the .catch will still return the value we failed to set, @@ -271,19 +277,18 @@ export default class AppearanceUserSettingsTab extends React.Component {_t("Theme")} {systemThemeSection} -
    - {orderedThemes.map(theme => { - return - {theme.name} - ; - })} +
    + ({ + value: t.id, + label: t.name, + disabled: this.state.useSystemTheme, + className: "mx_ThemeSelector_" + t.id, + }))} + onChange={this.onThemeChange} + value={this.state.useSystemTheme ? undefined : this.state.theme} + />
    {customThemeForm} @@ -374,6 +379,53 @@ export default class AppearanceUserSettingsTab extends React.Component; }; + private renderAdvancedSection() { + const toggle =
    this.setState({showAdvanced: !this.state.showAdvanced})} + > + {this.state.showAdvanced ? "Hide advanced" : "Show advanced"} +
    ; + + let advanced: React.ReactNode; + + if (this.state.showAdvanced) { + advanced = <> + + this.setState({useSystemFont: checked})} + /> + { + this.setState({ + systemFont: value.target.value, + }); + + SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value); + }} + tooltipContent="Set the name of a font installed on your system & Riot will attempt to use it." + forceTooltipVisible={true} + disabled={!this.state.useSystemFont} + value={this.state.systemFont} + /> + ; + } + return
    + {toggle} + {advanced} +
    ; + } + render() { return (
    @@ -384,6 +436,7 @@ export default class AppearanceUserSettingsTab extends React.Component ); } diff --git a/src/components/views/terms/InlineTermsAgreement.js b/src/components/views/terms/InlineTermsAgreement.js index bccd686cd3..55719fe57f 100644 --- a/src/components/views/terms/InlineTermsAgreement.js +++ b/src/components/views/terms/InlineTermsAgreement.js @@ -18,6 +18,7 @@ import React from "react"; import PropTypes from "prop-types"; import {_t, pickBestLanguage} from "../../../languageHandler"; import * as sdk from "../../.."; +import {objectClone} from "../../../utils/objects"; export default class InlineTermsAgreement extends React.Component { static propTypes = { @@ -56,7 +57,7 @@ export default class InlineTermsAgreement extends React.Component { } _togglePolicy = (index) => { - const policies = JSON.parse(JSON.stringify(this.state.policies)); // deep & cheap clone + const policies = objectClone(this.state.policies); policies[index].checked = !policies[index].checked; this.setState({policies}); }; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 5f7ca1293c..379a0a4451 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -69,4 +69,14 @@ export enum Action { * Opens the user menu (previously known as the top left menu). No additional payload information required. */ ToggleUserMenu = "toggle_user_menu", + + /** + * Sets the apps root font size. Should be used with UpdateFontSizePayload + */ + UpdateFontSize = "update_font_size", + + /** + * Sets a system font. Should be used with UpdateSystemFontPayload + */ + UpdateSystemFont = "update_system_font", } diff --git a/src/dispatcher/payloads/UpdateFontSizePayload.ts b/src/dispatcher/payloads/UpdateFontSizePayload.ts new file mode 100644 index 0000000000..6577acd594 --- /dev/null +++ b/src/dispatcher/payloads/UpdateFontSizePayload.ts @@ -0,0 +1,27 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../payloads"; +import { Action } from "../actions"; + +export interface UpdateFontSizePayload extends ActionPayload { + action: Action.UpdateFontSize; + + /** + * The font size to set the root to + */ + size: number; +} diff --git a/src/dispatcher/payloads/UpdateSystemFontPayload.ts b/src/dispatcher/payloads/UpdateSystemFontPayload.ts new file mode 100644 index 0000000000..aa59db5aa9 --- /dev/null +++ b/src/dispatcher/payloads/UpdateSystemFontPayload.ts @@ -0,0 +1,32 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../payloads"; +import { Action } from "../actions"; + +export interface UpdateSystemFontPayload extends ActionPayload { + action: Action.UpdateSystemFont; + + /** + * Specify whether to use a system font or the stylesheet font + */ + useSystemFont: boolean; + + /** + * The system font to use + */ + font: string; +} diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts new file mode 100644 index 0000000000..dd0d53f0d3 --- /dev/null +++ b/src/hooks/useAccountData.ts @@ -0,0 +1,50 @@ +/* +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 {useCallback, useState} from "react"; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {Room} from "matrix-js-sdk/src/models/room"; + +import {useEventEmitter} from "./useEventEmitter"; + +const tryGetContent = (ev?: MatrixEvent) => ev ? ev.getContent() : undefined; + +// Hook to simplify listening to Matrix account data +export const useAccountData = (cli: MatrixClient, eventType: string) => { + const [value, setValue] = useState(() => tryGetContent(cli.getAccountData(eventType))); + + const handler = useCallback((event) => { + if (event.getType() !== eventType) return; + setValue(event.getContent()); + }, [cli, eventType]); + useEventEmitter(cli, "accountData", handler); + + return value || {} as T; +}; + +// Hook to simplify listening to Matrix room account data +export const useRoomAccountData = (room: Room, eventType: string) => { + const [value, setValue] = useState(() => tryGetContent(room.getAccountData(eventType))); + + const handler = useCallback((event) => { + if (event.getType() !== eventType) return; + setValue(event.getContent()); + }, [room, eventType]); + useEventEmitter(room, "Room.accountData", handler); + + return value || {} as T; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 646f43af33..495300f3fe 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -247,7 +247,6 @@ "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.", - "sent an image.": "sent an image.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", @@ -421,11 +420,66 @@ "Restart": "Restart", "Upgrade your Riot": "Upgrade your Riot", "A new version of Riot is available!": "A new version of Riot is available!", - "You: %(message)s": "You: %(message)s", + "Guest": "Guest", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", + "You joined the call": "You joined the call", + "%(senderName)s joined the call": "%(senderName)s joined the call", + "Call in progress": "Call in progress", + "You left the call": "You left the call", + "%(senderName)s left the call": "%(senderName)s left the call", + "Call ended": "Call ended", + "You started a call": "You started a call", + "%(senderName)s started a call": "%(senderName)s started a call", + "Waiting for answer": "Waiting for answer", + "%(senderName)s is calling": "%(senderName)s is calling", + "You created the room": "You created the room", + "%(senderName)s created the room": "%(senderName)s created the room", + "You made the chat encrypted": "You made the chat encrypted", + "%(senderName)s made the chat encrypted": "%(senderName)s made the chat encrypted", + "You made history visible to new members": "You made history visible to new members", + "%(senderName)s made history visible to new members": "%(senderName)s made history visible to new members", + "You made history visible to anyone": "You made history visible to anyone", + "%(senderName)s made history visible to anyone": "%(senderName)s made history visible to anyone", + "You made history visible to future members": "You made history visible to future members", + "%(senderName)s made history visible to future members": "%(senderName)s made history visible to future members", + "You were invited": "You were invited", + "%(targetName)s was invited": "%(targetName)s was invited", + "You left": "You left", + "%(targetName)s left": "%(targetName)s left", + "You were kicked (%(reason)s)": "You were kicked (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s was kicked (%(reason)s)", + "You were kicked": "You were kicked", + "%(targetName)s was kicked": "%(targetName)s was kicked", + "You rejected the invite": "You rejected the invite", + "%(targetName)s rejected the invite": "%(targetName)s rejected the invite", + "You were uninvited": "You were uninvited", + "%(targetName)s was uninvited": "%(targetName)s was uninvited", + "You were banned (%(reason)s)": "You were banned (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s was banned (%(reason)s)", + "You were banned": "You were banned", + "%(targetName)s was banned": "%(targetName)s was banned", + "You joined": "You joined", + "%(targetName)s joined": "%(targetName)s joined", + "You changed your name": "You changed your name", + "%(targetName)s changed their name": "%(targetName)s changed their name", + "You changed your avatar": "You changed your avatar", + "%(targetName)s changed their avatar": "%(targetName)s changed their avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "You changed the room name", + "%(senderName)s changed the room name": "%(senderName)s changed the room name", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "You uninvited %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s uninvited %(targetName)s", + "You invited %(targetName)s": "You invited %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", + "You changed the room topic": "You changed the room topic", + "%(senderName)s changed the room topic": "%(senderName)s changed the room topic", + "New spinner design": "New spinner design", "Font scaling": "Font scaling", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", @@ -440,7 +494,7 @@ "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", - "Use compact timeline layout": "Use compact timeline layout", + "Use a more compact ‘Modern’ layout": "Use a more compact ‘Modern’ layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)", "Show avatar changes": "Show avatar changes", @@ -460,6 +514,8 @@ "Mirror local video feed": "Mirror local video feed", "Enable Community Filter Panel": "Enable Community Filter Panel", "Match system theme": "Match system theme", + "Use a system font": "Use a system font", + "System font name": "System font name", "Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls", "Send analytics data": "Send analytics data", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", @@ -1028,6 +1084,7 @@ "Encrypted by an unverified session": "Encrypted by an unverified session", "Unencrypted": "Unencrypted", "Encrypted by a deleted session": "Encrypted by a deleted session", + "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.", "Please select the destination room for this message": "Please select the destination room for this message", "Invite only": "Invite only", "Scroll to most recent messages": "Scroll to most recent messages", @@ -1163,7 +1220,6 @@ "Unread messages.": "Unread messages.", "Favourite": "Favourite", "Low Priority": "Low Priority", - "Direct Chat": "Direct Chat", "Leave Room": "Leave Room", "Room options": "Room options", "Add a topic": "Add a topic", @@ -1371,6 +1427,7 @@ "reacted with %(shortName)s": "reacted with %(shortName)s", "Message deleted": "Message deleted", "Message deleted by %(name)s": "Message deleted by %(name)s", + "Message deleted on %(date)s": "Message deleted on %(date)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", @@ -1841,6 +1898,7 @@ "Mentions only": "Mentions only", "Leave": "Leave", "Forget": "Forget", + "Direct Chat": "Direct Chat", "Clear status": "Clear status", "Update status": "Update status", "Set status": "Set status", @@ -2056,7 +2114,6 @@ "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", - "Guest": "Guest", "Your profile": "Your profile", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", diff --git a/src/notifications/ContentRules.js b/src/notifications/ContentRules.ts similarity index 69% rename from src/notifications/ContentRules.js rename to src/notifications/ContentRules.ts index 8c285220c7..a3ec017e37 100644 --- a/src/notifications/ContentRules.js +++ b/src/notifications/ContentRules.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,9 +15,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import {PushRuleVectorState, State} from "./PushRuleVectorState"; +import {IExtendedPushRule, IPushRuleSet, IRuleSets} from "./types"; -import {PushRuleVectorState} from "./PushRuleVectorState"; +export interface IContentRules { + vectorState: State; + rules: IExtendedPushRule[]; + externalRules: IExtendedPushRule[]; +} + +export const SCOPE = "global"; +export const KIND = "content"; export class ContentRules { /** @@ -31,7 +39,7 @@ export class ContentRules { * externalRules: a list of other keyword rules, with states other than * vectorState */ - static parseContentRules(rulesets) { + static parseContentRules(rulesets: IRuleSets): IContentRules { // first categorise the keyword rules in terms of their actions const contentRules = this._categoriseContentRules(rulesets); @@ -51,59 +59,72 @@ export class ContentRules { if (contentRules.loud.length) { return { - vectorState: PushRuleVectorState.LOUD, + vectorState: State.Loud, rules: contentRules.loud, - externalRules: [].concat(contentRules.loud_but_disabled, contentRules.on, contentRules.on_but_disabled, contentRules.other), + externalRules: [ + ...contentRules.loud_but_disabled, + ...contentRules.on, + ...contentRules.on_but_disabled, + ...contentRules.other, + ], }; } else if (contentRules.loud_but_disabled.length) { return { - vectorState: PushRuleVectorState.OFF, + vectorState: State.Off, rules: contentRules.loud_but_disabled, - externalRules: [].concat(contentRules.on, contentRules.on_but_disabled, contentRules.other), + externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other], }; } else if (contentRules.on.length) { return { - vectorState: PushRuleVectorState.ON, + vectorState: State.On, rules: contentRules.on, - externalRules: [].concat(contentRules.on_but_disabled, contentRules.other), + externalRules: [...contentRules.on_but_disabled, ...contentRules.other], }; } else if (contentRules.on_but_disabled.length) { return { - vectorState: PushRuleVectorState.OFF, + vectorState: State.Off, rules: contentRules.on_but_disabled, externalRules: contentRules.other, }; } else { return { - vectorState: PushRuleVectorState.ON, + vectorState: State.On, rules: [], externalRules: contentRules.other, }; } } - static _categoriseContentRules(rulesets) { - const contentRules = {on: [], on_but_disabled: [], loud: [], loud_but_disabled: [], other: []}; + static _categoriseContentRules(rulesets: IRuleSets) { + const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = { + on: [], + on_but_disabled: [], + loud: [], + loud_but_disabled: [], + other: [], + }; + for (const kind in rulesets.global) { for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) { const r = rulesets.global[kind][i]; // check it's not a default rule - if (r.rule_id[0] === '.' || kind !== 'content') { + if (r.rule_id[0] === '.' || kind !== "content") { continue; } - r.kind = kind; // is this needed? not sure + // this is needed as we are flattening an object of arrays into a single array + r.kind = kind; switch (PushRuleVectorState.contentRuleVectorStateKind(r)) { - case PushRuleVectorState.ON: + case State.On: if (r.enabled) { contentRules.on.push(r); } else { contentRules.on_but_disabled.push(r); } break; - case PushRuleVectorState.LOUD: + case State.Loud: if (r.enabled) { contentRules.loud.push(r); } else { diff --git a/src/notifications/NotificationUtils.js b/src/notifications/NotificationUtils.ts similarity index 80% rename from src/notifications/NotificationUtils.js rename to src/notifications/NotificationUtils.ts index bf393da060..e3b7f66447 100644 --- a/src/notifications/NotificationUtils.js +++ b/src/notifications/NotificationUtils.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,7 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import {Action, Actions} from "./types"; + +interface IEncodedActions { + notify: boolean; + sound?: string; + highlight?: boolean; +} export class NotificationUtils { // Encodes a dictionary of { @@ -24,12 +30,12 @@ export class NotificationUtils { // "highlight: true/false, // } // to a list of push actions. - static encodeActions(action) { + static encodeActions(action: IEncodedActions) { const notify = action.notify; const sound = action.sound; const highlight = action.highlight; if (notify) { - const actions = ["notify"]; + const actions: Action[] = [Actions.Notify]; if (sound) { actions.push({"set_tweak": "sound", "value": sound}); } @@ -40,7 +46,7 @@ export class NotificationUtils { } return actions; } else { - return ["dont_notify"]; + return [Actions.DontNotify]; } } @@ -50,18 +56,18 @@ export class NotificationUtils { // "highlight: true/false, // } // If the actions couldn't be decoded then returns null. - static decodeActions(actions) { + static decodeActions(actions: Action[]): IEncodedActions { let notify = false; let sound = null; let highlight = false; for (let i = 0; i < actions.length; ++i) { const action = actions[i]; - if (action === "notify") { + if (action === Actions.Notify) { notify = true; - } else if (action === "dont_notify") { + } else if (action === Actions.DontNotify) { notify = false; - } else if (typeof action === 'object') { + } else if (typeof action === "object") { if (action.set_tweak === "sound") { sound = action.value; } else if (action.set_tweak === "highlight") { @@ -81,7 +87,7 @@ export class NotificationUtils { highlight = true; } - const result = {notify: notify, highlight: highlight}; + const result: IEncodedActions = { notify, highlight }; if (sound !== null) { result.sound = sound; } diff --git a/src/notifications/PushRuleVectorState.js b/src/notifications/PushRuleVectorState.ts similarity index 69% rename from src/notifications/PushRuleVectorState.js rename to src/notifications/PushRuleVectorState.ts index 263226ce1c..d33426cfc4 100644 --- a/src/notifications/PushRuleVectorState.js +++ b/src/notifications/PushRuleVectorState.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,43 +15,42 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import {StandardActions} from "./StandardActions"; import {NotificationUtils} from "./NotificationUtils"; +import {IPushRule} from "./types"; + +export enum State { + /** The push rule is disabled */ + Off = "off", + /** The user will receive push notification for this rule */ + On = "on", + /** The user will receive push notification for this rule with sound and + highlight if this is legitimate */ + Loud = "loud", +} export class PushRuleVectorState { - // Backwards compatibility (things should probably be using .states instead) - static OFF = "off"; - static ON = "on"; - static LOUD = "loud"; + // Backwards compatibility (things should probably be using the enum above instead) + static OFF = State.Off; + static ON = State.On; + static LOUD = State.Loud; /** * Enum for state of a push rule as defined by the Vector UI. * @readonly * @enum {string} */ - static states = { - /** The push rule is disabled */ - OFF: PushRuleVectorState.OFF, - - /** The user will receive push notification for this rule */ - ON: PushRuleVectorState.ON, - - /** The user will receive push notification for this rule with sound and - highlight if this is legitimate */ - LOUD: PushRuleVectorState.LOUD, - }; + static states = State; /** * Convert a PushRuleVectorState to a list of actions * * @return [object] list of push-rule actions */ - static actionsFor(pushRuleVectorState) { - if (pushRuleVectorState === PushRuleVectorState.ON) { + static actionsFor(pushRuleVectorState: State) { + if (pushRuleVectorState === State.On) { return StandardActions.ACTION_NOTIFY; - } else if (pushRuleVectorState === PushRuleVectorState.LOUD) { + } else if (pushRuleVectorState === State.Loud) { return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND; } } @@ -63,7 +62,7 @@ export class PushRuleVectorState { * category or in PushRuleVectorState.LOUD, regardless of its enabled * state. Returns null if it does not match these categories. */ - static contentRuleVectorStateKind(rule) { + static contentRuleVectorStateKind(rule: IPushRule): State { const decoded = NotificationUtils.decodeActions(rule.actions); if (!decoded) { @@ -81,10 +80,10 @@ export class PushRuleVectorState { let stateKind = null; switch (tweaks) { case 0: - stateKind = PushRuleVectorState.ON; + stateKind = State.On; break; case 2: - stateKind = PushRuleVectorState.LOUD; + stateKind = State.Loud; break; } return stateKind; diff --git a/src/notifications/StandardActions.js b/src/notifications/StandardActions.ts similarity index 98% rename from src/notifications/StandardActions.js rename to src/notifications/StandardActions.ts index b54cea332a..c17010af9a 100644 --- a/src/notifications/StandardActions.js +++ b/src/notifications/StandardActions.ts @@ -15,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import {NotificationUtils} from "./NotificationUtils"; const encodeActions = NotificationUtils.encodeActions; diff --git a/src/notifications/types.ts b/src/notifications/types.ts new file mode 100644 index 0000000000..9622193740 --- /dev/null +++ b/src/notifications/types.ts @@ -0,0 +1,111 @@ +/* +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. +*/ + +export enum NotificationSetting { + AllMessages = "all_messages", // .m.rule.message = notify + DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default. + MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread + Never = "never", // .m.rule.master = enabled (dont_notify) +} + +export interface ISoundTweak { + set_tweak: "sound"; + value: string; +} +export interface IHighlightTweak { + set_tweak: "highlight"; + value?: boolean; +} + +export type Tweak = ISoundTweak | IHighlightTweak; + +export enum Actions { + Notify = "notify", + DontNotify = "dont_notify", // no-op + Coalesce = "coalesce", // unused + MarkUnread = "mark_unread", // new +} + +export type Action = Actions | Tweak; + +// Push rule kinds in descending priority order +export enum Kind { + Override = "override", + ContentSpecific = "content", + RoomSpecific = "room", + SenderSpecific = "sender", + Underride = "underride", +} + +export interface IEventMatchCondition { + kind: "event_match"; + key: string; + pattern: string; +} + +export interface IContainsDisplayNameCondition { + kind: "contains_display_name"; +} + +export interface IRoomMemberCountCondition { + kind: "room_member_count"; + is: string; +} + +export interface ISenderNotificationPermissionCondition { + kind: "sender_notification_permission"; + key: string; +} + +export type Condition = + IEventMatchCondition | + IContainsDisplayNameCondition | + IRoomMemberCountCondition | + ISenderNotificationPermissionCondition; + +export enum RuleIds { + MasterRule = ".m.rule.master", // The master rule (all notifications disabling) + MessageRule = ".m.rule.message", + EncryptedMessageRule = ".m.rule.encrypted", + RoomOneToOneRule = ".m.rule.room_one_to_one", + EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one", +} + +export interface IPushRule { + enabled: boolean; + rule_id: RuleIds | string; + actions: Action[]; + default: boolean; + conditions?: Condition[]; // only applicable to `underride` and `override` rules + pattern?: string; // only applicable to `content` rules +} + +// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor +export interface IExtendedPushRule extends IPushRule { + kind: Kind; +} + +export interface IPushRuleSet { + override: IPushRule[]; + content: IPushRule[]; + room: IPushRule[]; + sender: IPushRule[]; + underride: IPushRule[]; +} + +export interface IRuleSets { + global: IPushRuleSet; +} diff --git a/src/settings/Settings.js b/src/settings/Settings.js index ca8647e067..820329f6c6 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -30,6 +30,8 @@ import PushToMatrixClientController from './controllers/PushToMatrixClientContro import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases"; import FontSizeController from './controllers/FontSizeController'; +import SystemFontController from './controllers/SystemFontController'; +import UseSystemFontController from './controllers/UseSystemFontController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config']; @@ -95,6 +97,12 @@ export const SETTINGS = { // // not use this for new settings. // invertedSettingName: "my-negative-setting", // }, + "feature_new_spinner": { + isFeature: true, + displayName: _td("New spinner design"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_font_scaling": { isFeature: true, displayName: _td("Font scaling"), @@ -188,9 +196,14 @@ export const SETTINGS = { default: true, invertedSettingName: 'MessageComposerInput.dontSuggestEmoji', }, + // TODO: Wire up appropriately to UI (FTUE notifications) + "Notifications.alwaysShowBadgeCounts": { + supportedLevels: LEVELS_ROOM_OR_ACCOUNT, + default: false, + }, "useCompactLayout": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Use compact timeline layout'), + displayName: _td('Use a more compact ‘Modern’ layout'), default: false, }, "showRedactions": { @@ -314,6 +327,18 @@ export const SETTINGS = { default: true, displayName: _td("Match system theme"), }, + "useSystemFont": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + displayName: _td("Use a system font"), + controller: new UseSystemFontController(), + }, + "systemFont": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: "", + displayName: _td("System font name"), + controller: new SystemFontController(), + }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, displayName: _td('Allow Peer-to-Peer for 1:1 calls'), diff --git a/src/settings/WatchManager.js b/src/settings/WatchManager.js index 472b13966f..3f54ca929e 100644 --- a/src/settings/WatchManager.js +++ b/src/settings/WatchManager.js @@ -51,8 +51,17 @@ export class WatchManager { const roomWatchers = this._watchers[settingName]; const callbacks = []; - if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]); - if (roomWatchers[null]) callbacks.push(...roomWatchers[null]); + if (inRoomId !== null && roomWatchers[inRoomId]) { + callbacks.push(...roomWatchers[inRoomId]); + } + + if (!inRoomId) { + // Fire updates to all the individual room watchers too, as they probably + // care about the change higher up. + callbacks.push(...Object.values(roomWatchers).reduce((r, a) => [...r, ...a], [])); + } else if (roomWatchers[null]) { + callbacks.push(...roomWatchers[null]); + } for (const callback of callbacks) { callback(inRoomId, atLevel, newValueAtLevel); diff --git a/src/settings/controllers/FontSizeController.js b/src/settings/controllers/FontSizeController.ts similarity index 80% rename from src/settings/controllers/FontSizeController.js rename to src/settings/controllers/FontSizeController.ts index 3ef01ab99b..6440fd32fe 100644 --- a/src/settings/controllers/FontSizeController.js +++ b/src/settings/controllers/FontSizeController.ts @@ -16,6 +16,8 @@ limitations under the License. import SettingController from "./SettingController"; import dis from "../../dispatcher/dispatcher"; +import { UpdateFontSizePayload } from "../../dispatcher/payloads/UpdateFontSizePayload"; +import { Action } from "../../dispatcher/actions"; export default class FontSizeController extends SettingController { constructor() { @@ -24,8 +26,8 @@ export default class FontSizeController extends SettingController { onChange(level, roomId, newValue) { // Dispatch font size change so that everything open responds to the change. - dis.dispatch({ - action: "update-font-size", + dis.dispatch({ + action: Action.UpdateFontSize, size: newValue, }); } diff --git a/src/settings/controllers/SystemFontController.ts b/src/settings/controllers/SystemFontController.ts new file mode 100644 index 0000000000..4f591efc17 --- /dev/null +++ b/src/settings/controllers/SystemFontController.ts @@ -0,0 +1,36 @@ +/* +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 SettingController from "./SettingController"; +import SettingsStore from "../SettingsStore"; +import dis from "../../dispatcher/dispatcher"; +import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload"; +import { Action } from "../../dispatcher/actions"; + +export default class SystemFontController extends SettingController { + constructor() { + super(); + } + + onChange(level, roomId, newValue) { + // Dispatch font size change so that everything open responds to the change. + dis.dispatch({ + action: Action.UpdateSystemFont, + useSystemFont: SettingsStore.getValue("useSystemFont"), + font: newValue, + }); + } +} diff --git a/src/settings/controllers/UseSystemFontController.ts b/src/settings/controllers/UseSystemFontController.ts new file mode 100644 index 0000000000..d598b25962 --- /dev/null +++ b/src/settings/controllers/UseSystemFontController.ts @@ -0,0 +1,36 @@ +/* +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 SettingController from "./SettingController"; +import SettingsStore from "../SettingsStore"; +import dis from "../../dispatcher/dispatcher"; +import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload"; +import { Action } from "../../dispatcher/actions"; + +export default class UseSystemFontController extends SettingController { + constructor() { + super(); + } + + onChange(level, roomId, newValue) { + // Dispatch font size change so that everything open responds to the change. + dis.dispatch({ + action: Action.UpdateSystemFont, + useSystemFont: newValue, + font: SettingsStore.getValue("systemFont"), + }); + } +} diff --git a/src/settings/handlers/AccountSettingsHandler.js b/src/settings/handlers/AccountSettingsHandler.js index fea2e92c62..732ce6c550 100644 --- a/src/settings/handlers/AccountSettingsHandler.js +++ b/src/settings/handlers/AccountSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms"; const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; @@ -45,7 +46,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa newClient.on("accountData", this._onAccountData); } - _onAccountData(event) { + _onAccountData(event, prevEvent) { if (event.getType() === "org.matrix.preview_urls") { let val = event.getContent()['disable']; if (typeof(val) !== "boolean") { @@ -56,8 +57,10 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { const val = event.getContent()[settingName]; this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val); } @@ -159,7 +162,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa const event = cli.getAccountData(eventType); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } _notifyBreadcrumbsUpdate(event) { diff --git a/src/settings/handlers/MatrixClientBackedSettingsHandler.js b/src/settings/handlers/MatrixClientBackedSettingsHandler.js index effe7ae9a7..63725b4dff 100644 --- a/src/settings/handlers/MatrixClientBackedSettingsHandler.js +++ b/src/settings/handlers/MatrixClientBackedSettingsHandler.js @@ -42,6 +42,10 @@ export default class MatrixClientBackedSettingsHandler extends SettingsHandler { MatrixClientBackedSettingsHandler._instances.push(this); } + get client() { + return MatrixClientBackedSettingsHandler._matrixClient; + } + initMatrixClient() { console.warn("initMatrixClient not overridden"); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.js b/src/settings/handlers/RoomAccountSettingsHandler.js index 1e9d3f7bed..b2af81779b 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.js +++ b/src/settings/handlers/RoomAccountSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets"; @@ -40,7 +41,7 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin newClient.on("Room.accountData", this._onAccountData); } - _onAccountData(event, room) { + _onAccountData(event, room, prevEvent) { const roomId = room.roomId; if (event.getType() === "org.matrix.room.preview_urls") { @@ -55,8 +56,10 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin } else if (event.getType() === "org.matrix.room.color_scheme") { this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { const val = event.getContent()[settingName]; this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val); } @@ -134,6 +137,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin const event = room.getAccountData(eventType); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } } diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js index 6407818450..d8e775742c 100644 --- a/src/settings/handlers/RoomSettingsHandler.js +++ b/src/settings/handlers/RoomSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; /** * Gets and sets settings at the "room" level. @@ -38,8 +39,15 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl newClient.on("RoomState.events", this._onEvent); } - _onEvent(event) { + _onEvent(event, state, prevEvent) { const roomId = event.getRoomId(); + const room = this.client.getRoom(roomId); + + // Note: the tests often fire setting updates that don't have rooms in the store, so + // we fail softly here. We shouldn't assume that the state being fired is current + // state, but we also don't need to explode just because we didn't find a room. + if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); + if (room && state !== room.currentState) return; // ignore state updates which are not current if (event.getType() === "org.matrix.room.preview_urls") { let val = event.getContent()['disable']; @@ -51,8 +59,10 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM, event.getContent()[settingName]); } } @@ -107,6 +117,6 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl const event = room.currentState.getStateEvents(eventType, ""); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } } diff --git a/src/settings/watchers/FontWatcher.ts b/src/settings/watchers/FontWatcher.ts index 5527284cd0..9af5156704 100644 --- a/src/settings/watchers/FontWatcher.ts +++ b/src/settings/watchers/FontWatcher.ts @@ -18,6 +18,8 @@ import dis from '../../dispatcher/dispatcher'; import SettingsStore, {SettingLevel} from '../SettingsStore'; import IWatcher from "./Watcher"; import { toPx } from '../../utils/units'; +import { Action } from '../../dispatcher/actions'; +import { UpdateSystemFontPayload } from '../../dispatcher/payloads/UpdateSystemFontPayload'; export class FontWatcher implements IWatcher { public static readonly MIN_SIZE = 8; @@ -33,6 +35,10 @@ export class FontWatcher implements IWatcher { public start() { this.setRootFontSize(SettingsStore.getValue("baseFontSize")); + this.setSystemFont({ + useSystemFont: SettingsStore.getValue("useSystemFont"), + font: SettingsStore.getValue("systemFont"), + }); this.dispatcherRef = dis.register(this.onAction); } @@ -41,8 +47,10 @@ export class FontWatcher implements IWatcher { } private onAction = (payload) => { - if (payload.action === 'update-font-size') { + if (payload.action === Action.UpdateFontSize) { this.setRootFontSize(payload.size); + } else if (payload.action === Action.UpdateSystemFont) { + this.setSystemFont(payload); } }; @@ -54,4 +62,8 @@ export class FontWatcher implements IWatcher { } (document.querySelector(":root")).style.fontSize = toPx(fontSize); }; + + private setSystemFont = ({useSystemFont, font}) => { + document.body.style.fontFamily = useSystemFont ? font : ""; + }; } diff --git a/src/stores/MessagePreviewStore.ts b/src/stores/MessagePreviewStore.ts deleted file mode 100644 index 3dad643ae6..0000000000 --- a/src/stores/MessagePreviewStore.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Room } from "matrix-js-sdk/src/models/room"; -import { ActionPayload } from "../dispatcher/payloads"; -import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; -import { textForEvent } from "../TextForEvent"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { _t } from "../languageHandler"; - -const PREVIEWABLE_EVENTS = [ - // This is the same list from RiotX - {type: "m.room.message", isState: false}, - {type: "m.room.name", isState: true}, - {type: "m.room.topic", isState: true}, - {type: "m.room.member", isState: true}, - {type: "m.room.history_visibility", isState: true}, - {type: "m.call.invite", isState: false}, - {type: "m.call.hangup", isState: false}, - {type: "m.call.answer", isState: false}, - {type: "m.room.encrypted", isState: false}, - {type: "m.room.encryption", isState: true}, - {type: "m.room.third_party_invite", isState: true}, - {type: "m.sticker", isState: false}, - {type: "m.room.create", isState: true}, -]; - -// The maximum number of events we're willing to look back on to get a preview. -const MAX_EVENTS_BACKWARDS = 50; - -interface IState { - [roomId: string]: string | null; // null indicates the preview is empty -} - -export class MessagePreviewStore extends AsyncStoreWithClient { - private static internalInstance = new MessagePreviewStore(); - - private constructor() { - super(defaultDispatcher, {}); - } - - public static get instance(): MessagePreviewStore { - return MessagePreviewStore.internalInstance; - } - - /** - * Gets the pre-translated preview for a given room - * @param room The room to get the preview for. - * @returns {string} The preview, or null if none present. - */ - public getPreviewForRoom(room: Room): string { - if (!room) return null; // invalid room, just return nothing - - // It's faster to do a lookup this way than it is to use Object.keys().includes() - // We only want to generate a preview if there's one actually missing and not explicitly - // set as 'none'. - const val = this.state[room.roomId]; - if (val !== null && typeof(val) !== "string") { - this.generatePreview(room); - } - - return this.state[room.roomId]; - } - - private generatePreview(room: Room) { - const timeline = room.getLiveTimeline(); - if (!timeline) return; // usually only happens in tests - const events = timeline.getEvents(); - - for (let i = events.length - 1; i >= 0; i--) { - if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached - - const event = events[i]; - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: preview.preview}); - return; // break - we found some text - } - } - - // if we didn't find anything, subscribe ourselves to an update - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: null}); - } - - protected async onAction(payload: ActionPayload) { - if (!this.matrixClient) return; - - // TODO: Remove when new room list is made the default - if (!RoomListStoreTempProxy.isUsingNewStore()) return; - - if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { - const event = payload.event; // TODO: Type out the dispatcher - if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important - - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - await this.updateState({[event.getRoomId()]: preview.preview}); - return; // break - we found some text - } - } - } - - private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } { - if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) { - const isSelf = event.getSender() === this.matrixClient.getUserId(); - let text = textForEvent(event, /*skipUserPrefix=*/isSelf); - if (!text || text.trim().length === 0) text = null; // force null if useless to us - if (text && isSelf) { - // XXX: i18n doesn't really work here if the language doesn't support prefixing. - // We'd ideally somehow route the `You:` bit to the textForEvent call, however - // threading that through is non-trivial. - text = _t("You: %(message)s", {message: text}); - } - return {isPreviewable: true, preview: text}; - } - return {isPreviewable: false, preview: null}; - } -} diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts new file mode 100644 index 0000000000..45d8829e30 --- /dev/null +++ b/src/stores/OwnProfileStore.ts @@ -0,0 +1,122 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../dispatcher/payloads"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { User } from "matrix-js-sdk/src/models/user"; +import { throttle } from "lodash"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { _t } from "../languageHandler"; + +interface IState { + displayName?: string; + avatarUrl?: string; +} + +export class OwnProfileStore extends AsyncStoreWithClient { + private static internalInstance = new OwnProfileStore(); + + private monitoredUser: User; + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): OwnProfileStore { + return OwnProfileStore.internalInstance; + } + + /** + * Gets the display name for the user, or null if not present. + */ + public get displayName(): string { + if (!this.matrixClient) return this.state.displayName || null; + + if (this.matrixClient.isGuest()) { + return _t("Guest"); + } else if (this.state.displayName) { + return this.state.displayName; + } else { + return this.matrixClient.getUserId(); + } + } + + /** + * Gets the MXC URI of the user's avatar, or null if not present. + */ + public get avatarMxc(): string { + return this.state.avatarUrl || null; + } + + /** + * Gets the user's avatar as an HTTP URL of the given size. If the user's + * avatar is not present, this returns null. + * @param size The size of the avatar + * @returns The HTTP URL of the user's avatar + */ + public getHttpAvatarUrl(size: number): string { + if (!this.avatarMxc) return null; + return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size); + } + + protected async onNotReady() { + if (this.monitoredUser) { + this.monitoredUser.removeListener("User.displayName", this.onProfileUpdate); + this.monitoredUser.removeListener("User.avatarUrl", this.onProfileUpdate); + } + if (this.matrixClient) { + this.matrixClient.removeListener("RoomState.events", this.onStateEvents); + } + await this.reset({}); + } + + protected async onReady() { + const myUserId = this.matrixClient.getUserId(); + this.monitoredUser = this.matrixClient.getUser(myUserId); + if (this.monitoredUser) { + this.monitoredUser.on("User.displayName", this.onProfileUpdate); + this.monitoredUser.on("User.avatarUrl", this.onProfileUpdate); + } + + // We also have to listen for membership events for ourselves as the above User events + // are fired only with presence, which matrix.org (and many others) has disabled. + this.matrixClient.on("RoomState.events", this.onStateEvents); + + await this.onProfileUpdate(); // trigger an initial update + } + + protected async onAction(payload: ActionPayload) { + // we don't actually do anything here + } + + private onProfileUpdate = async () => { + // We specifically do not use the User object we stored for profile info as it + // could easily be wrong (such as per-room instead of global profile). + const profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getUserId()); + await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url}); + }; + + // TSLint wants this to be a member, but we don't want that. + // tslint:disable-next-line + private onStateEvents = throttle(async (ev: MatrixEvent) => { + const myUserId = MatrixClientPeg.get().getUserId(); + if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { + await this.onProfileUpdate(); + } + }, 200, {trailing: true, leading: true}); +} diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index ebc7b95854..8ca8ad637b 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -18,6 +18,10 @@ import { TagID } from "./models"; const TILE_HEIGHT_PX = 44; +// the .65 comes from the CSS where the show more button is +// mathematically 65% of a tile when floating. +const RESIZER_BOX_FACTOR = 0.65; + interface ISerializedListLayout { numTiles: number; showPreviews: boolean; @@ -67,6 +71,7 @@ export class ListLayout { } public get visibleTiles(): number { + if (this._n === 0) return this.defaultVisibleTiles; return Math.max(this._n, this.minVisibleTiles); } @@ -76,9 +81,13 @@ export class ListLayout { } public get minVisibleTiles(): number { - // the .65 comes from the CSS where the show more button is - // mathematically 65% of a tile when floating. - return 4.65; + return 1 + RESIZER_BOX_FACTOR; + } + + public get defaultVisibleTiles(): number { + // TODO: Remove dogfood flag + const val = Number(localStorage.getItem("mx_dogfood_rl_defTiles") || 4); + return val + RESIZER_BOX_FACTOR; } public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number { @@ -92,6 +101,10 @@ export class ListLayout { return this.tilesToPixels(Math.min(maxTiles, n)) + padding; } + public tilesWithResizerBoxFactor(n: number): number { + return n + RESIZER_BOX_FACTOR; + } + public tilesWithPadding(n: number, paddingPx: number): number { return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts new file mode 100644 index 0000000000..b727069f9f --- /dev/null +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -0,0 +1,204 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/models/room"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { RoomListStoreTempProxy } from "./RoomListStoreTempProxy"; +import { MessageEventPreview } from "./previews/MessageEventPreview"; +import { NameEventPreview } from "./previews/NameEventPreview"; +import { TagID } from "./models"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; +import { TopicEventPreview } from "./previews/TopicEventPreview"; +import { MembershipEventPreview } from "./previews/MembershipEventPreview"; +import { HistoryVisibilityEventPreview } from "./previews/HistoryVisibilityEventPreview"; +import { CallInviteEventPreview } from "./previews/CallInviteEventPreview"; +import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview"; +import { CallHangupEvent } from "./previews/CallHangupEvent"; +import { EncryptionEventPreview } from "./previews/EncryptionEventPreview"; +import { ThirdPartyInviteEventPreview } from "./previews/ThirdPartyInviteEventPreview"; +import { StickerEventPreview } from "./previews/StickerEventPreview"; +import { ReactionEventPreview } from "./previews/ReactionEventPreview"; +import { CreationEventPreview } from "./previews/CreationEventPreview"; + +const PREVIEWS = { + 'm.room.message': { + isState: false, + previewer: new MessageEventPreview(), + }, + 'm.room.name': { + isState: true, + previewer: new NameEventPreview(), + }, + 'm.room.topic': { + isState: true, + previewer: new TopicEventPreview(), + }, + 'm.room.member': { + isState: true, + previewer: new MembershipEventPreview(), + }, + 'm.room.history_visibility': { + isState: true, + previewer: new HistoryVisibilityEventPreview(), + }, + 'm.call.invite': { + isState: false, + previewer: new CallInviteEventPreview(), + }, + 'm.call.answer': { + isState: false, + previewer: new CallAnswerEventPreview(), + }, + 'm.call.hangup': { + isState: false, + previewer: new CallHangupEvent(), + }, + 'm.room.encryption': { + isState: true, + previewer: new EncryptionEventPreview(), + }, + 'm.room.third_party_invite': { + isState: true, + previewer: new ThirdPartyInviteEventPreview(), + }, + 'm.sticker': { + isState: false, + previewer: new StickerEventPreview(), + }, + 'm.reaction': { + isState: false, + previewer: new ReactionEventPreview(), + }, + 'm.room.create': { + isState: true, + previewer: new CreationEventPreview(), + }, +}; + +// The maximum number of events we're willing to look back on to get a preview. +const MAX_EVENTS_BACKWARDS = 50; + +// type merging ftw +type TAG_ANY = "im.vector.any"; +const TAG_ANY: TAG_ANY = "im.vector.any"; + +interface IState { + [roomId: string]: Map; // null indicates the preview is empty / irrelevant +} + +export class MessagePreviewStore extends AsyncStoreWithClient { + private static internalInstance = new MessagePreviewStore(); + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): MessagePreviewStore { + return MessagePreviewStore.internalInstance; + } + + /** + * Gets the pre-translated preview for a given room + * @param room The room to get the preview for. + * @param inTagId The tag ID in which the room resides + * @returns The preview, or null if none present. + */ + public getPreviewForRoom(room: Room, inTagId: TagID): string { + if (!room) return null; // invalid room, just return nothing + + const val = this.state[room.roomId]; + if (!val) this.generatePreview(room, inTagId); + + const previews = this.state[room.roomId]; + if (!previews) return null; + + if (!previews.has(inTagId)) { + return previews.get(TAG_ANY); + } + return previews.get(inTagId); + } + + private generatePreview(room: Room, tagId?: TagID) { + const events = room.timeline; + if (!events) return; // should only happen in tests + + let map = this.state[room.roomId]; + if (!map) { + map = new Map(); + + // We set the state later with the map, so no need to send an update now + } + + // Set the tags so we know what to generate + if (!map.has(TAG_ANY)) map.set(TAG_ANY, null); + if (tagId && !map.has(tagId)) map.set(tagId, null); + + let changed = false; + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached + + const event = events[i]; + const previewDef = PREVIEWS[event.getType()]; + if (!previewDef) continue; + if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; + + const anyPreview = previewDef.previewer.getTextFor(event, null); + if (!anyPreview) continue; // not previewable for some reason + + changed = changed || anyPreview !== map.get(TAG_ANY); + map.set(TAG_ANY, anyPreview); + + const tagsToGenerate = Array.from(map.keys()).filter(t => t !== TAG_ANY); // we did the any tag above + for (const genTagId of tagsToGenerate) { + const realTagId: TagID = genTagId === TAG_ANY ? null : genTagId; + const preview = previewDef.previewer.getTextFor(event, realTagId); + if (preview === anyPreview) { + changed = changed || anyPreview !== map.get(genTagId); + map.delete(genTagId); + } else { + changed = changed || preview !== map.get(genTagId); + map.set(genTagId, preview); + } + } + + if (changed) { + // Update state for good measure - causes emit for update + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: map}); + } + return; // we're done + } + + // At this point, we didn't generate a preview so clear it + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: null}); + } + + protected async onAction(payload: ActionPayload) { + if (!this.matrixClient) return; + + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + + if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { + const event = payload.event; // TODO: Type out the dispatcher + if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important + this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); + } + } +} diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index 1081fb26ec..e5a4d91b04 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -158,12 +158,12 @@ export class RoomListStore2 extends AsyncStore { // First see if the receipt event is for our own user. If it was, trigger // a room update (we probably read the room on a different device). if (readReceiptChangeIsFor(payload.event, this.matrixClient)) { - console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`); - const room = this.matrixClient.getRoom(payload.event.roomId); + const room = payload.room; if (!room) { - console.warn(`Own read receipt was in unknown room ${payload.event.roomId}`); + console.warn(`Own read receipt was in unknown room ${room.roomId}`); return; } + console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`); await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt); return; } diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 06c6df1703..94d7bff4c8 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -170,12 +170,16 @@ export class Algorithm extends EventEmitter { // When we do have a room though, we expect to be able to find it const tag = this.roomIdsToTags[val.roomId][0]; if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); - let position = this.cachedRooms[tag].indexOf(val); + + // We specifically do NOT use the ordered rooms set as it contains the sticky room, which + // means we'll be off by 1 when the user is switching rooms. This leads to visual jumping + // when the user is moving south in the list (not north, because of math). + let position = this.getOrderedRoomsWithoutSticky()[tag].indexOf(val); if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`); // 🐉 Here be dragons. // Before we can go through with lying to the underlying algorithm about a room - // we need to ensure that when we do we're ready for the innevitable sticky room + // we need to ensure that when we do we're ready for the inevitable sticky room // update we'll receive. To prepare for that, we first remove the sticky room and // recalculate the state ourselves so that when the underlying algorithm calls for // the same thing it no-ops. After we're done calling the algorithm, we'll issue @@ -208,6 +212,12 @@ export class Algorithm extends EventEmitter { position: position, tag: tag, }; + + // We update the filtered rooms just in case, as otherwise users will end up visiting + // a room while filtering and it'll disappear. We don't update the filter earlier in + // this function simply because we don't have to. + this.recalculateFilteredRoomsForTag(tag); + if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateFilteredRoomsForTag(lastStickyRoom.tag); this.recalculateStickyRoom(); // Finally, trigger an update @@ -231,9 +241,7 @@ export class Algorithm extends EventEmitter { // We optimize our lookups by trying to reduce sample size as much as possible // to the rooms we know will be deduped by the Set. const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone - if (this._stickyRoom && this._stickyRoom.tag === tagId && this._stickyRoom.room) { - rooms.push(this._stickyRoom.room); - } + this.tryInsertStickyRoomToFilterSet(rooms, tagId); let remainingRooms = rooms.map(r => r); let allowedRoomsInThisTag = []; let lastFilterPriority = orderedFilters[0].relativePriority; @@ -263,6 +271,7 @@ export class Algorithm extends EventEmitter { this.emit(LIST_UPDATED_EVENT); } + // TODO: Remove or use. protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void { const filters = this.allowedByFilter.keys(); for (const room of added) { @@ -281,7 +290,8 @@ export class Algorithm extends EventEmitter { protected recalculateFilteredRoomsForTag(tagId: TagID): void { console.log(`Recalculating filtered rooms for ${tagId}`); delete this.filteredRooms[tagId]; - const rooms = this.cachedRooms[tagId]; + const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone + this.tryInsertStickyRoomToFilterSet(rooms, tagId); const filteredRooms = rooms.filter(r => this.allowedRoomsByFilters.has(r)); if (filteredRooms.length > 0) { this.filteredRooms[tagId] = filteredRooms; @@ -289,6 +299,17 @@ export class Algorithm extends EventEmitter { console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); } + protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) { + if (!this._stickyRoom || !this._stickyRoom.room || this._stickyRoom.tag !== tagId) return; + + const position = this._stickyRoom.position; + if (position >= rooms.length) { + rooms.push(this._stickyRoom.room); + } else { + rooms.splice(position, 0, this._stickyRoom.room); + } + } + /** * Recalculate the sticky room position. If this is being called in relation to * a specific tag being updated, it should be given to this function to optimize @@ -377,6 +398,20 @@ export class Algorithm extends EventEmitter { return this.filteredRooms; } + /** + * This returns the same as getOrderedRooms(), but without the sticky room + * map as it causes issues for sticky room handling (see sticky room handling + * for more information). + * @returns {ITagMap} The cached list of rooms, ordered, + * for each tag. May be empty, but never null/undefined. + */ + private getOrderedRoomsWithoutSticky(): ITagMap { + if (!this.hasFilters) { + return this.cachedRooms; + } + return this.filteredRooms; + } + /** * Seeds the Algorithm with a set of rooms. The algorithm will discard all * previously known information and instead use these rooms instead. diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/CallAnswerEventPreview.ts new file mode 100644 index 0000000000..b7207307e2 --- /dev/null +++ b/src/stores/room-list/previews/CallAnswerEventPreview.ts @@ -0,0 +1,35 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallAnswerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You joined the call"); + } else { + return _t("%(senderName)s joined the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call in progress"); + } + } +} diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/CallHangupEvent.ts new file mode 100644 index 0000000000..adc7d1aac8 --- /dev/null +++ b/src/stores/room-list/previews/CallHangupEvent.ts @@ -0,0 +1,35 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallHangupEvent implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You left the call"); + } else { + return _t("%(senderName)s left the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call ended"); + } + } +} diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/CallInviteEventPreview.ts new file mode 100644 index 0000000000..47486e3701 --- /dev/null +++ b/src/stores/room-list/previews/CallInviteEventPreview.ts @@ -0,0 +1,39 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You started a call"); + } else { + return _t("%(senderName)s started a call", {senderName: getSenderName(event)}); + } + } else { + if (isSelf(event)) { + return _t("Waiting for answer"); + } else { + return _t("%(senderName)s is calling", {senderName: getSenderName(event)}); + } + } + } +} diff --git a/src/stores/room-list/previews/CreationEventPreview.ts b/src/stores/room-list/previews/CreationEventPreview.ts new file mode 100644 index 0000000000..62bb5fe53a --- /dev/null +++ b/src/stores/room-list/previews/CreationEventPreview.ts @@ -0,0 +1,31 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CreationEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You created the room"); + } else { + return _t("%(senderName)s created the room", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/EncryptionEventPreview.ts b/src/stores/room-list/previews/EncryptionEventPreview.ts new file mode 100644 index 0000000000..d00fd7e7f9 --- /dev/null +++ b/src/stores/room-list/previews/EncryptionEventPreview.ts @@ -0,0 +1,31 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class EncryptionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You made the chat encrypted"); + } else { + return _t("%(senderName)s made the chat encrypted", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts new file mode 100644 index 0000000000..ac77a181f8 --- /dev/null +++ b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts @@ -0,0 +1,42 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class HistoryVisibilityEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const visibility = event.getContent()['history_visibility']; + const isUs = isSelf(event); + + if (visibility === 'invited' || visibility === 'joined') { + return isUs + ? _t("You made history visible to new members") + : _t("%(senderName)s made history visible to new members", {senderName: getSenderName(event)}); + } else if (visibility === 'world_readable') { + return isUs + ? _t("You made history visible to anyone") + : _t("%(senderName)s made history visible to anyone", {senderName: getSenderName(event)}); + } else { // shared, default + return isUs + ? _t("You made history visible to future members") + : _t("%(senderName)s made history visible to future members", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/IPreview.ts b/src/stores/room-list/previews/IPreview.ts new file mode 100644 index 0000000000..9beb92bfbf --- /dev/null +++ b/src/stores/room-list/previews/IPreview.ts @@ -0,0 +1,31 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { TagID } from "../models"; + +/** + * Represents an event preview. + */ +export interface IPreview { + /** + * Gets the text which represents the event as a preview. + * @param event The event to preview. + * @param tagId Optional. The tag where the room the event was sent in resides. + * @returns The preview. + */ + getTextFor(event: MatrixEvent, tagId?: TagID): string; +} diff --git a/src/stores/room-list/previews/MembershipEventPreview.ts b/src/stores/room-list/previews/MembershipEventPreview.ts new file mode 100644 index 0000000000..44339aab5f --- /dev/null +++ b/src/stores/room-list/previews/MembershipEventPreview.ts @@ -0,0 +1,90 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getTargetName, isSelfTarget } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class MembershipEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const newMembership = event.getContent()['membership']; + const oldMembership = event.getPrevContent()['membership']; + const reason = event.getContent()['reason']; + const isUs = isSelfTarget(event); + + if (newMembership === 'invite') { + return isUs + ? _t("You were invited") + : _t("%(targetName)s was invited", {targetName: getTargetName(event)}); + } else if (newMembership === 'leave' && oldMembership !== 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You left") + : _t("%(targetName)s left", {targetName: getTargetName(event)}); + } else { + if (reason) { + return isUs + ? _t("You were kicked (%(reason)s)", {reason}) + : _t("%(targetName)s was kicked (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were kicked") + : _t("%(targetName)s was kicked", {targetName: getTargetName(event)}); + } + } + } else if (newMembership === 'leave' && oldMembership === 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You rejected the invite") + : _t("%(targetName)s rejected the invite", {targetName: getTargetName(event)}); + } else { + return isUs + ? _t("You were uninvited") + : _t("%(targetName)s was uninvited", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'ban') { + if (reason) { + return isUs + ? _t("You were banned (%(reason)s)", {reason}) + : _t("%(targetName)s was banned (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were banned") + : _t("%(targetName)s was banned", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'join' && oldMembership !== 'join') { + return isUs + ? _t("You joined") + : _t("%(targetName)s joined", {targetName: getTargetName(event)}); + } else { + const isDisplayNameChange = event.getContent()['displayname'] !== event.getPrevContent()['displayname']; + const isAvatarChange = event.getContent()['avatar_url'] !== event.getPrevContent()['avatar_url']; + if (isDisplayNameChange) { + return isUs + ? _t("You changed your name") + : _t("%(targetName)s changed their name", {targetName: getTargetName(event)}); + } else if (isAvatarChange) { + return isUs + ? _t("You changed your avatar") + : _t("%(targetName)s changed their avatar", {targetName: getTargetName(event)}); + } else { + return null; // no change + } + } + } +} diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts new file mode 100644 index 0000000000..6f0dc14a58 --- /dev/null +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -0,0 +1,55 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { _t } from "../../../languageHandler"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import ReplyThread from "../../../components/views/elements/ReplyThread"; + +export class MessageEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + let eventContent = event.getContent(); + + if (event.isRelation("m.replace")) { + // It's an edit, generate the preview on the new text + eventContent = event.getContent()['m.new_content']; + } + + let body = (eventContent['body'] || '').trim(); + const msgtype = eventContent['msgtype']; + if (!body || !msgtype) return null; // invalid event, no preview + + // XXX: Newer relations have a getRelation() function which is not compatible with replies. + const mRelatesTo = event.getWireContent()['m.relates_to']; + if (mRelatesTo && mRelatesTo['m.in_reply_to']) { + // If this is a reply, get the real reply and use that + body = (ReplyThread.stripPlainReply(body) || '').trim(); + if (!body) return null; // invalid event, no preview + } + + if (msgtype === 'm.emote') { + return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); + } + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return body; + } else { + return _t("%(senderName)s: %(message)s", {senderName: getSenderName(event), message: body}); + } + } +} diff --git a/src/stores/room-list/previews/NameEventPreview.ts b/src/stores/room-list/previews/NameEventPreview.ts new file mode 100644 index 0000000000..4197abacfb --- /dev/null +++ b/src/stores/room-list/previews/NameEventPreview.ts @@ -0,0 +1,31 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class NameEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room name"); + } else { + return _t("%(senderName)s changed the room name", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts new file mode 100644 index 0000000000..d58f592feb --- /dev/null +++ b/src/stores/room-list/previews/ReactionEventPreview.ts @@ -0,0 +1,34 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class ReactionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const reaction = event.getRelation().key; + if (!reaction) return; + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return reaction; + } else { + return _t("%(senderName)s: %(reaction)s", {senderName: getSenderName(event), reaction}); + } + } +} diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts new file mode 100644 index 0000000000..f8263a4a45 --- /dev/null +++ b/src/stores/room-list/previews/StickerEventPreview.ts @@ -0,0 +1,34 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class StickerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const stickerName = event.getContent()['body']; + if (!stickerName) return null; + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return stickerName; + } else { + return _t("%(senderName)s: %(stickerName)s", {senderName: getSenderName(event), stickerName}); + } + } +} diff --git a/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts new file mode 100644 index 0000000000..b22cd9fac9 --- /dev/null +++ b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts @@ -0,0 +1,42 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; +import { isValid3pidInvite } from "../../../RoomInvite"; + +export class ThirdPartyInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (!isValid3pidInvite(event)) { + const targetName = event.getPrevContent().display_name || _t("Someone"); + if (isSelf(event)) { + return _t("You uninvited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s uninvited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } else { + const targetName = event.getContent().display_name; + if (isSelf(event)) { + return _t("You invited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s invited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } + } +} diff --git a/src/stores/room-list/previews/TopicEventPreview.ts b/src/stores/room-list/previews/TopicEventPreview.ts new file mode 100644 index 0000000000..9b499aae8f --- /dev/null +++ b/src/stores/room-list/previews/TopicEventPreview.ts @@ -0,0 +1,31 @@ +/* +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class TopicEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room topic"); + } else { + return _t("%(senderName)s changed the room topic", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts new file mode 100644 index 0000000000..ebbecd7bbd --- /dev/null +++ b/src/stores/room-list/previews/utils.ts @@ -0,0 +1,49 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { DefaultTagID, TagID } from "../models"; + +export function isSelf(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + if (event.getType() === 'm.room.member') { + return event.getStateKey() === selfUserId; + } + return event.getSender() === selfUserId; +} + +export function isSelfTarget(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + return event.getStateKey() === selfUserId; +} + +export function shouldPrefixMessagesIn(roomId: string, tagId: TagID): boolean { + if (tagId !== DefaultTagID.DM) return true; + + // We don't prefix anything in 1:1s + const room = MatrixClientPeg.get().getRoom(roomId); + if (!room) return true; + return room.currentState.getJoinedMemberCount() !== 2; +} + +export function getSenderName(event: MatrixEvent): string { + return event.sender ? event.sender.name : event.getSender(); +} + +export function getTargetName(event: MatrixEvent): string { + return event.target ? event.target.name : event.getStateKey(); +} diff --git a/src/theme.js b/src/theme.js index 22cfd8b076..c79e466933 100644 --- a/src/theme.js +++ b/src/theme.js @@ -35,11 +35,67 @@ export function enumerateThemes() { return Object.assign({}, customThemeNames, BUILTIN_THEMES); } +function clearCustomTheme() { + // remove all css variables, we assume these are there because of the custom theme + const inlineStyleProps = Object.values(document.body.style); + for (const prop of inlineStyleProps) { + if (prop.startsWith("--")) { + document.body.style.removeProperty(prop); + } + } + const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']"); + if (customFontFaceStyle) { + customFontFaceStyle.remove(); + } +} + +const allowedFontFaceProps = [ + "font-display", + "font-family", + "font-stretch", + "font-style", + "font-weight", + "font-variant", + "font-feature-settings", + "font-variation-settings", + "src", + "unicode-range", +]; + +function generateCustomFontFaceCSS(faces) { + return faces.map(face => { + const src = face.src && face.src.map(srcElement => { + let format; + if (srcElement.format) { + format = `format("${srcElement.format}")`; + } + if (srcElement.url) { + return `url("${srcElement.url}") ${format}`; + } else if (srcElement.local) { + return `local("${srcElement.local}") ${format}`; + } + return ""; + }).join(", "); + const props = Object.keys(face).filter(prop => allowedFontFaceProps.includes(prop)); + const body = props.map(prop => { + let value; + if (prop === "src") { + value = src; + } else if (prop === "font-family") { + value = `"${face[prop]}"`; + } else { + value = face[prop]; + } + return `${prop}: ${value}`; + }).join(";"); + return `@font-face {${body}}`; + }).join("\n"); +} function setCustomThemeVars(customTheme) { const {style} = document.body; - function setCSSVariable(name, hexColor, doPct = true) { + function setCSSColorVariable(name, hexColor, doPct = true) { style.setProperty(`--${name}`, hexColor); if (doPct) { // uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50% @@ -53,13 +109,30 @@ function setCustomThemeVars(customTheme) { for (const [name, value] of Object.entries(customTheme.colors)) { if (Array.isArray(value)) { for (let i = 0; i < value.length; i += 1) { - setCSSVariable(`${name}_${i}`, value[i], false); + setCSSColorVariable(`${name}_${i}`, value[i], false); } } else { - setCSSVariable(name, value); + setCSSColorVariable(name, value); } } } + if (customTheme.fonts) { + const {fonts} = customTheme; + if (fonts.faces) { + const css = generateCustomFontFaceCSS(fonts.faces); + const style = document.createElement("style"); + style.setAttribute("title", "custom-theme-font-faces"); + style.setAttribute("type", "text/css"); + style.appendChild(document.createTextNode(css)); + document.head.appendChild(style); + } + if (fonts.general) { + style.setProperty("--font-family", fonts.general); + } + if (fonts.monospace) { + style.setProperty("--font-family-monospace", fonts.monospace); + } + } } export function getCustomTheme(themeName) { @@ -88,6 +161,7 @@ export async function setTheme(theme) { const themeWatcher = new ThemeWatcher(); theme = themeWatcher.getEffectiveTheme(); } + clearCustomTheme(); let stylesheetName = theme; if (theme.startsWith("custom-")) { const customTheme = getCustomTheme(theme.substr(7)); @@ -136,7 +210,7 @@ export async function setTheme(theme) { if (a == styleElements[stylesheetName]) return; a.disabled = true; }); - const bodyStyles = global.getComputedStyle(document.getElementsByTagName("body")[0]); + const bodyStyles = global.getComputedStyle(document.body); if (bodyStyles.backgroundColor) { document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor; } diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index 7cd59222dd..b186a65d9d 100644 --- a/src/toasts/AnalyticsToast.tsx +++ b/src/toasts/AnalyticsToast.tsx @@ -24,14 +24,12 @@ import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; const onAccept = () => { - console.log("DEBUG onAccept AnalyticsToast"); dis.dispatch({ action: 'accept_cookies', }); }; const onReject = () => { - console.log("DEBUG onReject AnalyticsToast"); dis.dispatch({ action: "reject_cookies", }); diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js index d65bc4bd07..f726a43e08 100644 --- a/src/utils/ResizeNotifier.js +++ b/src/utils/ResizeNotifier.js @@ -15,9 +15,13 @@ limitations under the License. */ /** - * Fires when the middle panel has been resized. + * Fires when the middle panel has been resized (throttled). * @event module:utils~ResizeNotifier#"middlePanelResized" */ +/** + * Fires when the middle panel has been resized by a pixel. + * @event module:utils~ResizeNotifier#"middlePanelResizedNoisy" + */ import { EventEmitter } from "events"; import { throttle } from "lodash"; @@ -29,15 +33,24 @@ export default class ResizeNotifier extends EventEmitter { this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); } + _noisyMiddlePanel() { + this.emit("middlePanelResizedNoisy"); + } + + _updateMiddlePanel() { + this._throttledMiddlePanel(); + this._noisyMiddlePanel(); + } + // can be called in quick succession notifyLeftHandleResized() { // don't emit event for own region - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } // can be called in quick succession notifyRightHandleResized() { - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } // can be called in quick succession @@ -48,7 +61,7 @@ export default class ResizeNotifier extends EventEmitter { // taller than the available space this.emit("leftPanelResized"); - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } } diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index b48ec481ba..f7f4be202b 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -31,6 +31,7 @@ import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {Capability} from "../widgets/WidgetApi"; import {Room} from "matrix-js-sdk/src/models/room"; import {WidgetType} from "../widgets/WidgetType"; +import {objectClone} from "./objects"; export default class WidgetUtils { /* Returns true if user is able to send state events to modify widgets in this room @@ -222,7 +223,7 @@ export default class WidgetUtils { const client = MatrixClientPeg.get(); // Get the current widgets and clone them before we modify them, otherwise // we'll modify the content of the old event. - const userWidgets = JSON.parse(JSON.stringify(WidgetUtils.getUserWidgets())); + const userWidgets = objectClone(WidgetUtils.getUserWidgets()); // Delete existing widget with ID try { diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index fea376afcd..8175d89464 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -46,6 +46,28 @@ export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { }; } +/** + * Returns the union of two arrays. + * @param a The first array. Must be defined. + * @param b The second array. Must be defined. + * @returns The union of the arrays. + */ +export function arrayUnion(a: T[], b: T[]): T[] { + return a.filter(i => b.includes(i)); +} + +/** + * Merges arrays, deduping contents using a Set. + * @param a The arrays to merge. + * @returns The merged array. + */ +export function arrayMerge(...a: T[][]): T[] { + return Array.from(a.reduce((c, v) => { + v.forEach(i => c.add(i)); + return c; + }, new Set())); +} + /** * Helper functions to perform LINQ-like queries on arrays. */ diff --git a/src/utils/objects.ts b/src/utils/objects.ts new file mode 100644 index 0000000000..14fa928ce2 --- /dev/null +++ b/src/utils/objects.ts @@ -0,0 +1,60 @@ +/* +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 { arrayDiff, arrayMerge, arrayUnion } from "./arrays"; + +/** + * Determines the keys added, changed, and removed between two objects. + * For changes, simple triple equal comparisons are done, not in-depth + * tree checking. + * @param a The first object. Must be defined. + * @param b The second object. Must be defined. + * @returns The difference between the keys of each object. + */ +export function objectDiff(a: any, b: any): { changed: string[], added: string[], removed: string[] } { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + const keyDiff = arrayDiff(aKeys, bKeys); + const possibleChanges = arrayUnion(aKeys, bKeys); + const changes = possibleChanges.filter(k => a[k] !== b[k]); + + return {changed: changes, added: keyDiff.added, removed: keyDiff.removed}; +} + +/** + * Gets all the key changes (added, removed, or value difference) between + * two objects. Triple equals is used to compare values, not in-depth tree + * checking. + * @param a The first object. Must be defined. + * @param b The second object. Must be defined. + * @returns The keys which have been added, removed, or changed between the + * two objects. + */ +export function objectKeyChanges(a: any, b: any): string[] { + const diff = objectDiff(a, b); + return arrayMerge(diff.removed, diff.added, diff.changed); +} + +/** + * Clones an object by running it through JSON parsing. Note that this + * will destroy any complicated object types which do not translate to + * JSON. + * @param obj The object to clone. + * @returns The cloned object + */ +export function objectClone(obj: any): any { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index 39a4554a81..4775c30c95 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -19,6 +19,7 @@ limitations under the License. import { randomString } from "matrix-js-sdk/src/randomstring"; import { EventEmitter } from "events"; +import { objectClone } from "../utils/objects"; export enum Capability { Screenshot = "m.capability.screenshot", @@ -140,7 +141,7 @@ export class WidgetApi extends EventEmitter { private replyToRequest(payload: ToWidgetRequest, reply: any) { if (!window.parent) return; - const request = JSON.parse(JSON.stringify(payload)); + const request = objectClone(payload); request.response = reply; window.parent.postMessage(request, this.origin); diff --git a/yarn.lock b/yarn.lock index 64dd36aaaf..29c97a604b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5893,8 +5893,8 @@ mathml-tag-names@^2.0.1: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "6.2.2" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/1c194e81637fb07fe6ad67cda33be0d5d4c10115" + version "7.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f683f4544aa5da150836b01c754062809119fa97" dependencies: "@babel/runtime" "^7.8.3" another-json "^0.2.0"