Merge branch 'develop' into improve-image-view

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-04-02 08:15:51 +02:00
commit 9606297f7c
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
187 changed files with 7597 additions and 2588 deletions

View file

@ -1,3 +1,219 @@
Changes in [3.17.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0) (2021-03-29)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0-rc.1...v3.17.0)
* Upgrade to JS SDK 9.10.0
* [Release] Tweak cross-signing copy
[\#5808](https://github.com/matrix-org/matrix-react-sdk/pull/5808)
* [Release] Fix crash on login when using social login
[\#5809](https://github.com/matrix-org/matrix-react-sdk/pull/5809)
* [Release] Fix edge case with redaction grouper messing up continuations
[\#5799](https://github.com/matrix-org/matrix-react-sdk/pull/5799)
Changes in [3.17.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0-rc.1) (2021-03-25)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0...v3.17.0-rc.1)
* Upgrade to JS SDK 9.10.0-rc.1
* Translations update from Weblate
[\#5788](https://github.com/matrix-org/matrix-react-sdk/pull/5788)
* Track next event [tile] over group boundaries
[\#5784](https://github.com/matrix-org/matrix-react-sdk/pull/5784)
* Fixing the minor UI issues in the email discovery
[\#5780](https://github.com/matrix-org/matrix-react-sdk/pull/5780)
* Don't overwrite callback with undefined if no customization provided
[\#5783](https://github.com/matrix-org/matrix-react-sdk/pull/5783)
* Fix redaction event list summaries breaking sender profiles
[\#5781](https://github.com/matrix-org/matrix-react-sdk/pull/5781)
* Fix CIDER formatting buttons on Safari
[\#5782](https://github.com/matrix-org/matrix-react-sdk/pull/5782)
* Improve discovery of rooms in a space
[\#5776](https://github.com/matrix-org/matrix-react-sdk/pull/5776)
* Spaces improve creation journeys
[\#5777](https://github.com/matrix-org/matrix-react-sdk/pull/5777)
* Make buttons in verify dialog respect the system font
[\#5778](https://github.com/matrix-org/matrix-react-sdk/pull/5778)
* Collapse redactions into an event list summary
[\#5728](https://github.com/matrix-org/matrix-react-sdk/pull/5728)
* Added invite option to room's context menu
[\#5648](https://github.com/matrix-org/matrix-react-sdk/pull/5648)
* Add an optional config option to make the welcome page the login page
[\#5658](https://github.com/matrix-org/matrix-react-sdk/pull/5658)
* Fix username showing instead of display name in Jitsi widgets
[\#5770](https://github.com/matrix-org/matrix-react-sdk/pull/5770)
* Convert a bunch more js-sdk imports to absolute paths
[\#5774](https://github.com/matrix-org/matrix-react-sdk/pull/5774)
* Remove forgotten rooms from the room list once forgotten
[\#5775](https://github.com/matrix-org/matrix-react-sdk/pull/5775)
* Log error when failing to list usermedia devices
[\#5771](https://github.com/matrix-org/matrix-react-sdk/pull/5771)
* Fix weird timeline jumps
[\#5772](https://github.com/matrix-org/matrix-react-sdk/pull/5772)
* Replace type declaration in Registration.tsx
[\#5773](https://github.com/matrix-org/matrix-react-sdk/pull/5773)
* Add possibility to delay rageshake persistence in app startup
[\#5767](https://github.com/matrix-org/matrix-react-sdk/pull/5767)
* Fix left panel resizing and lower min-width improving flexibility
[\#5764](https://github.com/matrix-org/matrix-react-sdk/pull/5764)
* Work around more cases where a rageshake server might not be present
[\#5766](https://github.com/matrix-org/matrix-react-sdk/pull/5766)
* Iterate space panel visually and functionally
[\#5761](https://github.com/matrix-org/matrix-react-sdk/pull/5761)
* Make some dispatches async
[\#5765](https://github.com/matrix-org/matrix-react-sdk/pull/5765)
* fix: make room directory correct when using a homeserver with explicit port
[\#5762](https://github.com/matrix-org/matrix-react-sdk/pull/5762)
* Hangup all calls on logout
[\#5756](https://github.com/matrix-org/matrix-react-sdk/pull/5756)
* Remove now-unused assets and CSS from CompleteSecurity step
[\#5757](https://github.com/matrix-org/matrix-react-sdk/pull/5757)
* Add details and summary to allowed HTML tags
[\#5760](https://github.com/matrix-org/matrix-react-sdk/pull/5760)
* Support a media handling customisation endpoint
[\#5714](https://github.com/matrix-org/matrix-react-sdk/pull/5714)
* Edit button on View Source dialog that takes you to devtools ->
SendCustomEvent
[\#5718](https://github.com/matrix-org/matrix-react-sdk/pull/5718)
* Show room alias in plain/formatted body
[\#5748](https://github.com/matrix-org/matrix-react-sdk/pull/5748)
* Allow pills on the beginning of a part string
[\#5754](https://github.com/matrix-org/matrix-react-sdk/pull/5754)
* [SK-3] Decorate easy components with replaceableComponent
[\#5734](https://github.com/matrix-org/matrix-react-sdk/pull/5734)
* Use fsync in reskindex to ensure file is written to disk
[\#5753](https://github.com/matrix-org/matrix-react-sdk/pull/5753)
* Remove unused common CSS classes
[\#5752](https://github.com/matrix-org/matrix-react-sdk/pull/5752)
* Rebuild space previews with new designs
[\#5751](https://github.com/matrix-org/matrix-react-sdk/pull/5751)
* Rework cross-signing login flow
[\#5727](https://github.com/matrix-org/matrix-react-sdk/pull/5727)
* Change read receipt drift to be non-fractional
[\#5745](https://github.com/matrix-org/matrix-react-sdk/pull/5745)
Changes in [3.16.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0) (2021-03-15)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.2...v3.16.0)
* Upgrade to JS SDK 9.9.0
* [Release] Change read receipt drift to be non-fractional
[\#5746](https://github.com/matrix-org/matrix-react-sdk/pull/5746)
* [Release] Properly gate SpaceRoomView behind labs
[\#5750](https://github.com/matrix-org/matrix-react-sdk/pull/5750)
Changes in [3.16.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.2) (2021-03-10)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.1...v3.16.0-rc.2)
* Fixed incorrect build output in rc.1
Changes in [3.16.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.1) (2021-03-10)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0...v3.16.0-rc.1)
* Upgrade to JS SDK 9.9.0-rc.1
* Translations update from Weblate
[\#5743](https://github.com/matrix-org/matrix-react-sdk/pull/5743)
* Document behaviour of showReadReceipts=false for sent receipts
[\#5739](https://github.com/matrix-org/matrix-react-sdk/pull/5739)
* Tweak sent marker code style
[\#5741](https://github.com/matrix-org/matrix-react-sdk/pull/5741)
* Fix sent markers disappearing for edits/reactions
[\#5737](https://github.com/matrix-org/matrix-react-sdk/pull/5737)
* Ignore to-device decryption in the room list store
[\#5740](https://github.com/matrix-org/matrix-react-sdk/pull/5740)
* Spaces suggested rooms support
[\#5736](https://github.com/matrix-org/matrix-react-sdk/pull/5736)
* Add tooltips to sent/sending receipts
[\#5738](https://github.com/matrix-org/matrix-react-sdk/pull/5738)
* Remove a bunch of useless 'use strict' definitions
[\#5735](https://github.com/matrix-org/matrix-react-sdk/pull/5735)
* [SK-1] Fix types for replaceableComponent
[\#5732](https://github.com/matrix-org/matrix-react-sdk/pull/5732)
* [SK-2] Make debugging skinning problems easier
[\#5733](https://github.com/matrix-org/matrix-react-sdk/pull/5733)
* Support sending invite reasons with /invite command
[\#5695](https://github.com/matrix-org/matrix-react-sdk/pull/5695)
* Fix clicking on the avatar for opening member info requires pixel-perfect
accuracy
[\#5717](https://github.com/matrix-org/matrix-react-sdk/pull/5717)
* Display decrypted and encrypted event source on the same dialog
[\#5713](https://github.com/matrix-org/matrix-react-sdk/pull/5713)
* Fix units of TURN server expiry time
[\#5730](https://github.com/matrix-org/matrix-react-sdk/pull/5730)
* Display room name in pills instead of address
[\#5624](https://github.com/matrix-org/matrix-react-sdk/pull/5624)
* Refresh UI for file uploads
[\#5723](https://github.com/matrix-org/matrix-react-sdk/pull/5723)
* UI refresh for uploaded files
[\#5719](https://github.com/matrix-org/matrix-react-sdk/pull/5719)
* Improve message sending states to match new designs
[\#5699](https://github.com/matrix-org/matrix-react-sdk/pull/5699)
* Add clipboard write permission for widgets
[\#5725](https://github.com/matrix-org/matrix-react-sdk/pull/5725)
* Fix widget resizing
[\#5722](https://github.com/matrix-org/matrix-react-sdk/pull/5722)
* Option for audio streaming
[\#5707](https://github.com/matrix-org/matrix-react-sdk/pull/5707)
* Show a specific error for hs_disabled
[\#5576](https://github.com/matrix-org/matrix-react-sdk/pull/5576)
* Add Edge to the targets list
[\#5721](https://github.com/matrix-org/matrix-react-sdk/pull/5721)
* File drop UI fixes and improvements
[\#5505](https://github.com/matrix-org/matrix-react-sdk/pull/5505)
* Fix Bottom border of state counters is white on the dark theme
[\#5715](https://github.com/matrix-org/matrix-react-sdk/pull/5715)
* Trim spurious whitespace of nicknames
[\#5332](https://github.com/matrix-org/matrix-react-sdk/pull/5332)
* Ensure HostSignupDialog border colour matches light theme
[\#5716](https://github.com/matrix-org/matrix-react-sdk/pull/5716)
* Don't place another call if there's already one ongoing
[\#5712](https://github.com/matrix-org/matrix-react-sdk/pull/5712)
* Space room hierarchies
[\#5706](https://github.com/matrix-org/matrix-react-sdk/pull/5706)
* Iterate Space view and right panel
[\#5705](https://github.com/matrix-org/matrix-react-sdk/pull/5705)
* Add a scroll to bottom on message sent setting
[\#5692](https://github.com/matrix-org/matrix-react-sdk/pull/5692)
* Add .tmp files to gitignore
[\#5708](https://github.com/matrix-org/matrix-react-sdk/pull/5708)
* Initial Space Room View and Creation UX
[\#5704](https://github.com/matrix-org/matrix-react-sdk/pull/5704)
* Add multi language spell check
[\#5452](https://github.com/matrix-org/matrix-react-sdk/pull/5452)
* Fix tetris effect (holes) in read receipts
[\#5697](https://github.com/matrix-org/matrix-react-sdk/pull/5697)
* Fixed edit for markdown images
[\#5703](https://github.com/matrix-org/matrix-react-sdk/pull/5703)
* Iterate Space Panel
[\#5702](https://github.com/matrix-org/matrix-react-sdk/pull/5702)
* Fix read receipts for compact layout
[\#5700](https://github.com/matrix-org/matrix-react-sdk/pull/5700)
* Space Store and Space Panel for Room List filtering
[\#5689](https://github.com/matrix-org/matrix-react-sdk/pull/5689)
* Log when turn creds expire
[\#5691](https://github.com/matrix-org/matrix-react-sdk/pull/5691)
* Null check for maxHeight in call view
[\#5690](https://github.com/matrix-org/matrix-react-sdk/pull/5690)
* Autocomplete invited users
[\#5687](https://github.com/matrix-org/matrix-react-sdk/pull/5687)
* Add send message button
[\#5535](https://github.com/matrix-org/matrix-react-sdk/pull/5535)
* Move call buttons to the room header
[\#5693](https://github.com/matrix-org/matrix-react-sdk/pull/5693)
* Use the default SSSS key if the default is set
[\#5638](https://github.com/matrix-org/matrix-react-sdk/pull/5638)
* Initial Spaces feature flag
[\#5668](https://github.com/matrix-org/matrix-react-sdk/pull/5668)
* Clean up code edge cases and add helpers
[\#5667](https://github.com/matrix-org/matrix-react-sdk/pull/5667)
* Clean up widgets when leaving the room
[\#5684](https://github.com/matrix-org/matrix-react-sdk/pull/5684)
* Fix read receipts?
[\#5567](https://github.com/matrix-org/matrix-react-sdk/pull/5567)
* Fix MAU usage alerts
[\#5678](https://github.com/matrix-org/matrix-react-sdk/pull/5678)
Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0) (2021-03-01)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0-rc.1...v3.15.0)

View file

@ -21,14 +21,14 @@ caret nodes (more on that later).
For these reasons it doesn't use `innerText`, `textContent` or anything similar.
The model addresses any content in the editor within as an offset within this string.
The caret position is thus also converted from a position in the DOM tree
to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.js`.
to an offset in the content string. This happens in `getCaretOffsetAndText` in `dom.ts`.
Once the content string and caret offset is calculated, it is passed to the `update()`
method of the model. The model first calculates the same content string of its current parts,
basically just concatenating their text. It then looks for differences between
the current and the new content string. The diffing algorithm is very basic,
and assumes there is only one change around the caret offset,
so this should be very inexpensive. See `diff.js` for details.
so this should be very inexpensive. See `diff.ts` for details.
The result of the diffing is the strings that were added and/or removed from
the current content. These differences are then applied to the parts,
@ -51,7 +51,7 @@ which relate poorly to text input or changes, and don't need the `beforeinput` e
which isn't broadly supported yet.
Once the parts of the model are updated, the DOM of the editor is then reconciled
with the new model state, see `renderModel` in `render.js` for this.
with the new model state, see `renderModel` in `render.ts` for this.
If the model didn't reject the input and didn't make any additional changes,
this won't make any changes to the DOM at all, and should thus be fairly efficient.

View file

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "3.15.0",
"version": "3.17.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -83,6 +83,7 @@
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.13",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"parse5": "^6.0.1",
"png-chunks-extract": "^1.0.0",

View file

@ -396,6 +396,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
border: 1px solid $accent-color;
color: $accent-color;
background-color: $button-secondary-bg-color;
font-family: inherit;
}
.mx_Dialog button:last-child {

View file

@ -111,12 +111,13 @@
@import "./views/elements/_AddressSelector.scss";
@import "./views/elements/_AddressTile.scss";
@import "./views/elements/_DesktopBuildsNotice.scss";
@import "./views/elements/_DirectorySearchBox.scss";
@import "./views/elements/_DesktopCapturerSourcePicker.scss";
@import "./views/elements/_DirectorySearchBox.scss";
@import "./views/elements/_Dropdown.scss";
@import "./views/elements/_EditableItemList.scss";
@import "./views/elements/_ErrorBoundary.scss";
@import "./views/elements/_EventListSummary.scss";
@import "./views/elements/_FacePile.scss";
@import "./views/elements/_Field.scss";
@import "./views/elements/_FormButton.scss";
@import "./views/elements/_ImageView.scss";
@ -211,13 +212,13 @@
@import "./views/rooms/_SendMessageComposer.scss";
@import "./views/rooms/_Stickers.scss";
@import "./views/rooms/_TopUnreadMessagesBar.scss";
@import "./views/rooms/_VoiceRecordComposerTile.scss";
@import "./views/rooms/_WhoIsTypingTile.scss";
@import "./views/settings/_AvatarSetting.scss";
@import "./views/settings/_CrossSigningPanel.scss";
@import "./views/settings/_DevicesPanel.scss";
@import "./views/settings/_E2eAdvancedPanel.scss";
@import "./views/settings/_EmailAddresses.scss";
@import "./views/settings/_SpellCheckLanguages.scss";
@import "./views/settings/_IntegrationManager.scss";
@import "./views/settings/_Notifications.scss";
@import "./views/settings/_PhoneNumbers.scss";
@ -225,6 +226,7 @@
@import "./views/settings/_SecureBackupPanel.scss";
@import "./views/settings/_SetIdServer.scss";
@import "./views/settings/_SetIntegrationManager.scss";
@import "./views/settings/_SpellCheckLanguages.scss";
@import "./views/settings/_UpdateCheckButton.scss";
@import "./views/settings/tabs/_SettingsTab.scss";
@import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss";
@ -245,6 +247,7 @@
@import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voice_messages/_Waveform.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
@import "./views/voip/_DialPad.scss";

View file

@ -19,7 +19,8 @@ $roomListCollapsedWidth: 68px;
.mx_LeftPanel {
background-color: $roomlist-bg-color;
min-width: 260px;
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
min-width: 206px;
max-width: 50%;
// Create a row-based flexbox for the GroupFilterPanel and the room list
@ -129,6 +130,10 @@ $roomListCollapsedWidth: 68px;
mask-repeat: no-repeat;
background: $secondary-fg-color;
}
&.mx_LeftPanel_exploreButton_space::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
}
}
}

View file

@ -66,7 +66,7 @@ limitations under the License.
}
/* not the left panel, and not the resize handle, so the roomview/groupview/... */
.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_ResizeHandle) {
.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle) {
background-color: $primary-bg-color;
flex: 1 1 0;

View file

@ -22,7 +22,7 @@ limitations under the License.
// keep border thickness consistent to prevent movement
border: 1px solid transparent;
height: 28px;
padding: 2px;
padding: 1px;
// Create a flexbox for the icons (easier to manage)
display: flex;

View file

@ -16,9 +16,8 @@ limitations under the License.
$topLevelHeight: 32px;
$nestedHeight: 24px;
$gutterSize: 17px;
$activeStripeSize: 4px;
$activeBorderTransparentGap: 2px;
$gutterSize: 16px;
$activeBorderTransparentGap: 1px;
$activeBackgroundColor: $roomtile-selected-bg-color;
$activeBorderColor: $secondary-fg-color;
@ -36,6 +35,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_spaceTreeWrapper {
flex: 1;
overflow-y: scroll;
}
.mx_SpacePanel_toggleCollapse {
@ -63,21 +63,26 @@ $activeBorderColor: $secondary-fg-color;
}
.mx_AutoHideScrollbar {
padding: 16px 12px 16px 0;
padding: 8px 0 16px;
}
.mx_SpaceButton_toggleCollapse {
cursor: pointer;
}
.mx_SpaceItem.collapsed {
.mx_SpaceButton {
.mx_NotificationBadge {
right: -4px;
top: -4px;
}
.mx_SpaceTreeLevel {
display: flex;
flex-direction: column;
max-width: 250px;
flex-grow: 1;
}
.mx_SpaceItem {
display: inline-flex;
flex-flow: wrap;
}
.mx_SpaceItem.collapsed {
& > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse {
transform: rotate(-90deg);
}
@ -89,34 +94,42 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton {
margin-left: $gutterSize;
min-width: 40px;
}
.mx_SpaceButton {
border-radius: 8px;
position: relative;
margin-bottom: 2px;
display: flex;
align-items: center;
padding: 4px;
padding: 4px 4px 4px 0;
width: 100%;
&.mx_SpaceButton_active {
&:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper {
background-color: $activeBackgroundColor;
border-radius: 8px;
}
&.mx_SpaceButton_narrow {
.mx_BaseAvatar, .mx_SpaceButton_avatarPlaceholder {
border: 2px $activeBorderColor solid;
border-radius: 11px;
}
&.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper {
padding: $activeBorderTransparentGap;
border: 3px $activeBorderColor solid;
}
}
.mx_SpaceButton_selectionWrapper {
position: relative;
display: flex;
flex: 1;
align-items: center;
border-radius: 12px;
padding: 4px;
}
&:not(.mx_SpaceButton_narrow) {
.mx_SpaceButton_selectionWrapper {
width: 100%;
padding-right: 16px;
overflow: hidden;
}
}
.mx_SpaceButton_name {
@ -124,7 +137,6 @@ $activeBorderColor: $secondary-fg-color;
margin-left: 8px;
white-space: nowrap;
display: block;
max-width: 150px;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 8px;
@ -133,8 +145,7 @@ $activeBorderColor: $secondary-fg-color;
}
.mx_SpaceButton_toggleCollapse {
width: calc($gutterSize - $activeStripeSize);
margin-left: 1px;
width: $gutterSize;
height: 20px;
mask-position: center;
mask-size: 20px;
@ -172,11 +183,6 @@ $activeBorderColor: $secondary-fg-color;
}
}
.mx_SpaceButton_avatarPlaceholder {
border: $activeBorderTransparentGap transparent solid;
padding: $activeBorderTransparentGap;
}
&.mx_SpaceButton_new .mx_SpaceButton_icon {
background-color: $accent-color;
transition: all .1s ease-in-out; // TODO transition
@ -196,22 +202,9 @@ $activeBorderColor: $secondary-fg-color;
}
}
.mx_BaseAvatar {
/* moving the border-radius to this element from _image
element so we can add a border to it without the initials being displaced */
overflow: hidden;
border: 2px transparent solid;
padding: $activeBorderTransparentGap;
.mx_BaseAvatar_initial {
top: $activeBorderTransparentGap;
left: $activeBorderTransparentGap;
}
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
.mx_SpaceButton_menuButton {
width: 20px;
@ -219,8 +212,9 @@ $activeBorderColor: $secondary-fg-color;
height: 20px;
margin-top: auto;
margin-bottom: auto;
position: relative;
display: none;
position: absolute;
right: 4px;
&::before {
top: 2px;
@ -239,9 +233,8 @@ $activeBorderColor: $secondary-fg-color;
}
.mx_SpacePanel_badgeContainer {
position: absolute;
height: 16px;
// don't set width so that it takes no space when there is no badge to show
margin: auto 0; // vertically align
// Create a flexbox to make aligning dot badges easier
display: flex;
@ -261,14 +254,25 @@ $activeBorderColor: $secondary-fg-color;
&.collapsed {
.mx_SpaceButton {
.mx_SpacePanel_badgeContainer {
position: absolute;
right: 0px;
top: 2px;
right: -3px;
top: -3px;
}
&.mx_SpaceButton_active .mx_SpacePanel_badgeContainer {
// when we draw the selection border we move the relative bounds of our parent
// so update our position within the bounds of the parent to maintain position overall
right: -6px;
top: -6px;
}
}
}
&:not(.collapsed) {
.mx_SpacePanel_badgeContainer {
position: absolute;
right: 4px;
}
.mx_SpaceButton:hover,
.mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen {
@ -326,20 +330,20 @@ $activeBorderColor: $secondary-fg-color;
mask-image: url('$(res)/img/element-icons/leave.svg');
}
.mx_SpacePanel_iconHome::before {
mask-image: url('$(res)/img/element-icons/roomlist/home.svg');
}
.mx_SpacePanel_iconMembers::before {
mask-image: url('$(res)/img/element-icons/room/members.svg');
}
.mx_SpacePanel_iconPlus::before {
mask-image: url('$(res)/img/element-icons/plus.svg');
mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg');
}
.mx_SpacePanel_iconHash::before {
mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg');
}
.mx_SpacePanel_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
}
}

View file

@ -31,7 +31,8 @@ limitations under the License.
display: flex;
.mx_BaseAvatar {
margin-right: 16px;
margin-right: 12px;
align-self: center;
}
.mx_BaseAvatar_image {
@ -47,6 +48,7 @@ limitations under the License.
}
> div {
font-weight: 400;
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
@ -55,38 +57,71 @@ limitations under the License.
}
.mx_Dialog_content {
// TODO fix scrollbar
//display: flex;
//flex-direction: column;
//height: calc(100% - 80px);
.mx_AccessibleButton_kind_link {
padding: 0;
}
.mx_SearchBox {
margin: 24px 0 28px;
margin: 24px 0 16px;
}
.mx_SpaceRoomDirectory_noResults {
text-align: center;
> div {
font-size: $font-15px;
line-height: $font-24px;
color: $secondary-fg-color;
}
}
.mx_SpaceRoomDirectory_listHeader {
display: flex;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
min-height: 32px;
align-items: center;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
.mx_FormButton {
margin-bottom: 8px;
.mx_AccessibleButton {
padding: 2px 8px;
font-weight: normal;
& + .mx_AccessibleButton {
margin-left: 16px;
}
}
> span {
margin: auto 0 0 auto;
margin-left: auto;
}
}
.mx_SpaceRoomDirectory_error {
position: relative;
font-weight: $font-semi-bold;
color: $notice-primary-color;
font-size: $font-15px;
line-height: $font-18px;
margin: 20px auto 12px;
padding-left: 24px;
width: max-content;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 0;
background-image: url("$(res)/img/element-icons/warning-badge.svg");
}
}
}
}
.mx_SpaceRoomDirectory_list {
margin-top: 8px;
margin-top: 16px;
padding-bottom: 40px;
.mx_SpaceRoomDirectory_roomCount {
> h3 {
@ -106,114 +141,141 @@ limitations under the License.
}
.mx_SpaceRoomDirectory_subspace {
margin-top: 8px;
.mx_SpaceRoomDirectory_subspace_info {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 8px;
color: $secondary-fg-color;
font-weight: $font-semi-bold;
font-size: $font-12px;
line-height: $font-15px;
.mx_BaseAvatar {
margin-right: 12px;
vertical-align: middle;
}
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
.mx_SpaceRoomDirectory_actions {
text-align: right;
height: min-content;
margin-left: auto;
margin-right: 16px;
.mx_SpaceRoomDirectory_subspace_toggle {
position: absolute;
left: -1px;
top: 10px;
height: 16px;
width: 16px;
border-radius: 4px;
background-color: $primary-bg-color;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 16px;
width: 16px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $tertiary-fg-color;
mask-size: 16px;
transform: rotate(270deg);
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
&.mx_SpaceRoomDirectory_subspace_toggle_shown::before {
transform: rotate(0deg);
}
}
.mx_SpaceRoomDirectory_subspace_children {
margin-left: 12px;
border-left: 2px solid $space-button-outline-color;
padding-left: 24px;
}
position: relative;
padding-left: 12px;
}
.mx_SpaceRoomDirectory_roomTile {
padding: 16px;
position: relative;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid $space-button-outline-color;
margin: 8px 0 16px;
display: flex;
min-height: 76px;
min-height: 56px;
box-sizing: border-box;
&.mx_AccessibleButton:hover {
background-color: rgba(141, 151, 165, 0.1);
}
display: grid;
grid-template-columns: 20px auto max-content;
grid-column-gap: 8px;
grid-row-gap: 6px;
align-items: center;
.mx_BaseAvatar {
margin-right: 16px;
margin-top: 6px;
grid-row: 1;
grid-column: 1;
}
.mx_SpaceRoomDirectory_roomTile_info {
display: inline-block;
font-size: $font-15px;
flex-grow: 1;
height: min-content;
margin: auto 0;
.mx_SpaceRoomDirectory_roomTile_name {
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-18px;
}
.mx_SpaceRoomDirectory_roomTile_topic {
line-height: $font-24px;
color: $secondary-fg-color;
}
}
grid-row: 1;
grid-column: 2;
.mx_SpaceRoomDirectory_roomTile_memberCount {
.mx_InfoTooltip {
display: inline;
margin-left: 12px;
color: $tertiary-fg-color;
font-size: $font-12px;
line-height: $font-15px;
.mx_InfoTooltip_icon {
margin-right: 4px;
position: relative;
margin: auto 0 auto 24px;
padding: 0 0 0 28px;
line-height: $font-24px;
display: inline-block;
width: 32px;
vertical-align: text-top;
&::before {
position: absolute;
content: '';
width: 24px;
height: 24px;
top: 0;
left: 0;
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
background-color: $secondary-fg-color;
mask-image: url('$(res)/img/element-icons/community-members.svg');
}
}
}
}
.mx_SpaceRoomDirectory_roomTile_info {
font-size: $font-14px;
line-height: $font-18px;
color: $secondary-fg-color;
grid-row: 2;
grid-column: 1/3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.mx_SpaceRoomDirectory_actions {
width: 180px;
text-align: right;
margin-left: 28px;
display: inline-flex;
align-items: center;
margin-left: 20px;
grid-column: 3;
grid-row: 1/3;
.mx_AccessibleButton {
vertical-align: middle;
padding: 8px 18px;
display: inline-block;
visibility: hidden;
}
& + .mx_AccessibleButton {
margin-left: 24px;
.mx_Checkbox {
display: inline-flex;
vertical-align: middle;
margin-left: 12px;
}
}
&:hover {
background-color: $groupFilterPanel-bg-color;
.mx_AccessibleButton {
visibility: visible;
}
}
}
.mx_SpaceRoomDirectory_roomTile,
.mx_SpaceRoomDirectory_subspace_children {
&::before {
content: "";
position: absolute;
background-color: $groupFilterPanel-bg-color;
width: 1px;
height: 100%;
left: 6px;
top: 0;
}
}
@ -225,4 +287,17 @@ limitations under the License.
color: $secondary-fg-color;
}
}
> hr {
border: none;
height: 1px;
background-color: rgba(141, 151, 165, 0.2);
margin: 20px 0;
}
.mx_SpaceRoomDirectory_createRoom {
display: block;
margin: 16px auto 0;
width: max-content;
}
}

View file

@ -16,6 +16,51 @@ limitations under the License.
$SpaceRoomViewInnerWidth: 428px;
@define-mixin SpacePillButton {
position: relative;
padding: 16px 32px 16px 72px;
width: 432px;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid $input-border-color;
font-size: $font-15px;
margin: 20px 0;
> h3 {
font-weight: $font-semi-bold;
margin: 0 0 4px;
}
> span {
color: $secondary-fg-color;
}
&::before {
position: absolute;
content: '';
width: 32px;
height: 32px;
top: 24px;
left: 20px;
mask-position: center;
mask-repeat: no-repeat;
mask-size: 24px;
background-color: $tertiary-fg-color;
}
&:hover {
border-color: $accent-color;
&::before {
background-color: $accent-color;
}
> span {
color: $primary-fg-color;
}
}
}
.mx_SpaceRoomView {
.mx_MainSplit > div:first-child {
padding: 80px 60px;
@ -44,7 +89,7 @@ $SpaceRoomViewInnerWidth: 428px;
width: $SpaceRoomViewInnerWidth;
text-align: right; // button alignment right
.mx_FormButton {
.mx_AccessibleButton_hasKind {
padding: 8px 22px;
margin-left: 16px;
}
@ -77,7 +122,6 @@ $SpaceRoomViewInnerWidth: 428px;
max-width: 480px;
box-sizing: border-box;
box-shadow: 2px 15px 30px $dialog-shadow-color;
border: 1px solid $input-border-color;
border-radius: 8px;
.mx_SpaceRoomView_preview_inviter {
@ -109,53 +153,6 @@ $SpaceRoomViewInnerWidth: 428px;
margin: 20px 0 !important; // override default margin from above
}
.mx_SpaceRoomView_preview_info {
color: $tertiary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
margin: 20px 0;
.mx_SpaceRoomView_preview_info_public,
.mx_SpaceRoomView_preview_info_private {
padding-left: 20px;
position: relative;
&::before {
position: absolute;
content: "";
width: 20px;
height: 20px;
top: 0;
left: -2px;
mask-position: center;
mask-repeat: no-repeat;
background-color: $tertiary-fg-color;
}
}
.mx_SpaceRoomView_preview_info_public::before {
mask-size: 12px;
mask-image: url("$(res)/img/globe.svg");
}
.mx_SpaceRoomView_preview_info_private::before {
mask-size: 14px;
mask-image: url("$(res)/img/element-icons/lock.svg");
}
.mx_AccessibleButton_kind_link {
color: inherit;
position: relative;
padding-left: 16px;
&::before {
content: "·"; // visual separator
position: absolute;
left: 6px;
}
}
}
.mx_SpaceRoomView_preview_topic {
font-size: $font-14px;
line-height: $font-22px;
@ -209,36 +206,90 @@ $SpaceRoomViewInnerWidth: 428px;
vertical-align: middle;
}
}
}
.mx_SpaceRoomView_landing_memberCount {
.mx_SpaceRoomView_landing_info {
display: flex;
align-items: center;
.mx_SpaceRoomView_info {
display: inline-block;
margin: 0;
}
.mx_FacePile {
display: inline-block;
margin-left: auto;
margin-right: 12px;
.mx_FacePile_faces {
cursor: pointer;
> span:hover {
.mx_BaseAvatar {
filter: brightness(0.8);
}
}
> span:first-child {
position: relative;
margin-left: 24px;
padding: 0 0 0 28px;
line-height: $font-24px;
vertical-align: text-bottom;
.mx_BaseAvatar {
filter: brightness(0.8);
}
&::before {
content: "";
z-index: 1;
position: absolute;
top: 0;
left: 0;
height: 30px;
width: 30px;
background: #ffffff; // white icon fill
mask-position: center;
mask-size: 24px;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
}
}
}
}
.mx_SpaceRoomView_landing_inviteButton {
position: relative;
padding-left: 40px;
height: min-content;
&::before {
position: absolute;
content: '';
width: 24px;
height: 24px;
top: 0;
left: 0;
content: "";
left: 8px;
height: 16px;
width: 16px;
background: #ffffff; // white icon fill
mask-position: center;
mask-size: 16px;
mask-repeat: no-repeat;
mask-size: contain;
background-color: $accent-color;
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-image: url('$(res)/img/element-icons/room/invite.svg');
}
}
}
.mx_SpaceRoomView_landing_topic {
font-size: $font-15px;
margin-top: 12px;
margin-bottom: 16px;
}
> hr {
border: none;
height: 1px;
background-color: $groupFilterPanel-bg-color;
}
.mx_SpaceRoomView_landing_adminButtons {
margin-top: 32px;
margin-top: 24px;
.mx_AccessibleButton {
position: relative;
@ -247,9 +298,9 @@ $SpaceRoomViewInnerWidth: 428px;
box-sizing: border-box;
padding: 72px 16px 0;
border-radius: 12px;
border: 1px solid $space-button-outline-color;
border: 1px solid $input-border-color;
margin-right: 28px;
margin-bottom: 28px;
margin-bottom: 20px;
font-size: $font-14px;
display: inline-block;
vertical-align: bottom;
@ -279,16 +330,6 @@ $SpaceRoomViewInnerWidth: 428px;
background: #ffffff; // white icon fill
}
&.mx_SpaceRoomView_landing_inviteButton {
&::before {
background-color: $accent-color;
}
&::after {
mask-image: url('$(res)/img/element-icons/room/invite.svg');
}
}
&.mx_SpaceRoomView_landing_addButton {
&::before {
background-color: #ac3ba8;
@ -321,74 +362,14 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
.mx_SpaceRoomDirectory_list {
max-width: 600px;
.mx_SpaceRoomDirectory_roomTile_actions {
display: none;
}
.mx_SearchBox {
margin: 0 0 20px;
}
}
.mx_SpaceRoomView_privateScope {
.mx_RadioButton {
width: $SpaceRoomViewInnerWidth;
border-radius: 8px;
border: 1px solid $space-button-outline-color;
padding: 16px 16px 16px 72px;
margin-top: 36px;
cursor: pointer;
box-sizing: border-box;
position: relative;
> div:first-of-type {
// hide radio dot
display: none;
}
.mx_RadioButton_content {
margin: 0;
> h3 {
margin: 0 0 4px;
font-size: $font-15px;
font-weight: $font-semi-bold;
line-height: $font-18px;
}
> div {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
}
}
&::before {
content: "";
position: absolute;
height: 32px;
width: 32px;
top: 24px;
left: 20px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
}
.mx_RadioButton_checked {
border-color: $accent-color;
.mx_RadioButton_content {
> div {
color: $primary-fg-color;
}
}
&::before {
background-color: $accent-color;
}
.mx_AccessibleButton {
@mixin SpacePillButton;
}
.mx_SpaceRoomView_privateScope_justMeButton::before {
@ -435,3 +416,50 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
}
.mx_SpaceRoomView_info {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
margin: 20px 0;
.mx_SpaceRoomView_info_public,
.mx_SpaceRoomView_info_private {
padding-left: 20px;
position: relative;
&::before {
position: absolute;
content: "";
width: 20px;
height: 20px;
top: 0;
left: -2px;
mask-position: center;
mask-repeat: no-repeat;
background-color: $tertiary-fg-color;
}
}
.mx_SpaceRoomView_info_public::before {
mask-size: 12px;
mask-image: url("$(res)/img/globe.svg");
}
.mx_SpaceRoomView_info_private::before {
mask-size: 14px;
mask-image: url("$(res)/img/element-icons/lock.svg");
}
.mx_AccessibleButton_kind_link {
color: inherit;
position: relative;
padding-left: 16px;
&::before {
content: "·"; // visual separator
position: absolute;
left: 6px;
}
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019-2021 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.
@ -158,6 +158,10 @@ limitations under the License.
}
}
.mx_Toast_detail {
color: $secondary-fg-color;
}
.mx_Toast_deviceID {
font-size: $font-10px;
}

View file

@ -14,14 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ViewSource_label_left {
float: left;
}
.mx_ViewSource_label_right {
float: right;
}
.mx_ViewSource_separator {
clear: both;
border-bottom: 1px solid #e5e5e5;

View file

@ -26,50 +26,6 @@ limitations under the License.
position: relative;
}
.mx_CompleteSecurity_clients {
width: max-content;
margin: 36px auto 0;
.mx_CompleteSecurity_clients_desktop, .mx_CompleteSecurity_clients_mobile {
position: relative;
width: 160px;
text-align: center;
padding-top: 64px;
display: inline-block;
&::before {
content: '';
position: absolute;
height: 48px;
width: 48px;
left: 56px;
top: 0;
background-color: $muted-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
}
}
.mx_CompleteSecurity_clients_desktop {
margin-right: 56px;
}
.mx_CompleteSecurity_clients_desktop::before {
mask-image: url('$(res)/img/feather-customised/monitor.svg');
}
.mx_CompleteSecurity_clients_mobile::before {
mask-image: url('$(res)/img/feather-customised/smartphone.svg');
}
p {
margin-top: 16px;
font-size: $font-12px;
color: $muted-fg-color;
text-align: center;
}
}
.mx_CompleteSecurity_heroIcon {
width: 128px;
height: 128px;

View file

@ -28,22 +28,23 @@ limitations under the License.
flex-direction: column;
flex-wrap: nowrap;
min-height: 0;
height: 80vh;
.mx_Dialog_title {
display: flex;
.mx_BaseAvatar {
display: inline-flex;
margin: 5px 16px 5px 5px;
vertical-align: middle;
}
.mx_BaseAvatar_image {
border-radius: 8px;
margin: 0;
vertical-align: unset;
}
.mx_BaseAvatar {
display: inline-flex;
margin: 5px 16px 5px 5px;
vertical-align: middle;
}
> div {
> h1 {
font-weight: $font-semi-bold;
@ -101,6 +102,7 @@ limitations under the License.
.mx_SearchBox {
margin: 0;
flex-grow: 0;
}
.mx_AddExistingToSpaceDialog_errorText {
@ -112,7 +114,10 @@ limitations under the License.
}
.mx_AddExistingToSpaceDialog_content {
flex-grow: 1;
.mx_AddExistingToSpaceDialog_noResults {
display: block;
margin-top: 24px;
}
}
@ -162,8 +167,14 @@ limitations under the License.
> span {
flex-grow: 1;
font-size: $font-12px;
font-size: $font-14px;
line-height: $font-15px;
font-weight: $font-semi-bold;
.mx_AccessibleButton {
font-size: inherit;
display: inline-block;
}
> * {
vertical-align: middle;

View file

@ -49,7 +49,7 @@ limitations under the License.
}
}
.mx_FormButton {
.mx_AccessibleButton_hasKind {
padding: 8px 22px;
}
}

View file

@ -0,0 +1,42 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_FacePile {
.mx_FacePile_faces {
display: inline-flex;
flex-direction: row-reverse;
vertical-align: middle;
> span + span {
margin-right: -8px;
}
.mx_BaseAvatar_image {
border: 1px solid $primary-bg-color;
}
.mx_BaseAvatar_initial {
margin: 1px; // to offset the border on the image
}
}
> span {
margin-left: 12px;
font-size: $font-14px;
line-height: $font-24px;
color: $tertiary-fg-color;
}
}

View file

@ -66,6 +66,11 @@ limitations under the License.
}
}
}
&.mx_BasicMessageComposer_input_disabled {
pointer-events: none;
cursor: not-allowed;
}
}
.mx_BasicMessageComposer_AutoCompleteWrapper {

View file

@ -227,6 +227,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
}
.mx_MessageComposer_voiceMessage::before {
mask-image: url('$(res)/img/voip/mic-on-mask.svg');
}
.mx_MessageComposer_emoji::before {
mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg');
}

View file

@ -60,6 +60,8 @@ limitations under the License.
width: 27px;
height: 24px;
box-sizing: border-box;
background: none;
vertical-align: middle;
}
.mx_MessageComposerFormatBar_button::after {

View file

@ -33,8 +33,13 @@ limitations under the License.
.mx_AccessibleButton {
line-height: $font-24px;
display: inline-block;
&::before {
& + .mx_AccessibleButton {
margin-left: 12px;
}
&:not(.mx_AccessibleButton_kind_primary_outline)::before {
content: '';
display: inline-block;
background-color: $button-fg-color;

View file

@ -27,6 +27,9 @@ limitations under the License.
.mx_RoomList_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
.mx_RoomList_iconBrowse::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
}
.mx_RoomList_iconDialpad::before {
mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg');
}
@ -34,29 +37,33 @@ limitations under the License.
.mx_RoomList_explorePrompt {
margin: 4px 12px 4px;
padding-top: 12px;
border-top: 1px solid $tertiary-fg-color;
font-size: $font-13px;
border-top: 1px solid $input-border-color;
font-size: $font-14px;
div:first-child {
font-weight: $font-semi-bold;
line-height: $font-18px;
color: $primary-fg-color;
}
.mx_AccessibleButton {
color: $secondary-fg-color;
color: $primary-fg-color;
position: relative;
padding: 0 0 0 24px;
padding: 8px 8px 8px 32px;
font-size: inherit;
margin-top: 8px;
margin-top: 12px;
display: block;
text-align: start;
background-color: $roomlist-button-bg-color;
border-radius: 4px;
&::before {
content: '';
width: 16px;
height: 16px;
position: absolute;
top: 0;
left: 0;
top: 8px;
left: 8px;
background: $secondary-fg-color;
mask-position: center;
mask-size: contain;
@ -70,5 +77,13 @@ limitations under the License.
&.mx_RoomList_explorePrompt_explore::before {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
&.mx_RoomList_explorePrompt_spaceInvite::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg');
}
&.mx_RoomList_explorePrompt_spaceExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
}
}
}

View file

@ -189,6 +189,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/settings.svg');
}
.mx_RoomTile_iconInvite::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg');
}
.mx_RoomTile_iconSignOut::before {
mask-image: url('$(res)/img/element-icons/leave.svg');
}

View file

@ -0,0 +1,76 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VoiceRecordComposerTile_stop {
// 28px plus a 2px border makes this a 32px square (as intended)
width: 28px;
height: 28px;
border: 2px solid $voice-record-stop-border-color;
border-radius: 32px;
margin-right: 16px; // between us and the send button
position: relative;
&::after {
content: '';
width: 14px;
height: 14px;
position: absolute;
top: 7px;
left: 7px;
border-radius: 2px;
background-color: $voice-record-stop-symbol-color;
}
}
.mx_VoiceRecordComposerTile_waveformContainer {
padding: 5px;
padding-right: 4px; // there's 1px from the waveform itself, so account for that
padding-left: 15px; // +10px for the live circle, +5px for regular padding
background-color: $voice-record-waveform-bg-color;
border-radius: 12px;
margin-right: 12px; // isolate from stop button
// Cheat at alignment a bit
display: flex;
align-items: center;
position: relative; // important for the live circle
color: $voice-record-waveform-fg-color;
font-size: $font-14px;
&::before {
// TODO: @@ TravisR: Animate
content: '';
background-color: $voice-record-live-circle-color;
width: 10px;
height: 10px;
position: absolute;
left: 8px;
top: 16px; // vertically center
border-radius: 10px;
}
.mx_Waveform_bar {
background-color: $voice-record-waveform-fg-color;
}
.mx_Clock {
padding-right: 8px; // isolate from waveform
padding-left: 10px; // isolate from live circle
width: 42px; // we're not using a monospace font, so fake it
}
}

View file

@ -14,10 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: the space panel currently does not have a fixed width,
// just the headers at each level have a max-width of 150px
// so this will look slightly off for now. We should probably use css grid for the whole main layout...
$spacePanelWidth: 200px;
$spacePanelWidth: 71px;
.mx_SpaceCreateMenu_wrapper {
// background blur everything except SpacePanel
@ -48,53 +45,11 @@ $spacePanelWidth: 200px;
}
.mx_SpaceCreateMenuType {
position: relative;
padding: 16px 32px 16px 72px;
width: 432px;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid $input-darker-bg-color;
font-size: $font-15px;
margin: 20px 0;
> h3 {
font-weight: $font-semi-bold;
margin: 0 0 4px;
}
> span {
color: $secondary-fg-color;
}
&::before {
position: absolute;
content: '';
width: 32px;
height: 32px;
top: 24px;
left: 20px;
mask-position: center;
mask-repeat: no-repeat;
mask-size: 32px;
background-color: $tertiary-fg-color;
}
&:hover {
border-color: $accent-color;
&::before {
background-color: $accent-color;
}
> span {
color: $primary-fg-color;
}
}
@mixin SpacePillButton;
}
.mx_SpaceCreateMenuType_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 26px;
}
.mx_SpaceCreateMenuType_private::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
@ -124,7 +79,7 @@ $spacePanelWidth: 200px;
}
}
.mx_FormButton {
.mx_AccessibleButton_kind_primary {
padding: 8px 22px;
margin-left: auto;
display: block;

View file

@ -16,38 +16,7 @@ limitations under the License.
.mx_SpacePublicShare {
.mx_AccessibleButton {
border: 1px solid $space-button-outline-color;
box-sizing: border-box;
border-radius: 8px;
padding: 12px 24px 12px 52px;
margin-top: 16px;
width: $SpaceRoomViewInnerWidth;
font-size: $font-15px;
line-height: $font-24px;
position: relative;
display: flex;
> span {
color: #368bd6;
margin-left: auto;
}
&:hover {
background-color: rgba(141, 151, 165, 0.1);
}
&::before {
content: "";
position: absolute;
width: 30px;
height: 30px;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
background: $muted-fg-color;
left: 12px;
top: 9px;
}
@mixin SpacePillButton;
&.mx_SpacePublicShare_shareButton::before {
mask-image: url('$(res)/img/element-icons/link.svg');

View file

@ -0,0 +1,40 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_Waveform {
position: relative;
height: 30px; // tallest bar can only be 30px
top: 1px; // because of our border trick (see below), we're off by 1px of aligntment
display: flex;
align-items: center; // so the bars grow from the middle
overflow: hidden; // this is cheaper than a `max-height: calc(100% - 4px)` in the bar's CSS.
// A bar is meant to be a 2x2 circle when at zero height, and otherwise a 2px wide line
// with rounded caps.
.mx_Waveform_bar {
width: 0; // 0px width means we'll end up using the border as our width
border: 1px solid transparent; // transparent means we'll use the background colour
border-radius: 2px; // rounded end caps, based on the border
min-height: 0; // like the width, we'll rely on the border to give us height
max-height: 100%; // this makes the `height: 42%` work on the element
margin-left: 1px; // we want 2px between each bar, so 1px on either side for balance
margin-right: 1px;
// background color is handled by the parent components
}
}

View file

@ -55,7 +55,7 @@ limitations under the License.
}
}
.mx_CallView_voice_holdText {
.mx_CallView_holdTransferContent {
padding-top: 10px;
padding-bottom: 25px;
}
@ -82,7 +82,7 @@ limitations under the License.
}
}
.mx_CallView_voice_hold {
.mx_CallView_voice .mx_CallView_holdTransferContent {
// This masks the avatar image so when it's blurred, the edge is still crisp
.mx_CallView_voice_avatarContainer {
border-radius: 2000px;
@ -91,7 +91,7 @@ limitations under the License.
}
}
.mx_CallView_voice_holdText {
.mx_CallView_holdTransferContent {
height: 20px;
padding-top: 20px;
padding-bottom: 15px;
@ -142,7 +142,7 @@ limitations under the License.
}
}
.mx_CallView_video_holdContent {
.mx_CallView_video .mx_CallView_holdTransferContent {
position: absolute;
top: 50%;
left: 50%;

View file

@ -0,0 +1,4 @@
<svg width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.00262 5.60945C7.02444 6.31867 7.18204 6.99371 7.45029 7.60945H5.83106L5.49798 11.0235H8.60274L8.757 9.44233C9.29964 9.94168 9.94406 10.3321 10.6556 10.579L10.6122 11.0235H12.7966C13.3489 11.0235 13.7966 11.4712 13.7966 12.0235C13.7966 12.5758 13.3489 13.0235 12.7966 13.0235H10.4171L10.1823 15.4305C10.1287 15.9802 9.63959 16.3823 9.08991 16.3287C8.54024 16.2751 8.13811 15.786 8.19174 15.2363L8.40762 13.0235H5.30286L5.06803 15.4305C5.0144 15.9802 4.52533 16.3823 3.97565 16.3287C3.42598 16.2751 3.02385 15.786 3.07748 15.2363L3.29336 13.0235H1.6665C1.11422 13.0235 0.666504 12.5758 0.666504 12.0235C0.666504 11.4712 1.11422 11.0235 1.6665 11.0235H3.48848L3.82156 7.60945H2.26807C1.71578 7.60945 1.26807 7.16173 1.26807 6.60945C1.26807 6.05716 1.71578 5.60945 2.26807 5.60945H4.01668L4.28073 2.90297C4.33436 2.3533 4.82343 1.95117 5.37311 2.0048C5.92278 2.05842 6.32491 2.5475 6.27128 3.09717L6.02618 5.60945H7.00262Z" fill="#8D99A5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4224 5.37843C14.4224 6.50754 13.5071 7.42287 12.3779 7.42287C11.2488 7.42287 10.3335 6.50754 10.3335 5.37843C10.3335 4.24931 11.2488 3.33398 12.3779 3.33398C13.5071 3.33398 14.4224 4.24931 14.4224 5.37843ZM15.8496 7.45454C16.2133 6.84764 16.4224 6.13745 16.4224 5.37843C16.4224 3.14474 14.6116 1.33398 12.3779 1.33398C10.1443 1.33398 8.3335 3.14474 8.3335 5.37843C8.3335 7.61211 10.1443 9.42287 12.3779 9.42287C13.1369 9.42287 13.8471 9.21381 14.454 8.85013C14.4853 8.89368 14.5205 8.93528 14.5597 8.97444L16.293 10.7078C16.6835 11.0983 17.3167 11.0983 17.7072 10.7078C18.0977 10.3172 18.0977 9.68408 17.7072 9.29356L15.9739 7.56023C15.9347 7.52107 15.8931 7.48584 15.8496 7.45454Z" fill="#8D99A5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#737D8C" style="mix-blend-mode:multiply"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
<circle cx="8" cy="8" r="8" fill="#FF4B55"/>
<rect x="7" y="3" width="2" height="6" rx="1" fill="white"/>
<rect x="7" y="11" width="2" height="2" rx="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 283 B

View file

@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 5C2 3.89543 2.89543 3 4 3H20C21.1046 3 22 3.89543 22 5V15C22 16.1046 21.1046 17 20 17H4C2.89543 17 2 16.1046 2 15V5Z" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 21H16" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 17V21" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 510 B

View file

@ -1,4 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 4C5 2.89543 5.89543 2 7 2H17C18.1046 2 19 2.89543 19 4V20C19 21.1046 18.1046 22 17 22H7C5.89543 22 5 21.1046 5 20V4Z" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="18" r="1" fill="#2E2F32"/>
</svg>

Before

Width:  |  Height:  |  Size: 386 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.09999 4.15C6.09999 1.99609 7.84608 0.25 9.99999 0.25C12.1539 0.25 13.9 1.99609 13.9 4.15V9.98254C13.9 12.1365 12.1539 13.8825 9.99999 13.8825C7.84608 13.8825 6.09999 12.1365 6.09999 9.98254V4.15ZM3.1748 8.73755C3.86516 8.73755 4.4248 9.29719 4.4248 9.98755C4.4248 13.0574 6.91483 15.5493 9.9915 15.5538C9.99433 15.5538 9.99717 15.5538 10 15.5538C10.0028 15.5538 10.0056 15.5538 10.0084 15.5538C13.085 15.5492 15.5748 13.0573 15.5748 9.98755C15.5748 9.29719 16.1344 8.73755 16.8248 8.73755C17.5152 8.73755 18.0748 9.29719 18.0748 9.98755C18.0748 14.0189 15.115 17.3576 11.25 17.9577V18.7513C11.25 19.4416 10.6904 20.0013 10 20.0013C9.30964 20.0013 8.75 19.4416 8.75 18.7513V17.9578C4.88483 17.3578 1.9248 14.0191 1.9248 9.98755C1.9248 9.29719 2.48445 8.73755 3.1748 8.73755Z" fill="#C1C6CD"/>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View file

@ -123,7 +123,6 @@ $roomsublist-divider-color: $primary-fg-color;
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
$groupFilterPanel-divider-color: $roomlist-header-color;
$space-button-outline-color: rgba(141, 151, 165, 0.2);
$roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b;

View file

@ -120,7 +120,6 @@ $roomsublist-divider-color: $primary-fg-color;
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
$groupFilterPanel-divider-color: $roomlist-header-color;
$space-button-outline-color: rgba(141, 151, 165, 0.2);
$roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b;

View file

@ -187,7 +187,12 @@ $roomsublist-divider-color: $primary-fg-color;
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
$groupFilterPanel-divider-color: $roomlist-header-color;
$space-button-outline-color: #E3E8F0;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: $warning-color;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-live-circle-color: $warning-color;
$roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b;

View file

@ -178,7 +178,12 @@ $roomsublist-divider-color: $primary-fg-color;
$roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
$groupFilterPanel-divider-color: $roomlist-header-color;
$space-button-outline-color: #E3E8F0;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: $warning-color;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-live-circle-color: $warning-color;
$roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b;

View file

@ -39,6 +39,7 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore";
import {VoiceRecorder} from "../voice/VoiceRecorder";
declare global {
interface Window {
@ -70,6 +71,7 @@ declare global {
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecorder: typeof VoiceRecorder;
}
interface Document {

View file

@ -212,6 +212,18 @@ export default abstract class BasePlatform {
throw new Error("Unimplemented");
}
supportsWarnBeforeExit(): boolean {
return false;
}
async shouldWarnBeforeExit(): Promise<boolean> {
return false;
}
async setWarnBeforeExit(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}

View file

@ -154,6 +154,9 @@ function getRemoteAudioElement(): HTMLAudioElement {
export default class CallHandler {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
private audioPromises = new Map<AudioID, Promise<void>>();
private dispatcherRef: string = null;
private supportsPstnProtocol = null;
@ -325,6 +328,10 @@ export default class CallHandler {
return callsNotInThatRoom;
}
getTransfereeForCallId(callId: string): MatrixCall {
return this.transferees[callId];
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
@ -622,6 +629,7 @@ export default class CallHandler {
private async placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -634,6 +642,9 @@ export default class CallHandler {
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
this.calls.set(roomId, call);
if (transferee) {
this.transferees[call.callId] = transferee;
}
this.setCallListeners(call);
this.setCallAudioElement(call);
@ -723,7 +734,10 @@ export default class CallHandler {
} else if (members.length === 2) {
console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
this.placeCall(
payload.room_id, payload.type, payload.local_element, payload.remote_element,
payload.transferee,
);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
@ -788,6 +802,11 @@ export default class CallHandler {
// don't remove the call yet: let the hangup event handler do it (otherwise it will throw
// the hangup event away)
break;
case 'hangup_all':
for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false);
}
break;
case 'answer': {
if (!this.calls.has(payload.room_id)) {
return; // no call to answer

View file

@ -14,9 +14,9 @@
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default {
hasAnyLabeledDevices: async function() {
@ -54,24 +54,24 @@ export default {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
Matrix.setMatrixCallAudioOutput(audioOutDeviceId);
Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallVideoInput(videoDeviceId);
setMatrixCallAudioOutput(audioOutDeviceId);
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioOutput(deviceId);
setMatrixCallAudioOutput(deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);
setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {

View file

@ -237,6 +237,7 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = {
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
'details', 'summary',
],
allowedAttributes: {
// custom ones first:

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createClient, SERVICE_TYPES } from 'matrix-js-sdk';
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
import { createClient } from 'matrix-js-sdk/src/matrix';
import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal';

407
src/KeyBindingsDefaults.ts Normal file
View file

@ -0,0 +1,407 @@
/*
Copyright 2021 Clemens Zeidler
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 { AutocompleteAction, IKeyBindingsProvider, KeyBinding, MessageComposerAction, NavigationAction, RoomAction,
RoomListAction } from "./KeyBindingsManager";
import { isMac, Key } from "./Keyboard";
import SettingsStore from "./settings/SettingsStore";
const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
const bindings: KeyBinding<MessageComposerAction>[] = [
{
action: MessageComposerAction.SelectPrevSendHistory,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.SelectNextSendHistory,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
ctrlKey: true,
},
},
{
action: MessageComposerAction.EditPrevMessage,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: MessageComposerAction.EditNextMessage,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: MessageComposerAction.CancelEditing,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: MessageComposerAction.FormatBold,
keyCombo: {
key: Key.B,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatItalics,
keyCombo: {
key: Key.I,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.FormatQuote,
keyCombo: {
key: Key.GREATER_THAN,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: MessageComposerAction.EditUndo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToStart,
keyCombo: {
key: Key.HOME,
ctrlOrCmd: true,
},
},
{
action: MessageComposerAction.MoveCursorToEnd,
keyCombo: {
key: Key.END,
ctrlOrCmd: true,
},
},
];
if (isMac) {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Z,
ctrlOrCmd: true,
shiftKey: true,
},
});
} else {
bindings.push({
action: MessageComposerAction.EditRedo,
keyCombo: {
key: Key.Y,
ctrlOrCmd: true,
},
});
}
if (SettingsStore.getValue('MessageComposerInput.ctrlEnterToSend')) {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
ctrlOrCmd: true,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
},
});
} else {
bindings.push({
action: MessageComposerAction.Send,
keyCombo: {
key: Key.ENTER,
},
});
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
shiftKey: true,
},
});
if (isMac) {
bindings.push({
action: MessageComposerAction.NewLine,
keyCombo: {
key: Key.ENTER,
altKey: true,
},
});
}
}
return bindings;
}
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
return [
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
},
},
{
action: AutocompleteAction.CompleteOrNextSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
shiftKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
shiftKey: true,
},
},
{
action: AutocompleteAction.Cancel,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: AutocompleteAction.PrevSelection,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: AutocompleteAction.NextSelection,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
];
}
const roomListBindings = (): KeyBinding<RoomListAction>[] => {
return [
{
action: RoomListAction.ClearSearch,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomListAction.PrevRoom,
keyCombo: {
key: Key.ARROW_UP,
},
},
{
action: RoomListAction.NextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
},
},
{
action: RoomListAction.SelectRoom,
keyCombo: {
key: Key.ENTER,
},
},
{
action: RoomListAction.CollapseSection,
keyCombo: {
key: Key.ARROW_LEFT,
},
},
{
action: RoomListAction.ExpandSection,
keyCombo: {
key: Key.ARROW_RIGHT,
},
},
];
}
const roomBindings = (): KeyBinding<RoomAction>[] => {
const bindings: KeyBinding<RoomAction>[] = [
{
action: RoomAction.ScrollUp,
keyCombo: {
key: Key.PAGE_UP,
},
},
{
action: RoomAction.RoomScrollDown,
keyCombo: {
key: Key.PAGE_DOWN,
},
},
{
action: RoomAction.DismissReadMarker,
keyCombo: {
key: Key.ESCAPE,
},
},
{
action: RoomAction.JumpToOldestUnread,
keyCombo: {
key: Key.PAGE_UP,
shiftKey: true,
},
},
{
action: RoomAction.UploadFile,
keyCombo: {
key: Key.U,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: RoomAction.JumpToFirstMessage,
keyCombo: {
key: Key.HOME,
ctrlKey: true,
},
},
{
action: RoomAction.JumpToLatestMessage,
keyCombo: {
key: Key.END,
ctrlKey: true,
},
},
];
if (SettingsStore.getValue('ctrlFForSearch')) {
bindings.push({
action: RoomAction.FocusSearch,
keyCombo: {
key: Key.F,
ctrlOrCmd: true,
},
});
}
return bindings;
}
const navigationBindings = (): KeyBinding<NavigationAction>[] => {
return [
{
action: NavigationAction.FocusRoomSearch,
keyCombo: {
key: Key.K,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleRoomSidePanel,
keyCombo: {
key: Key.PERIOD,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleUserMenu,
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
keyCombo: {
key: Key.BACKTICK,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
},
},
{
action: NavigationAction.ToggleShortCutDialog,
keyCombo: {
key: Key.SLASH,
ctrlOrCmd: true,
shiftKey: true,
},
},
{
action: NavigationAction.GoToHome,
keyCombo: {
key: Key.H,
ctrlOrCmd: true,
altKey: true,
},
},
{
action: NavigationAction.SelectPrevRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
},
},
{
action: NavigationAction.SelectNextRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
},
},
{
action: NavigationAction.SelectPrevUnreadRoom,
keyCombo: {
key: Key.ARROW_UP,
altKey: true,
shiftKey: true,
},
},
{
action: NavigationAction.SelectNextUnreadRoom,
keyCombo: {
key: Key.ARROW_DOWN,
altKey: true,
shiftKey: true,
},
},
];
}
export const defaultBindingsProvider: IKeyBindingsProvider = {
getMessageComposerBindings: messageComposerBindings,
getAutocompleteBindings: autocompleteBindings,
getRoomListBindings: roomListBindings,
getRoomBindings: roomBindings,
getNavigationBindings: navigationBindings,
}

271
src/KeyBindingsManager.ts Normal file
View file

@ -0,0 +1,271 @@
/*
Copyright 2021 Clemens Zeidler
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 { defaultBindingsProvider } from './KeyBindingsDefaults';
import { isMac } from './Keyboard';
/** Actions for the chat message composer component */
export enum MessageComposerAction {
/** Send a message */
Send = 'Send',
/** Go backwards through the send history and use the message in composer view */
SelectPrevSendHistory = 'SelectPrevSendHistory',
/** Go forwards through the send history */
SelectNextSendHistory = 'SelectNextSendHistory',
/** Start editing the user's last sent message */
EditPrevMessage = 'EditPrevMessage',
/** Start editing the user's next sent message */
EditNextMessage = 'EditNextMessage',
/** Cancel editing a message or cancel replying to a message */
CancelEditing = 'CancelEditing',
/** Set bold format the current selection */
FormatBold = 'FormatBold',
/** Set italics format the current selection */
FormatItalics = 'FormatItalics',
/** Format the current selection as quote */
FormatQuote = 'FormatQuote',
/** Undo the last editing */
EditUndo = 'EditUndo',
/** Redo editing */
EditRedo = 'EditRedo',
/** Insert new line */
NewLine = 'NewLine',
/** Move the cursor to the start of the message */
MoveCursorToStart = 'MoveCursorToStart',
/** Move the cursor to the end of the message */
MoveCursorToEnd = 'MoveCursorToEnd',
}
/** Actions for text editing autocompletion */
export enum AutocompleteAction {
/**
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
* selection.
*/
CompleteOrPrevSelection = 'ApplySelection',
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
CompleteOrNextSelection = 'CompleteOrNextSelection',
/** Move to the previous autocomplete selection */
PrevSelection = 'PrevSelection',
/** Move to the next autocomplete selection */
NextSelection = 'NextSelection',
/** Close the autocompletion window */
Cancel = 'Cancel',
}
/** Actions for the room list sidebar */
export enum RoomListAction {
/** Clear room list filter field */
ClearSearch = 'ClearSearch',
/** Navigate up/down in the room list */
PrevRoom = 'PrevRoom',
/** Navigate down in the room list */
NextRoom = 'NextRoom',
/** Select room from the room list */
SelectRoom = 'SelectRoom',
/** Collapse room list section */
CollapseSection = 'CollapseSection',
/** Expand room list section, if already expanded, jump to first room in the selection */
ExpandSection = 'ExpandSection',
}
/** Actions for the current room view */
export enum RoomAction {
/** Scroll up in the timeline */
ScrollUp = 'ScrollUp',
/** Scroll down in the timeline */
RoomScrollDown = 'RoomScrollDown',
/** Dismiss read marker and jump to bottom */
DismissReadMarker = 'DismissReadMarker',
/** Jump to oldest unread message */
JumpToOldestUnread = 'JumpToOldestUnread',
/** Upload a file */
UploadFile = 'UploadFile',
/** Focus search message in a room (must be enabled) */
FocusSearch = 'FocusSearch',
/** Jump to the first (downloaded) message in the room */
JumpToFirstMessage = 'JumpToFirstMessage',
/** Jump to the latest message in the room */
JumpToLatestMessage = 'JumpToLatestMessage',
}
/** Actions for navigating do various menus, dialogs or screens */
export enum NavigationAction {
/** Jump to room search (search for a room) */
FocusRoomSearch = 'FocusRoomSearch',
/** Toggle the room side panel */
ToggleRoomSidePanel = 'ToggleRoomSidePanel',
/** Toggle the user menu */
ToggleUserMenu = 'ToggleUserMenu',
/** Toggle the short cut help dialog */
ToggleShortCutDialog = 'ToggleShortCutDialog',
/** Got to the Element home screen */
GoToHome = 'GoToHome',
/** Select prev room */
SelectPrevRoom = 'SelectPrevRoom',
/** Select next room */
SelectNextRoom = 'SelectNextRoom',
/** Select prev room with unread messages */
SelectPrevUnreadRoom = 'SelectPrevUnreadRoom',
/** Select next room with unread messages */
SelectNextUnreadRoom = 'SelectNextUnreadRoom',
}
/**
* Represent a key combination.
*
* The combo is evaluated strictly, i.e. the KeyboardEvent must match exactly what is specified in the KeyCombo.
*/
export type KeyCombo = {
key?: string;
/** On PC: ctrl is pressed; on Mac: meta is pressed */
ctrlOrCmd?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
}
export type KeyBinding<T extends string> = {
action: T;
keyCombo: KeyCombo;
}
/**
* Helper method to check if a KeyboardEvent matches a KeyCombo
*
* Note, this method is only exported for testing.
*/
export function isKeyComboMatch(ev: KeyboardEvent | React.KeyboardEvent, combo: KeyCombo, onMac: boolean): boolean {
if (combo.key !== undefined) {
// When shift is pressed, letters are returned as upper case chars. In this case do a lower case comparison.
// This works for letter combos such as shift + U as well for none letter combos such as shift + Escape.
// If shift is not pressed, the toLowerCase conversion can be avoided.
if (ev.shiftKey) {
if (ev.key.toLowerCase() !== combo.key.toLowerCase()) {
return false;
}
} else if (ev.key !== combo.key) {
return false;
}
}
const comboCtrl = combo.ctrlKey ?? false;
const comboAlt = combo.altKey ?? false;
const comboShift = combo.shiftKey ?? false;
const comboMeta = combo.metaKey ?? false;
// Tests mock events may keep the modifiers undefined; convert them to booleans
const evCtrl = ev.ctrlKey ?? false;
const evAlt = ev.altKey ?? false;
const evShift = ev.shiftKey ?? false;
const evMeta = ev.metaKey ?? false;
// When ctrlOrCmd is set, the keys need do evaluated differently on PC and Mac
if (combo.ctrlOrCmd) {
if (onMac) {
if (!evMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
} else {
if (!evCtrl
|| evMeta !== comboMeta
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
}
return true;
}
if (evMeta !== comboMeta
|| evCtrl !== comboCtrl
|| evAlt !== comboAlt
|| evShift !== comboShift) {
return false;
}
return true;
}
export type KeyBindingGetter<T extends string> = () => KeyBinding<T>[];
export interface IKeyBindingsProvider {
getMessageComposerBindings: KeyBindingGetter<MessageComposerAction>;
getAutocompleteBindings: KeyBindingGetter<AutocompleteAction>;
getRoomListBindings: KeyBindingGetter<RoomListAction>;
getRoomBindings: KeyBindingGetter<RoomAction>;
getNavigationBindings: KeyBindingGetter<NavigationAction>;
}
export class KeyBindingsManager {
/**
* List of key bindings providers.
*
* Key bindings from the first provider(s) in the list will have precedence over key bindings from later providers.
*
* To overwrite the default key bindings add a new providers before the default provider, e.g. a provider for
* customized key bindings.
*/
bindingsProviders: IKeyBindingsProvider[] = [
defaultBindingsProvider,
];
/**
* Finds a matching KeyAction for a given KeyboardEvent
*/
private getAction<T extends string>(getters: KeyBindingGetter<T>[], ev: KeyboardEvent | React.KeyboardEvent)
: T | undefined {
for (const getter of getters) {
const bindings = getter();
const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac));
if (binding) {
return binding.action;
}
}
return undefined;
}
getMessageComposerAction(ev: KeyboardEvent | React.KeyboardEvent): MessageComposerAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getMessageComposerBindings), ev);
}
getAutocompleteAction(ev: KeyboardEvent | React.KeyboardEvent): AutocompleteAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getAutocompleteBindings), ev);
}
getRoomListAction(ev: KeyboardEvent | React.KeyboardEvent): RoomListAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomListBindings), ev);
}
getRoomAction(ev: KeyboardEvent | React.KeyboardEvent): RoomAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getRoomBindings), ev);
}
getNavigationAction(ev: KeyboardEvent | React.KeyboardEvent): NavigationAction | undefined {
return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev);
}
}
const manager = new KeyBindingsManager();
export function getKeyBindingsManager(): KeyBindingsManager {
return manager;
}

View file

@ -17,8 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes";
@ -219,7 +218,7 @@ export function attemptTokenLogin(
button: _t("Try again"),
onFinished: tryAgain => {
if (tryAgain) {
const cli = Matrix.createClient({
const cli = createClient({
baseUrl: homeserver,
idBaseUrl: identityServer,
});
@ -276,7 +275,7 @@ function registerAsGuest(
console.log(`Doing guest login on ${hsUrl}`);
// create a temporary MatrixClient to do the login
const client = Matrix.createClient({
const client = createClient({
baseUrl: hsUrl,
});

View file

@ -19,7 +19,7 @@ limitations under the License.
*/
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
import Matrix from "matrix-js-sdk";
import {createClient} from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
@ -115,7 +115,7 @@ export default class Login {
*/
public createTemporaryClient(): MatrixClient {
if (this.tempClient) return this.tempClient; // use memoization
return this.tempClient = Matrix.createClient({
return this.tempClient = createClient({
baseUrl: this.hsUrl,
idBaseUrl: this.isUrl,
});
@ -210,7 +210,7 @@ export async function sendLoginRequest(
loginType: string,
loginParams: ILoginParams,
): Promise<IMatrixClientCreds> {
const client = Matrix.createClient({
const client = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});

View file

@ -261,7 +261,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
}
public getHomeserverName(): string {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive homeserver name from user ID!");
}
@ -296,10 +296,11 @@ class _MatrixClientPeg implements IMatrixClientPeg {
// These are always installed regardless of the labs flag so that
// cross-signing features can toggle on without reloading and also be
// accessed immediately after login.
const customisedCallbacks = {
getDehydrationKey: SecurityCustomisations.getDehydrationKey,
};
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks);
Object.assign(opts.cryptoCallbacks, crossSigningCallbacks);
if (SecurityCustomisations.getDehydrationKey) {
opts.cryptoCallbacks.getDehydrationKey =
SecurityCustomisations.getDehydrationKey;
}
this.matrixClient = createMatrixClient(opts);

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import { createClient } from 'matrix-js-sdk/src/matrix';
import { _t } from './languageHandler';
/**
@ -32,7 +32,7 @@ export default class PasswordReset {
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({
this.client = createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl,
});

View file

@ -17,7 +17,7 @@ limitations under the License.
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import { EventStatus } from 'matrix-js-sdk';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
export default class Resend {
static resendUnsentEvents(room) {

View file

@ -49,11 +49,12 @@ export function showStartChatInviteDialog(initialText) {
);
}
export function showRoomInviteDialog(roomId) {
export function showRoomInviteDialog(roomId, initialText = "") {
// This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog(
"Invite Users", "", InviteDialog, {
kind: KIND_INVITE,
initialText,
roomId,
},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,

View file

@ -21,9 +21,9 @@ import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
import {MatrixClientPeg} from "./MatrixClientPeg";
import request from "browser-request";
import * as Matrix from 'matrix-js-sdk';
import SdkConfig from "./SdkConfig";
import {WidgetType} from "./widgets/WidgetType";
import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types";
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
@ -153,7 +153,7 @@ export default class ScalarAuthClient {
parsedImRestUrl.path = '';
parsedImRestUrl.pathname = '';
return startTermsFlow([new Service(
Matrix.SERVICE_TYPES.IM,
SERVICE_TYPES.IM,
parsedImRestUrl.format(),
token,
)], this.termsInteractionCallback).then(() => {

View file

@ -237,7 +237,7 @@ Example:
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';

View file

@ -395,6 +395,8 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
} catch (e) {
SecurityCustomisations.catchAccessSecretStorageError?.(e);
console.error(e);
// Re-throw so that higher level logic can abort as needed
throw e;
} finally {
// Clear secret storage key cache now that work is complete
secretStorageBeingAccessed = false;

View file

@ -20,6 +20,7 @@ limitations under the License.
import * as React from 'react';
import { ContentHelpers } from 'matrix-js-sdk';
import {MatrixClientPeg} from './MatrixClientPeg';
import dis from './dispatcher/dispatcher';
import * as sdk from './index';
@ -126,10 +127,10 @@ export class Command {
return this.getCommand() + " " + this.args;
}
run(roomId: string, args: string, cmd: string) {
run(roomId: string, args: string) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) return reject(_t("Command error"));
return this.runFn.bind(this)(roomId, args, cmd);
return this.runFn.bind(this)(roomId, args);
}
getUsage() {
@ -154,6 +155,18 @@ function success(promise?: Promise<any>) {
*/
export const Commands = [
new Command({
command: 'spoiler',
args: '<message>',
description: _td('Sends the given message as a spoiler'),
runFn: function(roomId, message) {
return success(ContentHelpers.makeHtmlMessage(
message,
`<span data-mx-spoiler>${message}</span>`,
));
},
category: CommandCategories.messages,
}),
new Command({
command: 'shrug',
args: '<message>',
@ -163,7 +176,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -176,7 +189,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -189,7 +202,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -202,7 +215,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
return success(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -211,7 +224,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendTextMessage(roomId, messages));
return success(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
@ -220,7 +233,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, messages, messages));
return success(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
@ -965,7 +978,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -975,7 +988,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -1200,10 +1213,13 @@ export function parseCommandString(input: string) {
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function getCommand(roomId: string, input: string) {
export function getCommand(input: string) {
const {cmd, args} = parseCommandString(input);
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
return () => CommandMap.get(cmd).run(roomId, args, cmd);
return {
cmd: CommandMap.get(cmd),
args,
};
}
}

View file

@ -19,7 +19,7 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption';
import * as sdk from '../../../../index';
import { _t } from '../../../../languageHandler';

View file

@ -23,7 +23,6 @@ interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
// whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true
fuzzy?: boolean;
}
@ -56,12 +55,6 @@ export default class QueryMatcher<T extends Object> {
if (this._options.shouldMatchWordsOnly === undefined) {
this._options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this._options.shouldMatchPrefix === undefined) {
this._options.shouldMatchPrefix = false;
}
}
setObjects(objects: T[]) {
@ -112,7 +105,7 @@ export default class QueryMatcher<T extends Object> {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
if (index !== -1) {
matches.push(
...candidates.map((candidate) => ({index, ...candidate})),
);

View file

@ -56,7 +56,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new QueryMatcher([], {
keys: ['name'],
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchPrefix: true,
shouldMatchWordsOnly: false,
});

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {Filter} from 'matrix-js-sdk';
import {Filter} from 'matrix-js-sdk/src/filter';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";

View file

@ -35,7 +35,7 @@ import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk";
import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {InteractiveAuth} from "matrix-js-sdk";
import {InteractiveAuth} from "matrix-js-sdk/src/interactive-auth";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';

View file

@ -16,9 +16,11 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
@ -32,15 +34,15 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
interface IProps {
isMinimized: boolean;
@ -50,6 +52,7 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showGroupFilterPanel: boolean;
activeSpace?: Room;
}
// List of CSS classes which should be included in keyboard navigation within the room list
@ -75,11 +78,13 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
activeSpace: SpaceStore.instance.activeSpace,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
@ -97,9 +102,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
}
private updateActiveSpace = (activeSpace: Room) => {
this.setState({ activeSpace });
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
@ -287,17 +297,18 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
ev.stopPropagation();
ev.preventDefault();
this.onMoveFocus(ev.key === Key.ARROW_UP);
this.onMoveFocus(action === RoomListAction.PrevRoom);
break;
}
};
private onEnter = () => {
private selectRoom = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
@ -378,11 +389,13 @@ export default class LeftPanel extends React.Component<IProps, IState> {
>
<RoomSearch
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter}
onKeyDown={this.onKeyDown}
onSelectRoom={this.selectRoom}
/>
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={_t("Explore rooms")}
/>
@ -392,11 +405,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
public render(): React.ReactNode {
let leftLeftPanel;
// Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now
// ignore it and force the rendering of SpacePanel if that Labs flag is enabled.
if (SettingsStore.getValue("feature_spaces")) {
leftLeftPanel = <SpacePanel />;
} else if (this.state.showGroupFilterPanel) {
if (this.state.showGroupFilterPanel) {
leftLeftPanel = (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
@ -412,6 +421,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
activeSpace={this.state.activeSpace}
/>;
const containerClasses = classNames({

View file

@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard';
import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
@ -55,7 +55,9 @@ import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
// We need to fetch each pinned message individually (if we don't already have it)
@ -73,7 +75,6 @@ function canElementReceiveInput(el) {
interface IProps {
matrixClient: MatrixClient;
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
viaServers?: string[];
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
// eslint-disable-next-line camelcase
@ -142,9 +143,6 @@ class LoggedInView extends React.Component<IProps, IState> {
// transitioned to PWLU)
onRegistered: PropTypes.func,
// Used by the RoomView to handle joining rooms
viaServers: PropTypes.arrayOf(PropTypes.string),
// and lots and lots of other stuff.
};
@ -229,21 +227,15 @@ class LoggedInView extends React.Component<IProps, IState> {
let size;
let collapsed;
const collapseConfig: ICollapseConfig = {
// TODO: the space panel currently does not have a fixed width,
// just the headers at each level have a max-width of 150px
// Taking 222px for the space panel for now,
// so this will look slightly off for now,
// depending on the depth of your space tree.
// To fix this, we'll need to turn toggleSize
// into a callback so it can be measured when starting the resize operation
toggleSize: 222 + 68,
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
toggleSize: 206 - 50,
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
dis.dispatch({action: "hide_left_panel"}, true);
dis.dispatch({action: "hide_left_panel"});
window.localStorage.setItem("mx_lhs_size", '0');
} else {
dis.dispatch({action: "show_left_panel"}, true);
dis.dispatch({action: "show_left_panel"});
}
},
onResized: (_size) => {
@ -445,86 +437,55 @@ class LoggedInView extends React.Component<IProps, IState> {
_onKeyDown = (ev) => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
const modKey = isMac ? ev.metaKey : ev.ctrlKey;
switch (ev.key) {
case Key.PAGE_UP:
case Key.PAGE_DOWN:
if (!hasModifier && !isModifier) {
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case RoomAction.ScrollUp:
case RoomAction.RoomScrollDown:
case RoomAction.JumpToFirstMessage:
case RoomAction.JumpToLatestMessage:
// pass the event down to the scroll panel
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case Key.HOME:
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case Key.K:
if (ctrlCmdOnly) {
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
}
break;
case Key.F:
if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
case RoomAction.FocusSearch:
dis.dispatch({
action: 'focus_search',
});
handled = true;
}
break;
case Key.BACKTICK:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
return;
}
if (ctrlCmdOnly) {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navAction) {
case NavigationAction.FocusRoomSearch:
dis.dispatch({
action: 'focus_room_filter',
});
handled = true;
break;
case NavigationAction.ToggleUserMenu:
dis.fire(Action.ToggleUserMenu);
handled = true;
}
break;
case Key.SLASH:
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev)) {
case NavigationAction.ToggleShortCutDialog:
KeyboardShortcuts.toggleDialog();
handled = true;
}
break;
case Key.H:
if (ev.altKey && modKey) {
case NavigationAction.GoToHome:
dis.dispatch({
action: 'view_home_page',
});
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
}
break;
case Key.ARROW_UP:
case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey,
});
handled = true;
}
break;
case Key.PERIOD:
if (ctrlCmdOnly && (this.props.page_type === "room_view" || this.props.page_type === "group_view")) {
case NavigationAction.ToggleRoomSidePanel:
if (this.props.page_type === "room_view" || this.props.page_type === "group_view") {
dis.dispatch<ToggleRightPanelPayload>({
action: Action.ToggleRightPanel,
type: this.props.page_type === "room_view" ? "room" : "group",
@ -532,16 +493,48 @@ class LoggedInView extends React.Component<IProps, IState> {
handled = true;
}
break;
case NavigationAction.SelectPrevRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: -1,
unread: false,
});
handled = true;
break;
case NavigationAction.SelectNextRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: 1,
unread: false,
});
handled = true;
break;
case NavigationAction.SelectPrevUnreadRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: -1,
unread: true,
});
break;
case NavigationAction.SelectNextUnreadRoom:
dis.dispatch<ViewRoomDeltaPayload>({
action: Action.ViewRoomDelta,
delta: 1,
unread: true,
});
break;
default:
// if we do not have a handler for it, pass it to the platform which might
handled = PlatformPeg.get().onKeyDown(ev);
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
} else if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
return;
}
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
@ -630,11 +623,9 @@ class LoggedInView extends React.Component<IProps, IState> {
case PageTypes.RoomView:
pageElement = <RoomView
ref={this._roomView}
autoJoin={this.props.autoJoin}
onRegistered={this.props.onRegistered}
threepidInvite={this.props.threepidInvite}
oobData={this.props.roomOobData}
viaServers={this.props.viaServers}
key={this.props.currentRoomId || 'roomview'}
resizeNotifier={this.props.resizeNotifier}
justCreatedOpts={this.props.roomJustCreatedOpts}
@ -670,13 +661,6 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const leftPanel = (
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -688,7 +672,11 @@ class LoggedInView extends React.Component<IProps, IState> {
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
{ leftPanel }
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>

View file

@ -1,8 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017-2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2021 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.
@ -18,8 +15,7 @@ limitations under the License.
*/
import React, { createRef } from 'react';
// @ts-ignore - XXX: no idea why this import fails
import * as Matrix from "matrix-js-sdk";
import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -82,9 +78,12 @@ import {UIFeature} from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import { shouldUseLoginForWelcome } from "../../utils/pages";
import SpaceStore from "../../stores/SpaceStore";
import SpaceRoomDirectory from "./SpaceRoomDirectory";
import {replaceableComponent} from "../../utils/replaceableComponent";
import RoomListStore from "../../stores/room-list/RoomListStore";
import {RoomUpdateCause} from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
/** constants for MatrixChat.state.view */
export enum Views {
@ -203,7 +202,6 @@ interface IState {
ready: boolean;
threepidInvite?: IThreepidInvite,
roomOobData?: object;
viaServers?: string[];
pendingInitialSync?: boolean;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
@ -582,6 +580,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
dis.dispatch({action: "hangup_all"});
Lifecycle.logout();
break;
case 'require_registration':
@ -606,12 +605,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (payload.screenAfterLogin) {
this.screenAfterLogin = payload.screenAfterLogin;
}
this.setStateForNewView({
view: Views.LOGIN,
});
this.notifyNewScreen('login');
ThemeController.isLogin = true;
this.themeWatcher.recheck();
this.viewLogin();
break;
case 'start_password_recovery':
this.setStateForNewView({
@ -696,10 +690,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewRoomDirectory: {
if (SpaceStore.instance.activeSpace) {
Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, {
space: SpaceStore.instance.activeSpace,
initialText: payload.initialText,
}, "mx_SpaceRoomDirectory_dialogWrapper", false, true);
defaultDispatcher.dispatch({
action: "view_room",
room_id: SpaceStore.instance.activeSpace.roomId,
});
} else {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
@ -934,7 +928,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
page_type: PageTypes.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
viaServers: roomInfo.via_servers,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
@ -975,6 +968,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private viewWelcome() {
if (shouldUseLoginForWelcome(SdkConfig.get())) {
return this.viewLogin();
}
this.setStateForNewView({
view: Views.WELCOME,
});
@ -983,6 +979,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.themeWatcher.recheck();
}
private viewLogin(otherState?: any) {
this.setStateForNewView({
view: Views.LOGIN,
...otherState,
});
this.notifyNewScreen('login');
ThemeController.isLogin = true;
this.themeWatcher.recheck();
}
private viewHome(justRegistered = false) {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
@ -1139,11 +1145,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private forgetRoom(roomId: string) {
const room = MatrixClientPeg.get().getRoom(roomId);
MatrixClientPeg.get().forget(roomId).then(() => {
// Switch to home page if we're currently viewing the forgotten room
if (this.state.currentRoomId === roomId) {
dis.dispatch({ action: "view_home_page" });
}
// We have to manually update the room list because the forgotten room will not
// be notified to us, therefore the room list will have no other way of knowing
// the room is forgotten.
RoomListStore.instance.manualRoomUpdate(room, RoomUpdateCause.RoomRemoved);
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
@ -1298,17 +1310,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* Called when the session is logged out
*/
private onLoggedOut() {
this.notifyNewScreen('login');
this.setStateForNewView({
view: Views.LOGIN,
this.viewLogin({
ready: false,
collapseLhs: false,
currentRoomId: null,
});
this.subTitleStatus = '';
this.setPageSubtitle();
ThemeController.isLogin = true;
this.themeWatcher.recheck();
}
/**
@ -1546,7 +1554,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else if (request.pending) {
ToastStore.sharedInstance().addOrReplaceToast({
key: 'verifreq_' + request.channel.transactionId,
title: request.isSelfVerification ? _t("Self-verification request") : _t("Verification Request"),
title: _t("Verification requested"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
@ -1648,7 +1656,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let cli = MatrixClientPeg.get();
if (!cli) {
const {hsUrl, isUrl} = this.props.serverConfig;
cli = Matrix.createClient({
cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});

View file

@ -23,7 +23,6 @@ import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index';
import dis from "../../dispatcher/dispatcher";
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
@ -47,6 +46,9 @@ function shouldFormContinuation(prevEvent, mxEvent) {
// check if within the max continuation period
if (mxEvent.getTs() - prevEvent.getTs() > CONTINUATION_MAX_INTERVAL) return false;
// As we summarise redactions, do not continue a redacted event onto a non-redacted one and vice-versa
if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false;
// Some events should appear as continuations from previous events of different types.
if (mxEvent.getType() !== prevEvent.getType() &&
(!continuedTypes.includes(mxEvent.getType()) ||
@ -210,13 +212,11 @@ export default class MessagePanel extends React.Component {
componentDidMount() {
this._isMounted = true;
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
this._isMounted = false;
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
dis.unregister(this.dispatcherRef);
}
componentDidUpdate(prevProps, prevState) {
@ -229,14 +229,6 @@ export default class MessagePanel extends React.Component {
}
}
onAction = (payload) => {
switch (payload.action) {
case "scroll_to_bottom":
this.scrollToBottom();
break;
}
}
onShowTypingNotificationsChange = () => {
this.setState({
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@ -463,6 +455,20 @@ export default class MessagePanel extends React.Component {
});
};
_getNextEventInfo(arr, i) {
const nextEvent = i < arr.length - 1
? arr[i + 1]
: null;
// The next event with tile is used to to determine the 'last successful' flag
// when rendering the tile. The shouldShowEvent function is pretty quick at what
// it does, so this should have no significant cost even when a room is used for
// not-chat purposes.
const nextTile = arr.slice(i + 1).find(e => this._shouldShowEvent(e));
return {nextEvent, nextTile};
}
_getEventTiles() {
this.eventNodes = {};
@ -514,6 +520,7 @@ export default class MessagePanel extends React.Component {
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
const {nextEvent, nextTile} = this._getNextEventInfo(this.props.events, i);
if (grouper) {
if (grouper.shouldGroup(mxEv)) {
@ -530,22 +537,12 @@ export default class MessagePanel extends React.Component {
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent);
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
}
}
if (!grouper) {
const wantTile = this._shouldShowEvent(mxEv);
if (wantTile) {
const nextEvent = i < this.props.events.length - 1
? this.props.events[i + 1]
: null;
// The next event with tile is used to to determine the 'last successful' flag
// when rendering the tile. The shouldShowEvent function is pretty quick at what
// it does, so this should have no significant cost even when a room is used for
// not-chat purposes.
const nextTile = this.props.events.slice(i + 1).find(e => this._shouldShowEvent(e));
// make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up
// replacing all of the DOM elements every time we paginate.
@ -1038,6 +1035,103 @@ class CreationGrouper {
}
}
class RedactionGrouper {
static canStartGroup = function(panel, ev) {
return panel._shouldShowEvent(ev) && ev.isRedacted();
}
constructor(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile) {
this.panel = panel;
this.readMarker = panel._readMarkerForEvent(
ev.getId(),
ev === lastShownEvent,
);
this.events = [ev];
this.prevEvent = prevEvent;
this.lastShownEvent = lastShownEvent;
this.nextEvent = nextEvent;
this.nextEventTile = nextEventTile;
}
shouldGroup(ev) {
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped
if (!this.panel._shouldShowEvent(ev)) {
return true;
}
if (this.panel._wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return ev.isRedacted();
}
add(ev) {
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
ev.getId(),
ev === this.lastShownEvent,
);
if (!this.panel._shouldShowEvent(ev)) {
return;
}
this.events.push(ev);
}
getTiles() {
if (!this.events || !this.events.length) return [];
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const panel = this.panel;
const ret = [];
const lastShownEvent = this.lastShownEvent;
if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
const ts = this.events[0].getTs();
ret.push(
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
);
}
const key = "redactioneventlistsummary-" + (
this.prevEvent ? this.events[0].getId() : "initial"
);
const senders = new Set();
let eventTiles = this.events.map((e, i) => {
senders.add(e.sender);
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
eventTiles = null;
}
ret.push(
<EventListSummary
key={key}
threshold={2}
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={Array.from(senders)}
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
>
{ eventTiles }
</EventListSummary>,
);
if (this.readMarker) {
ret.push(this.readMarker);
}
return ret;
}
getNewPrevEvent() {
return this.events[this.events.length - 1];
}
}
// Wrap consecutive member events in a ListSummary, ignore if redacted
class MemberGrouper {
static canStartGroup = function(panel, ev) {
@ -1148,4 +1242,4 @@ class MemberGrouper {
}
// all the grouper classes that we use
const groupers = [CreationGrouper, MemberGrouper];
const groupers = [CreationGrouper, MemberGrouper, RedactionGrouper];

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 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.
@ -20,17 +20,21 @@ import classNames from "classnames";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
interface IProps {
isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent): void;
onEnter(ev: React.KeyboardEvent): boolean;
onKeyDown(ev: React.KeyboardEvent): void;
/**
* @returns true if a room has been selected and the search field should be cleared
*/
onSelectRoom(): boolean;
}
interface IState {
@ -53,6 +57,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -72,6 +78,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
}
private onAction = (payload: ActionPayload) => {
@ -108,19 +115,27 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ESCAPE) {
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case RoomListAction.ClearSearch:
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} else if (ev.key === Key.ENTER) {
const shouldClear = this.props.onEnter(ev);
break;
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
// we don't handle these actions here put pass the event on to the interested party (LeftPanel)
this.props.onKeyDown(ev);
break;
case RoomListAction.SelectRoom: {
const shouldClear = this.props.onSelectRoom();
if (shouldClear) {
// wrap in set immediate to delay it so that we don't clear the filter & then change room
setImmediate(() => {
this.clearInput();
});
}
break;
}
}
};

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend';
@ -24,6 +23,7 @@ import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {EventStatus} from "matrix-js-sdk/src/models/event";
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -32,7 +32,7 @@ const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
return ev.status === EventStatus.NOT_SENT;
});
}

View file

@ -40,7 +40,6 @@ import Tinter from '../../Tinter';
import rateLimitedFunc from '../../ratelimitedfunc';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
@ -79,6 +78,7 @@ import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
@ -112,10 +112,6 @@ interface IProps {
inviterName?: string;
};
// Servers the RoomView can use to try and assist joins
viaServers?: string[];
autoJoin?: boolean;
resizeNotifier: ResizeNotifier;
justCreatedOpts?: IOpts;
@ -450,9 +446,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// now not joined because the js-sdk peeking API will clobber our historical room,
// making it impossible to indicate a newly joined room.
if (!joining && roomId) {
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (!room && shouldPeek) {
if (!room && shouldPeek) {
console.info("Attempting to peek into room %s", roomId);
this.setState({
peekLoading: true,
@ -668,26 +662,20 @@ export default class RoomView extends React.Component<IProps, IState> {
private onReactKeyDown = ev => {
let handled = false;
switch (ev.key) {
case Key.ESCAPE:
if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) {
const action = getKeyBindingsManager().getRoomAction(ev);
switch (action) {
case RoomAction.DismissReadMarker:
this.messagePanel.forgetReadMarker();
this.jumpToLiveTimeline();
handled = true;
}
break;
case Key.PAGE_UP:
if (!ev.altKey && !ev.ctrlKey && ev.shiftKey && !ev.metaKey) {
case RoomAction.JumpToOldestUnread:
this.jumpToReadMarker();
handled = true;
}
break;
case Key.U: // Mac returns lowercase
case Key.U.toUpperCase():
if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) {
case RoomAction.UploadFile:
dis.dispatch({ action: "upload_file" }, true);
handled = true;
}
break;
}
@ -1123,7 +1111,7 @@ export default class RoomView extends React.Component<IProps, IState> {
const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({
action: 'join_room',
opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers },
opts: { inviteSignUrl: signUrl },
_type: "unknown", // TODO: instrumentation
});
return Promise.resolve();

View file

@ -16,10 +16,10 @@ limitations under the License.
import React, {createRef} from "react";
import PropTypes from 'prop-types';
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager";
const DEBUG_SCROLL = false;
@ -535,29 +535,19 @@ export default class ScrollPanel extends React.Component {
* @param {object} ev the keyboard event
*/
handleScrollKey = ev => {
switch (ev.key) {
case Key.PAGE_UP:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case RoomAction.ScrollUp:
this.scrollRelative(-1);
}
break;
case Key.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
case RoomAction.RoomScrollDown:
this.scrollRelative(1);
}
break;
case Key.HOME:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
case RoomAction.JumpToFirstMessage:
this.scrollToTop();
}
break;
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
case RoomAction.JumpToLatestMessage:
this.scrollToBottom();
}
break;
}
};

View file

@ -32,6 +32,8 @@ export default class SearchBox extends React.Component {
onKeyDown: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
autoFocus: PropTypes.bool,
initialValue: PropTypes.string,
// If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take
@ -49,7 +51,7 @@ export default class SearchBox extends React.Component {
this._search = createRef();
this.state = {
searchTerm: "",
searchTerm: this.props.initialValue || "",
blurred: true,
};
}
@ -158,6 +160,7 @@ export default class SearchBox extends React.Component {
onBlur={this._onBlur}
placeholder={ placeholder }
autoComplete="off"
autoFocus={this.props.autoFocus}
/>
{ clearButton }
</div>

View file

@ -14,32 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useRef, useState} from "react";
import Room from "matrix-js-sdk/src/models/room";
import MatrixEvent from "matrix-js-sdk/src/models/event";
import React, {useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import classNames from "classnames";
import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import FormButton from "../views/elements/FormButton";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {shouldShowSpaceSettings} from "../../utils/space";
import {EnhancedMap} from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
interface IProps {
interface IHierarchyProps {
space: Room;
initialText?: string;
onFinished(): void;
refreshToken?: any;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
/* eslint-disable camelcase */
@ -72,227 +77,131 @@ export interface ISpaceSummaryEvent {
}
/* eslint-enable camelcase */
interface ISubspaceProps {
space: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick?(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
interface ITileProps {
room: ISpaceSummaryRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const SubSpace: React.FC<ISubspaceProps> = ({
space,
editing,
event,
queueAction,
onJoinClick,
onPreviewClick,
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space");
const name = room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const evContent = event?.getContent();
const [suggested, _setSuggested] = useState(evContent?.suggested);
const [removed, _setRemoved] = useState(!evContent?.via);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(space.room_id);
const myMembership = cliRoom?.getMyMembership();
// TODO DRY code
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setSuggested = () => {
_setSuggested(v => {
queueAction({
event,
removed,
suggested: !v,
});
return !v;
});
};
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
suggested,
});
return !v;
});
};
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={suggested} onChange={setSuggested} />
</React.Fragment>;
}
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this space")}
</span>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
}
}
let url: string;
if (space.avatar_url) {
url = mediaFromMxc(space.avatar_url).getSquareThumbnailHttp(Math.floor(24 * window.devicePixelRatio));
}
return <div className="mx_SpaceRoomDirectory_subspace">
<div className="mx_SpaceRoomDirectory_subspace_info">
<BaseAvatar name={name} idName={space.room_id} url={url} width={24} height={24} />
{ name }
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
</div>
</div>
<div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>
</div>
};
interface IAction {
event: MatrixEvent;
suggested: boolean;
removed: boolean;
}
interface IRoomTileProps {
room: ISpaceSummaryRoom;
event?: MatrixEvent;
editing?: boolean;
onPreviewClick(): void;
queueAction?(action: IAction): void;
onJoinClick?(): void;
}
const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room");
const evContent = event?.getContent();
const [suggested, _setSuggested] = useState(evContent?.suggested);
const [removed, _setRemoved] = useState(!evContent?.via);
const [showChildren, toggleShowChildren] = useStateToggle(true);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
let actions;
if (editing && queueAction) {
if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) {
const setSuggested = () => {
_setSuggested(v => {
queueAction({
event,
removed,
suggested: !v,
});
return !v;
});
};
const onPreviewClick = () => onViewRoomClick(false);
const onJoinClick = () => onViewRoomClick(true);
const setRemoved = () => {
_setRemoved(v => {
queueAction({
event,
removed: !v,
suggested,
});
return !v;
});
};
if (removed) {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Undo")} />
</React.Fragment>;
} else {
actions = <React.Fragment>
<FormButton kind="danger" onClick={setRemoved} label={_t("Remove from Space")} />
<StyledCheckbox checked={suggested} onChange={setSuggested} />
</React.Fragment>;
}
} else {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("No permissions")}
</span>;
}
// TODO confirm remove from space click behaviour here
} else {
let button;
if (myMembership === "join") {
actions = <span className="mx_SpaceRoomDirectory_actionsText">
{ _t("You're in this room")}
</span>;
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
actions = <React.Fragment>
<AccessibleButton onClick={onPreviewClick} kind="link">
{ _t("Preview") }
</AccessibleButton>
<FormButton onClick={onJoinClick} label={_t("Join")} />
</React.Fragment>
button = <AccessibleButton onClick={onJoinClick} kind="primary">
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation() }}
>
<StyledCheckbox disabled={true} />
</TextWithTooltip>;
}
}
let url: string;
if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(32 * window.devicePixelRatio));
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio));
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
if (room.topic) {
description += " · " + room.topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={32} height={32} />
<div className="mx_SpaceRoomDirectory_roomTile_info">
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_topic">
{ room.topic }
</div>
</div>
<div className="mx_SpaceRoomDirectory_roomTile_memberCount">
{ room.num_joined_members }
{ suggestedSection }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_info">
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ actions }
{ button }
{ checkbox }
</div>
</React.Fragment>;
if (editing) {
return <div className="mx_SpaceRoomDirectory_roomTile">
{ content }
</div>
let childToggle;
let childSection;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>;
}
}
return <AccessibleButton className="mx_SpaceRoomDirectory_roomTile" onClick={onPreviewClick}>
return <>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
>
{ content }
</AccessibleButton>;
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
@ -325,99 +234,337 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi
interface IHierarchyLevelProps {
spaceId: string;
rooms: Map<string, ISpaceSummaryRoom>;
editing?: boolean;
relations: EnhancedMap<string, string[]>;
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
parents: Set<string>;
queueAction?(action: IAction): void;
onPreviewClick(roomId: string): void;
onRemoveFromSpaceClick?(roomId: string): void;
onJoinClick?(roomId: string): void;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
editing,
relations,
parents,
onPreviewClick,
onJoinClick,
queueAction,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
const space = cli.getRoom(spaceId);
// TODO respect order
const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => {
if (!rooms.has(roomId)) return result; // TODO wat
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null);
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
return result;
}, [[], []]) || [[], []];
// Don't render this subspace if it has no rooms we can show
// TODO this is broken - as a space may have subspaces we still need to show
// if (!childRooms.length) return null;
const userId = cli.getUserId();
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<RoomTile
<Tile
key={roomId}
room={rooms.get(roomId)}
event={space?.currentState.maySendStateEvent(EventType.SpaceChild, userId)
? space?.currentState.getStateEvents(EventType.SpaceChild, roomId)
: undefined}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
onJoinClick={onJoinClick ? () => {
onJoinClick(roomId);
} : undefined}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<SubSpace
<Tile
key={roomId}
space={rooms.get(roomId)}
event={space?.currentState.getStateEvents(EventType.SpaceChild, roomId)}
editing={editing}
queueAction={queueAction}
onPreviewClick={() => {
onPreviewClick(roomId);
}}
onJoinClick={() => {
onJoinClick(roomId);
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
>
<HierarchyLevel
spaceId={roomId}
rooms={rooms}
editing={editing}
relations={relations}
parents={newParents}
onPreviewClick={onPreviewClick}
onJoinClick={onJoinClick}
queueAction={queueAction}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</SubSpace>
</Tile>
))
}
</React.Fragment>
};
const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinished }) => {
// mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>,
Map<string, Set<string>>,
Map<string, Set<string>>,
] | [] => {
// TODO pagination
const cli = MatrixClientPeg.get();
const [query, setQuery] = useState(initialText);
const [isEditing, setIsEditing] = useState(false);
return useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
const childParentRelations = new EnhancedMap<string, Set<string>>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
}
if (Array.isArray(ev.content["via"])) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
}
});
return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
} catch (e) {
console.error(e); // TODO
}
return [];
}, [space, refreshToken], []);
};
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space,
initialText = "",
showRoom,
refreshToken,
children,
}) => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase().trim();
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
if (!lcQuery) return roomsMap;
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
childParentMap.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
// Remove any mappings for rooms which were not visited in the walk
Array.from(roomsMap.keys()).forEach(roomId => {
if (!visited.has(roomId)) {
roomsMap.delete(roomId);
}
});
return roomsMap;
}, [rooms, childParentMap, query]);
const [error, setError] = useState("");
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let editSection;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
let buttons;
if (selectedRelations.length) {
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = removing || saving;
buttons = <>
<AccessibleButton
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</AccessibleButton>
<AccessibleButton
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</AccessibleButton>
</>;
}
editSection = <span>
{ buttons }
</span>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
{ editSection }
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else if (!rooms) {
content = <Spinner />;
} else {
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
// TODO loading state/error state
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Search names and description") }
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
{ content }
</>;
};
interface IProps {
space: Room;
initialText?: string;
onFinished(): void;
}
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
const onCreateRoomClick = () => {
dis.dispatch({
action: 'view_create_room',
@ -426,134 +573,40 @@ const SpaceRoomDirectory: React.FC<IProps> = ({ space, initialText = "", onFinis
onFinished();
};
// stored within a ref as we don't need to re-render when it changes
const pendingActions = useRef(new Map<string, IAction>());
let adminButton;
if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test
const onManageButtonClicked = () => {
setIsEditing(true);
};
const onSaveButtonClicked = () => {
// TODO setBusy
pendingActions.current.forEach(({event, suggested, removed}) => {
const content = {
...event.getContent(),
suggested,
};
if (removed) {
delete content["via"];
}
cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey());
});
setIsEditing(false);
};
if (isEditing) {
adminButton = <React.Fragment>
<FormButton label={_t("Save changes")} onClick={onSaveButtonClicked} />
<span>{ _t("Promoted to users") }</span>
</React.Fragment>;
} else {
adminButton = <FormButton label={_t("Manage rooms")} onClick={onManageButtonClicked} />;
}
}
const [rooms, relations, viaMap] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, string[]>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
if (Array.isArray(ev.content["via"])) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
}
});
return [data.rooms, parentChildRelations, viaMap];
} catch (e) {
console.error(e); // TODO
}
return [];
}, [space], []);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase();
const filteredRooms = rooms.filter(r => {
return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms
|| r.name?.toLowerCase().includes(lcQuery)
|| r.topic?.toLowerCase().includes(lcQuery);
});
return new Map<string, ISpaceSummaryRoom>(filteredRooms.map(r => [r.room_id, r]));
// const root = rooms.get(space.roomId);
}, [rooms, query]);
const title = <React.Fragment>
<RoomAvatar room={space} height={40} width={40} />
<RoomAvatar room={space} height={32} width={32} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => {
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
}},
);
let content;
if (roomsMap) {
content = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={isEditing}
relations={relations}
parents={new Set()}
queueAction={action => {
pendingActions.current.set(action.event.room_id, action);
}}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false);
onFinished();
}}
onJoinClick={(roomId) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true);
onFinished();
}}
/>
</AutoHideScrollbar>;
}
// TODO loading state/error state
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ explanation }
{ _t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
null,
{a: sub => {
return <AccessibleButton kind="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
}},
) }
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Find a room...") }
onSearch={setQuery}
/>
<div className="mx_SpaceRoomDirectory_listHeader">
{ adminButton }
</div>
{ content }
<SpaceHierarchy
space={space}
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
showRoom(room, viaServers, autoJoin);
onFinished();
}}
initialText={initialText}
>
<AccessibleButton
onClick={onCreateRoomClick}
kind="primary"
className="mx_SpaceRoomDirectory_createRoom"
>
{ _t("Create room") }
</AccessibleButton>
</SpaceHierarchy>
</div>
</BaseDialog>
);

View file

@ -15,8 +15,9 @@ limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {EventSubscription} from "fbemitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomAvatar from "../views/avatars/RoomAvatar";
@ -25,13 +26,11 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import FormButton from "../views/elements/FormButton";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom";
import Field from "../views/elements/Field";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import StyledRadioGroup from "../views/elements/StyledRadioGroup";
import withValidation from "../views/elements/Validation";
import * as Email from "../../email";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -42,18 +41,16 @@ import ErrorBoundary from "../views/elements/ErrorBoundary";
import {ActionPayload} from "../../dispatcher/payloads";
import RightPanel from "./RightPanel";
import RightPanelStore from "../../stores/RightPanelStore";
import {EventSubscription} from "fbemitter";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import {useStateArray} from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory";
import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
interface IProps {
space: Room;
@ -66,6 +63,7 @@ interface IProps {
interface IState {
phase: Phase;
showRightPanel: boolean;
myMembership: string;
}
enum Phase {
@ -94,10 +92,47 @@ const useMyRoomMembership = (room: Room) => {
return membership;
};
const SpaceInfo = ({ space }) => {
const joinRule = space.getJoinRule();
let visibilitySection;
if (joinRule === "public") {
visibilitySection = <span className="mx_SpaceRoomView_info_public">
{ _t("Public space") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_info_private">
{ _t("Private space") }
</span>;
}
return <div className="mx_SpaceRoomView_info">
{ visibilitySection }
{ joinRule === "public" && <RoomMemberCount room={space}>
{(count) => count > 0 ? (
<AccessibleButton
kind="link"
onClick={() => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
</RoomMemberCount> }
</div>
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const [busy, setBusy] = useState(false);
let inviterSection;
let joinButtons;
if (myMembership === "invite") {
@ -121,22 +156,41 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
}
joinButtons = <>
<FormButton label={_t("Reject")} kind="secondary" onClick={onRejectButtonClicked} />
<FormButton label={_t("Accept")} onClick={onJoinButtonClicked} />
<AccessibleButton
kind="secondary"
onClick={() => {
setBusy(true);
onRejectButtonClicked();
}}
>
{ _t("Reject") }
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Accept") }
</AccessibleButton>
</>;
} else {
joinButtons = <FormButton label={_t("Join")} onClick={onJoinButtonClicked} />
joinButtons = (
<AccessibleButton
kind="primary"
onClick={() => {
setBusy(true);
onJoinButtonClicked();
}}
>
{ _t("Join") }
</AccessibleButton>
)
}
let visibilitySection;
if (space.getJoinRule() === "public") {
visibilitySection = <span className="mx_SpaceRoomView_preview_info_public">
{ _t("Public space") }
</span>;
} else {
visibilitySection = <span className="mx_SpaceRoomView_preview_info_private">
{ _t("Private space") }
</span>;
if (busy) {
joinButtons = <InlineSpinner />;
}
return <div className="mx_SpaceRoomView_preview">
@ -145,26 +199,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<h1 className="mx_SpaceRoomView_preview_name">
<RoomName room={space} />
</h1>
<div className="mx_SpaceRoomView_preview_info">
{ visibilitySection }
<RoomMemberCount room={space}>
{(count) => count > 0 ? (
<AccessibleButton
className="mx_SpaceRoomView_preview_memberCount"
kind="link"
onClick={() => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
</RoomMemberCount>
</div>
<SpaceInfo space={space} />
<RoomTopic room={space}>
{(topic, ref) =>
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
@ -172,6 +207,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>
}
</RoomTopic>
{ space.getJoinRule() === "public" && <FacePile room={space} /> }
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
@ -186,17 +222,21 @@ const SpaceLanding = ({ space }) => {
let inviteButton;
if (myMembership === "join" && space.canInvite(userId)) {
inviteButton = (
<AccessibleButton className="mx_SpaceRoomView_landing_inviteButton" onClick={() => {
<AccessibleButton
kind="primary"
className="mx_SpaceRoomView_landing_inviteButton"
onClick={() => {
showRoomInviteDialog(space.roomId);
}}>
{ _t("Invite people") }
}}
>
{ _t("Invite") }
</AccessibleButton>
);
}
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [_, forceUpdate] = useStateToggle(false); // TODO
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons;
if (canAddRooms) {
@ -226,50 +266,13 @@ const SpaceLanding = ({ space }) => {
</AccessibleButton>;
}
const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join");
const parentChildRelations = new EnhancedMap<string, string[]>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key);
}
const onMembersClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
const roomsMap = new Map<string, ISpaceSummaryRoom>(data.rooms.map(r => [r.room_id, r]));
const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length;
return [false, roomsMap, parentChildRelations, numRooms];
} catch (e) {
console.error(e); // TODO
}
return [false];
}, [space, _], [true]);
let previewRooms;
if (roomsMap) {
previewRooms = <AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<div className="mx_SpaceRoomDirectory_roomCount">
<h3>{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}</h3>
<span>{ numRooms }</span>
</div>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
editing={false}
relations={relations}
parents={new Set()}
onPreviewClick={roomId => {
showRoom(roomsMap.get(roomId), [], false); // TODO
}}
/>
</AutoHideScrollbar>;
} else if (loading) {
previewRooms = <InlineSpinner />;
} else {
previewRooms = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
};
return <div className="mx_SpaceRoomView_landing">
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
@ -278,45 +281,26 @@ const SpaceLanding = ({ space }) => {
{(name) => {
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
<h1>{ name }</h1>
<RoomMemberCount room={space}>
{(count) => count > 0 ? (
<AccessibleButton
className="mx_SpaceRoomView_landing_memberCount"
kind="link"
onClick={() => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomMemberList,
refireParams: { space },
});
}}
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
</RoomMemberCount>
</div> };
if (shouldShowSpaceSettings(cli, space)) {
if (space.getJoinRule() === "public") {
return _t("Your public space <name/>", {}, tags) as JSX.Element;
} else {
return _t("Your private space <name/>", {}, tags) as JSX.Element;
}
}
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
}}
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_info">
<SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
</div>
<div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} />
</div>
<hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ inviteButton }
{ addRoomButtons }
{ settingsButton }
</div>
{ previewRooms }
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
</div>;
};
@ -337,6 +321,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
placeholder={placeholders[i]}
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
/>;
});
@ -369,7 +354,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Next")
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue")
}
return <div>
@ -380,61 +365,55 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
{ fields }
<div className="mx_SpaceRoomView_buttons">
<FormButton
label={buttonLabel}
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
/>
>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share your public space") }</h1>
<div className="mx_SpacePublicShare_description">{ _t("At the moment only you can see it.") }</div>
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") }
</div>
<SpacePublicShare space={space} onFinished={onFinished} />
<SpacePublicShare space={space} />
<div className="mx_SpaceRoomView_buttons">
<FormButton label={_t("Finish")} onClick={onFinished} />
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Go to my first room") }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPrivateScope = ({ onFinished }) => {
const [option, setOption] = useState<string>(null);
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
</div>
<StyledRadioGroup
name="privateSpaceScope"
value={option}
onChange={setOption}
definitions={[
{
value: "justMe",
className: "mx_SpaceRoomView_privateScope_justMeButton",
label: <React.Fragment>
<h3>{ _t("Just Me") }</h3>
<div>{ _t("A private space just for you") }</div>
</React.Fragment>,
}, {
value: "meAndMyTeammates",
className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton",
label: <React.Fragment>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_justMeButton"
onClick={() => { onFinished(false) }}
>
<h3>{ _t("Just me") }</h3>
<div>{ _t("A private space to organise your rooms") }</div>
</AccessibleButton>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
onClick={() => { onFinished(true) }}
>
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</React.Fragment>,
},
]}
/>
<div className="mx_SpaceRoomView_buttons">
<FormButton label={_t("Next")} disabled={!option} onClick={() => onFinished(option !== "justMe")} />
</div>
</AccessibleButton>
</div>;
};
@ -464,6 +443,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
onChange={ev => setEmailAddress(i, ev.target.value)}
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
/>;
});
@ -501,9 +481,18 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Inviting...") : _t("Continue")
}
return <div className="mx_SpaceRoomView_inviteTeammates">
<h1>{ _t("Invite your teammates") }</h1>
<div className="mx_SpaceRoomView_description">{ _t("Ensure the right people have access to the space.") }</div>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access. You can invite more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
@ -518,8 +507,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton onClick={onFinished} kind="link">{_t("Skip for now")}</AccessibleButton>
<FormButton label={busy ? _t("Inviting...") : _t("Next")} disabled={busy} onClick={onNextClick} />
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
@ -547,17 +537,26 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
this.state = {
phase,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
myMembership: this.props.space.getMyMembership(),
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
this.context.on("Room.myMembership", this.onMyMembership);
}
componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
this.rightPanelStoreToken.remove();
this.context.off("Room.myMembership", this.onMyMembership);
}
private onMyMembership = (room: Room, myMembership: string) => {
if (room.roomId === this.props.space.roomId) {
this.setState({ myMembership });
}
};
private onRightPanelStoreUpdate = () => {
this.setState({
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
@ -594,10 +593,45 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
}
};
private goToFirstRoom = async () => {
// TODO actually go to the first room
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
if (childRooms.length) {
const room = childRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.roomId,
});
return;
}
let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms;
}
if (suggestedRooms.length) {
const room = suggestedRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
},
});
return;
}
this.setState({ phase: Phase.Landing });
};
private renderBody() {
switch (this.state.phase) {
case Phase.Landing:
if (this.props.space.getMyMembership() === "join") {
if (this.state.myMembership === "join") {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
@ -609,18 +643,21 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
case Phase.PublicCreateRooms:
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What are some things you want to discuss?")}
description={_t("We'll create rooms for each topic.")}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.space.name,
})}
description={
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={() => this.setState({ phase: Phase.PublicShare })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
case Phase.PrivateScope:
return <SpaceSetupPrivateScope
space={this.props.space}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
}}
@ -634,7 +671,8 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. You can add existing rooms after setup.")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
}

View file

@ -22,8 +22,8 @@ import {LayoutPropType} from "../../settings/Layout";
import React, {createRef} from 'react';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
import {EventTimeline} from "matrix-js-sdk";
import * as Matrix from "matrix-js-sdk";
import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline";
import {TimelineWindow} from "matrix-js-sdk/src/timeline-window";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import UserActivity from "../../UserActivity";
@ -463,6 +463,9 @@ class TimelinePanel extends React.Component {
}
});
}
if (payload.action === "scroll_to_bottom") {
this.jumpToLiveTimeline();
}
};
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
@ -1007,7 +1010,7 @@ class TimelinePanel extends React.Component {
* returns a promise which will resolve when the load completes.
*/
_loadTimeline(eventId, pixelOffset, offsetBase) {
this._timelineWindow = new Matrix.TimelineWindow(
this._timelineWindow = new TimelineWindow(
MatrixClientPeg.get(), this.props.timelineSet,
{windowLimit: this.props.timelineCap});

View file

@ -43,7 +43,11 @@ export default class UploadBar extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {uploadsHere: []};
// Set initial state to any available upload in this room - we might be mounting
// earlier than the first progress event, so should show something relevant.
const uploadsHere = this.getUploadsInRoom();
this.state = {currentUpload: uploadsHere[0], uploadsHere};
}
componentDidMount() {
@ -56,6 +60,11 @@ export default class UploadBar extends React.Component<IProps, IState> {
dis.unregister(this.dispatcherRef);
}
private getUploadsInRoom(): IUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
return uploads.filter(u => u.roomId === this.props.room.roomId);
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case Action.UploadStarted:
@ -64,8 +73,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
case Action.UploadCanceled:
case Action.UploadFailed: {
if (!this.mounted) return;
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
const uploadsHere = uploads.filter(u => u.roomId === this.props.room.roomId);
const uploadsHere = this.getUploadsInRoom();
this.setState({currentUpload: uploadsHere[0], uploadsHere});
break;
}

View file

@ -17,13 +17,14 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import Matrix from "matrix-js-sdk";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import HomePage from "./HomePage";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
@replaceableComponent("structures.UserView")
export default class UserView extends React.Component {
@ -68,8 +69,8 @@ export default class UserView extends React.Component {
this.setState({loading: false});
return;
}
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new Matrix.RoomMember(null, this.props.userId);
const fakeEvent = new MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
}

View file

@ -176,8 +176,8 @@ export default class ViewSource extends React.Component {
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
<div>
<div className="mx_ViewSource_label_left">Room ID: {roomId}</div>
<div className="mx_ViewSource_label_left">Event ID: {eventId}</div>
<div>Room ID: {roomId}</div>
<div>Event ID: {eventId}</div>
<div className="mx_ViewSource_separator" />
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
</div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import {createClient} from 'matrix-js-sdk/src/matrix';
import React, {ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -181,7 +181,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
const {hsUrl, isUrl} = serverConfig;
const cli = Matrix.createClient({
const cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});

View file

@ -155,15 +155,14 @@ export default class SetupEncryptionBody extends React.Component {
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this._onVerifyClick}>
{ _t("Verify with another session") }
{ _t("Use another login") }
</AccessibleButton>;
}
return (
<div>
<p>{_t(
"Verify this login to access your encrypted messages and " +
"prove to others that this login is really you.",
"Verify your identity to access encrypted messages and prove your identity to others.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
@ -205,8 +204,8 @@ export default class SetupEncryptionBody extends React.Component {
return (
<div>
<p>{_t(
"Without completing security on this session, it wont have " +
"access to encrypted messages.",
"Without verifying, you wont have access to all your messages " +
"and may appear as untrusted to others.",
)}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton

View file

@ -26,6 +26,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { _t } from '../../../languageHandler';
interface IProps {
name: string; // The name (first initial used as default)
@ -140,6 +141,7 @@ const BaseAvatar = (props: IProps) => {
if (onClick) {
return (
<AccessibleButton
aria-label={_t("Avatar")}
{...otherProps}
element="span"
className={classNames("mx_BaseAvatar", className)}

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import {Group} from 'matrix-js-sdk';
import {Group} from 'matrix-js-sdk/src/models/group';
import GroupStore from "../../../stores/GroupStore";
import {MenuItem} from "../../structures/ContextMenu";
import {replaceableComponent} from "../../../utils/replaceableComponent";

View file

@ -19,7 +19,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';

View file

@ -22,7 +22,6 @@ import {MatrixClient} from "matrix-js-sdk/src/client";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import FormButton from "../elements/FormButton";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
@ -69,6 +68,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
const existingRoomsSet = new Set(existingRooms);
const rooms = cli.getVisibleRooms().filter(room => {
return !existingRoomsSet.has(room) // not already in space
&& !room.isSpaceRoom() // not a space itself
&& room.name.toLowerCase().includes(lcQuery) // contains query
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
});
@ -109,7 +109,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
<div>
<h1>{ _t("Add existing spaces/rooms") }</h1>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
</div>
</React.Fragment>;
@ -127,29 +127,9 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
@ -171,6 +151,27 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
@ -184,8 +185,8 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</AccessibleButton>
</span>
<FormButton
label={busy ? _t("Applying...") : _t("Apply")}
<AccessibleButton
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
setBusy(true);
@ -199,7 +200,9 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
}
setBusy(false);
}}
/>
>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
</BaseDialog>;
};

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups';

View file

@ -19,7 +19,6 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import { Room, MatrixEvent } from "matrix-js-sdk";
import Field from "../elements/Field";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
@ -39,6 +38,8 @@ import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
class GenericEditor extends React.PureComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};

View file

@ -100,6 +100,20 @@ export default (props) => {
);
}
let bugReports = null;
if (SdkConfig.get().bug_report_endpoint_url) {
bugReports = (
<p>{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
),
})
}</p>
);
}
return (<QuestionDialog
className="mx_FeedbackDialog"
hasCancelButton={!!hasFeedback}
@ -120,14 +134,7 @@ export default (props) => {
},
})
}</p>
<p>{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
<AccessibleButton kind="link" onClick={onDebugLogsLinkClick}>{sub}</AccessibleButton>
),
})
}</p>
{bugReports}
</div>
{ countlyFeedbackSection }
</React.Fragment>}

View file

@ -29,7 +29,9 @@ import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
@ -43,6 +45,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -331,6 +334,7 @@ interface IInviteDialogState {
threepidResultsMixin: { user: Member, userId: string}[];
canUseIdentityServer: boolean;
tryingIdentityServer: boolean;
consultFirst: boolean;
// These two flags are used for the 'Go' button to communicate what is going on.
busy: boolean,
@ -379,6 +383,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
threepidResultsMixin: [],
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false,
consultFirst: false,
// These two flags are used for the 'Go' button to communicate what is going on.
busy: false,
@ -394,6 +399,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
}
private onConsultFirstChange = (ev) => {
this.setState({consultFirst: ev.target.checked});
}
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
@ -673,19 +682,20 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
console.error(err);
this.setState({
busy: false,
errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."),
errorText: _t("We couldn't create your DM."),
});
});
};
_inviteUsers = () => {
_inviteUsers = async () => {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true});
this._convertFilter();
const targets = this._convertFilter();
const targetIds = targets.map(t => t.userId);
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
if (!room) {
console.error("Failed to find the room to invite users to");
this.setState({
@ -695,12 +705,34 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return;
}
inviteMultipleToRoom(this.props.roomId, targetIds).then(result => {
try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished();
}
}).catch(err => {
if (cli.isRoomEncrypted(this.props.roomId) &&
SettingsStore.getValue("feature_room_history_key_sharing")) {
const visibilityEvent = room.currentState.getStateEvents(
"m.room.history_visibility", "",
);
const visibility = visibilityEvent && visibilityEvent.getContent() &&
visibilityEvent.getContent().history_visibility;
if (visibility == "world_readable" || visibility == "shared") {
const invitedUsers = [];
for (const [addr, state] of Object.entries(result.states)) {
if (state === "invited" && getAddressType(addr) === "mx-user-id") {
invitedUsers.push(addr);
}
}
console.log("Sharing history with", invitedUsers);
cli.sendSharedHistoryKeys(
this.props.roomId, invitedUsers,
);
}
}
} catch (err) {
console.error(err);
this.setState({
busy: false,
@ -708,7 +740,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
"We couldn't invite those users. Please check the users you want to invite and try again.",
),
});
});
}
};
_transferCall = async () => {
@ -721,6 +753,23 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
});
}
if (this.state.consultFirst) {
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]);
dis.dispatch({
action: 'place_call',
type: this.props.call.type,
room_id: dmRoomId,
transferee: this.props.call,
});
dis.dispatch({
action: 'view_room',
room_id: dmRoomId,
should_peek: false,
joining: false,
});
this.props.onFinished();
} else {
this.setState({busy: true});
try {
await this.props.call.transfer(targetIds[0]);
@ -732,6 +781,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
errorText: _t("Failed to transfer call"),
});
}
}
};
_onKeyDown = (e) => {
@ -886,6 +936,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
};
_toggleMember = (member: Member) => {
if (!this.state.busy) {
let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
@ -900,6 +951,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
}
}
};
_removeMember = (member: Member) => {
@ -1189,10 +1241,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
let helpText;
let buttonText;
let goButtonFn;
let consultSection;
let keySharingWarning = <span />;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
const userId = MatrixClientPeg.get().getUserId();
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
if (this.props.kind === KIND_DM) {
title = _t("Direct Messages");
@ -1256,7 +1311,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"),
})
: _t("Invite to this room");
: _t("Invite to %(roomName)s", {
roomName: room.name || _t("Unnamed Room"),
});
let helpTextUntranslated;
if (isSpace) {
@ -1286,10 +1343,35 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
buttonText = _t("Invite");
goButtonFn = this._inviteUsers;
if (SettingsStore.getValue("feature_room_history_key_sharing") &&
cli.isRoomEncrypted(this.props.roomId)) {
const room = cli.getRoom(this.props.roomId);
const visibilityEvent = room.currentState.getStateEvents(
"m.room.history_visibility", "",
);
const visibility = visibilityEvent && visibilityEvent.getContent() &&
visibilityEvent.getContent().history_visibility;
if (visibility === "world_readable" || visibility === "shared") {
keySharingWarning =
<p className='mx_InviteDialog_helpText'>
<img
src={require("../../../../res/img/element-icons/info.svg")}
width={14} height={14} />
{" " + _t("Invited people will be able to read old messages.")}
</p>;
}
}
} else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer");
buttonText = _t("Transfer");
goButtonFn = this._transferCall;
consultSection = <div>
<label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("Consult first")}
</label>
</div>;
} else {
console.error("Unknown kind of InviteDialog: " + this.props.kind);
}
@ -1319,12 +1401,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
{spinner}
</div>
</div>
{keySharingWarning}
{this._renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')}
{this._renderSection('suggestions')}
</div>
{consultSection}
</div>
</BaseDialog>
);

View file

@ -18,7 +18,7 @@ import React, {PureComponent} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';

View file

@ -82,6 +82,33 @@ export default class RoomUpgradeWarningDialog extends React.Component {
const title = this.state.isPrivate ? _t("Upgrade private room") : _t("Upgrade public room");
let bugReports = (
<p>
{_t(
"This usually only affects how the room is processed on the server. If you're " +
"having problems with your %(brand)s, please report a bug.", {brand},
)}
</p>
);
if (SdkConfig.get().bug_report_endpoint_url) {
bugReports = (
<p>
{_t(
"This usually only affects how the room is processed on the server. If you're " +
"having problems with your %(brand)s, please <a>report a bug</a>.",
{
brand,
},
{
"a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{sub}</a>;
},
},
)}
</p>
);
}
return (
<BaseDialog
className='mx_RoomUpgradeWarningDialog'
@ -97,20 +124,7 @@ export default class RoomUpgradeWarningDialog extends React.Component {
"is unstable due to bugs, missing features or security vulnerabilities.",
)}
</p>
<p>
{_t(
"This usually only affects how the room is processed on the server. If you're " +
"having problems with your %(brand)s, please <a>report a bug</a>.",
{
brand,
},
{
"a": (sub) => {
return <a href='#' onClick={this._openBugReportDialog}>{sub}</a>;
},
},
)}
</p>
{bugReports}
<p>
{_t(
"You'll upgrade this room from <oldVersion /> to <newVersion />.",

View file

@ -0,0 +1,54 @@
/*
Copyright 2021 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 {_t} from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import {IDialogProps} from "./IDialogProps";
@replaceableComponent("views.dialogs.SeshatResetDialog")
export default class SeshatResetDialog extends React.PureComponent<IDialogProps> {
render() {
return (
<BaseDialog
hasCancel={true}
onFinished={this.props.onFinished.bind(null, false)}
title={_t("Reset event store?")}>
<div>
<p>
{_t("You most likely do not want to reset your event index store")}
<br />
{_t("If you do, please note that none of your messages will be deleted, " +
"but the search experience might be degraded for a few moments" +
"whilst the index is recreated",
)}
</p>
</div>
<DialogButtons
primaryButton={_t("Reset event store")}
onPrimaryButtonClick={this.props.onFinished.bind(null, true)}
primaryButtonClass="danger"
cancelButton={_t("Cancel")}
onCancel={this.props.onFinished.bind(null, false)}
/>
</BaseDialog>
);
}
}

View file

@ -28,7 +28,6 @@ import {getTopic} from "../elements/RoomTopic";
import {avatarUrlForRoom} from "../../../Avatar";
import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import FormButton from "../elements/FormButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {allSettled} from "../../../utils/promise";
@ -127,23 +126,24 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
<div>
{ _t("Make this space private") }
<ToggleSwitch
checked={joinRule === "private"}
onChange={checked => setJoinRule(checked ? "private" : "invite")}
checked={joinRule !== "public"}
onChange={checked => setJoinRule(checked ? "invite" : "public")}
disabled={!canSetJoinRule}
aria-label={_t("Make this space private")}
/>
</div>
<FormButton
<AccessibleButton
kind="danger"
label={_t("Leave Space")}
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
/>
>
{ _t("Leave Space") }
</AccessibleButton>
<div className="mx_SpaceSettingsDialog_buttons">
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
@ -152,7 +152,9 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
{ _t("Cancel") }
</AccessibleButton>
<FormButton onClick={onSave} disabled={busy} label={busy ? _t("Saving...") : _t("Save Changes")} />
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
</div>
</BaseDialog>;

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import {Room} from "matrix-js-sdk";
import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import {dialogTermsInteractionCallback, TermsNotSignedError} from "../../../Terms";
import classNames from 'classnames';

View file

@ -20,8 +20,8 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t, pickBestLanguage } from '../../../languageHandler';
import Matrix from 'matrix-js-sdk';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types";
class TermsCheckbox extends React.PureComponent {
static propTypes = {
@ -85,22 +85,22 @@ export default class TermsDialog extends React.PureComponent {
_nameForServiceType(serviceType, host) {
switch (serviceType) {
case Matrix.SERVICE_TYPES.IS:
case SERVICE_TYPES.IS:
return <div>{_t("Identity Server")}<br />({host})</div>;
case Matrix.SERVICE_TYPES.IM:
case SERVICE_TYPES.IM:
return <div>{_t("Integration Manager")}<br />({host})</div>;
}
}
_summaryForServiceType(serviceType) {
switch (serviceType) {
case Matrix.SERVICE_TYPES.IS:
case SERVICE_TYPES.IS:
return <div>
{_t("Find others by phone or email")}
<br />
{_t("Be found by phone or email")}
</div>;
case Matrix.SERVICE_TYPES.IM:
case SERVICE_TYPES.IM:
return <div>
{_t("Use bots, bridges, widgets and sticker packs")}
</div>;

View file

@ -50,7 +50,7 @@ export default class VerificationRequestDialog extends React.Component {
const member = this.props.member ||
otherUserId && MatrixClientPeg.get().getUser(otherUserId);
const title = request && request.isSelfVerification ?
_t("Verify other session") : _t("Verification Request");
_t("Verify other login") : _t("Verification Request");
return <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
contentId="mx_Dialog_content"

View file

@ -1,6 +1,5 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018-2021 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.
@ -17,14 +16,15 @@ limitations under the License.
import {debounce} from "lodash";
import classNames from 'classnames';
import React from 'react';
import PropTypes from "prop-types";
import React, {ChangeEvent, FormEvent} from 'react';
import {ISecretStorageKeyInfo} from "matrix-js-sdk/src";
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import Field from '../../elements/Field';
import AccessibleButton from '../../elements/AccessibleButton';
import { _t } from '../../../../languageHandler';
import {_t} from '../../../../languageHandler';
import {IDialogProps} from "../IDialogProps";
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
// so this should be plenty and allow for people putting extra whitespace in the file because
@ -34,22 +34,30 @@ const KEY_FILE_MAX_SIZE = 128;
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
const VALIDATION_THROTTLE_MS = 200;
interface IProps extends IDialogProps {
keyInfo: ISecretStorageKeyInfo;
checkPrivateKey: (k: {passphrase?: string, recoveryKey?: string}) => boolean;
}
interface IState {
recoveryKey: string;
recoveryKeyValid: boolean | null;
recoveryKeyCorrect: boolean | null;
recoveryKeyFileError: boolean | null;
forceRecoveryKey: boolean;
passPhrase: string;
keyMatches: boolean | null;
}
/*
* Access Secure Secret Storage by requesting the user's passphrase.
*/
export default class AccessSecretStorageDialog extends React.PureComponent {
static propTypes = {
// { passphrase, pubkey }
keyInfo: PropTypes.object.isRequired,
// Function from one of { passphrase, recoveryKey } -> boolean
checkPrivateKey: PropTypes.func.isRequired,
}
export default class AccessSecretStorageDialog extends React.PureComponent<IProps, IState> {
private fileUpload = React.createRef<HTMLInputElement>();
constructor(props) {
super(props);
this._fileUpload = React.createRef();
this.state = {
recoveryKey: "",
recoveryKeyValid: null,
@ -61,21 +69,21 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
};
}
_onCancel = () => {
private onCancel = () => {
this.props.onFinished(false);
}
};
_onUseRecoveryKeyClick = () => {
private onUseRecoveryKeyClick = () => {
this.setState({
forceRecoveryKey: true,
});
}
};
_validateRecoveryKeyOnChange = debounce(() => {
this._validateRecoveryKey();
private validateRecoveryKeyOnChange = debounce(async () => {
await this.validateRecoveryKey();
}, VALIDATION_THROTTLE_MS);
async _validateRecoveryKey() {
private async validateRecoveryKey() {
if (this.state.recoveryKey === '') {
this.setState({
recoveryKeyValid: null,
@ -102,27 +110,27 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
}
}
_onRecoveryKeyChange = (e) => {
private onRecoveryKeyChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({
recoveryKey: e.target.value,
recoveryKey: ev.target.value,
recoveryKeyFileError: null,
});
// also clear the file upload control so that the user can upload the same file
// the did before (otherwise the onchange wouldn't fire)
if (this._fileUpload.current) this._fileUpload.current.value = null;
if (this.fileUpload.current) this.fileUpload.current.value = null;
// We don't use Field's validation here because a) we want it in a separate place rather
// than in a tooltip and b) we want it to display feedback based on the uploaded file
// as well as the text box. Ideally we would refactor Field's validation logic so we could
// re-use some of it.
this._validateRecoveryKeyOnChange();
}
this.validateRecoveryKeyOnChange();
};
_onRecoveryKeyFileChange = async e => {
if (e.target.files.length === 0) return;
private onRecoveryKeyFileChange = async (ev: ChangeEvent<HTMLInputElement>) => {
if (ev.target.files.length === 0) return;
const f = e.target.files[0];
const f = ev.target.files[0];
if (f.size > KEY_FILE_MAX_SIZE) {
this.setState({
@ -140,7 +148,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
recoveryKeyFileError: null,
recoveryKey: contents.trim(),
});
this._validateRecoveryKey();
await this.validateRecoveryKey();
} else {
this.setState({
recoveryKeyFileError: true,
@ -150,14 +158,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
});
}
}
};
private onRecoveryKeyFileUploadClick = () => {
this.fileUpload.current.click();
}
_onRecoveryKeyFileUploadClick = () => {
this._fileUpload.current.click();
}
_onPassPhraseNext = async (e) => {
e.preventDefault();
private onPassPhraseNext = async (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (this.state.passPhrase.length <= 0) return;
@ -169,10 +177,10 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
} else {
this.setState({ keyMatches });
}
}
};
_onRecoveryKeyNext = async (e) => {
e.preventDefault();
private onRecoveryKeyNext = async (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (!this.state.recoveryKeyValid) return;
@ -184,16 +192,16 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
} else {
this.setState({ keyMatches });
}
}
};
_onPassPhraseChange = (e) => {
private onPassPhraseChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({
passPhrase: e.target.value,
passPhrase: ev.target.value,
keyMatches: null,
});
}
};
getKeyValidationText() {
private getKeyValidationText(): string {
if (this.state.recoveryKeyFileError) {
return _t("Wrong file type");
} else if (this.state.recoveryKeyCorrect) {
@ -208,7 +216,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
// Caution: Making this an import will break tests.
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const hasPassphrase = (
this.props.keyInfo &&
@ -244,18 +253,18 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
{
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
onClick={this.onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
},
)}</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onPassPhraseNext}>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}>
<input
type="password"
className="mx_AccessSecretStorageDialog_passPhraseInput"
onChange={this._onPassPhraseChange}
onChange={this.onPassPhraseChange}
value={this.state.passPhrase}
autoFocus={true}
autoComplete="new-password"
@ -264,9 +273,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
{keyStatus}
<DialogButtons
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNext}
onPrimaryButtonClick={this.onPassPhraseNext}
hasCancel={true}
onCancel={this._onCancel}
onCancel={this.onCancel}
focus={false}
primaryDisabled={this.state.passPhrase.length === 0}
/>
@ -291,7 +300,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
<form
className="mx_AccessSecretStorageDialog_primaryContainer"
onSubmit={this._onRecoveryKeyNext}
onSubmit={this.onRecoveryKeyNext}
spellCheck={false}
autoComplete="off"
>
@ -301,7 +310,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
type="password"
label={_t('Security Key')}
value={this.state.recoveryKey}
onChange={this._onRecoveryKeyChange}
onChange={this.onRecoveryKeyChange}
forceValidity={this.state.recoveryKeyCorrect}
autoComplete="off"
/>
@ -312,10 +321,10 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
<div>
<input type="file"
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
ref={this._fileUpload}
onChange={this._onRecoveryKeyFileChange}
ref={this.fileUpload}
onChange={this.onRecoveryKeyFileChange}
/>
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
<AccessibleButton kind="primary" onClick={this.onRecoveryKeyFileUploadClick}>
{_t("Upload")}
</AccessibleButton>
</div>
@ -323,11 +332,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
{recoveryKeyFeedback}
<DialogButtons
primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onRecoveryKeyNext}
onPrimaryButtonClick={this.onRecoveryKeyNext}
hasCancel={true}
cancelButton={_t("Go Back")}
cancelButtonClass='danger'
onCancel={this._onCancel}
onCancel={this.onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}
/>

View file

@ -19,7 +19,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../SecurityManager';

View file

@ -0,0 +1,66 @@
/*
Copyright 2021 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, { HTMLAttributes } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { sortBy } from "lodash";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers";
const DEFAULT_NUM_FACES = 5;
interface IProps extends HTMLAttributes<HTMLSpanElement> {
room: Room;
onlyKnownUsers?: boolean;
numShown?: number;
}
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
let members = useRoomMembers(room);
// sort users with an explicit avatar first
const iteratees = [member => !!member.getMxcAvatarUrl()];
if (onlyKnownUsers) {
members = members.filter(isKnownMember);
} else {
// sort known users first
iteratees.unshift(member => isKnownMember(member));
}
if (members.length < 1) return null;
const shownMembers = sortBy(members, iteratees).slice(0, numShown);
return <div {...props} className="mx_FacePile">
<div className="mx_FacePile_faces">
{ shownMembers.map(member => {
return <TextWithTooltip key={member.userId} tooltip={member.name}>
<MemberAvatar member={member} width={28} height={28} />
</TextWithTooltip>;
}) }
</div>
{ onlyKnownUsers && <span>
{ _t("%(count)s people you know have already joined", { count: members.length }) }
</span> }
</div>
};
export default FacePile;

View file

@ -17,7 +17,8 @@ import React from 'react';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import classNames from 'classnames';
import { Room, RoomMember } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import FlairStore from "../../../stores/FlairStore";

View file

@ -21,7 +21,7 @@ import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
import {MatrixEvent} from 'matrix-js-sdk/src/models/event';
import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import {LayoutPropType} from "../../../settings/Layout";

View file

@ -73,7 +73,7 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
const src = mediaFromMxc(idp.icon).getSquareThumbnailHttp(24);
const src = mediaFromMxc(idp.icon, matrixClient).getSquareThumbnailHttp(24);
icon = <img src={src} height="24" width="24" alt={idp.name} />;
}

Some files were not shown because too many files have changed in this diff Show more