Merge remote-tracking branch 'origin/develop' into dbkr/recovery_key_passphrase_2

This commit is contained in:
David Baker 2020-06-26 13:12:06 +01:00
commit f4460ca78f
84 changed files with 1822 additions and 474 deletions

View file

@ -1,3 +1,193 @@
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) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.1...v2.7.2)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "2.7.2", "version": "2.8.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View file

@ -428,6 +428,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
border-radius: 8px; border-radius: 8px;
padding: 0px; padding: 0px;
box-shadow: none; 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 // 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 { &:last-child {
padding-bottom: 20px; padding-bottom: 16px;
} }
} }
.mx_IconizedContextMenu_optionList { .mx_IconizedContextMenu_optionList {
// the notFirst class is for cases where the optionList might be under a header of sorts. // the notFirst class is for cases where the optionList might be under a header of sorts.
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { &: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, // 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. // 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 { li {
margin: 0; margin: 0;
padding: 20px 0 0; padding: 12px 0 0;
.mx_AccessibleButton { .mx_AccessibleButton {
text-decoration: none; text-decoration: none;

View file

@ -48,7 +48,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
flex-direction: column; flex-direction: column;
.mx_LeftPanel2_userHeader { .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 // Create another flexbox column for the rows to stack within
display: flex; display: flex;
@ -65,6 +65,11 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
.mx_LeftPanel2_userAvatarContainer { .mx_LeftPanel2_userAvatarContainer {
position: relative; // to make default avatars work position: relative; // to make default avatars work
margin-right: 8px; margin-right: 8px;
height: 32px; // to remove the unknown 4px gap the browser puts below it
.mx_LeftPanel2_userAvatar {
border-radius: 32px; // should match avatar size
}
} }
.mx_LeftPanel2_userName { .mx_LeftPanel2_userName {
@ -72,6 +77,11 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
font-size: $font-15px; font-size: $font-15px;
line-height: $font-20px; line-height: $font-20px;
flex: 1; flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
} }
.mx_LeftPanel2_headerButtons { .mx_LeftPanel2_headerButtons {

View file

@ -15,29 +15,50 @@ limitations under the License.
*/ */
.mx_UserMenuButton { .mx_UserMenuButton {
// No special styles on the button itself > span {
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_UserMenuButton_contextMenu { .mx_UserMenuButton_contextMenu {
width: 247px; width: 247px;
.mx_UserMenuButton_contextMenu_redRow {
.mx_AccessibleButton {
color: $warning-color !important; // !important to override styles from context menu
}
.mx_IconizedContextMenu_icon::before {
background-color: $warning-color;
}
}
.mx_UserMenuButton_contextMenu_header { .mx_UserMenuButton_contextMenu_header {
// Create a flexbox to organize the header a bit easier // Create a flexbox to organize the header a bit easier
display: flex; display: flex;
align-items: center; 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 { .mx_UserMenuButton_contextMenu_name {
// Create another flexbox of columns to handle large user IDs // Create another flexbox of columns to handle large user IDs
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// fit the container
flex: 1;
width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button
* { * {
@ -79,4 +100,49 @@ limitations under the License.
justify-content: 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_UserMenuButton_iconHome::before {
mask-image: url('$(res)/img/feather-customised/home.svg');
}
.mx_UserMenuButton_iconBell::before {
mask-image: url('$(res)/img/feather-customised/notifications.svg');
}
.mx_UserMenuButton_iconLock::before {
mask-image: url('$(res)/img/feather-customised/lock.svg');
}
.mx_UserMenuButton_iconSettings::before {
mask-image: url('$(res)/img/feather-customised/settings.svg');
}
.mx_UserMenuButton_iconArchive::before {
mask-image: url('$(res)/img/feather-customised/archive.svg');
}
.mx_UserMenuButton_iconMessage::before {
mask-image: url('$(res)/img/feather-customised/message-circle.svg');
}
.mx_UserMenuButton_iconSignOut::before {
mask-image: url('$(res)/img/feather-customised/sign-out.svg');
}
} }

View file

@ -18,7 +18,7 @@ limitations under the License.
display: inline; display: inline;
} }
.mx_InlineSpinner img { .mx_InlineSpinner_spin img {
margin: 0px 6px; margin: 0px 6px;
vertical-align: -3px; vertical-align: -3px;
} }

View file

@ -23,6 +23,16 @@ limitations under the License.
flex: 1; flex: 1;
} }
.mx_Spinner_spin img {
animation: spin 1s linear infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.mx_MatrixChat_middlePanel .mx_Spinner { .mx_MatrixChat_middlePanel .mx_Spinner {
height: auto; height: auto;
} }

View file

@ -77,8 +77,8 @@ limitations under the License.
} }
&:checked:disabled + label > .mx_Checkbox_background { &:checked:disabled + label > .mx_Checkbox_background {
background-color: $muted-fg-color; background-color: $accent-color;
border-color: rgba($muted-fg-color, 0.5); border-color: $accent-color;
} }
} }
} }

View file

@ -354,6 +354,11 @@ limitations under the License.
opacity: 1; opacity: 1;
} }
.mx_EventTile_e2eIcon_unauthenticated {
background-image: url('$(res)/img/e2e/normal.svg');
opacity: 1;
}
.mx_EventTile_e2eIcon_hidden { .mx_EventTile_e2eIcon_hidden {
display: none; display: none;
} }

View file

@ -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 { .mx_EventTile_reply {
order: 4; order: 4;
} }

View file

@ -22,10 +22,12 @@ limitations under the License.
flex-direction: column; flex-direction: column;
margin-left: 8px; margin-left: 8px;
margin-top: 12px;
margin-bottom: 12px;
width: 100%; width: 100%;
&:first-child {
margin-top: 12px; // so we're not up against the search/filter
}
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -83,23 +85,30 @@ limitations under the License.
// *************************** // ***************************
.mx_RoomSublist2_badgeContainer { .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. // Create another flexbox row because it's super easy to position the badge this way.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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_auxButton,
.mx_RoomSublist2_menuButton { .mx_RoomSublist2_menuButton {
width: 0; margin-left: 8px; // should be the same as the notification badge
margin: 0;
visibility: hidden;
position: relative; position: relative;
width: 24px;
height: 24px;
border-radius: 32px; border-radius: 32px;
&::before { &::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 { .mx_RoomSublist2_auxButton::before {
mask-image: url('$(res)/img/feather-customised/plus.svg'); mask-image: url('$(res)/img/feather-customised/plus.svg');
} }
@ -128,9 +144,9 @@ limitations under the License.
flex: 1; flex: 1;
max-width: calc(100% - 16px); // 16px is the badge width max-width: calc(100% - 16px); // 16px is the badge width
text-transform: uppercase; text-transform: uppercase;
opacity: 0.5;
line-height: $font-16px; line-height: $font-16px;
font-size: $font-12px; font-size: $font-12px;
font-weight: 600;
// Ellipsize any text overflow // Ellipsize any text overflow
text-overflow: ellipsis; text-overflow: ellipsis;
@ -140,11 +156,9 @@ limitations under the License.
.mx_RoomSublist2_collapseBtn { .mx_RoomSublist2_collapseBtn {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 12px;
// Default hidden height: 12px;
visibility: hidden; margin-right: 8px;
width: 0;
height: 0;
&::before { &::before {
content: ''; content: '';
@ -156,7 +170,7 @@ limitations under the License.
mask-position: center; mask-position: center;
mask-size: contain; mask-size: contain;
mask-repeat: no-repeat; mask-repeat: no-repeat;
background: $primary-fg-color; background-color: $primary-fg-color;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
} }
@ -224,6 +238,16 @@ limitations under the License.
.mx_RoomSublist2_showLessButtonChevron { .mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); 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 // Class name comes from the ResizableBox component
@ -231,69 +255,34 @@ limitations under the License.
// so that selector is below and one level higher. // so that selector is below and one level higher.
.react-resizable-handle { .react-resizable-handle {
cursor: ns-resize; 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. // This is positioned directly below the 'show more' button.
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0;
right: 0;
// This is to visually align the bar in the list. Should be 12px from // Together, these make the bar 64px wide
// either side of the list. We define this after the positioning to left: calc(50% - 32px);
// trick the browser. right: calc(50% - 32px);
margin-left: 4px;
margin-right: 4px;
}
} }
// The aforementioned selector for the hover state.
&:hover, &.mx_RoomSublist2_hasMenuOpen { &:hover, &.mx_RoomSublist2_hasMenuOpen {
.react-resizable-handle { .react-resizable-handle {
opacity: 0.2; opacity: 0.8;
background-color: $primary-fg-color;
// Update the render() function for RoomSublist2 if this changes }
border: 2px solid $primary-fg-color; }
} }
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer { &.mx_RoomSublist2_hasMenuOpen,
// If the header doesn't have an aux button we still need to hide the badge for &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
// 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 { .mx_RoomSublist2_menuButton {
visibility: visible;
width: 24px; width: 24px;
height: 24px; margin-left: 8px;
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;
}
}
} }
} }
@ -304,18 +293,18 @@ limitations under the License.
position: relative; position: relative;
.mx_RoomSublist2_badgeContainer { .mx_RoomSublist2_badgeContainer {
order: 1; order: 0;
align-self: flex-end; align-self: flex-end;
margin-right: 0; margin-right: 0;
} }
.mx_RoomSublist2_headerText { .mx_RoomSublist2_stickable {
order: 2; order: 1;
max-width: 100%; max-width: 100%;
} }
.mx_RoomSublist2_auxButton { .mx_RoomSublist2_auxButton {
order: 4; order: 2;
visibility: visible; visibility: visible;
width: 32px !important; // !important to override hover styles width: 32px !important; // !important to override hover styles
height: 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 { .mx_RoomSublist2_menuButton {
visibility: visible; visibility: visible;
position: absolute; 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 { .mx_RoomSublist2_menuButton {
bottom: 8px; // align to the middle of name, 40px less than the `bottom` above. 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 { .mx_RoomSublist2_contextMenu {
padding: 20px 16px; padding: 20px 16px;
width: 250px; width: 250px;
@ -402,6 +375,7 @@ limitations under the License.
margin-bottom: 16px; margin-bottom: 16px;
margin-right: 16px; // additional 16px margin-right: 16px; // additional 16px
border: 1px solid $roomsublist2-divider-color; border: 1px solid $roomsublist2-divider-color;
opacity: 0.1;
} }
.mx_RoomSublist2_contextMenu_title { .mx_RoomSublist2_contextMenu_title {

View file

@ -67,7 +67,7 @@ limitations under the License.
} }
.mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents { .mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
font-weight: 600; font-weight: 700;
} }
.mx_RoomTile2_messagePreview { .mx_RoomTile2_messagePreview {

View file

@ -16,11 +16,14 @@ limitations under the License.
.mx_AppearanceUserSettingsTab_fontSlider, .mx_AppearanceUserSettingsTab_fontSlider,
.mx_AppearanceUserSettingsTab_fontSlider_preview, .mx_AppearanceUserSettingsTab_fontSlider_preview,
.mx_AppearanceUserSettingsTab_Layout, .mx_AppearanceUserSettingsTab_Layout {
.mx_AppearanceUserSettingsTab .mx_Field {
@mixin mx_Settings_fullWidthField; @mixin mx_Settings_fullWidthField;
} }
.mx_AppearanceUserSettingsTab .mx_Field {
width: 256px;
}
.mx_AppearanceUserSettingsTab_fontScaling { .mx_AppearanceUserSettingsTab_fontScaling {
color: $primary-fg-color; color: $primary-fg-color;
} }
@ -30,7 +33,7 @@ limitations under the License.
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 15px; padding: 15px;
background: $font-slider-bg-color; background: rgba($appearance-tab-border-color, 0.2);
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 10px;
margin-top: 24px; margin-top: 24px;
@ -38,7 +41,7 @@ limitations under the License.
} }
.mx_AppearanceUserSettingsTab_fontSlider_preview { .mx_AppearanceUserSettingsTab_fontSlider_preview {
border: 1px solid $input-darker-bg-color; border: 1px solid $appearance-tab-border-color;
border-radius: 10px; border-radius: 10px;
padding: 0 16px 9px 16px; padding: 0 16px 9px 16px;
pointer-events: none; pointer-events: none;
@ -56,12 +59,14 @@ limitations under the License.
font-size: 15px; font-size: 15px;
padding-right: 20px; padding-right: 20px;
padding-left: 5px; padding-left: 5px;
font-weight: 500;
} }
.mx_AppearanceUserSettingsTab_fontSlider_largeText { .mx_AppearanceUserSettingsTab_fontSlider_largeText {
font-size: 18px; font-size: 18px;
padding-left: 20px; padding-left: 20px;
padding-right: 5px; padding-right: 5px;
font-weight: 500;
} }
.mx_AppearanceUserSettingsTab { .mx_AppearanceUserSettingsTab {
@ -115,7 +120,8 @@ limitations under the License.
} }
&.mx_ThemeSelector_dark { &.mx_ThemeSelector_dark {
background-color: #181b21; // 5% lightened version of 181b21
background-color: #25282e;
color: #f3f8fd; color: #f3f8fd;
> input > div { > input > div {
@ -163,10 +169,11 @@ limitations under the License.
width: 300px; width: 300px;
border: 1px solid $input-darker-bg-color; border: 1px solid $appearance-tab-border-color;
border-radius: 10px; border-radius: 10px;
.mx_EventTile_msgOption { .mx_EventTile_msgOption,
.mx_MessageActionBar {
display: none; display: none;
} }
@ -175,6 +182,7 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
pointer-events: none;
} }
.mx_RadioButton { .mx_RadioButton {
@ -188,7 +196,7 @@ limitations under the License.
} }
.mx_RadioButton { .mx_RadioButton {
border-top: 1px solid $input-darker-bg-color; border-top: 1px solid $appearance-tab-border-color;
> input + div { > input + div {
border-color: rgba($muted-fg-color, 0.2); border-color: rgba($muted-fg-color, 0.2);
@ -199,3 +207,20 @@ limitations under the License.
background-color: rgba($accent-color, 0.08); 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);
}
}

3
res/img/spinner.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.60236 3.67346C3.10764 5.59313 1.5 8.60882 1.5 12C1.5 17.799 6.20101 22.5 12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 8.6452 20.9267 5.65787 18.4776 3.73562L17.7648 4.44842C20.0354 6.18437 21.5 8.92114 21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C6.75329 21.5 2.5 17.2467 2.5 12C2.5 8.88471 3.9995 6.11966 6.31612 4.38722L5.60236 3.67346Z" fill="#03b381"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View file

@ -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-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
$roomlist2-bg-color: $header-panel-bg-color; $roomlist2-bg-color: $header-panel-bg-color;
$roomsublist2-divider-color: #e9eaeb; $roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e; $roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b; $roomtile2-default-badge-bg-color: #61708b;
@ -198,8 +198,8 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
// FontSlider colors // Appearance tab colors
$font-slider-bg-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;
// ***** Mixins! ***** // ***** Mixins! *****

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. 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
$accent-color: var(--accent-color); $accent-color: var(--accent-color);

View file

@ -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-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons
$roomlist2-bg-color: $header-panel-bg-color; $roomlist2-bg-color: $header-panel-bg-color;
$roomsublist2-divider-color: #e9eaeb; $roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e; $roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b; $roomtile2-default-badge-bg-color: #61708b;
@ -327,7 +327,7 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
// FontSlider colors // FontSlider colors
$font-slider-bg-color: rgba($input-darker-bg-color, 0.2); $appearance-tab-border-color: $input-darker-bg-color;
// ***** Mixins! ***** // ***** Mixins! *****

View file

@ -25,8 +25,8 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
import {Action} from "./dispatcher/actions"; import {Action} from "./dispatcher/actions";
import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
export const HOMESERVER_URL_KEY = "mx_hs_url"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const ID_SERVER_URL_KEY = "mx_is_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export enum UpdateCheckStatus { export enum UpdateCheckStatus {
Checking = "CHECKING", Checking = "CHECKING",
@ -221,7 +221,7 @@ export default abstract class BasePlatform {
setLanguage(preferredLangs: string[]) {} setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(fragmentAfterLogin: string): URL { protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || ""; url.hash = fragmentAfterLogin || "";
return url; return url;
@ -235,9 +235,9 @@ export default abstract class BasePlatform {
*/ */
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) { 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 // 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()) { 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); const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO

View file

@ -25,6 +25,7 @@ import RoomViewStore from "./stores/RoomViewStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {Capability} from "./widgets/WidgetApi"; import {Capability} from "./widgets/WidgetApi";
import {objectClone} from "./utils/objects";
const WIDGET_API_VERSION = '0.0.2'; // Current API version const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [ const SUPPORTED_WIDGET_API_VERSIONS = [
@ -247,7 +248,7 @@ export default class FromWidgetPostMessageApi {
* @param {Object} res Response data * @param {Object} res Response data
*/ */
sendResponse(event, res) { sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data)); const data = objectClone(event.data);
data.response = res; data.response = res;
event.source.postMessage(data, event.origin); event.source.postMessage(data, event.origin);
} }
@ -260,7 +261,7 @@ export default class FromWidgetPostMessageApi {
*/ */
sendError(event, msg, nestedError) { sendError(event, msg, nestedError) {
console.error('Action:' + event.data.action + ' failed with message: ' + msg); 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 = { data.response = {
error: { error: {
message: msg, message: msg,

View file

@ -41,7 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir"; import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener"; import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi"; 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 * 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); return Promise.resolve(false);
} }
const homeserver = localStorage.getItem(HOMESERVER_URL_KEY); const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const identityServer = localStorage.getItem(ID_SERVER_URL_KEY); const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
if (!homeserver) { if (!homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use"); console.warn("Cannot log in with token: can't determine HS URL to use");
return Promise.resolve(false); return Promise.resolve(false);

View file

@ -122,7 +122,7 @@ const Notifier = {
} }
}, },
getSoundForRoom: async function(roomId) { getSoundForRoom: function(roomId) {
// We do no caching here because the SDK caches setting // We do no caching here because the SDK caches setting
// and the browser will cache the sound. // and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId); const content = SettingsStore.getValue("notificationSound", roomId);
@ -151,7 +151,7 @@ const Notifier = {
}, },
_playAudioNotification: async function(ev, room) { _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}`); console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
try { try {

View file

@ -56,10 +56,11 @@ export function countRoomsWithNotif(rooms) {
} }
export function aggregateNotificationCount(rooms) { export function aggregateNotificationCount(rooms) {
return rooms.reduce((result, room, index) => { return rooms.reduce((result, room) => {
const roomNotifState = getRoomNotifsState(room.roomId); const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0; 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 notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);

View file

@ -244,16 +244,17 @@ import RoomViewStore from './stores/RoomViewStore';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {WidgetType} from "./widgets/WidgetType"; import {WidgetType} from "./widgets/WidgetType";
import {objectClone} from "./utils/objects";
function sendResponse(event, res) { function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data)); const data = objectClone(event.data);
data.response = res; data.response = res;
event.source.postMessage(data, event.origin); event.source.postMessage(data, event.origin);
} }
function sendError(event, msg, nestedError) { function sendError(event, msg, nestedError) {
console.error("Action:" + event.data.action + " failed with message: " + msg); 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 = { data.response = {
error: { error: {
message: msg, message: msg,

View file

@ -15,11 +15,11 @@ limitations under the License.
*/ */
import * as React from "react"; import * as React from "react";
import { createRef } from "react";
import TagPanel from "./TagPanel"; import TagPanel from "./TagPanel";
import classNames from "classnames"; import classNames from "classnames";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import SearchBox from "./SearchBox";
import RoomList2 from "../views/rooms/RoomList2"; import RoomList2 from "../views/rooms/RoomList2";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
@ -30,6 +30,10 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2"; import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { throttle } from 'lodash';
import { OwnProfileStore } from "../../stores/OwnProfileStore";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -41,6 +45,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
resizeNotifier: ResizeNotifier;
} }
interface IState { interface IState {
@ -49,6 +54,8 @@ interface IState {
} }
export default class LeftPanel2 extends React.Component<IProps, IState> { export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
// TODO: Properly support TagPanel // TODO: Properly support TagPanel
// TODO: Properly support searching/filtering // TODO: Properly support searching/filtering
// TODO: Properly support breadcrumbs // TODO: Properly support breadcrumbs
@ -65,12 +72,36 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
// 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);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
} }
// TSLint wants this to be a member, but we don't want that.
// tslint:disable-next-line
private onRoomStateUpdate = throttle((ev: MatrixEvent) => {
const myUserId = MatrixClientPeg.get().getUserId();
if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {
// noinspection JSIgnoredPromiseFromCall
this.onProfileUpdate();
}
}, 200, {trailing: true, leading: true});
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 onSearch = (term: string): void => { private onSearch = (term: string): void => {
this.setState({searchFilter: term}); this.setState({searchFilter: term});
}; };
@ -86,9 +117,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
} }
}; };
// TODO: Apply this on resize, init, etc for reliability private handleStickyHeaders(list: HTMLDivElement) {
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
const list = ev.target as HTMLDivElement;
const rlRect = list.getBoundingClientRect(); const rlRect = list.getBoundingClientRect();
const bottom = rlRect.bottom; const bottom = rlRect.bottom;
const top = rlRect.top; const top = rlRect.top;
@ -123,6 +152,17 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
header.style.top = `unset`; header.style.top = `unset`;
} }
} }
}
// TODO: Apply this on resize, init, etc for reliability
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
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 { private renderHeader(): React.ReactNode {
@ -130,16 +170,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
// TODO: Presence // TODO: Presence
// TODO: Breadcrumbs toggle // TODO: Breadcrumbs toggle
// TODO: Menu button // TODO: Menu button
const avatarSize = 32; const avatarSize = 32; // should match border-radius of the avatar
// 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; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs) {
@ -150,7 +181,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
); );
} }
let name = <span className="mx_LeftPanel2_userName">{displayName}</span>; let name = <span className="mx_LeftPanel2_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = ( let buttons = (
<span className="mx_LeftPanel2_headerButtons"> <span className="mx_LeftPanel2_headerButtons">
<UserMenuButton /> <UserMenuButton />
@ -167,8 +198,8 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<span className="mx_LeftPanel2_userAvatarContainer"> <span className="mx_LeftPanel2_userAvatarContainer">
<BaseAvatar <BaseAvatar
idName={MatrixClientPeg.get().getUserId()} idName={MatrixClientPeg.get().getUserId()}
name={displayName} name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={avatarUrl} url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize} width={avatarSize}
height={avatarSize} height={avatarSize}
resizeMethod="crop" resizeMethod="crop"
@ -230,9 +261,11 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<aside className="mx_LeftPanel2_roomListContainer"> <aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
<div className="mx_LeftPanel2_actualRoomListContainer" onScroll={this.onScroll}> <div
{roomList} className="mx_LeftPanel2_actualRoomListContainer"
</div> onScroll={this.onScroll}
ref={this.listContainerRef}
>{roomList}</div>
</aside> </aside>
</div> </div>
); );

View file

@ -123,7 +123,7 @@ interface IState {
* *
* Components mounted below us can access the matrix client via the react context. * Components mounted below us can access the matrix client via the react context.
*/ */
class LoggedInView extends React.PureComponent<IProps, IState> { class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView'; static displayName = 'LoggedInView';
static propTypes = { static propTypes = {
@ -677,7 +677,10 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
// TODO: Supply props like collapsed and disabled to LeftPanel2 // TODO: Supply props like collapsed and disabled to LeftPanel2
leftPanel = ( leftPanel = (
<LeftPanel2 isMinimized={this.props.collapseLhs || false} /> <LeftPanel2
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
); );
} }

View file

@ -18,6 +18,8 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; 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 { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -1612,6 +1614,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} else if (screen === 'directory') { } else if (screen === 'directory') {
dis.fire(Action.ViewRoomDirectory); 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') { } else if (screen === 'groups') {
dis.dispatch({ dis.dispatch({
action: 'view_my_groups', action: 'view_my_groups',
@ -1828,7 +1843,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
updateStatusIndicator(state: string, prevState: string) { 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()) { if (PlatformPeg.get()) {
PlatformPeg.get().setErrorStatus(state === 'ERROR'); PlatformPeg.get().setErrorStatus(state === 'ERROR');
@ -1913,9 +1930,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn(); this.onLoggedIn();
}; };
render() { getFragmentAfterLogin() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
let fragmentAfterLogin = ""; let fragmentAfterLogin = "";
if (this.props.initialScreenAfterLogin && if (this.props.initialScreenAfterLogin &&
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop // 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<IProps, IState> {
) { ) {
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`; fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
} }
return fragmentAfterLogin;
}
render() {
const fragmentAfterLogin = this.getFragmentAfterLogin();
let view; let view;
if (this.state.view === Views.LOADING) { if (this.state.view === Views.LOADING) {
@ -2002,7 +2021,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
} else if (this.state.view === Views.WELCOME) { } else if (this.state.view === Views.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome'); const Welcome = sdk.getComponent('auth.Welcome');
view = <Welcome {...this.getServerProperties()} fragmentAfterLogin={fragmentAfterLogin} />; view = <Welcome />;
} else if (this.state.view === Views.REGISTER) { } else if (this.state.view === Views.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration'); const Registration = sdk.getComponent('structures.auth.Registration');
view = ( view = (

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import * as React from "react"; import * as React from "react";
import {User} from "matrix-js-sdk/src/models/user";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
@ -34,12 +33,13 @@ import {getHostingLink} from "../../utils/HostingLink";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages"; import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
interface IProps { interface IProps {
} }
interface IState { interface IState {
user: User;
menuDisplayed: boolean; menuDisplayed: boolean;
isDarkTheme: boolean; isDarkTheme: boolean;
} }
@ -54,19 +54,10 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
this.state = { this.state = {
menuDisplayed: false, menuDisplayed: false,
user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()),
isDarkTheme: this.isUserOnDarkTheme(), isDarkTheme: this.isUserOnDarkTheme(),
}; };
}
private get displayName(): string { OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
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 { private get hasHomePage(): boolean {
@ -81,6 +72,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
} }
private isUserOnDarkTheme(): boolean { private isUserOnDarkTheme(): boolean {
@ -91,6 +83,12 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
return theme === "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 = () => { private onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()}); this.setState({isDarkTheme: this.isUserOnDarkTheme()});
}; };
@ -117,7 +115,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
const newTheme = this.state.isDarkTheme ? "light" : "dark"; const newTheme = this.state.isDarkTheme ? "light" : "dark";
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme); SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
}; };
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => { private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
@ -190,7 +188,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
homeButton = ( homeButton = (
<li> <li>
<AccessibleButton onClick={this.onHomeClick}> <AccessibleButton onClick={this.onHomeClick}>
<img src={require("../../../res/img/feather-customised/home.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconHome" />
<span>{_t("Home")}</span> <span>{_t("Home")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
@ -209,7 +207,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
<div className="mx_UserMenuButton_contextMenu_header"> <div className="mx_UserMenuButton_contextMenu_header">
<div className="mx_UserMenuButton_contextMenu_name"> <div className="mx_UserMenuButton_contextMenu_name">
<span className="mx_UserMenuButton_contextMenu_displayName"> <span className="mx_UserMenuButton_contextMenu_displayName">
{this.displayName} {OwnProfileStore.instance.displayName}
</span> </span>
<span className="mx_UserMenuButton_contextMenu_userId"> <span className="mx_UserMenuButton_contextMenu_userId">
{MatrixClientPeg.get().getUserId()} {MatrixClientPeg.get().getUserId()}
@ -233,31 +231,31 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
{homeButton} {homeButton}
<li> <li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<img src={require("../../../res/img/feather-customised/notifications.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconBell" />
<span>{_t("Notification settings")}</span> <span>{_t("Notification settings")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<img src={require("../../../res/img/feather-customised/lock.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconLock" />
<span>{_t("Security & privacy")}</span> <span>{_t("Security & privacy")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<img src={require("../../../res/img/feather-customised/settings.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSettings" />
<span>{_t("All settings")}</span> <span>{_t("All settings")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={this.onShowArchived}> <AccessibleButton onClick={this.onShowArchived}>
<img src={require("../../../res/img/feather-customised/archive.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconArchive" />
<span>{_t("Archived rooms")}</span> <span>{_t("Archived rooms")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={this.onProvideFeedback}> <AccessibleButton onClick={this.onProvideFeedback}>
<img src={require("../../../res/img/feather-customised/message-circle.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconMessage" />
<span>{_t("Feedback")}</span> <span>{_t("Feedback")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
@ -265,9 +263,9 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
</div> </div>
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<ul> <ul>
<li> <li className="mx_UserMenuButton_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}> <AccessibleButton onClick={this.onSignOutClick}>
<img src={require("../../../res/img/feather-customised/sign-out.svg")} width={16} /> <span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSignOut" />
<span>{_t("Sign out")}</span> <span>{_t("Sign out")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
@ -287,7 +285,7 @@ export default class UserMenuButton extends React.Component<IProps, IState> {
label={_t("Account settings")} label={_t("Account settings")}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
> >
<img src={require("../../../res/img/feather-customised/more-horizontal.svg")} alt="..." width={14} /> <span>{/* masked image in CSS */}</span>
</ContextMenuButton> </ContextMenuButton>
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>

View file

@ -25,7 +25,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login"; import {sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton"; 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 = { const LOGIN_VIEW = {
LOADING: 1, LOADING: 1,
@ -158,8 +158,8 @@ export default class SoftLogout extends React.Component {
async trySsoLogin() { async trySsoLogin() {
this.setState({busy: true}); this.setState({busy: true});
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
const loginType = "m.login.token"; const loginType = "m.login.token";
const loginParams = { const loginParams = {
token: this.props.realQueryParams['loginToken'], token: this.props.realQueryParams['loginToken'],

View file

@ -18,9 +18,7 @@ import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import AuthPage from "./AuthPage"; import AuthPage from "./AuthPage";
import * as Matrix from "matrix-js-sdk";
import {_td} from "../../../languageHandler"; import {_td} from "../../../languageHandler";
import PlatformPeg from "../../../PlatformPeg";
// translatable strings for Welcome pages // translatable strings for Welcome pages
_td("Sign in with SSO"); _td("Sign in with SSO");
@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent {
pageUrl = 'welcome.html'; 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 ( return (
<AuthPage> <AuthPage>
<div className="mx_Welcome"> <div className="mx_Welcome">
@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent {
className="mx_WelcomePage" className="mx_WelcomePage"
url={pageUrl} url={pageUrl}
replaceMap={{ replaceMap={{
"$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"), "$riot:ssoUrl": "#/start_sso",
"$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"), "$riot:casUrl": "#/start_cas",
}} }}
/> />
<LanguageSelector /> <LanguageSelector />

View file

@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import AppPermission from './AppPermission'; import AppPermission from './AppPermission';
import AppWarning from './AppWarning'; import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner'; import Spinner from './Spinner';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
@ -740,7 +740,7 @@ export default class AppTile extends React.Component {
if (this.props.show) { if (this.props.show) {
const loadingElement = ( const loadingElement = (
<div className="mx_AppLoading_spinner_fadeIn"> <div className="mx_AppLoading_spinner_fadeIn">
<MessageSpinner msg='Loading...' /> <Spinner message={_t("Loading...")} />
</div> </div>
); );
if (!this.state.hasPermissionToLoad) { if (!this.state.hasPermissionToLoad) {

View file

@ -108,7 +108,7 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
}, },
}; };
return event;
} }
public render() { public render() {

View file

@ -54,6 +54,8 @@ interface IProps {
// If specified, contents will appear as a tooltip on the element and // If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed. // validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode; 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 // If specified alongside tooltipContent, the class name to apply to the
// tooltip itself. // tooltip itself.
tooltipClassName?: string; tooltipClassName?: string;
@ -242,10 +244,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
const Tooltip = sdk.getComponent("elements.Tooltip"); const Tooltip = sdk.getComponent("elements.Tooltip");
let fieldTooltip; let fieldTooltip;
if (tooltipContent || this.state.feedback) { if (tooltipContent || this.state.feedback) {
const addlClassName = tooltipClassName ? tooltipClassName : '';
fieldTooltip = <Tooltip fieldTooltip = <Tooltip
tooltipClassName={`mx_Field_tooltip ${addlClassName}`} tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
visible={this.state.feedbackVisible} visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
label={tooltipContent || this.state.feedback} label={tooltipContent || this.state.feedback}
/>; />;
} }

View file

@ -16,6 +16,8 @@ limitations under the License.
import React from "react"; import React from "react";
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
export default createReactClass({ export default createReactClass({
displayName: 'InlineSpinner', displayName: 'InlineSpinner',
@ -25,9 +27,25 @@ export default createReactClass({
const h = this.props.h || 16; const h = this.props.h || 16;
const imgClass = this.props.imgClassName || ""; 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 ( return (
<div className="mx_InlineSpinner"> <div className={divClass}>
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} /> <img
src={imageSource}
width={w}
height={h}
className={imgClass}
aria-label={_t("Loading...")}
/>
</div> </div>
); );
}, },

View file

@ -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 (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{ msg }</div>&nbsp;
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
</div>
);
},
});

View file

@ -30,6 +30,7 @@ interface IProps {
isExplicit?: boolean; isExplicit?: boolean;
// XXX: once design replaces all toggles make this the default // XXX: once design replaces all toggles make this the default
useCheckbox?: boolean; useCheckbox?: boolean;
disabled?: boolean;
onChange?(checked: boolean): void; onChange?(checked: boolean): void;
} }
@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
else label = _t(label); else label = _t(label);
if (this.props.useCheckbox) { if (this.props.useCheckbox) {
return <StyledCheckbox checked={this.state.value} onChange={this.checkBoxOnChange} disabled={!canChange} > return <StyledCheckbox
checked={this.state.value}
onChange={this.checkBoxOnChange}
disabled={this.props.disabled || !canChange}
>
{label} {label}
</StyledCheckbox>; </StyledCheckbox>;
} else { } else {
return ( return (
<div className="mx_SettingsFlag"> <div className="mx_SettingsFlag">
<span className="mx_SettingsFlag_label">{label}</span> <span className="mx_SettingsFlag_label">{label}</span>
<ToggleSwitch checked={this.state.value} onChange={this.onChange} disabled={!canChange} aria-label={label} /> <ToggleSwitch
checked={this.state.value}
onChange={this.onChange}
disabled={this.props.disabled || !canChange}
aria-label={label}
/>
</div> </div>
); );
} }

View file

@ -16,19 +16,39 @@ limitations under the License.
*/ */
import React from "react"; 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({ const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
displayName: 'Spinner', 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 (
<div className="mx_Spinner"> <div className={divClass}>
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} /> { message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div>&nbsp;</React.Fragment> }
<img
src={imageSource}
width={w}
height={h}
className={imgClassName}
aria-label={_t("Loading...")}
/>
</div> </div>
); );
}, };
}); Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
imgClassName: PropTypes.string,
message: PropTypes.node,
};
export default Spinner;

View file

@ -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<T extends string> {
value: T;
className?: string;
disabled?: boolean;
label: React.ReactChild;
description?: React.ReactChild;
}
interface IProps<T extends string> {
name: string;
className?: string;
definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected
onChange(newValue: T);
}
function StyledRadioGroup<T extends string>({name, definitions, value, className, onChange}: IProps<T>) {
const _onChange = e => {
onChange(e.target.value);
};
return <React.Fragment>
{definitions.map(d => <React.Fragment>
<StyledRadioButton
key={d.value}
className={classNames(className, d.className)}
onChange={_onChange}
checked={d.value === value}
name={name}
value={d.value}
disabled={d.disabled}
>
{d.label}
</StyledRadioButton>
{d.description}
</React.Fragment>)}
</React.Fragment>;
}
export default StyledRadioGroup;

View file

@ -22,6 +22,7 @@ import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import InlineSpinner from '../elements/InlineSpinner';
export default class MAudioBody extends React.Component { export default class MAudioBody extends React.Component {
constructor(props) { 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. // Not sure how tall the audio player is so not sure how tall it should actually be.
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" /> <InlineSpinner />
</span> </span>
); );
} }

View file

@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner';
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component {
static propTypes = { static propTypes = {
@ -365,12 +366,7 @@ export default class MImageBody extends React.Component {
// e2e image hasn't been decrypted yet // e2e image hasn't been decrypted yet
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
placeholder = <img placeholder = <InlineSpinner w={32} h={32} />;
src={require("../../../../res/img/spinner.gif")}
alt={content.body}
width="32"
height="32"
/>;
} else if (!this.state.imgLoaded) { } else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides // Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder(); placeholder = this.getPlaceholder();

View file

@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
export default createReactClass({ export default createReactClass({
displayName: 'MVideoBody', displayName: 'MVideoBody',
@ -147,7 +148,7 @@ export default createReactClass({
return ( return (
<span className="mx_MVideoBody"> <span className="mx_MVideoBody">
<div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner"> <div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" /> <InlineSpinner />
</div> </div>
</span> </span>
); );

View file

@ -28,6 +28,7 @@ export const E2E_STATE = {
WARNING: "warning", WARNING: "warning",
UNKNOWN: "unknown", UNKNOWN: "unknown",
NORMAL: "normal", NORMAL: "normal",
UNAUTHENTICATED: "unauthenticated",
}; };
const crossSigningUserTitles = { const crossSigningUserTitles = {

View file

@ -313,35 +313,52 @@ export default createReactClass({
return; return;
} }
// If we directly trust the device, short-circuit here const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent);
const verified = await this.context.isEventSenderVerified(mxEvent); const senderId = mxEvent.getSender();
if (verified) { const userTrust = this.context.checkUserTrust(senderId);
if (encryptionInfo.mismatchedSender) {
// something definitely wrong is going on here
this.setState({ this.setState({
verified: E2E_STATE.VERIFIED, verified: E2E_STATE.WARNING,
}, () => { }, this.props.onHeightChanged); // Decryption may have caused a change in size
// Decryption may have caused a change in size
this.props.onHeightChanged();
});
return; return;
} }
if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) { if (!userTrust.isCrossSigningVerified()) {
// user is not verified, so default to everything is normal
this.setState({ this.setState({
verified: E2E_STATE.NORMAL, verified: E2E_STATE.NORMAL,
}, this.props.onHeightChanged); }, this.props.onHeightChanged); // Decryption may have caused a change in size
return; return;
} }
const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent); const eventSenderTrust = this.context.checkDeviceTrust(
senderId, encryptionInfo.sender.deviceId,
);
if (!eventSenderTrust) { if (!eventSenderTrust) {
this.setState({ this.setState({
verified: E2E_STATE.UNKNOWN, 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; return;
} }
this.setState({ 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 }, 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 return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) { } else if (this.state.verified === E2E_STATE.VERIFIED) {
return; // no icon for verified return; // no icon for verified
} else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) {
return (<E2ePadlockUnauthenticated />);
} else if (this.state.verified === E2E_STATE.UNKNOWN) { } else if (this.state.verified === E2E_STATE.UNKNOWN) {
return (<E2ePadlockUnknown />); return (<E2ePadlockUnknown />);
} else { } else {
@ -976,6 +995,12 @@ function E2ePadlockUnknown(props) {
); );
} }
function E2ePadlockUnauthenticated(props) {
return (
<E2ePadlock title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")} icon="unauthenticated" {...props} />
);
}
class E2ePadlock extends React.Component { class E2ePadlock extends React.Component {
static propTypes = { static propTypes = {
icon: PropTypes.string.isRequired, icon: PropTypes.string.isRequired,

View file

@ -18,27 +18,24 @@ import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import { Room } from "matrix-js-sdk/src/models/room"; 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 * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread'; import * as Unread from '../../../Unread';
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { arrayDiff } from "../../../utils/arrays"; import { arrayDiff } from "../../../utils/arrays";
import { IDestroyable } from "../../../utils/IDestroyable"; 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 const NOTIFICATION_STATE_UPDATE = "update";
export enum NotificationColor { export enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this // Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special 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 Grey, // unread notified messages
Red, // unread pings Red, // unread pings
} }
@ -53,18 +50,45 @@ interface IProps {
notification: INotificationState; 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 { interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
} }
export default class NotificationBadge extends React.PureComponent<IProps, IState> { export default class NotificationBadge extends React.PureComponent<IProps, IState> {
private countWatcherRef: string;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); 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<IProps>) { public componentDidUpdate(prevProps: Readonly<IProps>) {
@ -75,24 +99,34 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
} }
private countPreferenceChanged = () => {
this.setState({showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId)});
};
private onNotificationUpdate = () => { private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
public render(): React.ReactElement { public render(): React.ReactElement {
// Don't show a badge if we don't need to // 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 hasNotif = this.props.notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey; 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); let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
if (isEmptyBadge) symbol = ""; if (isEmptyBadge) symbol = "";
const classes = classNames({ const classes = classNames({
'mx_NotificationBadge': true, 'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': hasCount, 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount,
'mx_NotificationBadge_highlighted': hasNotif, 'mx_NotificationBadge_highlighted': hasNotif,
'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
@ -107,14 +141,28 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
} }
} }
export class RoomNotificationState extends EventEmitter implements IDestroyable { export class StaticNotificationState extends EventEmitter implements INotificationState {
constructor(public symbol: string, public count: number, public color: NotificationColor) {
super();
}
public static forCount(count: number, color: NotificationColor): StaticNotificationState {
return new StaticNotificationState(null, count, color);
}
public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState {
return new StaticNotificationState(symbol, 0, color);
}
}
export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState {
private _symbol: string; private _symbol: string;
private _count: number; private _count: number;
private _color: NotificationColor; private _color: NotificationColor;
constructor(private room: Room) { constructor(private room: Room) {
super(); super();
this.room.on("Room.receipt", this.handleRoomEventUpdate); this.room.on("Room.receipt", this.handleReadReceipt);
this.room.on("Room.timeline", this.handleRoomEventUpdate); this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate); this.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate); MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
@ -138,7 +186,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable
} }
public destroy(): void { public destroy(): void {
this.room.removeListener("Room.receipt", this.handleRoomEventUpdate); this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
@ -146,6 +194,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable
} }
} }
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
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) => { private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId(); 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 _count: number;
private _color: NotificationColor; private _color: NotificationColor;
private rooms: Room[] = []; private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {}; private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false) { constructor(private byTileCount = false, private tagId: TagID) {
super(); super();
} }
@ -246,7 +325,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable
state.destroy(); state.destroy();
} }
for (const newRoom of diff.added) { 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); state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
if (this.states[newRoom.roomId]) { if (this.states[newRoom.roomId]) {
// "Should never happen" disclaimer. // "Should never happen" disclaimer.
@ -259,6 +338,12 @@ export class ListNotificationState extends EventEmitter implements IDestroyable
this.calculateTotalState(); 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() { public destroy() {
for (const state of Object.values(this.states)) { for (const state of Object.values(this.states)) {
state.destroy(); state.destroy();

View file

@ -193,6 +193,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
components.push( components.push(
<RoomSublist2 <RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true} forRooms={true}
rooms={orderedRooms} rooms={orderedRooms}
startAsHidden={aesthetics.defaultHidden} startAsHidden={aesthetics.defaultHidden}

View file

@ -32,6 +32,7 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import StyledRadioButton from "../elements/StyledRadioButton"; import StyledRadioButton from "../elements/StyledRadioButton";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { TagID } from "../../../stores/room-list/models";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -56,6 +57,7 @@ interface IProps {
isInvite: boolean; isInvite: boolean;
layout: ListLayout; layout: ListLayout;
isMinimized: boolean; isMinimized: boolean;
tagId: TagID;
// TODO: Collapsed state // TODO: Collapsed state
// TODO: Group invites // TODO: Group invites
@ -68,6 +70,7 @@ interface IProps {
interface IState { interface IState {
notificationState: ListNotificationState; notificationState: ListNotificationState;
menuDisplayed: boolean; menuDisplayed: boolean;
isResizing: boolean;
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
@ -78,8 +81,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
notificationState: new ListNotificationState(this.props.isInvite), notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false, menuDisplayed: false,
isResizing: false,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
} }
@ -109,13 +113,21 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.forceUpdate(); // because the layout doesn't trigger a re-render 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 = () => { private onShowAllClick = () => {
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
private onShowLessClick = () => { 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 this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -130,13 +142,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private onUnreadFirstChanged = async () => { 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; 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) => { private onTagSortChanged = async (sort: SortAlgorithm) => {
await RoomListStore.instance.setTagSorting(this.props.layout.tagId, sort); await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
}; };
private onMessagePreviewChanged = () => { private onMessagePreviewChanged = () => {
@ -176,7 +188,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
key={`room-${room.roomId}`} key={`room-${room.roomId}`}
showMessagePreview={this.props.layout.showPreviews} showMessagePreview={this.props.layout.showPreviews}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
tag={this.props.layout.tagId} tag={this.props.tagId}
/> />
); );
} }
@ -189,8 +201,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
let contextMenu = null; let contextMenu = null;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect(); const elementRect = this.menuButtonRef.current.getBoundingClientRect();
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.layout.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
@ -204,14 +216,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<StyledRadioButton <StyledRadioButton
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)} onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical} checked={!isAlphabetical}
name={`mx_${this.props.layout.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("Activity")} {_t("Activity")}
</StyledRadioButton> </StyledRadioButton>
<StyledRadioButton <StyledRadioButton
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)} onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical} checked={isAlphabetical}
name={`mx_${this.props.layout.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("A-Z")} {_t("A-Z")}
</StyledRadioButton> </StyledRadioButton>
@ -267,7 +279,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// TODO: Collapsed state // TODO: Collapsed state
const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>; const badge = <NotificationBadge forceCount={true} notification={this.state.notificationState}/>;
let addRoomButton = null; let addRoomButton = null;
if (!!this.props.onAddRoom) { if (!!this.props.onAddRoom) {
@ -291,7 +303,18 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton, 'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
}); });
const badgeContainer = (
<div className="mx_RoomSublist2_badgeContainer">
{badge}
</div>
);
// TODO: a11y (see old component) // 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 ( return (
<div className={classes}> <div className={classes}>
<div className='mx_RoomSublist2_stickable'> <div className='mx_RoomSublist2_stickable'>
@ -307,11 +330,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<span>{this.props.label}</span> <span>{this.props.label}</span>
</AccessibleButton> </AccessibleButton>
{this.renderMenu()} {this.renderMenu()}
{addRoomButton} {this.props.isMinimized ? null : badgeContainer}
<div className="mx_RoomSublist2_badgeContainer"> {this.props.isMinimized ? null : addRoomButton}
{badge}
</div>
</div> </div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
</div> </div>
); );
}} }}
@ -343,6 +366,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const nVisible = Math.floor(layout.visibleTiles); const nVisible = Math.floor(layout.visibleTiles);
const visibleTiles = tiles.slice(0, nVisible); 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 // 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 // floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'. // tiles visible, it becomes 'show less'.
@ -357,7 +386,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showMoreText = null; if (this.props.isMinimized) showMoreText = null;
showNButton = ( showNButton = (
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showNButton'> <div onClick={this.onShowAllClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
@ -373,7 +402,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showLessText = null; if (this.props.isMinimized) showLessText = null;
showNButton = ( showNButton = (
<div onClick={this.onShowLessClick} className='mx_RoomSublist2_showNButton'> <div onClick={this.onShowLessClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
@ -419,6 +448,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
resizeHandles={handles} resizeHandles={handles}
onResize={this.onResize} onResize={this.onResize}
className="mx_RoomSublist2_resizeBox" className="mx_RoomSublist2_resizeBox"
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
> >
{visibleTiles} {visibleTiles}
{showNButton} {showNButton}

View file

@ -26,7 +26,11 @@ import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge"; import NotificationBadge, {
INotificationState,
NotificationColor,
TagSpecificNotificationState
} from "./NotificationBadge";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
@ -79,7 +83,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.state = { this.state = {
hover: false, hover: false,
notificationState: new RoomNotificationState(this.props.room), notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false, generalMenuDisplayed: false,
}; };
@ -248,7 +252,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
'mx_RoomTile2_minimized': this.props.isMinimized, 'mx_RoomTile2_minimized': this.props.isMinimized,
}); });
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />; const badge = (
<NotificationBadge
notification={this.state.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
);
// TODO: the original RoomTile uses state for the room name. Do we need to? // TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name; let name = this.props.room.name;

View file

@ -154,13 +154,6 @@ export default class CrossSigningPanel extends React.PureComponent {
errorSection = <div className="error">{error.toString()}</div>; errorSection = <div className="error">{error.toString()}</div>;
} }
// Whether the various keys exist on your account (but not necessarily
// on this device).
const enabledForAccount = (
crossSigningPrivateKeysInStorage &&
secretStorageKeyInAccount
);
let summarisedStatus; let summarisedStatus;
if (homeserverSupportsCrossSigning === undefined) { if (homeserverSupportsCrossSigning === undefined) {
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
@ -184,8 +177,19 @@ export default class CrossSigningPanel extends React.PureComponent {
)}</p>; )}</p>;
} }
const keysExistAnywhere = (
secretStorageKeyInAccount ||
crossSigningPrivateKeysInStorage ||
crossSigningPublicKeysOnDevice
);
const keysExistEverywhere = (
secretStorageKeyInAccount &&
crossSigningPrivateKeysInStorage &&
crossSigningPublicKeysOnDevice
);
let resetButton; let resetButton;
if (enabledForAccount) { if (keysExistAnywhere) {
resetButton = ( resetButton = (
<div className="mx_CrossSigningPanel_buttonRow"> <div className="mx_CrossSigningPanel_buttonRow">
<AccessibleButton kind="danger" onClick={this._destroySecureSecretStorage}> <AccessibleButton kind="danger" onClick={this._destroySecureSecretStorage}>
@ -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 // TODO: determine how better to expose this to users in addition to prompts at login/toast
let bootstrapButton; let bootstrapButton;
if ( if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
(!enabledForAccount || !crossSigningPublicKeysOnDevice) &&
homeserverSupportsCrossSigning
) {
bootstrapButton = ( bootstrapButton = (
<div className="mx_CrossSigningPanel_buttonRow"> <div className="mx_CrossSigningPanel_buttonRow">
<AccessibleButton kind="primary" onClick={this._onBootstrapClick}> <AccessibleButton kind="primary" onClick={this._onBootstrapClick}>

View file

@ -39,12 +39,11 @@ export default class NotificationsSettingsTab extends React.Component {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase UNSAFE_componentWillMount() { // eslint-disable-line camelcase
Notifier.getSoundForRoom(this.props.roomId).then((soundData) => { const soundData = Notifier.getSoundForRoom(this.props.roomId);
if (!soundData) { if (!soundData) {
return; return;
} }
this.setState({currentSound: soundData.name || soundData.url}); this.setState({currentSound: soundData.name || soundData.url});
});
this._soundUpload = createRef(); this._soundUpload = createRef();
} }

View file

@ -21,7 +21,6 @@ import {_t} from "../../../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
import { enumerateThemes } from "../../../../../theme"; import { enumerateThemes } from "../../../../../theme";
import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher";
import Field from "../../../elements/Field";
import Slider from "../../../elements/Slider"; import Slider from "../../../elements/Slider";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher"; import dis from "../../../../../dispatcher/dispatcher";
@ -32,7 +31,9 @@ import { IValidationResult, IFieldState } from '../../../elements/Validation';
import StyledRadioButton from '../../../elements/StyledRadioButton'; import StyledRadioButton from '../../../elements/StyledRadioButton';
import StyledCheckbox from '../../../elements/StyledCheckbox'; import StyledCheckbox from '../../../elements/StyledCheckbox';
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview'; import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
interface IProps { interface IProps {
} }
@ -55,6 +56,9 @@ interface IState extends IThemeState {
customThemeUrl: string; customThemeUrl: string;
customThemeMessage: CustomThemeMessage; customThemeMessage: CustomThemeMessage;
useCustomFontSize: boolean; useCustomFontSize: boolean;
useSystemFont: boolean;
systemFont: string;
showAdvanced: boolean;
useIRCLayout: boolean; useIRCLayout: boolean;
} }
@ -73,6 +77,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
customThemeUrl: "", customThemeUrl: "",
customThemeMessage: {isError: false, text: ""}, customThemeMessage: {isError: false, text: ""},
useCustomFontSize: SettingsStore.getValue("useCustomFontSize"), useCustomFontSize: SettingsStore.getValue("useCustomFontSize"),
useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
useIRCLayout: SettingsStore.getValue("useIRCLayout"), useIRCLayout: SettingsStore.getValue("useIRCLayout"),
}; };
} }
@ -110,8 +117,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
}; };
} }
private onThemeChange = (e: React.ChangeEvent<HTMLInputElement>): void => { private onThemeChange = (newTheme: string): void => {
const newTheme = e.target.value;
if (this.state.theme === newTheme) return; if (this.state.theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set, // 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<IProps, I
<div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection"> <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection">
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span> <span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
{systemThemeSection} {systemThemeSection}
<div className="mx_ThemeSelectors" onChange={this.onThemeChange}> <div className="mx_ThemeSelectors">
{orderedThemes.map(theme => { <StyledRadioGroup
return <StyledRadioButton
key={theme.id}
value={theme.id}
name="theme" name="theme"
disabled={this.state.useSystemTheme} definitions={orderedThemes.map(t => ({
checked={!this.state.useSystemTheme && theme.id === this.state.theme} value: t.id,
className={"mx_ThemeSelector_" + theme.id} label: t.name,
> disabled: this.state.useSystemTheme,
{theme.name} className: "mx_ThemeSelector_" + t.id,
</StyledRadioButton>; }))}
})} onChange={this.onThemeChange}
value={this.state.useSystemTheme ? undefined : this.state.theme}
/>
</div> </div>
{customThemeForm} {customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} useCheckbox={true} /> <SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} useCheckbox={true} />
@ -374,6 +379,53 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div>; </div>;
}; };
private renderAdvancedSection() {
const toggle = <div
className="mx_AppearanceUserSettingsTab_AdvancedToggle"
onClick={() => this.setState({showAdvanced: !this.state.showAdvanced})}
>
{this.state.showAdvanced ? "Hide advanced" : "Show advanced"}
</div>;
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
advanced = <>
<SettingsFlag
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.useIRCLayout}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({useSystemFont: checked})}
/>
<Field
className="mx_AppearanceUserSettingsTab_systemFont"
label={SettingsStore.getDisplayName("systemFont")}
onChange={(value) => {
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 <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Advanced">
{toggle}
{advanced}
</div>;
}
render() { render() {
return ( return (
<div className="mx_SettingsTab mx_AppearanceUserSettingsTab"> <div className="mx_SettingsTab mx_AppearanceUserSettingsTab">
@ -384,6 +436,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
{this.renderThemeSection()} {this.renderThemeSection()}
{SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null} {SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null}
{SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null} {SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null}
{this.renderAdvancedSection()}
</div> </div>
); );
} }

View file

@ -18,6 +18,7 @@ import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {_t, pickBestLanguage} from "../../../languageHandler"; import {_t, pickBestLanguage} from "../../../languageHandler";
import * as sdk from "../../.."; import * as sdk from "../../..";
import {objectClone} from "../../../utils/objects";
export default class InlineTermsAgreement extends React.Component { export default class InlineTermsAgreement extends React.Component {
static propTypes = { static propTypes = {
@ -56,7 +57,7 @@ export default class InlineTermsAgreement extends React.Component {
} }
_togglePolicy = (index) => { _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; policies[index].checked = !policies[index].checked;
this.setState({policies}); this.setState({policies});
}; };

View file

@ -69,4 +69,14 @@ export enum Action {
* Opens the user menu (previously known as the top left menu). No additional payload information required. * Opens the user menu (previously known as the top left menu). No additional payload information required.
*/ */
ToggleUserMenu = "toggle_user_menu", 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",
} }

View file

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

View file

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

View file

@ -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 = <T extends {}>(cli: MatrixClient, eventType: string) => {
const [value, setValue] = useState<T>(() => 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 = <T extends {}>(room: Room, eventType: string) => {
const [value, setValue] = useState<T>(() => 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;
};

View file

@ -422,10 +422,12 @@
"Upgrade your Riot": "Upgrade your Riot", "Upgrade your Riot": "Upgrade your Riot",
"A new version of Riot is available!": "A new version of Riot is available!", "A new version of Riot is available!": "A new version of Riot is available!",
"You: %(message)s": "You: %(message)s", "You: %(message)s": "You: %(message)s",
"Guest": "Guest",
"There was an error joining the room": "There was an error joining the room", "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.", "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.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
"Failed to join room": "Failed to join room", "Failed to join room": "Failed to join room",
"New spinner design": "New spinner design",
"Font scaling": "Font scaling", "Font scaling": "Font scaling",
"Message Pinning": "Message Pinning", "Message Pinning": "Message Pinning",
"Custom user status messages": "Custom user status messages", "Custom user status messages": "Custom user status messages",
@ -440,7 +442,7 @@
"Font size": "Font size", "Font size": "Font size",
"Use custom size": "Use custom size", "Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "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 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 join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)",
"Show avatar changes": "Show avatar changes", "Show avatar changes": "Show avatar changes",
@ -460,6 +462,8 @@
"Mirror local video feed": "Mirror local video feed", "Mirror local video feed": "Mirror local video feed",
"Enable Community Filter Panel": "Enable Community Filter Panel", "Enable Community Filter Panel": "Enable Community Filter Panel",
"Match system theme": "Match system theme", "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", "Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls",
"Send analytics data": "Send analytics data", "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", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session",
@ -1028,6 +1032,7 @@
"Encrypted by an unverified session": "Encrypted by an unverified session", "Encrypted by an unverified session": "Encrypted by an unverified session",
"Unencrypted": "Unencrypted", "Unencrypted": "Unencrypted",
"Encrypted by a deleted session": "Encrypted by a deleted session", "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", "Please select the destination room for this message": "Please select the destination room for this message",
"Invite only": "Invite only", "Invite only": "Invite only",
"Scroll to most recent messages": "Scroll to most recent messages", "Scroll to most recent messages": "Scroll to most recent messages",
@ -2055,7 +2060,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 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.", "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", "Failed to load timeline position": "Failed to load timeline position",
"Guest": "Guest",
"Your profile": "Your profile", "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|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,9 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { export class ContentRules {
/** /**
@ -31,7 +39,7 @@ export class ContentRules {
* externalRules: a list of other keyword rules, with states other than * externalRules: a list of other keyword rules, with states other than
* vectorState * vectorState
*/ */
static parseContentRules(rulesets) { static parseContentRules(rulesets: IRuleSets): IContentRules {
// first categorise the keyword rules in terms of their actions // first categorise the keyword rules in terms of their actions
const contentRules = this._categoriseContentRules(rulesets); const contentRules = this._categoriseContentRules(rulesets);
@ -51,59 +59,72 @@ export class ContentRules {
if (contentRules.loud.length) { if (contentRules.loud.length) {
return { return {
vectorState: PushRuleVectorState.LOUD, vectorState: State.Loud,
rules: contentRules.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) { } else if (contentRules.loud_but_disabled.length) {
return { return {
vectorState: PushRuleVectorState.OFF, vectorState: State.Off,
rules: contentRules.loud_but_disabled, 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) { } else if (contentRules.on.length) {
return { return {
vectorState: PushRuleVectorState.ON, vectorState: State.On,
rules: contentRules.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) { } else if (contentRules.on_but_disabled.length) {
return { return {
vectorState: PushRuleVectorState.OFF, vectorState: State.Off,
rules: contentRules.on_but_disabled, rules: contentRules.on_but_disabled,
externalRules: contentRules.other, externalRules: contentRules.other,
}; };
} else { } else {
return { return {
vectorState: PushRuleVectorState.ON, vectorState: State.On,
rules: [], rules: [],
externalRules: contentRules.other, externalRules: contentRules.other,
}; };
} }
} }
static _categoriseContentRules(rulesets) { static _categoriseContentRules(rulesets: IRuleSets) {
const contentRules = {on: [], on_but_disabled: [], loud: [], loud_but_disabled: [], other: []}; 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 (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) { for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i]; const r = rulesets.global[kind][i];
// check it's not a default rule // check it's not a default rule
if (r.rule_id[0] === '.' || kind !== 'content') { if (r.rule_id[0] === '.' || kind !== "content") {
continue; 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)) { switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
case PushRuleVectorState.ON: case State.On:
if (r.enabled) { if (r.enabled) {
contentRules.on.push(r); contentRules.on.push(r);
} else { } else {
contentRules.on_but_disabled.push(r); contentRules.on_but_disabled.push(r);
} }
break; break;
case PushRuleVectorState.LOUD: case State.Loud:
if (r.enabled) { if (r.enabled) {
contentRules.loud.push(r); contentRules.loud.push(r);
} else { } else {

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import {Action, Actions} from "./types";
interface IEncodedActions {
notify: boolean;
sound?: string;
highlight?: boolean;
}
export class NotificationUtils { export class NotificationUtils {
// Encodes a dictionary of { // Encodes a dictionary of {
@ -24,12 +30,12 @@ export class NotificationUtils {
// "highlight: true/false, // "highlight: true/false,
// } // }
// to a list of push actions. // to a list of push actions.
static encodeActions(action) { static encodeActions(action: IEncodedActions) {
const notify = action.notify; const notify = action.notify;
const sound = action.sound; const sound = action.sound;
const highlight = action.highlight; const highlight = action.highlight;
if (notify) { if (notify) {
const actions = ["notify"]; const actions: Action[] = [Actions.Notify];
if (sound) { if (sound) {
actions.push({"set_tweak": "sound", "value": sound}); actions.push({"set_tweak": "sound", "value": sound});
} }
@ -40,7 +46,7 @@ export class NotificationUtils {
} }
return actions; return actions;
} else { } else {
return ["dont_notify"]; return [Actions.DontNotify];
} }
} }
@ -50,18 +56,18 @@ export class NotificationUtils {
// "highlight: true/false, // "highlight: true/false,
// } // }
// If the actions couldn't be decoded then returns null. // If the actions couldn't be decoded then returns null.
static decodeActions(actions) { static decodeActions(actions: Action[]): IEncodedActions {
let notify = false; let notify = false;
let sound = null; let sound = null;
let highlight = false; let highlight = false;
for (let i = 0; i < actions.length; ++i) { for (let i = 0; i < actions.length; ++i) {
const action = actions[i]; const action = actions[i];
if (action === "notify") { if (action === Actions.Notify) {
notify = true; notify = true;
} else if (action === "dont_notify") { } else if (action === Actions.DontNotify) {
notify = false; notify = false;
} else if (typeof action === 'object') { } else if (typeof action === "object") {
if (action.set_tweak === "sound") { if (action.set_tweak === "sound") {
sound = action.value; sound = action.value;
} else if (action.set_tweak === "highlight") { } else if (action.set_tweak === "highlight") {
@ -81,7 +87,7 @@ export class NotificationUtils {
highlight = true; highlight = true;
} }
const result = {notify: notify, highlight: highlight}; const result: IEncodedActions = { notify, highlight };
if (sound !== null) { if (sound !== null) {
result.sound = sound; result.sound = sound;
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,43 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
import {StandardActions} from "./StandardActions"; import {StandardActions} from "./StandardActions";
import {NotificationUtils} from "./NotificationUtils"; 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 { export class PushRuleVectorState {
// Backwards compatibility (things should probably be using .states instead) // Backwards compatibility (things should probably be using the enum above instead)
static OFF = "off"; static OFF = State.Off;
static ON = "on"; static ON = State.On;
static LOUD = "loud"; static LOUD = State.Loud;
/** /**
* Enum for state of a push rule as defined by the Vector UI. * Enum for state of a push rule as defined by the Vector UI.
* @readonly * @readonly
* @enum {string} * @enum {string}
*/ */
static states = { static states = State;
/** 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,
};
/** /**
* Convert a PushRuleVectorState to a list of actions * Convert a PushRuleVectorState to a list of actions
* *
* @return [object] list of push-rule actions * @return [object] list of push-rule actions
*/ */
static actionsFor(pushRuleVectorState) { static actionsFor(pushRuleVectorState: State) {
if (pushRuleVectorState === PushRuleVectorState.ON) { if (pushRuleVectorState === State.On) {
return StandardActions.ACTION_NOTIFY; return StandardActions.ACTION_NOTIFY;
} else if (pushRuleVectorState === PushRuleVectorState.LOUD) { } else if (pushRuleVectorState === State.Loud) {
return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND; return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
} }
} }
@ -63,7 +62,7 @@ export class PushRuleVectorState {
* category or in PushRuleVectorState.LOUD, regardless of its enabled * category or in PushRuleVectorState.LOUD, regardless of its enabled
* state. Returns null if it does not match these categories. * state. Returns null if it does not match these categories.
*/ */
static contentRuleVectorStateKind(rule) { static contentRuleVectorStateKind(rule: IPushRule): State {
const decoded = NotificationUtils.decodeActions(rule.actions); const decoded = NotificationUtils.decodeActions(rule.actions);
if (!decoded) { if (!decoded) {
@ -81,10 +80,10 @@ export class PushRuleVectorState {
let stateKind = null; let stateKind = null;
switch (tweaks) { switch (tweaks) {
case 0: case 0:
stateKind = PushRuleVectorState.ON; stateKind = State.On;
break; break;
case 2: case 2:
stateKind = PushRuleVectorState.LOUD; stateKind = State.Loud;
break; break;
} }
return stateKind; return stateKind;

View file

@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
import {NotificationUtils} from "./NotificationUtils"; import {NotificationUtils} from "./NotificationUtils";
const encodeActions = NotificationUtils.encodeActions; const encodeActions = NotificationUtils.encodeActions;

111
src/notifications/types.ts Normal file
View file

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

View file

@ -30,6 +30,8 @@ import PushToMatrixClientController from './controllers/PushToMatrixClientContro
import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases"; import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases";
import FontSizeController from './controllers/FontSizeController'; 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 // 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']; const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config'];
@ -95,6 +97,12 @@ export const SETTINGS = {
// // not use this for new settings. // // not use this for new settings.
// invertedSettingName: "my-negative-setting", // invertedSettingName: "my-negative-setting",
// }, // },
"feature_new_spinner": {
isFeature: true,
displayName: _td("New spinner design"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_font_scaling": { "feature_font_scaling": {
isFeature: true, isFeature: true,
displayName: _td("Font scaling"), displayName: _td("Font scaling"),
@ -188,9 +196,14 @@ export const SETTINGS = {
default: true, default: true,
invertedSettingName: 'MessageComposerInput.dontSuggestEmoji', invertedSettingName: 'MessageComposerInput.dontSuggestEmoji',
}, },
// TODO: Wire up appropriately to UI (FTUE notifications)
"Notifications.alwaysShowBadgeCounts": {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: false,
},
"useCompactLayout": { "useCompactLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Use compact timeline layout'), displayName: _td('Use a more compact Modern layout'),
default: false, default: false,
}, },
"showRedactions": { "showRedactions": {
@ -314,6 +327,18 @@ export const SETTINGS = {
default: true, default: true,
displayName: _td("Match system theme"), 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": { "webRtcAllowPeerToPeer": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Allow Peer-to-Peer for 1:1 calls'), displayName: _td('Allow Peer-to-Peer for 1:1 calls'),

View file

@ -51,8 +51,17 @@ export class WatchManager {
const roomWatchers = this._watchers[settingName]; const roomWatchers = this._watchers[settingName];
const callbacks = []; const callbacks = [];
if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]); if (inRoomId !== null && roomWatchers[inRoomId]) {
if (roomWatchers[null]) callbacks.push(...roomWatchers[null]); 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) { for (const callback of callbacks) {
callback(inRoomId, atLevel, newValueAtLevel); callback(inRoomId, atLevel, newValueAtLevel);

View file

@ -16,6 +16,8 @@ limitations under the License.
import SettingController from "./SettingController"; import SettingController from "./SettingController";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { UpdateFontSizePayload } from "../../dispatcher/payloads/UpdateFontSizePayload";
import { Action } from "../../dispatcher/actions";
export default class FontSizeController extends SettingController { export default class FontSizeController extends SettingController {
constructor() { constructor() {
@ -24,8 +26,8 @@ export default class FontSizeController extends SettingController {
onChange(level, roomId, newValue) { onChange(level, roomId, newValue) {
// Dispatch font size change so that everything open responds to the change. // Dispatch font size change so that everything open responds to the change.
dis.dispatch({ dis.dispatch<UpdateFontSizePayload>({
action: "update-font-size", action: Action.UpdateFontSize,
size: newValue, size: newValue,
}); });
} }

View file

@ -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<UpdateSystemFontPayload>({
action: Action.UpdateSystemFont,
useSystemFont: SettingsStore.getValue("useSystemFont"),
font: newValue,
});
}
}

View file

@ -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<UpdateSystemFontPayload>({
action: Action.UpdateSystemFont,
useSystemFont: newValue,
font: SettingsStore.getValue("systemFont"),
});
}
}

View file

@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore"; import {SettingLevel} from "../SettingsStore";
import {objectClone, objectKeyChanges} from "../../utils/objects";
const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms"; const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms";
const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs";
@ -45,7 +46,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
newClient.on("accountData", this._onAccountData); newClient.on("accountData", this._onAccountData);
} }
_onAccountData(event) { _onAccountData(event, prevEvent) {
if (event.getType() === "org.matrix.preview_urls") { if (event.getType() === "org.matrix.preview_urls") {
let val = event.getContent()['disable']; let val = event.getContent()['disable'];
if (typeof(val) !== "boolean") { if (typeof(val) !== "boolean") {
@ -56,8 +57,10 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val); this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val);
} else if (event.getType() === "im.vector.web.settings") { } else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything // Figure out what changed and fire those updates
for (const settingName of Object.keys(event.getContent())) { const prevContent = prevEvent ? prevEvent.getContent() : {};
const changedSettings = objectKeyChanges(prevContent, event.getContent());
for (const settingName of changedSettings) {
const val = event.getContent()[settingName]; const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val); this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val);
} }
@ -159,7 +162,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
const event = cli.getAccountData(eventType); const event = cli.getAccountData(eventType);
if (!event || !event.getContent()) return null; if (!event || !event.getContent()) return null;
return event.getContent(); return objectClone(event.getContent()); // clone to prevent mutation
} }
_notifyBreadcrumbsUpdate(event) { _notifyBreadcrumbsUpdate(event) {

View file

@ -42,6 +42,10 @@ export default class MatrixClientBackedSettingsHandler extends SettingsHandler {
MatrixClientBackedSettingsHandler._instances.push(this); MatrixClientBackedSettingsHandler._instances.push(this);
} }
get client() {
return MatrixClientBackedSettingsHandler._matrixClient;
}
initMatrixClient() { initMatrixClient() {
console.warn("initMatrixClient not overridden"); console.warn("initMatrixClient not overridden");
} }

View file

@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore"; import {SettingLevel} from "../SettingsStore";
import {objectClone, objectKeyChanges} from "../../utils/objects";
const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets"; 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); newClient.on("Room.accountData", this._onAccountData);
} }
_onAccountData(event, room) { _onAccountData(event, room, prevEvent) {
const roomId = room.roomId; const roomId = room.roomId;
if (event.getType() === "org.matrix.room.preview_urls") { 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") { } else if (event.getType() === "org.matrix.room.color_scheme") {
this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent());
} else if (event.getType() === "im.vector.web.settings") { } else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything // Figure out what changed and fire those updates
for (const settingName of Object.keys(event.getContent())) { const prevContent = prevEvent ? prevEvent.getContent() : {};
const changedSettings = objectKeyChanges(prevContent, event.getContent());
for (const settingName of changedSettings) {
const val = event.getContent()[settingName]; const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val); this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val);
} }
@ -134,6 +137,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
const event = room.getAccountData(eventType); const event = room.getAccountData(eventType);
if (!event || !event.getContent()) return null; if (!event || !event.getContent()) return null;
return event.getContent(); return objectClone(event.getContent()); // clone to prevent mutation
} }
} }

View file

@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore"; import {SettingLevel} from "../SettingsStore";
import {objectClone, objectKeyChanges} from "../../utils/objects";
/** /**
* Gets and sets settings at the "room" level. * Gets and sets settings at the "room" level.
@ -38,8 +39,15 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
newClient.on("RoomState.events", this._onEvent); newClient.on("RoomState.events", this._onEvent);
} }
_onEvent(event) { _onEvent(event, state, prevEvent) {
const roomId = event.getRoomId(); 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") { if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable']; let val = event.getContent()['disable'];
@ -51,8 +59,10 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val); this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val);
} else if (event.getType() === "im.vector.web.settings") { } else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything // Figure out what changed and fire those updates
for (const settingName of Object.keys(event.getContent())) { 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]); 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, ""); const event = room.currentState.getStateEvents(eventType, "");
if (!event || !event.getContent()) return null; if (!event || !event.getContent()) return null;
return event.getContent(); return objectClone(event.getContent()); // clone to prevent mutation
} }
} }

View file

@ -18,6 +18,8 @@ import dis from '../../dispatcher/dispatcher';
import SettingsStore, {SettingLevel} from '../SettingsStore'; import SettingsStore, {SettingLevel} from '../SettingsStore';
import IWatcher from "./Watcher"; import IWatcher from "./Watcher";
import { toPx } from '../../utils/units'; import { toPx } from '../../utils/units';
import { Action } from '../../dispatcher/actions';
import { UpdateSystemFontPayload } from '../../dispatcher/payloads/UpdateSystemFontPayload';
export class FontWatcher implements IWatcher { export class FontWatcher implements IWatcher {
public static readonly MIN_SIZE = 8; public static readonly MIN_SIZE = 8;
@ -33,6 +35,10 @@ export class FontWatcher implements IWatcher {
public start() { public start() {
this.setRootFontSize(SettingsStore.getValue("baseFontSize")); this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
this.setSystemFont({
useSystemFont: SettingsStore.getValue("useSystemFont"),
font: SettingsStore.getValue("systemFont"),
});
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
@ -41,8 +47,10 @@ export class FontWatcher implements IWatcher {
} }
private onAction = (payload) => { private onAction = (payload) => {
if (payload.action === 'update-font-size') { if (payload.action === Action.UpdateFontSize) {
this.setRootFontSize(payload.size); this.setRootFontSize(payload.size);
} else if (payload.action === Action.UpdateSystemFont) {
this.setSystemFont(payload);
} }
}; };
@ -54,4 +62,8 @@ export class FontWatcher implements IWatcher {
} }
(<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize); (<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize);
}; };
private setSystemFont = ({useSystemFont, font}) => {
document.body.style.fontFamily = useSystemFont ? font : "";
};
} }

View file

@ -78,9 +78,8 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
} }
private generatePreview(room: Room) { private generatePreview(room: Room) {
const timeline = room.getLiveTimeline(); const events = room.timeline;
if (!timeline) return; // usually only happens in tests if (!events) return; // should only happen in tests
const events = timeline.getEvents();
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached

View file

@ -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<IState> {
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});
}

View file

@ -18,6 +18,10 @@ import { TagID } from "./models";
const TILE_HEIGHT_PX = 44; 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 { interface ISerializedListLayout {
numTiles: number; numTiles: number;
showPreviews: boolean; showPreviews: boolean;
@ -67,6 +71,7 @@ export class ListLayout {
} }
public get visibleTiles(): number { public get visibleTiles(): number {
if (this._n === 0) return this.defaultVisibleTiles;
return Math.max(this._n, this.minVisibleTiles); return Math.max(this._n, this.minVisibleTiles);
} }
@ -76,9 +81,13 @@ export class ListLayout {
} }
public get minVisibleTiles(): number { public get minVisibleTiles(): number {
// the .65 comes from the CSS where the show more button is return 1 + RESIZER_BOX_FACTOR;
// mathematically 65% of a tile when floating. }
return 4.65;
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 { public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
@ -92,6 +101,10 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding; 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 { public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
} }

View file

@ -158,12 +158,12 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
// First see if the receipt event is for our own user. If it was, trigger // 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). // a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) { if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`); const room = payload.room;
const room = this.matrixClient.getRoom(payload.event.roomId);
if (!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; return;
} }
console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt); await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
return; return;
} }

View file

@ -170,12 +170,16 @@ export class Algorithm extends EventEmitter {
// When we do have a room though, we expect to be able to find it // When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0]; const tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); 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`); if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons. // 🐉 Here be dragons.
// Before we can go through with lying to the underlying algorithm about a room // 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 // 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 // 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 // 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, position: position,
tag: tag, 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(); this.recalculateStickyRoom();
// Finally, trigger an update // 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 // 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. // to the rooms we know will be deduped by the Set.
const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
if (this._stickyRoom && this._stickyRoom.tag === tagId && this._stickyRoom.room) { this.tryInsertStickyRoomToFilterSet(rooms, tagId);
rooms.push(this._stickyRoom.room);
}
let remainingRooms = rooms.map(r => r); let remainingRooms = rooms.map(r => r);
let allowedRoomsInThisTag = []; let allowedRoomsInThisTag = [];
let lastFilterPriority = orderedFilters[0].relativePriority; let lastFilterPriority = orderedFilters[0].relativePriority;
@ -263,6 +271,7 @@ export class Algorithm extends EventEmitter {
this.emit(LIST_UPDATED_EVENT); this.emit(LIST_UPDATED_EVENT);
} }
// TODO: Remove or use.
protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void { protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void {
const filters = this.allowedByFilter.keys(); const filters = this.allowedByFilter.keys();
for (const room of added) { for (const room of added) {
@ -281,7 +290,8 @@ export class Algorithm extends EventEmitter {
protected recalculateFilteredRoomsForTag(tagId: TagID): void { protected recalculateFilteredRoomsForTag(tagId: TagID): void {
console.log(`Recalculating filtered rooms for ${tagId}`); console.log(`Recalculating filtered rooms for ${tagId}`);
delete this.filteredRooms[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)); const filteredRooms = rooms.filter(r => this.allowedRoomsByFilters.has(r));
if (filteredRooms.length > 0) { if (filteredRooms.length > 0) {
this.filteredRooms[tagId] = filteredRooms; this.filteredRooms[tagId] = filteredRooms;
@ -289,6 +299,17 @@ export class Algorithm extends EventEmitter {
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); 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 * 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 * 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; 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 * Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead. * previously known information and instead use these rooms instead.

View file

@ -35,11 +35,67 @@ export function enumerateThemes() {
return Object.assign({}, customThemeNames, BUILTIN_THEMES); 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) { function setCustomThemeVars(customTheme) {
const {style} = document.body; const {style} = document.body;
function setCSSVariable(name, hexColor, doPct = true) { function setCSSColorVariable(name, hexColor, doPct = true) {
style.setProperty(`--${name}`, hexColor); style.setProperty(`--${name}`, hexColor);
if (doPct) { if (doPct) {
// uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50% // 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)) { for (const [name, value] of Object.entries(customTheme.colors)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (let i = 0; i < value.length; i += 1) { for (let i = 0; i < value.length; i += 1) {
setCSSVariable(`${name}_${i}`, value[i], false); setCSSColorVariable(`${name}_${i}`, value[i], false);
} }
} else { } 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) { export function getCustomTheme(themeName) {
@ -88,6 +161,7 @@ export async function setTheme(theme) {
const themeWatcher = new ThemeWatcher(); const themeWatcher = new ThemeWatcher();
theme = themeWatcher.getEffectiveTheme(); theme = themeWatcher.getEffectiveTheme();
} }
clearCustomTheme();
let stylesheetName = theme; let stylesheetName = theme;
if (theme.startsWith("custom-")) { if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.substr(7)); const customTheme = getCustomTheme(theme.substr(7));
@ -136,7 +210,7 @@ export async function setTheme(theme) {
if (a == styleElements[stylesheetName]) return; if (a == styleElements[stylesheetName]) return;
a.disabled = true; a.disabled = true;
}); });
const bodyStyles = global.getComputedStyle(document.getElementsByTagName("body")[0]); const bodyStyles = global.getComputedStyle(document.body);
if (bodyStyles.backgroundColor) { if (bodyStyles.backgroundColor) {
document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor; document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor;
} }

View file

@ -24,14 +24,12 @@ import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
const onAccept = () => { const onAccept = () => {
console.log("DEBUG onAccept AnalyticsToast");
dis.dispatch({ dis.dispatch({
action: 'accept_cookies', action: 'accept_cookies',
}); });
}; };
const onReject = () => { const onReject = () => {
console.log("DEBUG onReject AnalyticsToast");
dis.dispatch({ dis.dispatch({
action: "reject_cookies", action: "reject_cookies",
}); });

View file

@ -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" * @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 { EventEmitter } from "events";
import { throttle } from "lodash"; import { throttle } from "lodash";
@ -29,15 +33,24 @@ export default class ResizeNotifier extends EventEmitter {
this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200);
} }
_noisyMiddlePanel() {
this.emit("middlePanelResizedNoisy");
}
_updateMiddlePanel() {
this._throttledMiddlePanel();
this._noisyMiddlePanel();
}
// can be called in quick succession // can be called in quick succession
notifyLeftHandleResized() { notifyLeftHandleResized() {
// don't emit event for own region // don't emit event for own region
this._throttledMiddlePanel(); this._updateMiddlePanel();
} }
// can be called in quick succession // can be called in quick succession
notifyRightHandleResized() { notifyRightHandleResized() {
this._throttledMiddlePanel(); this._updateMiddlePanel();
} }
// can be called in quick succession // can be called in quick succession
@ -48,7 +61,7 @@ export default class ResizeNotifier extends EventEmitter {
// taller than the available space // taller than the available space
this.emit("leftPanelResized"); this.emit("leftPanelResized");
this._throttledMiddlePanel(); this._updateMiddlePanel();
} }
} }

View file

@ -31,6 +31,7 @@ import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Capability} from "../widgets/WidgetApi"; import {Capability} from "../widgets/WidgetApi";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
import {objectClone} from "./objects";
export default class WidgetUtils { export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room /* Returns true if user is able to send state events to modify widgets in this room
@ -222,7 +223,7 @@ export default class WidgetUtils {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
// Get the current widgets and clone them before we modify them, otherwise // Get the current widgets and clone them before we modify them, otherwise
// we'll modify the content of the old event. // 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 // Delete existing widget with ID
try { try {

View file

@ -46,6 +46,28 @@ export function arrayDiff<T>(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<T>(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<T>(...a: T[][]): T[] {
return Array.from(a.reduce((c, v) => {
v.forEach(i => c.add(i));
return c;
}, new Set<T>()));
}
/** /**
* Helper functions to perform LINQ-like queries on arrays. * Helper functions to perform LINQ-like queries on arrays.
*/ */

60
src/utils/objects.ts Normal file
View file

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

View file

@ -19,7 +19,7 @@ limitations under the License.
// converts a pixel value to rem. // converts a pixel value to rem.
export function toRem(pixelValue: number): string { export function toRem(pixelValue: number): string {
return pixelValue / 15 + "rem"; return pixelValue / 10 + "rem";
} }
export function toPx(pixelValue: number): string { export function toPx(pixelValue: number): string {

View file

@ -19,6 +19,7 @@ limitations under the License.
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { objectClone } from "../utils/objects";
export enum Capability { export enum Capability {
Screenshot = "m.capability.screenshot", Screenshot = "m.capability.screenshot",
@ -140,7 +141,7 @@ export class WidgetApi extends EventEmitter {
private replyToRequest(payload: ToWidgetRequest, reply: any) { private replyToRequest(payload: ToWidgetRequest, reply: any) {
if (!window.parent) return; if (!window.parent) return;
const request = JSON.parse(JSON.stringify(payload)); const request = objectClone(payload);
request.response = reply; request.response = reply;
window.parent.postMessage(request, this.origin); window.parent.postMessage(request, this.origin);

View file

@ -5821,8 +5821,8 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "6.2.2" version "7.0.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/1c194e81637fb07fe6ad67cda33be0d5d4c10115" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f683f4544aa5da150836b01c754062809119fa97"
dependencies: dependencies:
"@babel/runtime" "^7.8.3" "@babel/runtime" "^7.8.3"
another-json "^0.2.0" another-json "^0.2.0"