diff --git a/CHANGELOG.md b/CHANGELOG.md index c31eedf93b..38b1a2572f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,126 @@ +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) diff --git a/docs/ciderEditor.md b/docs/ciderEditor.md index f522dc2fc4..379b6f5b51 100644 --- a/docs/ciderEditor.md +++ b/docs/ciderEditor.md @@ -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. diff --git a/docs/media-handling.md b/docs/media-handling.md new file mode 100644 index 0000000000..a4307fb7d4 --- /dev/null +++ b/docs/media-handling.md @@ -0,0 +1,19 @@ +# Media handling + +Surely media should be as easy as just putting a URL into an `img` and calling it good, right? +Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify +content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that +URL can change depending on deployment considerations. + +The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md) +for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that +those obscure deployments can route all their media to the right place. + +For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`. +The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as +a parameter and will automatically parse out the source media and thumbnail. Both functions return +a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the +media. + +**It is extremely important that all media calls are put through this customisation endpoint.** So +much so it's a lint rule to avoid accidental use of the wrong functions. diff --git a/package.json b/package.json index 7ed1b272da..a4b425d0cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.15.0", + "version": "3.16.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", @@ -157,6 +158,7 @@ "jest": "^26.6.3", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", + "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", diff --git a/res/css/_common.scss b/res/css/_common.scss index 36a81e6651..0093bde0ab 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -395,6 +395,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 { diff --git a/res/css/_components.scss b/res/css/_components.scss index d894688cac..9c895490b3 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -111,8 +111,8 @@ @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"; @@ -211,13 +211,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 +225,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"; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index f1f27014ee..7c3cd1c513 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -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'); + } } } diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index 812a7f8472..a220c5d505 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -66,7 +66,7 @@ limitations under the License. } /* not the left panel, and not the resize handle, so the roomview/groupview/... */ -.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_ResizeHandle) { +.mx_MatrixChat > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle) { background-color: $primary-bg-color; flex: 1 1 0; diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 9937117086..5cca4aca11 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -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,21 +202,8 @@ $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_BaseAvatar_image { + border-radius: 8px; } .mx_SpaceButton_menuButton { @@ -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 { @@ -335,11 +339,15 @@ $activeBorderColor: $secondary-fg-color; } .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'); } } diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index c96398594f..b20554166a 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -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,128 @@ 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_children { - margin-left: 12px; - border-left: 2px solid $space-button-outline-color; - padding-left: 24px; + .mx_BaseAvatar_image { + border-radius: 8px; } } - .mx_SpaceRoomDirectory_roomTile { - padding: 16px; - border-radius: 8px; - border: 1px solid $space-button-outline-color; - margin: 8px 0 16px; - display: flex; - min-height: 76px; - box-sizing: border-box; + .mx_SpaceRoomDirectory_subspace_toggle { + position: absolute; + left: -1px; + top: 10px; + height: 16px; + width: 16px; + border-radius: 4px; + background-color: $primary-bg-color; - &.mx_AccessibleButton:hover { - background-color: rgba(141, 151, 165, 0.1); + &::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 { + position: relative; + padding-left: 12px; + } + + .mx_SpaceRoomDirectory_roomTile { + position: relative; + padding: 6px 16px; + border-radius: 8px; + min-height: 56px; + box-sizing: border-box; + + display: grid; + grid-template-columns: 20px auto max-content; + grid-column-gap: 8px; + align-items: center; + .mx_BaseAvatar { - margin-right: 16px; - margin-top: 6px; + grid-row: 1; + grid-column: 1; + } + + .mx_SpaceRoomDirectory_roomTile_name { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + grid-row: 1; + grid-column: 2; + + .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; + } + } } .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; - line-height: $font-18px; - } - .mx_SpaceRoomDirectory_roomTile_topic { - line-height: $font-24px; - color: $secondary-fg-color; - } - } - - .mx_SpaceRoomDirectory_roomTile_memberCount { - position: relative; - margin: auto 0 auto 24px; - padding: 0 0 0 28px; - line-height: $font-24px; - display: inline-block; - width: 32px; - - &::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'); - } + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-fg-color; + grid-row: 2; + grid-column: 1/3; } .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: 6px 18px; - & + .mx_AccessibleButton { - margin-left: 24px; - } + display: none; } + + .mx_Checkbox { + display: inline-flex; + vertical-align: middle; + margin-left: 12px; + } + } + + &:hover { + background-color: $groupFilterPanel-bg-color; + + .mx_AccessibleButton { + display: inline-block; + } + } + } + + .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 +274,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; + } } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 60abe36c29..3d3b5d1bb8 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -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 $space-button-outline-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; } @@ -331,64 +376,8 @@ $SpaceRoomViewInnerWidth: 428px; } .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 { diff --git a/res/css/structures/_ViewSource.scss b/res/css/structures/_ViewSource.scss index 0126c16599..248eab5d88 100644 --- a/res/css/structures/_ViewSource.scss +++ b/res/css/structures/_ViewSource.scss @@ -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; diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e4..80e7aaada0 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -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; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 0c9d8e3840..a7cfd7bde6 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -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; diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index c1fa539e9b..6e5fd9c8c8 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -49,7 +49,7 @@ limitations under the License. } } - .mx_FormButton { + .mx_AccessibleButton_hasKind { padding: 8px 22px; } } diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index e219c0c5e4..b45126acf8 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 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. @@ -16,6 +16,19 @@ limitations under the License. .mx_MFileBody_download { color: $accent-color; + + .mx_MFileBody_download_icon { + // 12px instead of 14px to better match surrounding font size + width: 12px; + height: 12px; + mask-size: 12px; + + mask-position: center; + mask-repeat: no-repeat; + mask-image: url("$(res)/img/download.svg"); + background-color: $accent-color; + display: inline-block; + } } .mx_MFileBody_download a { diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index e126e523a6..4f58c08617 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -66,6 +66,11 @@ limitations under the License. } } } + + &.mx_BasicMessageComposer_input_disabled { + pointer-events: none; + cursor: not-allowed; + } } .mx_BasicMessageComposer_AutoCompleteWrapper { diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index dea1b58741..e6c0cc3f46 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -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'); } diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss index d97c49630a..b305e91db0 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.scss +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -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 { diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index 4322ba341c..9c2a428cb3 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -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; diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index d49ed4b736..22440fa6db 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -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'); } @@ -35,28 +38,32 @@ limitations under the License. margin: 4px 12px 4px; padding-top: 12px; border-top: 1px solid $tertiary-fg-color; - font-size: $font-13px; + 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'); + } } } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 8eca3f1efa..72d29dfd4c 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -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'); } diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss new file mode 100644 index 0000000000..bb36991b4f --- /dev/null +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -0,0 +1,36 @@ +/* +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; + } +} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index 2a11ec9f23..ef3fea351b 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -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; diff --git a/res/css/views/spaces/_SpacePublicShare.scss b/res/css/views/spaces/_SpacePublicShare.scss index 9ba0549ae3..373fa94e00 100644 --- a/res/css/views/spaces/_SpacePublicShare.scss +++ b/res/css/views/spaces/_SpacePublicShare.scss @@ -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'); diff --git a/res/img/element-icons/roomlist/browse.svg b/res/img/element-icons/roomlist/browse.svg new file mode 100644 index 0000000000..04714e2881 --- /dev/null +++ b/res/img/element-icons/roomlist/browse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg index ac5991f221..1ae4e40ffe 100644 --- a/res/img/element-icons/warning-badge.svg +++ b/res/img/element-icons/warning-badge.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/res/img/feather-customised/monitor.svg b/res/img/feather-customised/monitor.svg deleted file mode 100644 index 231811d5a6..0000000000 --- a/res/img/feather-customised/monitor.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/feather-customised/smartphone.svg b/res/img/feather-customised/smartphone.svg deleted file mode 100644 index fde78c82e2..0000000000 --- a/res/img/feather-customised/smartphone.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/res/img/voip/mic-on-mask.svg b/res/img/voip/mic-on-mask.svg new file mode 100644 index 0000000000..418316b164 --- /dev/null +++ b/res/img/voip/mic-on-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 9ad154dd93..d7ee496d80 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -189,6 +189,9 @@ $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; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 25fbd0201b..577204ef0c 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -180,6 +180,9 @@ $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; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 4aa6df5488..051e5cc429 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -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 { diff --git a/src/Avatar.ts b/src/Avatar.ts index e2557e21a8..76c88faa1c 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,27 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {User} from "matrix-js-sdk/src/models/user"; import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClientPeg} from './MatrixClientPeg'; import DMRoomMap from './utils/DMRoomMap'; +import {mediaFromMxc} from "./customisations/Media"; export type ResizeMethod = "crop" | "scale"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { let url: string; - if (member && member.getAvatarUrl) { - url = member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), + if (member?.getMxcAvatarUrl()) { + url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), resizeMethod, - false, - false, ); } if (!url) { @@ -47,16 +43,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu } export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { - const url = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, + if (!user.avatarUrl) return null; + return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), resizeMethod, ); - if (!url || url.length === 0) { - return null; - } - return url; } function isValidHexColor(color: string): boolean { @@ -154,15 +146,8 @@ export function getInitialLetter(name: string): string { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard - const explicitRoomAvatar = room.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); - if (explicitRoomAvatar) { - return explicitRoomAvatar; + if (room.getMxcAvatarUrl()) { + return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } // space rooms cannot be DMs so skip the rest @@ -177,14 +162,8 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // then still try to show any avatar (pref. other member) otherMember = room.getAvatarFallbackMember(); } - if (otherMember) { - return otherMember.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); + if (otherMember?.getMxcAvatarUrl()) { + return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } return null; } diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index d2564e637b..ce779f12a5 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -788,6 +788,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 diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index 8d56467c57..7c7940cab5 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -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() { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 7d6b049914..1dc342fac5 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -32,10 +32,10 @@ import { AllHtmlEntities } from 'html-entities'; import SettingsStore from './settings/SettingsStore'; import cheerio from 'cheerio'; -import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; +import {mediaFromMxc} from "./customisations/Media"; linkifyMatrix(linkify); @@ -181,11 +181,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { return { tagName, attribs: {}}; } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); + const width = Number(attribs.width) || 800; + const height = Number(attribs.height) || 600; + attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -239,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: diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index d3bfee2380..1687adf13b 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -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'; diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 7780d4c87a..b0a1292ba1 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -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, }); diff --git a/src/Login.ts b/src/Login.ts index aecc0493c7..db3c4c11e4 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -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 { - const client = Matrix.createClient({ + const client = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 98ca446532..7db5ed1a4e 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -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); diff --git a/src/Notifier.ts b/src/Notifier.ts index 6460be20ad..f68bfabc18 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -36,6 +36,7 @@ import {SettingLevel} from "./settings/SettingLevel"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; +import {mediaFromMxc} from "./customisations/Media"; /* * Dispatches: @@ -150,7 +151,7 @@ export const Notifier = { // Ideally in here we could use MSC1310 to detect the type of file, and reject it. return { - url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + url: mediaFromMxc(content.url).srcHttp, name: content.name, type: content.type, size: content.size, diff --git a/src/PasswordReset.js b/src/PasswordReset.js index b38a9de960..6fe6ca82cc 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -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, }); diff --git a/src/Resend.js b/src/Resend.js index 5638313306..bf69e59c1a 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -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) { diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 9ae41b851a..aa758ecbdc 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -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, diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 1ea9d39e2f..200b4fd7b9 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -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(() => { diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 896e27d92c..3f75b3788c 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -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'; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index aedcf7af8c..6d9ed9467d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -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() { @@ -163,7 +164,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 +177,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 +190,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 +203,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 +212,7 @@ export const Commands = [ args: '', 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 +221,7 @@ export const Commands = [ args: '', 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 +966,7 @@ export const Commands = [ args: '', 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 +976,7 @@ export const Commands = [ args: '', 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 +1201,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, + }; } } diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index 4dd296a8f1..eeb68b94bd 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -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'; diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index e7bae3578b..670cb28b94 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -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'; diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index ebf5d536ec..b7a4e0960e 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -27,6 +27,7 @@ import {sortBy} from "lodash"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; +import {mediaFromMxc} from "../customisations/Media"; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -95,7 +96,7 @@ export default class CommunityProvider extends AutocompleteProvider { name={name || groupId} width={24} height={24} - url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} /> + url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} /> ), range, diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 9f5a0b6211..32db5c251c 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -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"; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index b4b871a0b4..b006b323fb 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -35,10 +35,11 @@ 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"; +import {mediaFromMxc} from "../../customisations/Media"; import {replaceableComponent} from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( @@ -368,8 +369,7 @@ class FeaturedUser extends React.Component { const permalink = makeUserPermalink(this.props.summaryInfo.user_id); const userNameNode = { name }; - const httpUrl = MatrixClientPeg.get() - .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); + const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64); const deleteButton = this.props.editing ? ; } - const httpInviterAvatar = this.state.inviterProfile ? - this._matrixClient.mxcUrlToHttp( - this.state.inviterProfile.avatarUrl, 36, 36, - ) : null; + const httpInviterAvatar = this.state.inviterProfile + ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36) + : null; const inviter = group.inviter || {}; let inviterName = inviter.userId; diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 9b61f71fd7..d419c9de6e 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -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'; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 88c7a71b35..2861cfd7e7 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -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"; @@ -36,11 +38,11 @@ import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; 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"; 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 { 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 { 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); }; @@ -121,7 +131,7 @@ export default class LeftPanel extends React.Component { let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); if (settingBgMxc) { - avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); + avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); } const avatarUrlProp = `url(${avatarUrl})`; @@ -382,7 +392,9 @@ export default class LeftPanel extends React.Component { onEnter={this.onEnter} /> @@ -392,11 +404,7 @@ export default class LeftPanel extends React.Component { 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 = ; - } else if (this.state.showGroupFilterPanel) { + if (this.state.showGroupFilterPanel) { leftLeftPanel = (
@@ -412,6 +420,7 @@ export default class LeftPanel extends React.Component { onBlur={this.onBlur} isMinimized={this.props.isMinimized} onResize={this.onResize} + activeSpace={this.state.activeSpace} />; const containerClasses = classNames({ diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 15e90a383a..20a3b811c5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -56,6 +56,7 @@ import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; 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 +74,6 @@ function canElementReceiveInput(el) { interface IProps { matrixClient: MatrixClient; onRegistered: (credentials: IMatrixClientCreds) => Promise; - viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase @@ -142,9 +142,6 @@ class LoggedInView extends React.Component { // 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 +226,15 @@ class LoggedInView extends React.Component { 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) => { @@ -630,11 +621,9 @@ class LoggedInView extends React.Component { case PageTypes.RoomView: pageElement = { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } - const leftPanel = ( - - ); - return (
{
- { leftPanel } + { SettingsStore.getValue("feature_spaces") ? : null } + { pageElement }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0272633e8f..689561fd60 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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"; /** 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 { } 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 { 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({ @@ -934,7 +928,6 @@ export default class MatrixChat extends React.PureComponent { 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 { } 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 { 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 { } 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 { * 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(); } /** @@ -1648,7 +1656,7 @@ export default class MatrixChat extends React.PureComponent { let cli = MatrixClientPeg.get(); if (!cli) { const {hsUrl, isUrl} = this.props.serverConfig; - cli = Matrix.createClient({ + cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 439bdbcef5..41a3015721 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -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( +
  • , + ); + } + + 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( + + { eventTiles } + , + ); + + 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]; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 363c67262b..3613261da6 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -27,7 +27,6 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; @@ -35,6 +34,7 @@ import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../customisations/Media"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -521,10 +521,9 @@ export default class RoomDirectory extends React.Component { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } topic = linkifyAndSanitizeHtml(topic); - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - room.avatar_url, 32, 32, "crop", - ); + let avatarUrl = null; + if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32); + return [
    this.onRoomClicked(room, ev)} diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 8b70998be0..54b6fee233 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -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; }); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 706cd5ded8..8a9c7cabd9 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { // 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, @@ -1123,7 +1117,7 @@ export default class RoomView extends React.Component { 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(); diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 6daa8526bc..abeb858274 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -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 }
    diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 72e52678b6..0dfb33379d 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -14,26 +14,31 @@ 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 { space: Room; @@ -71,237 +76,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 = ({ - space, - editing, - event, - queueAction, - onJoinClick, - onPreviewClick, +const Tile: React.FC = ({ + 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 = - - ; - } else { - actions = - - - ; - } - } else { - actions = - { _t("No permissions")} - ; - } - // TODO confirm remove from space click behaviour here - } else { - if (myMembership === "join") { - actions = - { _t("You're in this space")} - ; - } else if (onJoinClick) { - actions = - - { _t("Preview") } - - - - } - } - - let url: string; - if (space.avatar_url) { - url = MatrixClientPeg.get().mxcUrlToHttp( - space.avatar_url, - Math.floor(24 * window.devicePixelRatio), - Math.floor(24 * window.devicePixelRatio), - "crop", - ); - } - - return
    -
    - - { name } - -
    - { actions } -
    -
    -
    - { children } -
    -
    -}; - -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; - }); - }; + let button; + if (myMembership === "join") { + button = + { _t("Open") } + ; + } else if (onJoinClick) { + button = + { _t("Join") } + ; + } - if (removed) { - actions = - - ; - } else { - actions = - - - ; - } + let checkbox; + if (onToggleClick) { + if (hasPermissions) { + checkbox = ; } else { - actions = - { _t("No permissions")} - ; - } - // TODO confirm remove from space click behaviour here - } else { - if (myMembership === "join") { - actions = - { _t("You're in this room")} - ; - } else if (onJoinClick) { - actions = - - { _t("Preview") } - - - + checkbox = { ev.stopPropagation() }} + > + + ; } } let url: string; if (room.avatar_url) { - url = cli.mxcUrlToHttp( - room.avatar_url, - Math.floor(32 * window.devicePixelRatio), - Math.floor(32 * window.devicePixelRatio), - "crop", - ); + 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 = + { _t("Suggested") } + ; } const content = - + +
    + { name } + { suggestedSection } +
    -
    - { name } -
    -
    - { room.topic } -
    + { description }
    -
    - { room.num_joined_members } -
    -
    - { actions } + { button } + { checkbox }
    ; - if (editing) { - return
    - { content } -
    + let childToggle; + let childSection; + if (children) { + // the chevron is purposefully a div rather than a button as it should be ignored for a11y + childToggle =
    { + ev.stopPropagation(); + toggleShowChildren(); + }} + />; + if (showChildren) { + childSection =
    + { children } +
    ; + } } - return - { content } - ; + return <> + + { content } + { childToggle } + + { childSection } + ; }; export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => { @@ -334,98 +233,121 @@ export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoi interface IHierarchyLevelProps { spaceId: string; rooms: Map; - editing?: boolean; - relations: EnhancedMap; + relations: Map>; parents: Set; - queueAction?(action: IAction): void; - onPreviewClick(roomId: string): void; - onRemoveFromSpaceClick?(roomId: string): void; - onJoinClick?(roomId: string): void; + selectedMap?: Map>; + 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 { childRooms.map(roomId => ( - { - 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 => ( - { - 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} > - + )) } }; -const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { +// mutate argument refreshToken to force a reload +export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + ISpaceSummaryRoom[], + Map>, + Map>, + Map>, +] | [] => { // TODO pagination + return useAsyncMemo(async () => { + try { + const data = await cli.getSpaceSummary(space.roomId); + + const parentChildRelations = new EnhancedMap>(); + const childParentRelations = new EnhancedMap>(); + const viaMap = new EnhancedMap>(); + 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], []); +}; + +const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); const [query, setQuery] = useState(initialText); - const [isEditing, setIsEditing] = useState(false); const onCreateRoomClick = () => { dis.dispatch({ @@ -435,116 +357,210 @@ const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinis onFinished(); }; - // stored within a ref as we don't need to re-render when it changes - const pendingActions = useRef(new Map()); + const [selected, setSelected] = useState(new Map>()); // Map> - 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 = - - { _t("Promoted to users") } - ; - } else { - adminButton = ; - } - } - - const [rooms, relations, viaMap] = useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId); - - const parentChildRelations = new EnhancedMap(); - const viaMap = new EnhancedMap>(); - 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 [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space); const roomsMap = useMemo(() => { if (!rooms) return null; - const lcQuery = query.toLowerCase(); + const lcQuery = query.toLowerCase().trim(); - 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); + const roomsMap = new Map(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); }); - return new Map(filteredRooms.map(r => [r.room_id, r])); - // const root = rooms.get(space.roomId); - }, [rooms, query]); + // Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy + const visited = new Set(); + 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 title = - +

    { _t("Explore rooms") }

    ; + const explanation = - _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null, + _t("If you can't find the room you're looking for, ask for an invite or create a new room.", null, {a: sub => { return {sub}; }}, ); + const [error, setError] = useState(""); + const [removing, setRemoving] = useState(false); + const [saving, setSaving] = useState(false); + let content; if (roomsMap) { - content = - { - 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(); - }} - /> - ; + 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 = <> + { + 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") } + + { + 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")) + } + + ; + } + + editSection = + { buttons } + ; + } + + let results; + if (roomsMap.size) { + results = <> + { + 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)))); + }} + onViewRoomClick={(roomId, autoJoin) => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); + onFinished(); + }} + /> +
    + ; + } else { + results =
    +

    { _t("No results found") }

    +
    { _t("You may want to try a different search or check for typos.") }
    +
    ; + } + + content = <> +
    + { countsStr } + { editSection } +
    + { error &&
    + { error } +
    } + + { results } + + { _t("Create room") } + + + ; + } else { + content = ; } // TODO loading state/error state @@ -555,13 +571,12 @@ const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinis -
    - { adminButton } -
    { content }
    diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 0b0f2a2ac9..cea59093ac 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {RefObject, useContext, useRef, useState} from "react"; +import React, {RefObject, useContext, useMemo, useRef, useState} from "react"; import {EventType, RoomType} 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 {HierarchyLevel, ISpaceSummaryRoom, showRoom, useSpaceSummary} from "./SpaceRoomDirectory"; import AutoHideScrollbar from "./AutoHideScrollbar"; import MemberAvatar from "../views/avatars/MemberAvatar"; import {useStateToggle} from "../../hooks/useStateToggle"; +import SpaceStore from "../../stores/SpaceStore"; interface IProps { space: Room; @@ -66,6 +63,7 @@ interface IProps { interface IState { phase: Phase; showRightPanel: boolean; + myMembership: string; } enum Phase { @@ -98,6 +96,8 @@ 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,11 +121,41 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => } joinButtons = <> - - + { + setBusy(true); + onRejectButtonClicked(); + }} + > + { _t("Reject") } + + { + setBusy(true); + onJoinButtonClicked(); + }} + > + { _t("Accept") } + ; } else { - joinButtons = + joinButtons = ( + { + setBusy(true); + onJoinButtonClicked(); + }} + > + { _t("Join") } + + ) + } + + if (busy) { + joinButtons = ; } let visibilitySection; @@ -196,7 +226,7 @@ const SpaceLanding = ({ space }) => { 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,26 +256,13 @@ const SpaceLanding = ({ space }) => { ; } - const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => { - try { - const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join"); - - const parentChildRelations = new EnhancedMap(); - data.events.map((ev: ISpaceSummaryEvent) => { - if (ev.type === EventType.SpaceChild) { - parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key); - } - }); - - const roomsMap = new Map(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]); + const [rooms, relations, viaMap] = useSpaceSummary(cli, space, refreshToken); + const [roomsMap, numRooms] = useMemo(() => { + if (!rooms) return []; + const roomsMap = new Map(rooms.map(r => [r.room_id, r])); + const numRooms = rooms.filter(r => r.room_type !== RoomType.Space).length; + return [roomsMap, numRooms]; + }, [rooms]); let previewRooms; if (roomsMap) { @@ -257,15 +274,14 @@ const SpaceLanding = ({ space }) => { { - showRoom(roomsMap.get(roomId), [], false); // TODO + onViewRoomClick={(roomId, autoJoin) => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin); }} /> ; - } else if (loading) { + } else if (!rooms) { previewRooms = ; } else { previewRooms =

    {_t("Your server does not support showing space hierarchies.")}

    ; @@ -337,6 +353,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 +386,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
    @@ -380,61 +397,55 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { { fields }
    - + > + { buttonLabel } +
    ; }; const SpaceSetupPublicShare = ({ space, onFinished }) => { return
    -

    { _t("Share your public space") }

    -
    { _t("At the moment only you can see it.") }
    +

    { _t("Share %(name)s", { name: space.name }) }

    +
    + { _t("It's just you at the moment, it will be even better with others.") } +
    - +
    - + + { _t("Go to my first room") } +
    ; }; -const SpaceSetupPrivateScope = ({ onFinished }) => { - const [option, setOption] = useState(null); - +const SpaceSetupPrivateScope = ({ space, onFinished }) => { return

    { _t("Who are you working with?") }

    -
    { _t("Ensure the right people have access to the space.") }
    - - -

    { _t("Just Me") }

    -
    { _t("A private space just for you") }
    - , - }, { - value: "meAndMyTeammates", - className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton", - label: -

    { _t("Me and my teammates") }

    -
    { _t("A private space for you and your teammates") }
    -
    , - }, - ]} - /> - -
    - onFinished(option !== "justMe")} /> +
    + { _t("Make sure the right people have access to %(name)s", { name: space.name }) }
    + + { onFinished(false) }} + > +

    { _t("Just me") }

    +
    { _t("A private space to organise your rooms") }
    +
    + { onFinished(true) }} + > +

    { _t("Me and my teammates") }

    +
    { _t("A private space for you and your teammates") }
    +
    ; }; @@ -464,6 +475,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { onChange={ev => setEmailAddress(i, ev.target.value)} ref={fieldRefs[i]} onValidate={validateEmailRules} + autoFocus={i === 0} />; }); @@ -501,9 +513,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

    { _t("Invite your teammates") }

    -
    { _t("Ensure the right people have access to the space.") }
    +
    + { _t("Make sure the right people have access. You can invite more later.") } +
    { error &&
    { error }
    } { fields } @@ -518,8 +539,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
    - {_t("Skip for now")} - + + { buttonLabel } +
    ; }; @@ -547,17 +569,26 @@ export default class SpaceRoomView extends React.PureComponent { 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 +625,45 @@ export default class SpaceRoomView extends React.PureComponent { } }; + 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 ; } else { return { return this.setState({ phase: Phase.PublicShare })} />; case Phase.PublicShare: - return this.setState({ phase: Phase.Landing })} - />; + return ; case Phase.PrivateScope: return { this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); }} @@ -634,7 +699,8 @@ export default class SpaceRoomView extends React.PureComponent { return this.setState({ phase: Phase.Landing })} />; } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index f32b8ed0a9..12f5d6e890 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -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}); diff --git a/src/components/structures/UserView.js b/src/components/structures/UserView.js index dc05193ece..6b472783bb 100644 --- a/src/components/structures/UserView.js +++ b/src/components/structures/UserView.js @@ -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}); } diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index b762ad3fca..6fe99dd464 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -16,65 +16,176 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import SyntaxHighlight from '../views/elements/SyntaxHighlight'; -import {_t} from "../../languageHandler"; +import React from "react"; +import PropTypes from "prop-types"; +import SyntaxHighlight from "../views/elements/SyntaxHighlight"; +import { _t } from "../../languageHandler"; import * as sdk from "../../index"; -import {replaceableComponent} from "../../utils/replaceableComponent"; - +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; +import { canEditContent } from "../../utils/EventUtils"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.ViewSource") export default class ViewSource extends React.Component { static propTypes = { - content: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - roomId: PropTypes.string.isRequired, - eventId: PropTypes.string.isRequired, - isEncrypted: PropTypes.bool.isRequired, - decryptedContent: PropTypes.object, + mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + constructor(props) { + super(props); - let content; - if (this.props.isEncrypted) { - content = <> -
    - - {_t("Decrypted event source")} - - - { JSON.stringify(this.props.decryptedContent, null, 2) } - -
    -
    - - {_t("Original event source")} - - - { JSON.stringify(this.props.content, null, 2) } - -
    - ; + this.state = { + isEditing: false, + }; + } + + onBack() { + // TODO: refresh the "Event ID:" modal header + this.setState({ isEditing: false }); + } + + onEdit() { + this.setState({ isEditing: true }); + } + + // returns the dialog body for viewing the event source + viewSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private + const originalEventSource = mxEvent.event; + + if (isEncrypted) { + return ( + <> +
    + + {_t("Decrypted event source")} + + {JSON.stringify(decryptedEventSource, null, 2)} +
    +
    + + {_t("Original event source")} + + {JSON.stringify(originalEventSource, null, 2)} +
    + + ); } else { - content = <> -
    {_t("Original event source")}
    - - { JSON.stringify(this.props.content, null, 2) } - - ; + return ( + <> +
    {_t("Original event source")}
    + {JSON.stringify(originalEventSource, null, 2)} + + ); } + } + // returns the id of the initial message, not the id of the previous edit + getBaseEventId() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const baseMxEvent = this.props.mxEvent; + + if (isEncrypted) { + // `relates_to` field is inside the encrypted event + return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } else { + return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } + } + + // returns the SendCustomEvent component prefilled with the correct details + editSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isStateEvent = mxEvent.isState(); + const roomId = mxEvent.getRoomId(); + const originalContent = mxEvent.getContent(); + + if (isStateEvent) { + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(originalContent, null, "\t"), + stateKey: mxEvent.getStateKey(), + }} + /> + )} + + ); + } else { + // prefill an edit-message event + // keep only the `body` and `msgtype` fields of originalContent + const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there + const newContent = { + "body": ` * ${bodyToStartFrom}`, + "msgtype": originalContent.msgtype, + "m.new_content": { + body: bodyToStartFrom, + msgtype: originalContent.msgtype, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: this.getBaseEventId(), + }, + }; + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(newContent, null, "\t"), + }} + /> + )} + + ); + } + } + + canSendStateEvent(mxEvent) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(mxEvent.getRoomId()); + return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isEditing = this.state.isEditing; + const roomId = mxEvent.getRoomId(); + const eventId = mxEvent.getId(); + const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent); return ( - -
    -
    Room ID: { this.props.roomId }
    -
    Event ID: { this.props.eventId }
    + +
    +
    Room ID: {roomId}
    +
    Event ID: {eventId}
    - { content } + {isEditing ? this.editSourceContent() : this.viewSourceContent()}
    + {!isEditing && canEdit && ( +
    + +
    + )} ); } diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 32bdddb82a..9d004de2ec 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -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 { } const {hsUrl, isUrl} = serverConfig; - const cli = Matrix.createClient({ + const cli = createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, }); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 799a559263..e623439174 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {toPx} from "../../../utils/units"; +import {ResizeMethod} from "../../../Avatar"; interface IProps { name: string; // The name (first initial used as default) @@ -35,7 +36,7 @@ interface IProps { width?: number; height?: number; // XXX: resizeMethod not actually used. - resizeMethod?: string; + resizeMethod?: ResizeMethod; defaultToInitialLetter?: boolean; // true to add default url onClick?: React.MouseEventHandler; inputRef?: React.RefObject; diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index a033257871..3734ba9504 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017, 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. @@ -15,9 +15,10 @@ limitations under the License. */ import React from 'react'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import BaseAvatar from './BaseAvatar'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; export interface IProps { groupId?: string; @@ -25,7 +26,7 @@ export interface IProps { groupAvatarUrl?: string; width?: number; height?: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; onClick?: React.MouseEventHandler; } @@ -38,8 +39,8 @@ export default class GroupAvatar extends React.Component { }; getGroupAvatarUrl() { - return MatrixClientPeg.get().mxcUrlToHttp( - this.props.groupAvatarUrl, + if (!this.props.groupAvatarUrl) return null; + return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp( this.props.width, this.props.height, this.props.resizeMethod, diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 641046aa55..c79cbc0d32 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -20,16 +20,17 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import dis from "../../../dispatcher/dispatcher"; import {Action} from "../../../dispatcher/actions"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; height: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` @@ -63,18 +64,19 @@ export default class MemberAvatar extends React.Component { } private static getState(props: IProps): IState { - if (props.member && props.member.name) { - return { - name: props.member.name, - title: props.title || props.member.userId, - imageUrl: props.member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), + if (props.member?.name) { + let imageUrl = null; + if (props.member.getMxcAvatarUrl()) { + imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( Math.floor(props.width * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, - false, - false, - ), + ); + } + return { + name: props.member.name, + title: props.title || props.member.userId, + imageUrl: imageUrl, }; } else if (props.fallbackUserId) { return { diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 0a59f6e36a..31245b44b7 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, {ComponentProps} from 'react'; import Room from 'matrix-js-sdk/src/models/room'; -import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; @@ -24,6 +23,7 @@ import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; import {ResizeMethod} from "../../../Avatar"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, @@ -90,16 +90,16 @@ export default class RoomAvatar extends React.Component { }; private static getImageUrls(props: IProps): string[] { - return [ - getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - // Default props don't play nicely with getDerivedStateFromProps - //props.oobData !== undefined ? props.oobData.avatarUrl : {}, - props.oobData.avatarUrl, + let oobAvatar = null; + if (props.oobData.avatarUrl) { + oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( Math.floor(props.width * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, - ), // highest priority + ); + } + return [ + oobAvatar, // highest priority RoomAvatar.getRoomAvatarUrl(props), ].filter(function(url) { return (url !== null && url !== ""); diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index 04cfce7670..cca158269e 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,21 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps, useContext} from 'react'; +import React, {ComponentProps} from 'react'; import classNames from 'classnames'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {IApp} from "../../../stores/WidgetStore"; import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; } const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { - const cli = useContext(MatrixClientContext); - let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; // heuristics for some better icons until Widgets support their own icons if (app.type.includes("jitsi")) { @@ -47,7 +44,7 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 name={app.id} className={classNames("mx_WidgetAvatar", className)} // MSC2765 - url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined} + url={app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : undefined} urls={iconUrls} width={width} height={height} diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 11a9d90ac2..15078326b3 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -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"; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index f97b429abc..56f070ba36 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -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'; @@ -126,15 +126,9 @@ export default class MessageContextMenu extends React.Component { }; onViewSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - content: ev.event, - isEncrypted: ev.isEncrypted(), - // FIXME: _clearEvent is private - decryptedContent: ev._clearEvent, + mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); }; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 66efaefd9d..04bec39238 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -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 = ({ 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 = ({ matrixClient: cli, space, const title =
    -

    { _t("Add existing spaces/rooms") }

    +

    { _t("Add existing rooms") }

    { spaceOptionSection }
    ; @@ -127,29 +127,9 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, className="mx_textinput_icon mx_textinput_search" placeholder={ _t("Filter your rooms and spaces") } onSearch={setQuery} + autoComplete={true} /> - { spaces.length > 0 ? ( -
    -

    { _t("Spaces") }

    - { spaces.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : null } - { rooms.length > 0 ? (

    { _t("Rooms") }

    @@ -171,6 +151,27 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space,
    ) : undefined } + { spaces.length > 0 ? ( +
    +

    { _t("Spaces") }

    + { spaces.map(space => { + return { + if (checked) { + selectedToAdd.add(space); + } else { + selectedToAdd.delete(space); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
    + ) : null } + { spaces.length + rooms.length < 1 ? { _t("No results") } : undefined } @@ -184,8 +185,8 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, - { setBusy(true); @@ -199,7 +200,9 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, } setBusy(false); }} - /> + > + { busy ? _t("Adding...") : _t("Add") } +
    ; }; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index d1080566ac..2635f95bb7 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -26,12 +26,12 @@ import SdkConfig from "../../../SdkConfig"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import InviteDialog from "./InviteDialog"; import BaseAvatar from "../avatars/BaseAvatar"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; import StyledCheckbox from "../elements/StyledCheckbox"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { roomId: string; @@ -142,12 +142,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< private renderPerson(person: IPerson, key: any) { const avatarSize = 36; + let avatarUrl = null; + if (person.user.getMxcAvatarUrl()) { + avatarUrl = mediaFromMxc(person.user.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize); + } return (
    ; diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 9b24aaa571..9f5513e0a3 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -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}; @@ -74,13 +75,14 @@ class GenericEditor extends React.PureComponent { } } -class SendCustomEvent extends GenericEditor { +export class SendCustomEvent extends GenericEditor { static getLabel() { return _t('Send Custom Event'); } static propTypes = { onBack: PropTypes.func.isRequired, room: PropTypes.instanceOf(Room).isRequired, forceStateEvent: PropTypes.bool, + forceGeneralEvent: PropTypes.bool, inputs: PropTypes.object, }; @@ -141,6 +143,8 @@ class SendCustomEvent extends GenericEditor {
    ; } + const showTglFlip = !this.state.message && !this.props.forceStateEvent && !this.props.forceGeneralEvent; + return
    @@ -156,7 +160,7 @@ class SendCustomEvent extends GenericEditor {
    { !this.state.message && } - { !this.state.message && !this.props.forceStateEvent &&
    + { showTglFlip &&
    } diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index 504d563bd9..ee3696b427 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -24,6 +24,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import FlairStore from "../../../stores/FlairStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -118,7 +119,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; if (!this.state.avatarPreview) { if (this.state.currentAvatarUrl) { - const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + const url = mediaFromMxc(this.state.currentAvatarUrl).srcHttp; preview = ; } else { preview =
    diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js index cbe26af6cc..d80a935573 100644 --- a/src/components/views/dialogs/FeedbackDialog.js +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -100,6 +100,20 @@ export default (props) => { ); } + let bugReports = null; + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

    { + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

    + ); + } + return ( { }, }) }

    -

    { - _t("PRO TIP: If you start a bug, please submit debug logs " + - "to help us track down the problem.", {}, { - debugLogsLink: sub => ( - {sub} - ), - }) - }

    + {bugReports}
    { countlyFeedbackSection } } diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index d65ec7563f..f18b7a9d0c 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -20,6 +20,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; const PHASE_START = 0; const PHASE_SHOW_SAS = 1; @@ -123,22 +124,21 @@ export default class IncomingSasDialog extends React.Component { const Spinner = sdk.getComponent("views.elements.Spinner"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - const isSelf = this.props.verifier.userId == MatrixClientPeg.get().getUserId(); + const isSelf = this.props.verifier.userId === MatrixClientPeg.get().getUserId(); let profile; - if (this.state.opponentProfile) { + const oppProfile = this.state.opponentProfile; + if (oppProfile) { + const url = oppProfile.avatar_url + ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio)) + : null; profile =
    - -

    {this.state.opponentProfile.displayname}

    +

    {oppProfile.displayname}

    ; } else if (this.state.opponentProfileError) { profile =
    diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index b28cc1bf41..8ad262cff9 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -22,7 +22,6 @@ import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Pe import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import SdkConfig from "../../../SdkConfig"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import * as Email from "../../../email"; import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; import {abbreviateUrl} from "../../../utils/UrlUtils"; @@ -43,6 +42,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; 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"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -160,9 +160,9 @@ class DMUserTile extends React.PureComponent { width={avatarSize} height={avatarSize} /> : { src={require("../../../../res/img/icon-email-pill-avatar.svg")} width={avatarSize} height={avatarSize} /> : { - let filterText = this.state.filterText; - const targets = this.state.targets.map(t => t); // cheap clone for mutation - const idx = targets.indexOf(member); - if (idx >= 0) { - targets.splice(idx, 1); - } else { - targets.push(member); - filterText = ""; // clear the filter when the user accepts a suggestion - } - this.setState({targets, filterText}); + 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); + if (idx >= 0) { + targets.splice(idx, 1); + } else { + targets.push(member); + filterText = ""; // clear the filter when the user accepts a suggestion + } + this.setState({targets, filterText}); - if (this._editorRef && this._editorRef.current) { - this._editorRef.current.focus(); + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } } }; @@ -1256,7 +1258,9 @@ export default class InviteDialog extends React.PureComponent + {_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}, + )} +

    + ); + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

    + {_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, + }, + { + "a": (sub) => { + return {sub}; + }, + }, + )} +

    + ); + } + return ( -

    - {_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, - }, - { - "a": (sub) => { - return {sub}; - }, - }, - )} -

    + {bugReports}

    {_t( "You'll upgrade this room from to .", diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index f6bf5b87e6..b016e320eb 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -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"; @@ -134,16 +133,17 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin />

    - { defaultDispatcher.dispatch({ action: "leave_room", room_id: space.roomId, }); }} - /> + > + { _t("Leave Space") } +
    Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}> @@ -152,7 +152,9 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin { _t("Cancel") } - + + { busy ? _t("Saving...") : _t("Save Changes") } +
    ; diff --git a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js index 07e29adcff..618b0b4347 100644 --- a/src/components/views/dialogs/TabbedIntegrationManagerDialog.js +++ b/src/components/views/dialogs/TabbedIntegrationManagerDialog.js @@ -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'; diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index 72e6c3f3a0..e8625ec6cb 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -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
    {_t("Identity Server")}
    ({host})
    ; - case Matrix.SERVICE_TYPES.IM: + case SERVICE_TYPES.IM: return
    {_t("Integration Manager")}
    ({host})
    ; } } _summaryForServiceType(serviceType) { switch (serviceType) { - case Matrix.SERVICE_TYPES.IS: + case SERVICE_TYPES.IS: return
    {_t("Find others by phone or email")}
    {_t("Be found by phone or email")}
    ; - case Matrix.SERVICE_TYPES.IM: + case SERVICE_TYPES.IM: return
    {_t("Use bots, bridges, widgets and sticker packs")}
    ; diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js index ca28ca094c..1fafe03d95 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -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'; diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 4a216dbae4..df66d10a71 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -19,10 +19,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import * as sdk from "../../../index"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import { _t } from '../../../languageHandler'; import { UserAddressType } from '../../../UserAddress.js'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.elements.AddressTile") export default class AddressTile extends React.Component { @@ -47,9 +47,7 @@ export default class AddressTile extends React.Component { const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType); if (isMatrixAddress && address.avatarMxc) { - imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( - address.avatarMxc, 25, 25, 'crop', - )); + imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25)); } else if (address.addressType === 'email') { imgUrls.push(require("../../../../res/img/icon-email-user.svg")); } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index c539f2be1c..5fd73f974d 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -70,9 +70,7 @@ export default class EventTilePreview extends React.Component { const client = MatrixClientPeg.get(); const userId = client.getUserId(); const profileInfo = await client.getProfileInfo(userId); - const avatarUrl = Avatar.avatarUrlForUser( - {avatarUrl: profileInfo.avatar_url}, - AVATAR_SIZE, AVATAR_SIZE, "crop"); + const avatarUrl = profileInfo.avatar_url; this.setState({ userId, @@ -113,8 +111,9 @@ export default class EventTilePreview extends React.Component { name: displayname, userId: userId, getAvatarUrl: (..._) => { - return avatarUrl; + return Avatar.avatarUrlForUser({avatarUrl}, AVATAR_SIZE, AVATAR_SIZE, "crop"); }, + getMxcAvatarUrl: () => avatarUrl, }; return event; diff --git a/src/components/views/elements/Flair.js b/src/components/views/elements/Flair.js index 75998cb721..73d5b91511 100644 --- a/src/components/views/elements/Flair.js +++ b/src/components/views/elements/Flair.js @@ -20,6 +20,7 @@ import FlairStore from '../../../stores/FlairStore'; import dis from '../../../dispatcher/dispatcher'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; class FlairAvatar extends React.Component { @@ -39,8 +40,7 @@ class FlairAvatar extends React.Component { } render() { - const httpUrl = this.context.mxcUrlToHttp( - this.props.groupProfile.avatarUrl, 16, 16, 'scale', false); + const httpUrl = mediaFromMxc(this.props.groupProfile.avatarUrl).getSquareThumbnailHttp(16); const tooltip = this.props.groupProfile.name ? `${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`: this.props.groupProfile.groupId; diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index b0d4fc7fa2..a8e16813e6 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019, 2021 The Matrix.org Foundation C.I.C. +Copyright 2017 - 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. @@ -19,13 +17,15 @@ 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"; import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; +import {mediaFromMxc} from "../../../customisations/Media"; import Tooltip from './Tooltip'; import {replaceableComponent} from "../../../utils/replaceableComponent"; @@ -254,12 +254,12 @@ class Pill extends React.Component { case Pill.TYPE_GROUP_MENTION: { if (this.state.group) { const {avatarUrl, groupId, name} = this.state.group; - const cli = MatrixClientPeg.get(); linkText = groupId; if (this.props.shouldShowPillAvatar) { - avatar =
    ); + const httpUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(800); + avatarElement =
    ; } const groupRoomName = this.state.groupRoom.displayname; diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js index 8b25437f71..7edfc1a376 100644 --- a/src/components/views/groups/GroupRoomTile.js +++ b/src/components/views/groups/GroupRoomTile.js @@ -21,6 +21,7 @@ import dis from '../../../dispatcher/dispatcher'; import { GroupRoomType } from '../../../groups'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.groups.GroupRoomTile") class GroupRoomTile extends React.Component { @@ -42,10 +43,9 @@ class GroupRoomTile extends React.Component { render() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const avatarUrl = this.context.mxcUrlToHttp( - this.props.groupRoom.avatarUrl, - 36, 36, 'crop', - ); + const avatarUrl = this.props.groupRoom.avatarUrl + ? mediaFromMxc(this.props.groupRoom.avatarUrl).getSquareThumbnailHttp(36) + : null; const av = ( { profile.shortDescription }
    :
    ; - const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp( - profile.avatarUrl, avatarHeight, avatarHeight, "crop") : null; + const httpUrl = profile.avatarUrl + ? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight) + : null; let avatarElement = (
    diff --git a/src/components/views/messages/EditHistoryMessage.js b/src/components/views/messages/EditHistoryMessage.js index c5002b3308..e2eda1e12a 100644 --- a/src/components/views/messages/EditHistoryMessage.js +++ b/src/components/views/messages/EditHistoryMessage.js @@ -19,7 +19,7 @@ import PropTypes from 'prop-types'; import * as HtmlUtils from '../../../HtmlUtils'; import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils'; import {formatTime} from '../../../DateUtils'; -import {MatrixEvent} from 'matrix-js-sdk'; +import {MatrixEvent} from 'matrix-js-sdk/src/models/event'; import {pillifyLinks, unmountPills} from '../../../utils/pillify'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -76,12 +76,7 @@ export default class EditHistoryMessage extends React.PureComponent { _onViewSourceClick = () => { const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', 'Edit history', ViewSource, { - roomId: this.props.mxEvent.getRoomId(), - eventId: this.props.mxEvent.getId(), - content: this.props.mxEvent.event, - isEncrypted: this.props.mxEvent.isEncrypted(), - // FIXME: _clearEvent is private - decryptedContent: this.props.mxEvent._clearEvent, + mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); }; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 498e2db12a..0d5e449fc0 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -17,11 +17,11 @@ import React from 'react'; import MFileBody from './MFileBody'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; @replaceableComponent("views.messages.MAudioBody") export default class MAudioBody extends React.Component { @@ -41,11 +41,11 @@ export default class MAudioBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); + return media.srcHttp; } } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index e9893f99b6..8f464e08bd 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd +Copyright 2015, 2016, 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. @@ -18,52 +17,24 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import filesize from 'filesize'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import {decryptFile} from '../../../utils/DecryptFile'; -import Tinter from '../../../Tinter'; -import request from 'browser-request'; import Modal from '../../../Modal'; import AccessibleButton from "../elements/AccessibleButton"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; +import ErrorDialog from "../dialogs/ErrorDialog"; +let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on -// A cached tinted copy of require("../../../../res/img/download.svg") -let tintedDownloadImageURL; -// Track a list of mounted MFileBody instances so that we can update -// the require("../../../../res/img/download.svg") when the tint changes. -let nextMountId = 0; -const mounts = {}; - -/** - * Updates the tinted copy of require("../../../../res/img/download.svg") when the tint changes. - */ -function updateTintedDownloadImage() { - // Download the svg as an XML document. - // We could cache the XML response here, but since the tint rarely changes - // it's probably not worth it. - // Also note that we can't use fetch here because fetch doesn't support - // file URLs, which the download image will be if we're running from - // the filesystem (like in an Electron wrapper). - request({uri: require("../../../../res/img/download.svg")}, (err, response, body) => { - if (err) return; - - const svg = new DOMParser().parseFromString(body, "image/svg+xml"); - // Apply the fixups to the XML. - const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]); - Tinter.applySvgFixups(fixups); - // Encoded the fixed up SVG as a data URL. - const svgString = new XMLSerializer().serializeToString(svg); - tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString); - // Notify each mounted MFileBody that the URL has changed. - Object.keys(mounts).forEach(function(id) { - mounts[id].tint(); - }); - }); +async function cacheDownloadIcon() { + if (downloadIconUrl) return; // cached already + const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text()); + downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg); } -Tinter.registerTintable(updateTintedDownloadImage); +// Cache the asset immediately +cacheDownloadIcon(); // User supplied content can contain scripts, we have to be careful that // we don't accidentally run those script within the same origin as the @@ -106,6 +77,7 @@ function computedStyle(element) { } const style = window.getComputedStyle(element, null); let cssText = style.cssText; + // noinspection EqualityComparisonWithCoercionJS if (cssText == "") { // Firefox doesn't implement ".cssText" for computed styles. // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 @@ -145,7 +117,6 @@ export default class MFileBody extends React.Component { this._iframe = createRef(); this._dummyLink = createRef(); - this._downloadImage = createRef(); } /** @@ -178,16 +149,8 @@ export default class MFileBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - return MatrixClientPeg.get().mxcUrlToHttp(content.url); - } - - componentDidMount() { - // Add this to the list of mounted components to receive notifications - // when the tint changes. - this.id = nextMountId++; - mounts[this.id] = this; - this.tint(); + const media = mediaFromContent(this.props.mxEvent.getContent()); + return media.srcHttp; } componentDidUpdate(prevProps, prevState) { @@ -196,34 +159,12 @@ export default class MFileBody extends React.Component { } } - componentWillUnmount() { - // Remove this from the list of mounted components - delete mounts[this.id]; - } - - tint = () => { - // Update our tinted copy of require("../../../../res/img/download.svg") - if (this._downloadImage.current) { - this._downloadImage.current.src = tintedDownloadImageURL; - } - if (this._iframe.current) { - // If the attachment is encrypted then the download image - // will be inside the iframe so we wont be able to update - // it directly. - this._iframe.current.contentWindow.postMessage({ - imgSrc: tintedDownloadImageURL, - style: computedStyle(this._dummyLink.current), - }, "*"); - } - }; - render() { const content = this.props.mxEvent.getContent(); const text = this.presentableTextForFile(content); const isEncrypted = content.file !== undefined; const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const contentUrl = this._getContentUrl(); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const fileSize = content.info ? content.info.size : null; const fileType = content.info ? content.info.mimetype : "application/octet-stream"; @@ -280,7 +221,8 @@ export default class MFileBody extends React.Component { // When the iframe loads we tell it to render a download link const onIframeLoad = (ev) => { ev.target.contentWindow.postMessage({ - imgSrc: tintedDownloadImageURL, + imgSrc: downloadIconUrl, + imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon. style: computedStyle(this._dummyLink.current), blob: this.state.decryptedBlob, // Set a download attribute for encrypted files so that the file @@ -384,7 +326,7 @@ export default class MFileBody extends React.Component { {placeholder} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 59c5b4e66b..3683818027 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -28,6 +28,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; @replaceableComponent("views.messages.MImageBody") export default class MImageBody extends React.Component { @@ -70,7 +71,7 @@ export default class MImageBody extends React.Component { this._image = createRef(); } - // FIXME: factor this out and aplpy it to MVideoBody and MAudioBody too! + // FIXME: factor this out and apply it to MVideoBody and MAudioBody too! onClientSync(syncState, prevState) { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. @@ -167,16 +168,16 @@ export default class MImageBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return this.context.mxcUrlToHttp(content.url); + return media.srcHttp; } } _getThumbUrl() { - // FIXME: the dharma skin lets images grow as wide as you like, rather than capped to 800x600. + // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. // custom timeline widths seems preferable. @@ -185,21 +186,19 @@ export default class MImageBody extends React.Component { const thumbHeight = Math.round(600 * pixelRatio); const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(content); + + if (media.isEncrypted) { // Don't use the thumbnail for clients wishing to autoplay gifs. if (this.state.decryptedThumbnailUrl) { return this.state.decryptedThumbnailUrl; } return this.state.decryptedUrl; - } else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) { + } else if (content.info && content.info.mimetype === "image/svg+xml" && media.hasThumbnail) { // special case to return clientside sender-generated thumbnails for SVGs, if any, // given we deliberately don't thumbnail them serverside to prevent // billion lol attacks and similar - return this.context.mxcUrlToHttp( - content.info.thumbnail_url, - thumbWidth, - thumbHeight, - ); + return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale'); } else { // we try to download the correct resolution // for hi-res images (like retina screenshots). @@ -218,7 +217,7 @@ export default class MImageBody extends React.Component { pixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { - return this.context.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } else { // we should only request thumbnails if the image is bigger than 800x600 // (or 1600x1200 on retina) otherwise the image in the timeline will just @@ -233,24 +232,17 @@ export default class MImageBody extends React.Component { info.w > thumbWidth || info.h > thumbHeight ); - const isLargeFileSize = info.size > 1*1024*1024; + const isLargeFileSize = info.size > 1*1024*1024; // 1mb if (isLargeFileSize && isLargerThanThumbnail) { // image is too large physically and bytewise to clutter our timeline so // we ask for a thumbnail, despite knowing that it will be max 800x600 // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). - return this.context.mxcUrlToHttp( - content.url, - thumbWidth, - thumbHeight, - ); + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } else { // download the original image otherwise, so we can scale it client side // to take pixelRatio into account. - // ( no width/height means we want the original image) - return this.context.mxcUrlToHttp( - content.url, - ); + return media.srcHttp; } } } diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 89985dee7d..89e661cb2f 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -17,12 +17,12 @@ limitations under the License. import React from 'react'; import MFileBody from './MFileBody'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; interface IProps { /* the MatrixEvent to show */ @@ -76,11 +76,11 @@ export default class MVideoBody extends React.PureComponent { } private getContentUrl(): string|null { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); + return media.srcHttp; } } @@ -91,10 +91,11 @@ export default class MVideoBody extends React.PureComponent { private getThumbUrl(): string|null { const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(content); + if (media.isEncrypted) { return this.state.decryptedThumbnailUrl; - } else if (content.info && content.info.thumbnail_url) { - return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); + } else if (media.hasThumbnail) { + return media.thumbnailHttp; } else { return null; } diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index c33debe3f5..5a6e7d87b7 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -18,7 +18,7 @@ limitations under the License. import React, {useEffect} from 'react'; import PropTypes from 'prop-types'; -import { EventStatus } from 'matrix-js-sdk'; +import { EventStatus } from 'matrix-js-sdk/src/models/event'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 866e0f521d..28c2f8f9b9 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -71,6 +71,10 @@ export default class MessageEvent extends React.Component { 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), + + // TODO: @@ TravisR: Use labs flag determination. + // MSC: https://github.com/matrix-org/matrix-doc/pull/2516 + 'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js index ba860216f0..00aaf9bfda 100644 --- a/src/components/views/messages/RoomAvatarEvent.js +++ b/src/components/views/messages/RoomAvatarEvent.js @@ -24,6 +24,7 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import AccessibleButton from '../elements/AccessibleButton'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.messages.RoomAvatarEvent") export default class RoomAvatarEvent extends React.Component { @@ -35,7 +36,7 @@ export default class RoomAvatarEvent extends React.Component { onAvatarClick = () => { const cli = MatrixClientPeg.get(); const ev = this.props.mxEvent; - const httpUrl = cli.mxcUrlToHttp(ev.getContent().url); + const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp; const room = cli.getRoom(this.props.mxEvent.getRoomId()); const text = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', { diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index b0eb6f2f35..353f40b6a9 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -216,12 +216,12 @@ export default class TextualBody extends React.Component { } _addLineNumbers(pre) { + // Calculate number of lines in pre + const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length; pre.innerHTML = '' + pre.innerHTML + ''; const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0]; - // Calculate number of lines in pre - const number = pre.innerHTML.split(/\n/).length; // Iterate through lines starting with 1 (number of the first line is 1) - for (let i = 1; i < number; i++) { + for (let i = 1; i <= number; i++) { lineNumbers.innerHTML += '' + i + ''; } } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 5fbfd03bd1..12a6a2a311 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -64,6 +64,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IDevice { deviceId: string; @@ -1424,14 +1425,14 @@ const UserInfoHeader: React.FC<{ const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl; if (!avatarUrl) return; - const httpUrl = cli.mxcUrlToHttp(avatarUrl); + const httpUrl = mediaFromMxc(avatarUrl).srcHttp; const params = { src: httpUrl, name: member.name, }; Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); - }, [cli, member]); + }, [member]); const avatarElement = (
    diff --git a/src/components/views/room_settings/RelatedGroupSettings.js b/src/components/views/room_settings/RelatedGroupSettings.js index f82e238722..272ecd1228 100644 --- a/src/components/views/room_settings/RelatedGroupSettings.js +++ b/src/components/views/room_settings/RelatedGroupSettings.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {MatrixEvent} from 'matrix-js-sdk'; +import {MatrixEvent} from 'matrix-js-sdk/src/models/event'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; diff --git a/src/components/views/room_settings/RoomProfileSettings.js b/src/components/views/room_settings/RoomProfileSettings.js index 563368384b..3dbe2b2b7f 100644 --- a/src/components/views/room_settings/RoomProfileSettings.js +++ b/src/components/views/room_settings/RoomProfileSettings.js @@ -21,6 +21,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Field from "../elements/Field"; import * as sdk from "../../../index"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; // TODO: Merge with ProfileSettings? @replaceableComponent("views.room_settings.RoomProfileSettings") @@ -38,7 +39,7 @@ export default class RoomProfileSettings extends React.Component { const avatarEvent = room.currentState.getStateEvents("m.room.avatar", ""); let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null; - if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); + if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); const topicEvent = room.currentState.getStateEvents("m.room.topic", ""); const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()['topic'] : ''; @@ -112,7 +113,7 @@ export default class RoomProfileSettings extends React.Component { if (this.state.avatarFile) { const uri = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: uri}, ''); - newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); + newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; newState.avatarFile = null; } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 5ab2b82a32..1a95b4366a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -93,6 +93,7 @@ interface IProps { placeholder?: string; label?: string; initialCaret?: DocumentOffset; + disabled?: boolean; onChange?(); onPaste?(event: ClipboardEvent, model: EditorModel): boolean; @@ -672,6 +673,9 @@ export default class BasicMessageEditor extends React.Component }); const classes = classNames("mx_BasicMessageComposer_input", { "mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar, + + // TODO: @@ TravisR: This doesn't work properly. The composer resets in a strange way. + "mx_BasicMessageComposer_input_disabled": this.props.disabled, }); const shortcuts = { @@ -704,6 +708,7 @@ export default class BasicMessageEditor extends React.Component aria-expanded={Boolean(this.state.autoComplete)} aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined} dir="auto" + aria-disabled={this.props.disabled} />
    ); } diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.js index 6ecb2bd549..be04a50798 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -27,7 +27,7 @@ import {parseEvent} from '../../../editor/deserialize'; import {PartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import classNames from 'classnames'; -import {EventStatus} from 'matrix-js-sdk'; +import {EventStatus} from 'matrix-js-sdk/src/models/event'; import BasicMessageComposer from "./BasicMessageComposer"; import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index cbe3252c2b..644d64d322 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -28,7 +28,7 @@ import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; import SettingsStore from "../../../settings/SettingsStore"; import {Layout, LayoutPropType} from "../../../settings/Layout"; -import {EventStatus} from 'matrix-js-sdk'; +import {EventStatus} from 'matrix-js-sdk/src/models/event'; import {formatTime} from "../../../DateUtils"; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {ALL_RULE_TYPES} from "../../../mjolnir/BanList"; diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 39c9f0bcf7..536abf57fc 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -26,6 +26,7 @@ import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; import { _t } from "../../../languageHandler"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.rooms.LinkPreviewWidget") export default class LinkPreviewWidget extends React.Component { @@ -83,7 +84,7 @@ export default class LinkPreviewWidget extends React.Component { let src = p["og:image"]; if (src && src.startsWith("mxc://")) { - src = MatrixClientPeg.get().mxcUrlToHttp(src); + src = mediaFromMxc(src).srcHttp; } const params = { @@ -109,9 +110,11 @@ export default class LinkPreviewWidget extends React.Component { if (!SettingsStore.getValue("showImages")) { image = null; // Don't render a button to show the image, just hide it outright } - const imageMaxWidth = 100; const imageMaxHeight = 100; + const imageMaxWidth = 100; + const imageMaxHeight = 100; if (image && image.startsWith("mxc://")) { - image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight); + // We deliberately don't want a square here, so use the source HTTP thumbnail function + image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, 'scale'); } let thumbHeight = imageMaxHeight; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index ccf097c4fd..b7078766fb 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -33,6 +33,7 @@ import WidgetStore from "../../../stores/WidgetStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; function ComposerAvatar(props) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); @@ -187,6 +188,7 @@ export default class MessageComposer extends React.Component { hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room), joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room), isComposerEmpty: true, + haveRecording: false, }; } @@ -325,6 +327,10 @@ export default class MessageComposer extends React.Component { }); } + onVoiceUpdate = (haveRecording: boolean) => { + this.setState({haveRecording}); + }; + render() { const controls = [ this.state.me ? : null, @@ -346,17 +352,32 @@ export default class MessageComposer extends React.Component { permalinkCreator={this.props.permalinkCreator} replyToEvent={this.props.replyToEvent} onChange={this.onChange} + // TODO: @@ TravisR - Disabling the composer doesn't work + disabled={this.state.haveRecording} />, - , - , ); + if (!this.state.haveRecording) { + controls.push( + , + , + ); + } + if (SettingsStore.getValue(UIFeature.Widgets) && - SettingsStore.getValue("MessageComposerInput.showStickersButton")) { + SettingsStore.getValue("MessageComposerInput.showStickersButton") && + !this.state.haveRecording) { controls.push(); } - if (!this.state.isComposerEmpty) { + if (SettingsStore.getValue("feature_voice_messages")) { + controls.push(); + } + + if (!this.state.isComposerEmpty || this.state.haveRecording) { controls.push( , ); diff --git a/src/components/views/rooms/MessageComposerFormatBar.js b/src/components/views/rooms/MessageComposerFormatBar.js index d2539b1ef4..fc0f785b08 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.js +++ b/src/components/views/rooms/MessageComposerFormatBar.js @@ -85,8 +85,8 @@ class FormatButton extends React.PureComponent { return ( { const cli = useContext(MatrixClientContext); @@ -100,17 +101,48 @@ const NewRoomIntro = () => { }); } - let buttons; - if (room.canInvite(cli.getUserId())) { - const onInviteClick = () => { - dis.dispatch({ action: "view_invite", roomId }); - }; + let parentSpace; + if ( + SpaceStore.instance.activeSpace?.canInvite(cli.getUserId()) && + SpaceStore.instance.getSpaceFilteredRoomIds(SpaceStore.instance.activeSpace).has(room.roomId) + ) { + parentSpace = SpaceStore.instance.activeSpace; + } + let buttons; + if (parentSpace) { buttons =
    - + { + dis.dispatch({ action: "view_invite", roomId }); + }} + > + {_t("Invite to %(spaceName)s", { spaceName: parentSpace.name })} + + { room.canInvite(cli.getUserId()) && { + dis.dispatch({ action: "view_invite", roomId }); + }} + > + {_t("Invite to just this room")} + } +
    ; + } else if (room.canInvite(cli.getUserId())) { + buttons =
    + { + dis.dispatch({ action: "view_invite", roomId }); + }} + > {_t("Invite to this room")} -
    +
    ; } const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url; diff --git a/src/components/views/rooms/RoomDetailRow.js b/src/components/views/rooms/RoomDetailRow.js index e7c259cd98..62960930f2 100644 --- a/src/components/views/rooms/RoomDetailRow.js +++ b/src/components/views/rooms/RoomDetailRow.js @@ -18,10 +18,9 @@ import * as sdk from '../../../index'; import React, {createRef} from 'react'; import { _t } from '../../../languageHandler'; import { linkifyElement } from '../../../HtmlUtils'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import PropTypes from 'prop-types'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; export function getDisplayAliasForRoom(room) { return room.canonicalAlias || (room.aliases ? room.aliases[0] : ""); @@ -100,13 +99,14 @@ export default class RoomDetailRow extends React.Component { { guestJoin }
    ) :
    ; + let avatarUrl = null; + if (room.avatarUrl) avatarUrl = mediaFromMxc(room.avatarUrl).getSquareThumbnailHttp(24); + return + url={avatarUrl} />
    { name }
      diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 4378154d8f..e83b07f71b 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -20,6 +20,7 @@ import React, { ReactComponentElement } from "react"; import { Dispatcher } from "flux"; import { Room } from "matrix-js-sdk/src/models/room"; import * as fbEmitter from "fbemitter"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { _t, _td } from "../../../languageHandler"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; @@ -48,12 +49,15 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con import AccessibleButton from "../elements/AccessibleButton"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import CallHandler from "../../../CallHandler"; -import SpaceStore, { SUGGESTED_ROOMS } from "../../../stores/SpaceStore"; +import SpaceStore, {SUGGESTED_ROOMS} from "../../../stores/SpaceStore"; import { showAddExistingRooms, showCreateNewRoom } from "../../../utils/space"; -import { EventType } from "matrix-js-sdk/src/@types/event"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import RoomAvatar from "../avatars/RoomAvatar"; import { ISpaceSummaryRoom } from "../../structures/SpaceRoomDirectory"; +import { showRoomInviteDialog } from "../../../RoomInvite"; +import Modal from "../../../Modal"; +import SpacePublicShare from "../spaces/SpacePublicShare"; +import InfoDialog from "../dialogs/InfoDialog"; interface IProps { onKeyDown: (ev: React.KeyboardEvent) => void; @@ -62,6 +66,7 @@ interface IProps { onResize: () => void; resizeNotifier: ResizeNotifier; isMinimized: boolean; + activeSpace: Room; } interface IState { @@ -194,8 +199,8 @@ const TAG_AESTHETICS: ITagAestheticsMap = { : _t("You do not have permissions to add rooms to this space")} /> { e.preventDefault(); e.stopPropagation(); @@ -424,6 +429,25 @@ export default class RoomList extends React.PureComponent { dis.dispatch({ action: Action.ViewRoomDirectory, initialText }); }; + private onSpaceInviteClick = () => { + const initialText = RoomListStore.instance.getFirstNameFilterCondition()?.search; + if (this.props.activeSpace.getJoinRule() === "public") { + const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, { + title: _t("Invite to %(spaceName)s", { spaceName: this.props.activeSpace.name }), + description: + { _t("Share your public space") } + modal.close()} /> + , + fixedWidth: false, + button: false, + className: "mx_SpacePanel_sharePublicSpace", + hasCloseButton: true, + }); + } else { + showRoomInviteDialog(this.props.activeSpace.roomId, initialText); + } + }; + private renderSuggestedRooms(): ReactComponentElement[] { return this.state.suggestedRooms.map(room => { const name = room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"); @@ -569,7 +593,23 @@ export default class RoomList extends React.PureComponent { kind="link" onClick={this.onExplore} > - {_t("Explore all public rooms")} + { this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") } + +
    ; + } else if (this.props.activeSpace) { + explorePrompt =
    +
    { _t("Quick actions") }
    + { this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && + {_t("Invite people")} + } + + {_t("Explore rooms")}
    ; } else if (Object.values(this.state.sublists).some(list => list.length > 0)) { diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 07de70fe45..79db460275 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -333,6 +333,17 @@ export default class RoomTile extends React.PureComponent { this.setState({generalMenuPosition: null}); // hide the menu }; + private onInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + dis.dispatch({ + action: 'view_invite', + roomId: this.props.room.roomId, + }); + this.setState({generalMenuPosition: null}); // hide the menu + }; + private async saveNotifState(ev: ButtonEvent, newState: Volume) { ev.preventDefault(); ev.stopPropagation(); @@ -453,6 +464,8 @@ export default class RoomTile extends React.PureComponent { const isLowPriority = roomTags.includes(DefaultTagID.LowPriority); const lowPriorityLabel = _t("Low Priority"); + const userId = MatrixClientPeg.get().getUserId(); + const canInvite = this.props.room.canInvite(userId); contextMenu = { label={lowPriorityLabel} iconClassName="mx_RoomTile_iconArrowDown" /> - + {canInvite ? ( + + ) : null} { + return !this.model.isEmpty || this.props.replyToEvent; + } + _saveStoredEditorState = () => { - if (this.model.isEmpty) { - this._clearStoredEditorState(); - } else { + if (this._shouldSaveStoredEditorState()) { const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent); localStorage.setItem(this._editorStateKey, JSON.stringify(item)); + } else { + this._clearStoredEditorState(); } } @@ -556,6 +580,7 @@ export default class SendMessageComposer extends React.Component { label={this.props.placeholder} placeholder={this.props.placeholder} onPaste={this._onPaste} + disabled={this.props.disabled} />
    ); diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.js b/src/components/views/rooms/ThirdPartyMemberInfo.js index 5e2d82a1b2..67b86538c9 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.js +++ b/src/components/views/rooms/ThirdPartyMemberInfo.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {MatrixEvent} from "matrix-js-sdk"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {_t} from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import * as sdk from "../../../index"; diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx new file mode 100644 index 0000000000..0d381001a1 --- /dev/null +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -0,0 +1,88 @@ +/* +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 AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import {_t} from "../../../languageHandler"; +import React from "react"; +import {VoiceRecorder} from "../../../voice/VoiceRecorder"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import classNames from "classnames"; + +interface IProps { + room: Room; + onRecording: (haveRecording: boolean) => void; +} + +interface IState { + recorder?: VoiceRecorder; +} + +export default class VoiceRecordComposerTile extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + recorder: null, // not recording by default + }; + } + + private onStartStopVoiceMessage = async () => { + // TODO: @@ TravisR: We do not want to auto-send on stop. + if (this.state.recorder) { + await this.state.recorder.stop(); + const mxc = await this.state.recorder.upload(); + MatrixClientPeg.get().sendMessage(this.props.room.roomId, { + body: "Voice message", + msgtype: "org.matrix.msc2516.voice", + url: mxc, + }); + this.setState({recorder: null}); + this.props.onRecording(false); + return; + } + const recorder = new VoiceRecorder(MatrixClientPeg.get()); + await recorder.start(); + this.props.onRecording(true); + // TODO: @@ TravisR: Run through EQ component + // recorder.frequencyData.onUpdate((freq) => { + // console.log('@@ UPDATE', freq); + // }); + this.setState({recorder}); + }; + + public render() { + const classes = classNames({ + 'mx_MessageComposer_button': !this.state.recorder, + 'mx_MessageComposer_voiceMessage': !this.state.recorder, + 'mx_VoiceRecordComposerTile_stop': !!this.state.recorder, + }); + + let tooltip = _t("Record a voice message"); + if (!!this.state.recorder) { + // TODO: @@ TravisR: Change to match behaviour + tooltip = _t("Stop & send recording"); + } + + return ( + + ); + } +} diff --git a/src/components/views/settings/BridgeTile.tsx b/src/components/views/settings/BridgeTile.tsx index b33219ad4a..3565d1ba2e 100644 --- a/src/components/views/settings/BridgeTile.tsx +++ b/src/components/views/settings/BridgeTile.tsx @@ -16,9 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {_t} from "../../../languageHandler"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Pill from "../elements/Pill"; import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import BaseAvatar from "../avatars/BaseAvatar"; @@ -27,6 +25,7 @@ import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { isUrlPermitted } from '../../../HtmlUtils'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps { ev: MatrixEvent; @@ -114,10 +113,7 @@ export default class BridgeTile extends React.PureComponent { let networkIcon; if (protocol.avatar_url) { - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - protocol.avatar_url, 64, 64, "crop", - ); + const avatarUrl = mediaFromMxc(protocol.avatar_url).getSquareThumbnailHttp(64); networkIcon = ); - } else { + } else if (!EventIndexPeg.platformHasSupport()) { eventIndexingSettings = (
    { @@ -208,6 +208,23 @@ export default class EventIndexPanel extends React.Component { }
    ); + } else { + eventIndexingSettings = ( +
    +

    + {_t("Message search initilisation failed")} +

    + {EventIndexPeg.error && ( +
    + {_t("Advanced")} + + {EventIndexPeg.error.message} + +
    + )} + +
    + ); } return eventIndexingSettings; diff --git a/src/components/views/settings/ProfileSettings.js b/src/components/views/settings/ProfileSettings.js index 30dcdc3c47..971b868751 100644 --- a/src/components/views/settings/ProfileSettings.js +++ b/src/components/views/settings/ProfileSettings.js @@ -24,6 +24,7 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.settings.ProfileSettings") export default class ProfileSettings extends React.Component { @@ -32,7 +33,7 @@ export default class ProfileSettings extends React.Component { const client = MatrixClientPeg.get(); let avatarUrl = OwnProfileStore.instance.avatarMxc; - if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); + if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); this.state = { userId: client.getUserId(), originalDisplayName: OwnProfileStore.instance.displayName, @@ -97,7 +98,7 @@ export default class ProfileSettings extends React.Component { ` (${this.state.avatarFile.size}) bytes`); const uri = await client.uploadContent(this.state.avatarFile); await client.setAvatarUrl(uri); - newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); + newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; newState.avatarFile = null; } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { diff --git a/src/components/views/settings/discovery/EmailAddresses.js b/src/components/views/settings/discovery/EmailAddresses.js index 0493597537..b8d91aae2a 100644 --- a/src/components/views/settings/discovery/EmailAddresses.js +++ b/src/components/views/settings/discovery/EmailAddresses.js @@ -203,6 +203,7 @@ export class EmailAddress extends React.Component { className="mx_ExistingEmailAddress_confirmBtn" kind="primary_sm" onClick={this.onContinueClick} + disabled={this.state.continueDisabled} > {_t("Complete")} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index 314acf5d65..b1ad9f3d23 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -32,7 +32,7 @@ import * as sdk from "../../../../.."; import Modal from "../../../../../Modal"; import dis from "../../../../../dispatcher/dispatcher"; import {Service, startTermsFlow} from "../../../../../Terms"; -import {SERVICE_TYPES} from "matrix-js-sdk"; +import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; import IdentityAuthClient from "../../../../../IdentityAuthClient"; import {abbreviateUrl} from "../../../../../utils/UrlUtils"; import { getThreepidsWithBindStatus } from '../../../../../boundThreepids'; @@ -206,10 +206,10 @@ export default class GeneralUserSettingsTab extends React.Component { _onPasswordChangeError = (err) => { // TODO: Figure out a design that doesn't involve replacing the current dialog - let errMsg = err.error || ""; + let errMsg = err.error || err.message || ""; if (err.httpStatus === 403) { errMsg = _t("Failed to change password. Is your password correct?"); - } else if (err.httpStatus) { + } else if (!errMsg) { errMsg += ` (HTTP status ${err.httpStatus})`; } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js index bc6fe796b8..d8adab55f6 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js @@ -84,6 +84,7 @@ export default class VoiceUserSettingsTab extends React.Component { } } if (error) { + console.log("Failed to list userMedia devices", error); const brand = SdkConfig.get().brand; const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('No media permissions', '', ErrorDialog, { diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 88098d1b66..9ee6edc489 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -21,7 +21,6 @@ import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types import {_t} from "../../../languageHandler"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {ChevronFace, ContextMenu} from "../../structures/ContextMenu"; -import FormButton from "../elements/FormButton"; import createRoom, {IStateEvent, Preset} from "../../../createRoom"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import SpaceBasicSettings from "./SpaceBasicSettings"; @@ -89,6 +88,7 @@ const SpaceCreateMenu = ({ onFinished }) => { power_level_content_override: { // Only allow Admins to write to the timeline to prevent hidden sync spam events_default: 100, + ...Visibility.Public ? { invite: 0 } : {}, }, }, spinner: false, @@ -108,7 +108,7 @@ const SpaceCreateMenu = ({ onFinished }) => { body =

    { _t("Create a space") }

    { _t("Spaces are new ways to group rooms and people. " + - "To join an existing space you’ll need an invite") }

    + "To join an existing space you'll need an invite.") }

    {

    { - _t("Give it a photo, name and description to help you identify it.") + _t("Add some details to help people recognise it.") } { - _t("You can change these at any point.") + _t("You can change these anytime.") }

    - + + { busy ? _t("Creating...") : _t("Create") } +
    ; } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 48e2c86b2c..bacf1bd929 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -220,13 +220,19 @@ const SpacePanel = () => { { + openMenu(); + if (!isPanelCollapsed) setPanelCollapsed(true); + }} isNarrow={isPanelCollapsed} /> setPanelCollapsed(!isPanelCollapsed)} + onClick={() => { + setPanelCollapsed(!isPanelCollapsed); + if (menuDisplayed) closeMenu(); + }} title={expandCollapseButtonTitle} /> { contextMenu } diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index 3930c1db16..fa81b75525 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -26,7 +26,7 @@ import {showRoomInviteDialog} from "../../../RoomInvite"; interface IProps { space: Room; - onFinished(): void; + onFinished?(): void; } const SpacePublicShare = ({ space, onFinished }: IProps) => { @@ -41,23 +41,24 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { const success = await copyPlaintext(permalinkCreator.forRoom()); const text = success ? _t("Copied!") : _t("Failed to copy"); setCopiedText(text); - await sleep(10); + await sleep(5000); if (copiedText === text) { // if the text hasn't changed by another click then clear it after some time setCopiedText(_t("Click to copy")); } }} > - { _t("Share invite link") } +

    { _t("Share invite link") }

    { copiedText } { showRoomInviteDialog(space.roomId); - onFinished(); + if (onFinished) onFinished(); }} > - { _t("Invite by email or username") } +

    { _t("Invite people") }

    + { _t("Invite with email or username") }
    ; }; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 04d6c02208..ca6f90fa91 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -30,9 +30,14 @@ import IconizedContextMenu, { import {_t} from "../../../languageHandler"; import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import {toRightOf} from "../../structures/ContextMenu"; -import {shouldShowSpaceSettings, showCreateNewRoom, showSpaceSettings} from "../../../utils/space"; +import { + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showSpaceSettings, +} from "../../../utils/space"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import {ButtonEvent} from "../elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import Modal from "../../../Modal"; import SpacePublicShare from "./SpacePublicShare"; @@ -51,6 +56,7 @@ interface IItemProps { isNested?: boolean; isPanelCollapsed?: boolean; onExpand?: Function; + parents?: Set; } interface IItemState { @@ -126,7 +132,7 @@ export class SpaceItem extends React.PureComponent { if (this.props.space.getJoinRule() === "public") { const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, { - title: _t("Invite members"), + title: _t("Invite to %(spaceName)s", { spaceName: this.props.space.name }), description: { _t("Share your public space") } modal.close()} /> @@ -169,6 +175,14 @@ export class SpaceItem extends React.PureComponent { this.setState({contextMenuPosition: null}); // also close the menu }; + private onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(this.context, this.props.space); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private onMembersClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -235,15 +249,20 @@ export class SpaceItem extends React.PureComponent { ; } - let newRoomOption; + let newRoomSection; if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - newRoomOption = ( + newRoomSection = - ); + + ; } contextMenu = { label={_t("Explore rooms")} onClick={this.onExploreRoomsClick} /> - { newRoomOption } + { newRoomSection } { leaveSection } ; } @@ -299,7 +318,8 @@ export class SpaceItem extends React.PureComponent { const isNarrow = this.props.isPanelCollapsed; const collapsed = this.state.collapsed || forceCollapsed; - const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId); + const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId) + .filter(s => !this.props.parents?.has(s.roomId)); const isActive = activeSpaces.includes(space); const itemClasses = classNames({ "mx_SpaceItem": true, @@ -312,11 +332,17 @@ export class SpaceItem extends React.PureComponent { mx_SpaceButton_narrow: isNarrow, }); const notificationState = SpaceStore.instance.getNotificationState(space.roomId); - const childItems = childSpaces && !collapsed ? : null; + + let childItems; + if (childSpaces && !collapsed) { + childItems = ; + } + let notifBadge; if (notificationState) { notifBadge =
    @@ -327,7 +353,7 @@ export class SpaceItem extends React.PureComponent { const avatarSize = isNested ? 24 : 32; const toggleCollapseButton = childSpaces && childSpaces.length ? - to continue.": "Voer uw veiligheidszin in of om verder te gaan.", - "Security Phrase": "Veiligheidszin", + "Enter your Security Phrase or to continue.": "Voer uw veiligheidswachtwoord in of om verder te gaan.", + "Security Phrase": "Veiligheidswachtwoord", "Invalid Security Key": "Ongeldige veiligheidssleutel", "Wrong Security Key": "Verkeerde veiligheidssleutel", "Looks good!": "Ziet er goed uit!", @@ -2869,11 +2869,11 @@ "Upload completed": "Upload voltooid", "Maximize dialog": "Maximaliseer dialoog", "%(hostSignupBrand)s Setup": "%(hostSignupBrand)s Installatie", - "You should know": "U moet weten", + "You should know": "Dit moet u weten", "Privacy Policy": "Privacystatement", "Cookie Policy": "Cookiebeleid", - "Abort": "Annuleren", - "Confirm abort of host creation": "Bevestig het annuleren van de host creatie", + "Abort": "Afbreken", + "Confirm abort of host creation": "Bevestig het afbreken van host creatie", "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "U zou dit kunnen aanzetten als dit gesprek alleen gebruikt zal worden voor samenwerking met interne teams op uw homeserver. Dit kan later niet meer veranderd worden.", "You can’t disable this later. Bridges & most bots won’t work yet.": "U kunt dit later niet uitschakelen. Bruggen en de meeste bots zullen nog niet werken.", "There was an error creating your community. The name may be taken or the server is unable to process your request.": "Er is een fout opgetreden bij het aanmaken van uw gemeenschap. De naam kan bezet zijn of de server is niet in staat om uw aanvraag te verwerken.", @@ -2887,7 +2887,7 @@ "Screens": "Schermen", "Share your screen": "Uw scherm delen", "Submit logs": "Logs versturen", - "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Berichten in dit gesprek zijn eind-tot-eind-versleuteld. Als mensen deelnemen, kan u ze verifiëren in hun profiel, tik gewoon op hun avatar.", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Berichten in dit gesprek zijn eind-tot-eind-versleuteld. Als personen deelnemen, kan u ze verifiëren in hun profiel, tik hiervoor op hun avatar.", "In encrypted rooms, verify all users to ensure it’s secure.": "Controleer alle gebruikers in versleutelde gesprekken om er zeker van te zijn dat het veilig is.", "Verify all users in a room to ensure it's secure.": "Controleer alle gebruikers in een gesprek om er zeker van te zijn dat hij veilig is.", "%(count)s people|one": "%(count)s persoon", @@ -2903,7 +2903,7 @@ "Use the + to make a new room or explore existing ones below": "Gebruik de + om een nieuw gesprek te starten of ontdek de bestaande groepen hieronder", "Open dial pad": "Kiestoetsen openen", "Recently visited rooms": "Onlangs geopende gesprekken", - "Add a photo, so people can easily spot your room.": "Voeg een foto toe, zodat mensen je gemakkelijk kunnen herkennen in het gesprek.", + "Add a photo, so people can easily spot your room.": "Voeg een foto toe, zodat personen u gemakkelijk kunnen herkennen in het gesprek.", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Alleen u beiden nemen deel aan dit gesprek, tenzij een van u beiden iemand uitnodigt om deel te nemen.", "Emoji picker": "Emoji kiezer", "Room ID or address of ban list": "Gesprek-ID of het adres van de banlijst", @@ -2916,7 +2916,7 @@ "Hey you. You're the best!": "Hey. U bent de beste!", "Backup key cached:": "Back-up sleutel cached:", "Backup key stored:": "Back-up sleutel bewaard:", - "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "Maak een back-up van uw encryptiesleutels met uw accountgegevens voor het geval u de toegang tot uw sessies verliest. Uw sleutels worden beveiligd met een unieke veiligheidssleutel.", + "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "Maak een back-up van uw versleutelingssleutels met uw accountgegevens voor het geval u de toegang tot uw sessies verliest. Uw sleutels worden beveiligd met een unieke veiligheidssleutel.", "well formed": "goed gevormd", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s kan versleutelde berichten niet veilig lokaal opslaan in een webbrowser. Gebruik %(brand)s Desktop om versleutelde berichten in zoekresultaten te laten verschijnen.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Veilig lokaal opslaan van versleutelde berichten zodat ze in de zoekresultaten verschijnen, gebruik %(size)s voor het opslaan van berichten uit %(rooms)s gesprek.", @@ -2950,7 +2950,7 @@ "Create a Group Chat": "Maak een groepsgesprek aan", "Send a Direct Message": "Start een direct gesprek", "Welcome to %(appName)s": "Welkom bij %(appName)s", - "Add a topic to help people know what it is about.": "Stel een gespreksonderwerp in zodat mensen weten waar het over gaat.", + "Add a topic to help people know what it is about.": "Stel een gespreksonderwerp in zodat de personen weten waar het over gaat.", "Upgrade to %(hostSignupBrand)s": "Upgrade naar %(hostSignupBrand)s", "Edit Values": "Waarde wijzigen", "Values at explicit levels in this room:": "Waarde op expliciete niveaus in dit gesprek:", @@ -2972,5 +2972,120 @@ "Setting ID": "Instellingen-ID", "Failed to save settings": "Kan geen instellingen opslaan", "Settings Explorer": "Instellingen Ontdekken", - "Show chat effects (animations when receiving e.g. confetti)": "Effecten tonen (animaties bij ontvangst bijv. confetti)" + "Show chat effects (animations when receiving e.g. confetti)": "Effecten tonen (animaties bij ontvangst bijv. confetti)", + "Jump to the bottom of the timeline when you send a message": "Naar de onderkant van de tijdlijn springen wanneer u een bericht verstuurd", + "Original event source": "Originele gebeurtenisbron", + "Decrypted event source": "Ontsleutel de gebeurtenisbron", + "We'll create rooms for each of them. You can add existing rooms after setup.": "We maken gesprekken voor elk van hen. U kunt bestaande gesprekken toevoegen na het instellen.", + "What projects are you working on?": "Aan welke projecten werkt u?", + "We'll create rooms for each topic.": "We maken gesprekken voor elk onderwerp.", + "What are some things you want to discuss?": "Wat zijn dingen die u wilt bespreken?", + "Inviting...": "Uitnodigen...", + "Invite by username": "Op gebruikersnaam uitnodigen", + "Invite your teammates": "Uw teamgenoten uitnodigen", + "Failed to invite the following users to your space: %(csvUsers)s": "Het uitnodigen van de volgende gebruikers voor uw space is mislukt: %(csvUsers)s", + "A private space for you and your teammates": "Een privé space voor u en uw teamgenoten", + "Me and my teammates": "Ik en mijn teamgenoten", + "A private space just for you": "Een privé space alleen voor u", + "Just Me": "Alleen Ik", + "Ensure the right people have access to the space.": "Zorg ervoor dat de juiste personen toegang hebben tot deze space.", + "Who are you working with?": "Met wie werkt u samen?", + "Finish": "Voltooien", + "At the moment only you can see it.": "Op dit moment kan u deze alleen zien.", + "Creating rooms...": "Gesprekken aanmaken...", + "Skip for now": "Voorlopig overslaan", + "Failed to create initial space rooms": "Het maken van de space gesprekken is mislukt", + "Room name": "Gespreksnaam", + "Support": "Ondersteuning", + "Random": "Willekeurig", + "Welcome to ": "Welkom in ", + "Your private space ": "Uw privé space ", + "Your public space ": "Uw openbare space ", + "You have been invited to ": "U bent uitgenodigd voor ", + " invited you to ": " heeft u uitgenodigd voor ", + "%(count)s members|other": "%(count)s personen", + "%(count)s members|one": "%(count)s persoon", + "Your server does not support showing space hierarchies.": "Uw server heeft geen ondersteuning voor het weergeven van space indelingen.", + "Default Rooms": "Standaard Gesprekken", + "Add existing rooms & spaces": "Bestaande gesprekken en spaces toevoegen", + "Accept Invite": "Uitnodiging Accepteren", + "Find a room...": "Vind een gesprek...", + "Manage rooms": "Gesprekken beheren", + "Promoted to users": "Gepromoot aan gebruikers", + "Save changes": "Wijzigingen opslaan", + "You're in this room": "U bent in dit gesprek", + "You're in this space": "U bent in deze space", + "No permissions": "Geen rechten", + "Remove from Space": "Van space verwijderen", + "Undo": "Ongedaan maken", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Uw bericht is niet verstuurd, omdat deze homeserver is geblokkeerd door zijn beheerder. Gelieve contact op te nemen met uw beheerder om de dienst te blijven gebruiken.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Weet u zeker dat u de space '%(spaceName)s' wilt verlaten?", + "This space is not public. You will not be able to rejoin without an invite.": "Deze space is niet openbaar. Zonder uitnodiging zult u niet opnieuw kunnen toetreden.", + "Start audio stream": "Audio-stream starten", + "Failed to start livestream": "Starten van livestream is mislukt", + "Unable to start audio streaming.": "Kan audio-streaming niet starten.", + "Save Changes": "Wijzigingen Opslaan", + "Saving...": "Opslaan...", + "View dev tools": "Bekijk dev tools", + "Leave Space": "Space verlaten", + "Make this space private": "Maak deze space privé", + "Failed to save space settings.": "Het opslaan van de space-instellingen is mislukt.", + "Space settings": "Space-instellingen", + "Edit settings relating to your space.": "Bewerk instellingen gerelateerd aan uw space.", + "Invite someone using their name, username (like ) or share this space.": "Nodig iemand uit per naam, gebruikersnaam (zoals ) of deel deze space.", + "Invite someone using their name, email address, username (like ) or share this space.": "Nodig iemand uit per naam, e-mailadres, gebruikersnaam (zoals ) of deel deze space.", + "Unnamed Space": "Naamloze Space", + "Invite to %(spaceName)s": "Voor %(spaceName)s uitnodigen", + "Failed to add rooms to space": "Het toevoegen van gesprekken aan de space is mislukt", + "Apply": "Toepassen", + "Applying...": "Toepassen...", + "Create a new room": "Een nieuw gesprek aanmaken", + "Don't want to add an existing room?": "Wilt u geen bestaand gesprek toevoegen?", + "Spaces": "Spaces", + "Filter your rooms and spaces": "Gesprekken en spaces filteren", + "Add existing spaces/rooms": "Bestaande spaces/gesprekken toevoegen", + "Space selection": "Space-selectie", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "U kunt deze wijziging niet ongedaan maken omdat u uzelf rechten ontneemt, als u de laatste bevoegde gebruiker in de ruimte bent zal het onmogelijk zijn om weer rechten te krijgen.", + "Empty room": "Leeg gesprek", + "Suggested Rooms": "Gespreksuggesties", + "Explore space rooms": "Space-gesprekken ontdekken", + "You do not have permissions to add rooms to this space": "U hebt geen toestemming om gesprekken toe te voegen in deze space", + "Add existing room": "Bestaande gesprekken toevoegen", + "You do not have permissions to create new rooms in this space": "U hebt geen toestemming om gesprekken te maken in deze space", + "Send message": "Bericht versturen", + "Invite to this space": "Uitnodigen voor deze space", + "Your message was sent": "Uw bericht is verstuurd", + "Encrypting your message...": "Uw bericht versleutelen...", + "Sending your message...": "Uw bericht versturen...", + "Spell check dictionaries": "Spellingscontrole woordenboeken", + "Space options": "Space-opties", + "Space Home": "Space Thuis", + "New room": "Nieuw gesprek", + "Leave space": "Space verlaten", + "Invite people": "Personen uitnodigen", + "Share your public space": "Deel uw publieke space", + "Invite members": "Leden uitnodigen", + "Invite by email or username": "Uitnodigen per e-mail of gebruikersnaam", + "Share invite link": "Deel uitnodigingskoppeling", + "Click to copy": "Klik om te kopiëren", + "Collapse space panel": "Space-paneel invouwen", + "Expand space panel": "Space-paneel uitvouwen", + "Creating...": "Aanmaken...", + "You can change these at any point.": "U kan dit op elk moment aanpassen.", + "Give it a photo, name and description to help you identify it.": "Geef het een foto, naam en omschrijving om u te helpen het te herkennen.", + "Your private space": "Uw privé space", + "Your public space": "Uw openbare space", + "You can change this later": "U kan dit later aanpassen", + "Invite only, best for yourself or teams": "Alleen op uitnodiging, geschikt voor uzelf of teams", + "Private": "Privé", + "Open space for anyone, best for communities": "Openbare space voor iedereen, geschikt voor gemeenschappen", + "Public": "Openbaar", + "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "Spaces is een nieuwe manier van groeperen van gesprekken en personen. Om deel te nemen aan een bestaande space heeft u een uitnodiging nodig", + "Create a space": "Space aanmaken", + "Delete": "Verwijderen", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Niet compatibel met Gemeenschappen, Gemeenschappen v2 en Aangepaste Labels. Vereist een geschikte homeserver voor sommige functies.", + "This homeserver has been blocked by it's administrator.": "Deze homeserver is geblokkeerd door zijn beheerder.", + "This homeserver has been blocked by its administrator.": "Deze homeserver is geblokkeerd door uw beheerder.", + "Already in call": "Al in gesprek", + "You're already in a call with this person.": "U bent al in gesprek met deze persoon." } diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index ef748c5fd8..0ec835362a 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -3019,5 +3019,96 @@ "Value": "Valor", "Failed to save settings": "Falha ao salvar as configurações", "Show chat effects (animations when receiving e.g. confetti)": "Mostrar efeitos na conversa (por exemplo: animações ao receber confetes)", - "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "O Element Web é experimental para dispositivos móveis. Para uma melhor experiência e os recursos mais recentes, use nosso aplicativo gratuito." + "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "O Element Web é experimental para dispositivos móveis. Para uma melhor experiência e os recursos mais recentes, use nosso aplicativo gratuito.", + "Empty room": "Sala vazia", + "Suggested Rooms": "Salas sugeridas", + "Add existing room": "Adicionar sala existente", + "Send message": "Enviar mensagem", + "Just Me": "Somente eu", + "Finish": "Concluir", + "Creating rooms...": "Criando salas...", + "Skip for now": "Ignorar por enquanto", + "Room name": "Nome da sala", + "Random": "Aleatório", + "Welcome to ": "Boas-vindas ao ", + "Creating...": "Criando...", + "Private": "Privado", + "Public": "Público", + "Delete": "Excluir", + "This homeserver has been blocked by it's administrator.": "Este servidor local foi bloqueado pelo seu administrador.", + "This homeserver has been blocked by its administrator.": "Este servidor local foi bloqueado pelo seu administrador.", + "You're already in a call with this person.": "Você já está em uma chamada com essa pessoa.", + "At the moment only you can see it.": "No momento, só você pode ver.", + "Failed to create initial space rooms": "Falha ao criar salas de espaço iniciais", + "Your private space ": "Seu espaço privado ", + "Your public space ": "Seu espaço público ", + "You have been invited to ": "Você foi convidado para ", + " invited you to ": " convidou você para ", + "%(count)s members|one": "%(count)s integrante", + "%(count)s members|other": "%(count)s integrantes", + "Add existing rooms & spaces": "Adicionar salas & espaços já existentes", + "Accept Invite": "Aceitar o convite", + "Save changes": "Salvar alterações", + "You're in this room": "Você está nesta sala", + "You're in this space": "Você está neste espaço", + "No permissions": "Sem permissões", + "Undo": "Desfazer", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "A sua mensagem não foi enviada porque este servidor local foi bloqueado pelo seu administrador. Entre em contato com o administrador do serviço para continuar usando o serviço.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Tem certeza de que deseja sair desse espaço '%(spaceName)s'?", + "This space is not public. You will not be able to rejoin without an invite.": "Este espaço não é público. Você não poderá entrar novamente sem um convite.", + "Save Changes": "Salvar alterações", + "Saving...": "Salvando...", + "View dev tools": "Ver ferramentas de desenvolvimento", + "Leave space": "Sair desse espaço", + "Leave Space": "Sair desse espaço", + "Make this space private": "Tornar este espaço privado", + "Edit settings relating to your space.": "Editar configurações relacionadas ao seu espaço.", + "Space settings": "Configurações desse espaço", + "Failed to save space settings.": "Falha ao salvar as configurações desse espaço.", + "Invite someone using their name, username (like ) or share this space.": "Convide alguém a partir do nome, nome de usuário (como ) ou compartilhe este espaço.", + "Invite someone using their name, email address, username (like ) or share this space.": "Convide alguém a partir do nome, endereço de e-mail, nome de usuário (como ) ou compartilhe este espaço.", + "Unnamed Space": "Espaço sem nome", + "Invite to %(spaceName)s": "Convidar para %(spaceName)s", + "Failed to add rooms to space": "Falha ao adicionar salas ao espaço", + "Create a new room": "Criar uma nova sala", + "Don't want to add an existing room?": "Não deseja adicionar uma sala já existente?", + "Spaces": "Espaços", + "Filter your rooms and spaces": "Pesquisar suas salas e espaços", + "Add existing spaces/rooms": "Adicionar espaços/salas já existentes", + "Explore space rooms": "Explorar as salas deste espaço", + "You do not have permissions to add rooms to this space": "Você não tem permissão para adicionar salas neste espaço", + "You do not have permissions to create new rooms in this space": "Você não tem permissão para criar novas salas neste espaço", + "Invite to this space": "Convidar para este espaço", + "Your message was sent": "A sua mensagem foi enviada", + "Encrypting your message...": "Criptografando a sua mensagem...", + "Sending your message...": "Enviando a sua mensagem...", + "Spell check dictionaries": "Dicionários de verificação ortográfica", + "Space options": "Opções do espaço", + "New room": "Nova sala", + "Invite people": "Convidar pessoas", + "Share your public space": "Compartilhar o seu espaço público", + "Invite members": "Convidar integrantes", + "Invite by email or username": "Convidar por e-mail ou nome de usuário", + "Share invite link": "Compartilhar link de convite", + "Click to copy": "Clique para copiar", + "Collapse space panel": "Fechar o painel do espaço", + "Expand space panel": "Expandir o painel do espaço", + "You can change these at any point.": "Você pode alterar esses dados a qualquer momento.", + "Give it a photo, name and description to help you identify it.": "Insira uma foto, nome e descrição para ajudar a identificar o espaço.", + "Your private space": "O seu espaço privado", + "Your public space": "O seu espaço público", + "You can change this later": "Você pode mudar isso depois", + "Open space for anyone, best for communities": "Abra espaços para todos, especialmente para comunidades", + "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "Espaços são novas formas de agrupar salas e pessoas. Para entrar em um espaço existente, você precisará de um convite", + "Create a space": "Criar um espaço", + "Jump to the bottom of the timeline when you send a message": "Vá para o final da linha do tempo ao enviar uma mensagem", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Protótipo de Espaços. Incompatível com Comunidades, Comunidades v2 e tags personalizadas. Requer um servidor compatível com os recursos necessários.", + "Decrypted event source": "Fonte de evento descriptografada", + "We'll create rooms for each of them. You can add existing rooms after setup.": "Criaremos salas para cada um deles. Você pode adicionar salas já existentes após a configuração.", + "What projects are you working on?": "Em quais projetos você trabalha no momento?", + "We'll create rooms for each topic.": "Nós criaremos salas para cada tópico.", + "Inviting...": "Convidando...", + "Invite by username": "Convidar por nome de usuário", + "Support": "Suporte", + "Original event source": "Fonte do evento original" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 7cbcb08167..4cf444ac0e 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -96,7 +96,7 @@ "Failure to create room": "Не удалось создать комнату", "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "для %(userId)s с %(fromPowerLevel)s на %(toPowerLevel)s", "click to reveal": "нажмите для открытия", - "%(senderName)s invited %(targetName)s.": "%(senderName)s пригласил %(targetName)s.", + "%(senderName)s invited %(targetName)s.": "%(senderName)s пригласил(а) %(targetName)s.", "%(targetName)s joined the room.": "%(targetName)s вошёл в комнату.", "%(senderName)s kicked %(targetName)s.": "%(senderName)s исключил(а) %(targetName)s.", "%(targetName)s left the room.": "%(targetName)s покинул(а) комнату.", @@ -2064,7 +2064,7 @@ "You changed your avatar": "Вы поменяли свой аватар", "%(targetName)s changed their avatar": "%(targetName)s поменял(а) свой аватар", "You changed the room name": "Вы поменяли имя комнаты", - "Enable experimental, compact IRC style layout": "Включите экспериментальный, компактный стиль IRC", + "Enable experimental, compact IRC style layout": "Включить экспериментальный, компактный стиль IRC", "Unknown caller": "Неизвестный абонент", "Incoming call": "Входящий звонок", "Waiting for your other session to verify…": "Ожидание вашей другой сессии для начала подтверждения…", @@ -2613,7 +2613,7 @@ "Unable to validate homeserver": "Невозможно проверить домашний сервер", "Sign into your homeserver": "Войдите на свой домашний сервер", "with state key %(stateKey)s": "с ключом состояния %(stateKey)s", - "%(creator)s created this DM.": "%(creator)s начал этот чат.", + "%(creator)s created this DM.": "%(creator)s начал(а) этот чат.", "Show chat effects": "Показать эффекты чата", "Host account on": "Ваша учётная запись обслуживается", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s или %(usernamePassword)s", diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index dd58154b5a..cb08a6b5f9 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -3076,5 +3076,120 @@ "Value": "Vlerë", "Failed to save settings": "S’u arrit të ruhen rregullimet", "Settings Explorer": "Eksplorues Rregullimesh", - "Show chat effects (animations when receiving e.g. confetti)": "Shfaq efekte fjalosjeje (animacione kur merren bonbone, për shembull)" + "Show chat effects (animations when receiving e.g. confetti)": "Shfaq efekte fjalosjeje (animacione kur merren bonbone, për shembull)", + "Original event source": "Burim i veprimtarisë origjinale", + "Decrypted event source": "U shfshehtëzua burim veprimtarie", + "We'll create rooms for each of them. You can add existing rooms after setup.": "Do të krijojmë dhoma për çdo një prej tyre. Pas ujdisjes mund të shtoni dhoma ekzistuese.", + "What projects are you working on?": "Me çfarë projektesh po merreni?", + "We'll create rooms for each topic.": "Do të krijojmë dhoma për çdo temë.", + "What are some things you want to discuss?": "Cilat janë disa nga gjërat që doni të diskutoni?", + "Inviting...": "Po ftohen…", + "Invite by username": "Ftoni përmes emri përdoruesi", + "Invite your teammates": "Ftoni anëtarët e ekipit tuaj", + "Failed to invite the following users to your space: %(csvUsers)s": "S’u arrit të ftoheshin te hapësira juaj përdoruesit vijues: %(csvUsers)s", + "A private space for you and your teammates": "Një hapësirë private për ju dhe anëtarët e ekipit tuaj", + "Me and my teammates": "Unë dhe anëtarët e ekipit tim", + "A private space just for you": "Një hapësirë private vetëm për ju", + "Just Me": "Vetëm Unë", + "Ensure the right people have access to the space.": "Siguroni që personat e duhur të mund të hyjnë te hapësira", + "Who are you working with?": "Me cilët po punoni?", + "Finish": "Përfundoje", + "At the moment only you can see it.": "Hëpërhë mund ta shihni vetëm ju.", + "Creating rooms...": "Po krijohen dhoma…", + "Skip for now": "Hëpërhë anashkaloje", + "Failed to create initial space rooms": "S’u arrit të krijohen dhomat fillestare të hapësirës", + "Room name": "Emër dhome", + "Support": "Asistencë", + "Random": "Kuturu", + "Welcome to ": "Mirë se vini te ", + "Your private space ": "Hapësira juaj private ", + "Your public space ": "Hapësira juaj publike ", + "You have been invited to ": "Jeni ftuar te ", + " invited you to ": " ju ftoi te ", + "%(count)s members|one": "%(count)s anëtar", + "%(count)s members|other": "%(count)s anëtarë", + "Your server does not support showing space hierarchies.": "Shërbyesi juaj nuk mbulon shfaqje hierarkish hapësire.", + "Default Rooms": "Dhoma Parazgjedhje", + "Add existing rooms & spaces": "Shtoni dhoma & hapësira ekzistuese", + "Accept Invite": "Pranoje Ftesën", + "Find a room...": "Gjeni një dhomë…", + "Manage rooms": "Administroni dhoma", + "Save changes": "Ruaji ndryshimet", + "You're in this room": "Gjendeni në këtë dhomë", + "You're in this space": "Gjendeni në këtë hapësirë", + "No permissions": "S’ka leje", + "Remove from Space": "Hiqe prej Hapësire", + "Undo": "Zhbëje", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Mesazhi juaj s’u dërgua, ngaqë ky shërbyes Home është bllokuar nga përgjegjësi i tij. Ju lutemi, që të vazhdoni ta përdorni këtë shërbim, lidhuni me përgjegjësin e shërbimit tuaj.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Jeni i sigurt se doni të dilni nga hapësira '%(spaceName)s'?", + "This space is not public. You will not be able to rejoin without an invite.": "Kjo hapësirë s’është publike. S’do të jeni në gjendje të rihyni në të pa një ftesë.", + "Start audio stream": "Nisni transmetim audio", + "Unable to start audio streaming.": "S’arrihet të niset transmetim audio.", + "Save Changes": "Ruaji Ndryshimet", + "Saving...": "Po ruhet…", + "View dev tools": "Shihni mjete zhvilluesi", + "Leave Space": "Braktiseni Hapësirën", + "Make this space private": "Bëje këtë hapësirë private", + "Edit settings relating to your space.": "Përpunoni rregullime që lidhen me hapësirën tuaj", + "Space settings": "Rregullime hapësire", + "Failed to save space settings.": "S’u arrit të ruhen rregullime hapësire.", + "Invite someone using their name, username (like ) or share this space.": "Ftoni dikë duke përdorur emrin e tij, emrin e tij të përdoruesit (bie fjala, ) ose ndani me të këtë hapësirë.", + "Invite someone using their name, email address, username (like ) or share this space.": "Ftoni dikë duke përdorur emrin e tij, adresën email, emrin e përdoruesit (bie fjala, ) ose ndani me të këtë hapësirë.", + "Unnamed Space": "Hapësirë e Paemërtuar", + "Invite to %(spaceName)s": "Ftojeni te %(spaceName)s", + "Caution:": "Kujdes:", + "Setting ID": "ID Rregullimi", + "Failed to add rooms to space": "S’u arrit të shtoheshin dhomat te hapësira", + "Apply": "Aplikoje", + "Applying...": "Po aplikohet …", + "Create a new room": "Krijoni dhomë të re", + "Don't want to add an existing room?": "S’doni të shtoni një dhomë ekzistuese?", + "Spaces": "Hapësira", + "Filter your rooms and spaces": "Filtroni dhomat dhe hapësirat tuaja", + "Add existing spaces/rooms": "Shtoni hapësira/dhoma ekzistuese", + "Space selection": "Përzgjedhje hapësire", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "S’do të jeni në gjendje ta zhbëni këtë ndryshim, teksa zhgradoni veten, nëse jeni përdoruesi i fundit i privilegjuar te hapësira, s’do të jetë e mundur të rifitoni privilegjet.", + "Empty room": "Dhomë e zbrazët", + "Suggested Rooms": "Roma të Këshilluara", + "Explore space rooms": "Eksploroni dhoma hapësire", + "You do not have permissions to add rooms to this space": "S’keni leje të shtoni dhoma në këtë hapësirë", + "Add existing room": "Shtoni dhomë ekzistuese", + "You do not have permissions to create new rooms in this space": "S’keni leje të krijoni dhoma të reja në këtë hapësirë", + "Send message": "Dërgoje mesazhin", + "Invite to this space": "Ftoni në këtë hapësirë", + "Your message was sent": "Mesazhi juaj u dërgua", + "Encrypting your message...": "Po fshehtëzohet meszhi juaj…", + "Sending your message...": "Po dërgohet mesazhi juaj…", + "Spell check dictionaries": "Fjalorë kontrolli drejtshkrimi", + "Space options": "Mundësi Hapësire", + "Space Home": "Shtëpi Hapësire", + "New room": "Dhomë e re", + "Leave space": "Braktiseni hapësirën", + "Invite people": "Ftoni njerëz", + "Share your public space": "Ndani me të tjerët hapësirën tuaj publike", + "Invite members": "Ftoni anëtarë", + "Invite by email or username": "Ftoni përmes email-i ose emri përdoruesi", + "Share invite link": "Jepuni lidhje ftese", + "Click to copy": "Klikoni që të kopjohet", + "Collapse space panel": "Tkurre panelin e hapësirave", + "Expand space panel": "Zgjeroje panelin e hapësirave", + "Creating...": "Po krijohet…", + "You can change these at any point.": "Këto mund ti ndryshoni kur të doni.", + "Give it a photo, name and description to help you identify it.": "Jepini një foto, emër dhe përshkrim, për t’ju ndihmuar ta indentifikoni.", + "Your private space": "Hapësira juaj private", + "Your public space": "Hapësira juaj publike", + "You can change this later": "Këtë mund ta ndryshoni më vonë", + "Invite only, best for yourself or teams": "Vetëm me ftesa, më e mira për ju dhe ekipe", + "Private": "Private", + "Open space for anyone, best for communities": "Hapësirë e hapur për këdo, më e mira për bashkësi", + "Public": "Publike", + "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "Hapësirat janë rrugë e re për të grupuar dhoma dhe njerëz. Për t’u bërë pjesë e një hapësire ekzistuese, do t’ju duhet një ftesë", + "Create a space": "Krijoni një hapësirë", + "Delete": "Fshije", + "Jump to the bottom of the timeline when you send a message": "Kalo te fundi i rrjedhës kohore, kur dërgoni një mesazh", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototip hapësirash. I papërputhshëm me Bashkësi, Bashkësi v2 dhe Etiketa Vetjake. Për disa nga veçoritë, lyp shërbyes Home të përputhshëm.", + "This homeserver has been blocked by it's administrator.": "Ky shërbyes Home është bllokuar nga përgjegjësi i tij.", + "This homeserver has been blocked by its administrator.": "Ky shërbyes Home është bllokuar nga përgjegjësit e tij.", + "You're already in a call with this person.": "Gjendeni tashmë në thirrje me këtë person.", + "Already in call": "Tashmë në thirrje" } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index c8a5a41339..002b825cb0 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -3018,5 +3018,120 @@ "Setting ID": "Inställnings-ID", "Failed to save settings": "Misslyckades att spara inställningar", "Settings Explorer": "Inställningsutforskare", - "Show chat effects (animations when receiving e.g. confetti)": "Visa chatteffekter (animeringar när du tar emot t.ex. konfetti)" + "Show chat effects (animations when receiving e.g. confetti)": "Visa chatteffekter (animeringar när du tar emot t.ex. konfetti)", + "Original event source": "Ursprunglig händelsekällkod", + "Decrypted event source": "Avkrypterad händelsekällkod", + "We'll create rooms for each of them. You can add existing rooms after setup.": "Vi kommer att skapa rum för varje. Du kan lägga till existerande rum efter inställningen.", + "What projects are you working on?": "Vilka projekt jobbar du på?", + "We'll create rooms for each topic.": "Vi kommer att skapa rum för varje ämne.", + "What are some things you want to discuss?": "Vad är exempel på saker du vill diskutera?", + "Inviting...": "Bjuder in…", + "Invite by username": "Bjud in med användarnamn", + "Invite your teammates": "Bjud in dina teamkamrater", + "Failed to invite the following users to your space: %(csvUsers)s": "Misslyckades att bjuda in följande användare till ditt utrymme: %(csvUsers)s", + "A private space for you and your teammates": "Ett privat utrymme för dig och dina teamkamrater", + "Me and my teammates": "Jag och mina teamkamrater", + "A private space just for you": "Ett personligt utrymme för bara dig", + "Just Me": "Bara jag", + "Ensure the right people have access to the space.": "Försäkra att rätt personer har tillgång till utrymmet.", + "Who are you working with?": "Vem arbetar du med?", + "Finish": "Färdigställ", + "At the moment only you can see it.": "För tillfället så kan bara du se det.", + "Creating rooms...": "Skapar rum…", + "Skip for now": "Hoppa över för tillfället", + "Failed to create initial space rooms": "Misslyckades att skapa initiala utrymmesrum", + "Room name": "Rumsnamn", + "Support": "Hjälp", + "Random": "Slumpmässig", + "Welcome to ": "Välkommen till ", + "Your private space ": "Ditt privata utrymme ", + "Your public space ": "Ditt offentliga utrymme ", + "You have been invited to ": "Du har blivit inbjuden till ", + " invited you to ": " bjöd in dig till ", + "%(count)s members|one": "%(count)s medlem", + "%(count)s members|other": "%(count)s medlemmar", + "Your server does not support showing space hierarchies.": "Din server stöder inte att visa utrymmeshierarkier.", + "Default Rooms": "Förvalda rum", + "Add existing rooms & spaces": "Lägg till existerande rum och utrymmen", + "Accept Invite": "Acceptera inbjudan", + "Find a room...": "Hitta ett rum…", + "Manage rooms": "Hantera rum", + "Promoted to users": "Befordrad till användare", + "Save changes": "Spara ändringar", + "You're in this room": "Du är i det här rummet", + "You're in this space": "Du är i det här utrymmet", + "No permissions": "Inga behörigheter", + "Remove from Space": "Ta bort från utrymmet", + "Undo": "Ångra", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Ditt meddelande skickades inte eftersom att hemservern har blockerats av sin administratör. Vänligen kontakta din tjänsteadministratör för att fortsätta använda tjänsten.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Är du säker på att du vill lämna utrymmet '%(spaceName)s'?", + "This space is not public. You will not be able to rejoin without an invite.": "Det här utrymmet är inte offentligt. Du kommer inte kunna gå med igen utan en inbjudan.", + "Start audio stream": "Starta ljudström", + "Failed to start livestream": "Misslyckades att starta livestream", + "Unable to start audio streaming.": "Kunde inte starta ljudströmning.", + "Save Changes": "Spara inställningar", + "Saving...": "Sparar…", + "View dev tools": "Visa utvecklingsverktyg", + "Leave Space": "Lämna utrymmet", + "Make this space private": "Gör det här utrymmet privat", + "Edit settings relating to your space.": "Redigera inställningar relaterat till ditt utrymme.", + "Space settings": "Utrymmesinställningar", + "Failed to save space settings.": "Misslyckades att spara utrymmesinställningar.", + "Invite someone using their name, username (like ) or share this space.": "Bjud in någon med deras namn eller användarnamn (som ), eller dela det här utrymmet.", + "Invite someone using their name, email address, username (like ) or share this space.": "Bjud in någon med deras namn, e-postadress eller användarnamn (som ), eller dela det här rummet.", + "Unnamed Space": "Namnlöst utrymme", + "Invite to %(spaceName)s": "Bjud in till %(spaceName)s", + "Failed to add rooms to space": "Misslyckades att lägga till rum till utrymmet", + "Apply": "Verkställ", + "Applying...": "Verkställer…", + "Create a new room": "Skapa ett nytt rum", + "Don't want to add an existing room?": "Vill du inte lägga till ett existerande rum?", + "Spaces": "Utrymmen", + "Filter your rooms and spaces": "Filtrera dina rum och utrymmen", + "Add existing spaces/rooms": "Lägg till existerande utrymmen/rum", + "Space selection": "Utrymmesval", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Du kommer inte kunna ångra den här ändringen eftersom du degraderar dig själv, och om du är den sista privilegierade användaren i utrymmet så kommer det att vara omöjligt att återfå utrymmet.", + "Empty room": "Tomt rum", + "Suggested Rooms": "Föreslagna rum", + "Explore space rooms": "Utforska rum i utrymmet", + "You do not have permissions to add rooms to this space": "Du är inte behörig att lägga till rum till det här utrymmet", + "Add existing room": "Lägg till existerande rum", + "You do not have permissions to create new rooms in this space": "Du är inte behörig att skapa nya rum i det här utrymmet", + "Send message": "Skicka meddelande", + "Invite to this space": "Bjud in till det här utrymmet", + "Your message was sent": "Ditt meddelande skickades", + "Encrypting your message...": "Krypterar ditt meddelande…", + "Sending your message...": "Skickar dina meddelanden…", + "Spell check dictionaries": "Rättstavningsordböcker", + "Space options": "Utrymmesalternativ", + "Space Home": "Utrymmeshem", + "New room": "Nytt rum", + "Leave space": "Lämna utrymmet", + "Invite people": "Bjud in folk", + "Share your public space": "Dela ditt offentliga utrymme", + "Invite members": "Bjud in medlemmar", + "Invite by email or username": "Bjud in med e-post eller användarnamn", + "Share invite link": "Skapa inbjudningslänk", + "Click to copy": "Klicka för att kopiera", + "Collapse space panel": "Kollapsa utrymmespanelen", + "Expand space panel": "Expandera utrymmespanelen", + "Creating...": "Skapar…", + "You can change these at any point.": "Du kan ändra dessa när som helst.", + "Give it a photo, name and description to help you identify it.": "Ge den en bild, ett namn och en beskrivning för att hjälpa dig att identifiera den.", + "Your private space": "Ditt privata utrymme", + "Your public space": "Ditt offentliga utrymme", + "You can change this later": "Du kan ändra detta senare", + "Invite only, best for yourself or teams": "Endast inbjudan, bäst för dig själv eller team", + "Private": "Privat", + "Open space for anyone, best for communities": "Öppna utrymmet för alla, bäst för gemenskaper", + "Public": "Offentligt", + "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "Utrymmen är nya sätt att gruppera rum och personer. För att gå med i ett existerande utrymme så behöver du en inbjudan", + "Create a space": "Skapa ett utrymme", + "Delete": "Radera", + "Jump to the bottom of the timeline when you send a message": "Hoppa till botten av tidslinjen när du skickar ett meddelande", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototyp för utrymmen. Inkompatibel med gemenskaper, gemenskaper v2 och anpassade taggar. Kräver en kompatibel hemserver för viss funktionalitet.", + "This homeserver has been blocked by it's administrator.": "Den här hemservern har blockerats av sin administratör.", + "This homeserver has been blocked by its administrator.": "Hemservern har blockerats av sin administratör.", + "You're already in a call with this person.": "Du är redan i ett samtal med den här personen.", + "Already in call": "Redan i samtal" } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index 12fb5e2877..d07d6c83b6 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -166,7 +166,7 @@ "New passwords don't match": "两次输入的新密码不符", "not specified": "未指定", "Notifications": "通知", - "(not supported by this browser)": "(未被此浏览器支持)", + "(not supported by this browser)": "(此浏览器不支持)", "": "<不支持>", "No display name": "无昵称", "No results": "没有更多结果", @@ -910,10 +910,10 @@ "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s 将此聊天室对知道此聊天室链接的人公开。", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s 将此聊天室改为仅限邀请。", "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s 将加入规则改为 %(rule)s", - "%(displayName)s is typing …": "%(displayName)s 正在打字…", - "%(names)s and %(count)s others are typing …|other": "%(names)s 与其他 %(count)s 位正在打字…", - "%(names)s and %(count)s others are typing …|one": "%(names)s 与另一位正在打字…", - "%(names)s and %(lastPerson)s are typing …": "%(names)s 和 %(lastPerson)s正在打字…", + "%(displayName)s is typing …": "%(displayName)s 正在输入…", + "%(names)s and %(count)s others are typing …|other": "%(names)s 与其他 %(count)s 位正在输入…", + "%(names)s and %(count)s others are typing …|one": "%(names)s 与另一位正在输入…", + "%(names)s and %(lastPerson)s are typing …": "%(names)s 和 %(lastPerson)s 正在输入…", "Unrecognised address": "无法识别地址", "User %(user_id)s may or may not exist": "用户 %(user_id)s 不一定存在", "Predictable substitutions like '@' instead of 'a' don't help very much": "可预见的替换如将 '@' 替换为 'a' 并不会有太大效果", @@ -942,7 +942,7 @@ "Short keyboard patterns are easy to guess": "键位短序列很容易被猜到", "Group & filter rooms by custom tags (refresh to apply changes)": "按自定义标签分组和过滤聊天室(刷新以应用更改)", "Render simple counters in room header": "在聊天室标题中显示简单计数", - "Enable Emoji suggestions while typing": "键入时启用表情符号建议", + "Enable Emoji suggestions while typing": "启用实时表情符号建议", "Show a placeholder for removed messages": "已移除的消息显示为一个占位符", "Show join/leave messages (invites/kicks/bans unaffected)": "显示 加入/离开 信息(邀请/踢出/禁止 不受影响)", "Show avatar changes": "显示头像更改", @@ -951,7 +951,7 @@ "Show a reminder to enable Secure Message Recovery in encrypted rooms": "在加密聊天室中显示一条允许恢复安全消息的提醒", "Show avatars in user and room mentions": "在用户和聊天室提及中显示头像", "Enable big emoji in chat": "在聊天中启用大型表情符号", - "Send typing notifications": "发送键入状态通知", + "Send typing notifications": "发送正在输入通知", "Enable Community Filter Panel": "启用社区筛选器面板", "Allow Peer-to-Peer for 1:1 calls": "允许一对一通话使用 P2P", "Prompt before sending invites to potentially invalid matrix IDs": "在发送邀请之前提示可能无效的 Matrix ID", @@ -1259,14 +1259,14 @@ "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "您也可以尝试使用turn.matrix.org公共服务器,但通话质量稍差,并且其将会得知您的 IP。您可以在设置中更改此选项。", "Try using turn.matrix.org": "尝试使用 turn.matrix.org", "Your %(brand)s is misconfigured": "您的 %(brand)s 配置有错误", - "Use Single Sign On to continue": "使用单点登陆继续", - "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登陆来证明您的身份,并确认添加此邮件地址。", - "Single Sign On": "单点登陆", + "Use Single Sign On to continue": "使用单点登录继续", + "Confirm adding this email address by using Single Sign On to prove your identity.": "通过使用单点登录来证明您的身份,并确认添加此邮件地址。", + "Single Sign On": "单点登录", "Confirm adding email": "确认使用邮件", - "Click the button below to confirm adding this email address.": "点击下面的按钮,添加此邮箱地址。", + "Click the button below to confirm adding this email address.": "点击下面的按钮以确认添加此邮箱地址。", "Confirm adding this phone number by using Single Sign On to prove your identity.": "通过单点登录以证明您的身份,并确认添加此电话号码。", "Confirm adding phone number": "确认添加电话号码", - "Click the button below to confirm adding this phone number.": "点击下面的按钮,确认添加此电话号码。", + "Click the button below to confirm adding this phone number.": "点击下面的按钮以确认添加此电话号码。", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "是否在触屏设备上使用 %(brand)s", "Whether you're using %(brand)s as an installed Progressive Web App": "您是否已经安装 %(brand)s 作为一种渐进式的 Web 应用", "Your user agent": "您的代理用户", @@ -1282,14 +1282,14 @@ "Verify this session": "验证此会话", "Encryption upgrade available": "提供加密升级", "Set up encryption": "设置加密", - "Review where you’re logged in": "查看您的登陆位置", - "New login. Was this you?": "现在登陆。请问是您本人吗?", - "Name or Matrix ID": "姓名或Matrix账号", + "Review where you’re logged in": "查看您的登录位置", + "New login. Was this you?": "现在登录。请问是您本人吗?", + "Name or Matrix ID": "姓名或 Matrix ID", "Identity server has no terms of service": "身份服务器无服务条款", - "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "此操作需要访问默认的身份服务器以验证电子邮件地址或电话号码,但是此服务器无任何服务条款。", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "此操作需要访问默认的身份服务器 以验证邮箱地址或电话号码,但是此服务器无任何服务条款。", "Only continue if you trust the owner of the server.": "只有您信任服务器所有者才能继续。", "Trust": "信任", - "%(name)s is requesting verification": "%(name)s请求验证", + "%(name)s is requesting verification": "%(name)s 正在请求验证", "Sign In or Create Account": "登录或创建账户", "Use your account or create a new one to continue.": "使用已有账户或创建一个新账户。", "Create Account": "创建账户", @@ -1301,7 +1301,7 @@ "Sends a message as html, without interpreting it as markdown": "以html格式发送消息,而不是markdown", "You do not have the required permissions to use this command.": "您没有权限使用此命令。", "Error upgrading room": "升级聊天室出错", - "Double check that your server supports the room version chosen and try again.": "再次检查您的服务器是否支持所选聊天室版本,然后重试。", + "Double check that your server supports the room version chosen and try again.": "请再次检查您的服务器是否支持所选聊天室版本,然后再试一次。", "Changes the avatar of the current room": "更改当前聊天室头像", "Changes your avatar in this current room only": "仅改变您在当前聊天室的头像", "Changes your avatar in all rooms": "改变您在所有聊天室的头像", @@ -1351,14 +1351,14 @@ "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s 创建了因为%(reason)s而禁止用户匹配%(glob)s的规则", "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s 创建了由于%(reason)s而禁止聊天室匹配%(glob)s的规则", "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s 创建了由于%(reason)s而禁止服务器匹配%(glob)s的规则", - "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s 创建了由于%(reason)s而禁止匹配%(glob)s的股则", + "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s 创建了由于%(reason)s而禁止匹配%(glob)s的规则", "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s 更改了一个由于%(reason)s而禁止用户%(oldGlob)s跟%(newGlob)s匹配的规则", "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s更改了一个由于%(reason)s而禁止聊天室%(oldGlob)s跟%(newGlob)s匹配的规则", "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s 更新了一个由于%(reason)s而禁止服务器%(oldGlob)s跟%(newGlob)s匹配的规则", "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s 更新了一个由于%(reason)s而禁止%(oldGlob)s跟%(newGlob)s匹配的规则", - "You signed in to a new session without verifying it:": "您登陆了未经过验证的新会话:", + "You signed in to a new session without verifying it:": "您登录了未经过验证的新会话:", "Verify your other session using one of the options below.": "使用以下选项之一验证您的其他会话。", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s(%(userId)s)登陆到未验证的新会话:", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s(%(userId)s)登录到未验证的新会话:", "Ask this user to verify their session, or manually verify it below.": "要求该用户验证其会话,或在下面手动进行验证。", "Not Trusted": "不可信任", "Manually Verify by Text": "手动验证文字", @@ -1404,7 +1404,7 @@ "about a day from now": "从现在开始约一天", "%(num)s days from now": "从现在开始%(num)s天", "%(name)s (%(userId)s)": "%(name)s%(userId)s", - "Your browser does not support the required cryptography extensions": "您的浏览器不支持必需的加密插件", + "Your browser does not support the required cryptography extensions": "您的浏览器不支持所需的密码学扩展", "The user's homeserver does not support the version of the room.": "用户的主服务器不支持该聊天室版本。", "Help us improve %(brand)s": "请协助我们改进%(brand)s", "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "发送匿名使用情况数据,以协助我们改进%(brand)s。这将使用cookie。", @@ -1666,7 +1666,7 @@ "Enable advanced debugging for the room list": "为此聊天室列表启用高级调试", "Show info about bridges in room settings": "在聊天室设置中显示桥接信息", "Use a more compact ‘Modern’ layout": "使用更紧凑的「现代」布局", - "Show typing notifications": "显示输入通知", + "Show typing notifications": "显示正在输入通知", "Show shortcuts to recently viewed rooms above the room list": "在聊天室列表上方显示最近浏览过的聊天室的快捷方式", "Show hidden events in timeline": "显示时间线中的隐藏事件", "Low bandwidth mode": "低带宽模式", @@ -2416,17 +2416,17 @@ "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s 为此聊天室设置了服务器 ACL。", "Hong Kong": "香港", "Cook Islands": "库克群岛", - "Congo - Kinshasa": "刚果金", - "Congo - Brazzaville": "刚果布拉柴维尔", + "Congo - Kinshasa": "刚果 - 金沙萨", + "Congo - Brazzaville": "刚果 - 布拉柴维尔", "Comoros": "科摩罗", "Colombia": "哥伦比亚", - "Cocos (Keeling) Islands": "科科斯基林群岛", + "Cocos (Keeling) Islands": "科科斯(基林)群岛", "Christmas Island": "圣诞岛", "China": "中国", "Chile": "智利", "Chad": "乍得", "Central African Republic": "中非共和国", - "Cayman Islands": "开曼群岛(英)", + "Cayman Islands": "开曼群岛", "Caribbean Netherlands": "荷兰加勒比区", "Cape Verde": "佛得角", "Canada": "加拿大", @@ -2472,7 +2472,7 @@ "United States": "美国", "United Kingdom": "英国", "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "您的主服务器已拒绝您的登入尝试。请重试。如果此情况持续发生,请联系您的主服务器管理员。", - "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "您的主服务器不可达,无法使您登入。请重试。如果此情况持续发生,请联系您的主服务器管理员。", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "无法访问您的主服务器,因而无法登入。请重试。如果此情况持续发生,请联系您的主服务器管理员。", "Try again": "重试", "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "我们已要求浏览器记住您使用的主服务器,但不幸的是您的浏览器已忘记。请前往登录页面重试。", "We couldn't log you in": "我们无法使您登入", @@ -2482,5 +2482,263 @@ "Too Many Calls": "太多呼叫", "Call failed because webcam or microphone could not be accessed. Check that:": "通话失败,因为无法访问网络摄像头或麦克风。请检查:", "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "呼叫失败,因为无法访问任何麦克风。 检查是否已插入麦克风并正确设置。", - "Answered Elsewhere": "在其他地方已回答" + "Answered Elsewhere": "在其他地方已回答", + "Use the + to make a new room or explore existing ones below": "使用 + 创建新的聊天室或通过下面列出的方式探索已有聊天室", + "Start a new chat": "开始新会话", + "Room settings": "聊天室设置", + "%(count)s people|one": "%(count)s 位成员", + "Show files": "显示已发送的文件", + "About homeservers": "关于 homeservers", + "About": "关于", + "Invite members": "邀请成员", + "Invite by email or username": "通过电子邮件或用户名邀请", + "Share invite link": "分享邀请链接", + "Click to copy": "点击复制", + "Creating...": "创建中……", + "You can change these at any point.": "您可随时更改这些。", + "Give it a photo, name and description to help you identify it.": "为它添加一张照片、姓名与描述来帮助您辨认它。", + "Your private space": "您的私有空间", + "Your public space": "您的公共空间", + "You can change this later": "您可稍后更改此项", + "Invite only, best for yourself or teams": "仅邀请,适合您自己或团队", + "Private": "私有", + "Public": "公共", + "Delete": "删除", + "Dial pad": "拨号盘", + "There was an error looking up the phone number": "查询电话号码的过程中发生错误", + "Unable to look up phone number": "无法查询电话号码", + "Return to call": "返回通话", + "Voice Call": "语音通话", + "Video Call": "视频通话", + "%(peerName)s held the call": "%(peerName)s 挂起了通话", + "You held the call Resume": "您挂起了通话 恢复", + "You held the call Switch": "您挂起了通话 切换", + "Takes the call in the current room off hold": "解除挂起当前聊天室的通话", + "Places the call in the current room on hold": "挂起当前聊天室的通话", + "Show chat effects (animations when receiving e.g. confetti)": "显示聊天特效(如收到五彩纸屑时的动画效果)", + "Use Ctrl + Enter to send a message": "使用 Ctrl + Enter 发送信息", + "Use Command + Enter to send a message": "使用 Command + Enter 发送消息", + "Use Ctrl + F to search": "使用 Ctrl + F 搜索", + "Use Command + F to search": "使用 Command + F 搜索", + "Jump to the bottom of the timeline when you send a message": "发送信息时跳转到时间线底部", + "Show line numbers in code blocks": "在代码块中显示行号", + "Expand code blocks by default": "默认展开代码块", + "Show stickers button": "显示贴纸按钮", + "Render LaTeX maths in messages": "在信息中渲染 LaTeX 数学", + "%(senderName)s ended the call": "%(senderName)s 结束了通话", + "You ended the call": "您结束了通话", + "This homeserver has been blocked by it's administrator.": "此 homeserver 已被其管理员屏蔽。", + "Use app": "使用 app", + "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element 网页版在移动设备上仍处于试验阶段。使用免费的原生 app 以获得更好的体验与最新的功能。", + "Use app for a better experience": "使用 app 以获得更好的体验", + "Enable desktop notifications": "开启桌面通知", + "Don't miss a reply": "不要错过任何回复", + "This homeserver has been blocked by its administrator.": "此 homeserver 已被其管理员屏蔽。", + "Send stickers to this room as you": "以您的身份发送贴纸到此聊天室", + "Change the avatar of your active room": "更改活跃聊天室的头像", + "Change the avatar of this room": "更改当前聊天室的头像", + "Change the name of your active room": "更改活跃聊天室的名称", + "Change the name of this room": "更改当前聊天室的名称", + "Change the topic of your active room": "更改当前活跃聊天室的讨论主题", + "Change the topic of this room": "更改当前聊天室的讨论主题", + "Change which room, message, or user you're viewing": "更改当前正在查看哪个聊天室、消息或用户", + "Change which room you're viewing": "更改当前正在查看哪个聊天室", + "Send stickers into your active room": "发送贴纸到您的活跃聊天室", + "Send stickers into this room": "发送贴纸到此聊天室", + "Remain on your screen while running": "运行时始终保留在您的屏幕上", + "Remain on your screen when viewing another room, when running": "运行时始终保留在您的屏幕上,即使您在浏览其它聊天室", + "%(senderName)s declined the call.": "%(senderName)s 拒绝了通话。", + "(an error occurred)": "(发生了一个错误)", + "(their device couldn't start the camera / microphone)": "(对方的设备无法开启摄像头/麦克风)", + "Converts the room to a DM": "将此聊天室会话转化为私聊会话", + "Converts the DM to a room": "将此私聊会话转化为聊天室会话", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "在纯文字信息前添加 ┬──┬ ノ( ゜-゜ノ)", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "在纯文字信息前添加 (╯°□°)╯︵ ┻━┻", + "You're already in a call with this person.": "您与此人已处在通话中。", + "Already in call": "已在通话中", + "Navigate composer history": "浏览编辑区历史", + "Go to Home View": "转到主视图", + "Search (must be enabled)": "搜索(必须启用)", + "Your Security Key": "您的安全密钥", + "Use Security Key": "使用安全密钥", + "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s 或 %(usernamePassword)s", + "User settings": "用户设置", + "Creating rooms...": "正在创建聊天室…", + "Room name": "聊天室名称", + "Random": "随机", + "%(count)s members|one": "%(count)s 位成员", + "%(count)s members|other": "%(count)s 位成员", + "Default Rooms": "默认聊天室", + "Accept Invite": "接受邀请", + "Manage rooms": "管理聊天室", + "Save changes": "保存修改", + "Remove from Space": "从空间中移除", + "Undo": "撤销", + "Welcome %(name)s": "欢迎 %(name)s", + "Create community": "创建社区", + "Forgot password?": "忘记密码?", + "Enter Security Key": "输入安全密钥", + "Invalid Security Key": "安全密钥无效", + "Wrong Security Key": "安全密钥错误", + "Save Changes": "保存修改", + "Saving...": "正在保存…", + "View dev tools": "查看开发者工具", + "Leave Space": "离开空间", + "Space settings": "空间设置", + "Learn more": "了解更多", + "Other homeserver": "其他主服务器", + "Specify a homeserver": "指定主服务器", + "Transfer": "传输", + "Unnamed Space": "未命名空间", + "Cookie Policy": "Cookie 政策", + "Privacy Policy": "隐私政策", + "Abort": "放弃", + "Send feedback": "发送反馈", + "Report a bug": "反馈问题", + "Edit Values": "编辑值", + "Value:": "值:", + "Setting definition:": "设置定义:", + "Caution:": "警告:", + "Setting:": "设置:", + "Value": "值", + "Setting ID": "设置 ID", + "Enter name": "输入名称", + "Community ID: +:%(domain)s": "社区 ID:+:%(domain)s", + "Reason (optional)": "理由(可选)", + "Show": "显示", + "Apply": "应用", + "Applying...": "正在应用…", + "Create a new room": "创建新聊天室", + "Spaces": "空间", + "Continue with %(provider)s": "使用 %(provider)s 继续", + "Homeserver": "主服务器", + "Server Options": "服务器选项", + "Information": "信息", + "Windows": "窗口", + "Screens": "屏幕", + "Share your screen": "共享屏幕", + "Role": "角色", + "Not encrypted": "未加密", + "Unpin": "取消置顶", + "Empty room": "空聊天室", + "Add existing room": "添加现有的聊天室", + "Open dial pad": "打开拨号键盘", + "Start a Conversation": "开始对话", + "Show Widgets": "显示小挂件", + "Hide Widgets": "隐藏小挂件", + "%(displayName)s created this room.": "%(displayName)s 创建了此聊天室。", + "You created this room.": "你创建了此聊天室。", + "Remove messages sent by others": "移除其他人的消息", + "Sending your message...": "正在发送消息…", + "Encrypting your message...": "正在加密消息…", + "Send message": "发送消息", + "Invite to this space": "邀请至此空间", + "Your message was sent": "消息已发送", + "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "请使用您的帐户数据备份加密密钥,以免您无法访问您的会话。密钥将通过一个唯一的安全密钥进行保护。", + "Spell check dictionaries": "拼写检查字典", + "Failed to save your profile": "个人资料保存失败", + "The operation could not be completed": "操作无法完成", + "Space options": "空间选项", + "Space Home": "空间首页", + "New room": "新建聊天室", + "Leave space": "离开空间", + "Share your public space": "分享你的公共空间", + "Collapse space panel": "收起空间面板", + "Expand space panel": "展开空间面板", + "Create a space": "创建空间", + "Fill Screen": "填充屏幕", + "sends snowfall": "发送雪球", + "Sends the given message with snowfall": "附加雪球发送", + "sends confetti": "发送五彩纸屑", + "Sends the given message with confetti": "附加五彩纸屑发送", + "Sends the given message with fireworks": "附加烟火发送", + "sends fireworks": "发送烟火", + "Offline encrypted messaging using dehydrated devices": "需要离线设备(dehydrated devices)的加密消息离线传递", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "正在开发的空间功能的原型。与社区、社区 V2 和自定义标签功能不兼容。需要主服务器兼容才能使用某些功能。", + "The %(capability)s capability": "%(capability)s 容量", + "%(senderName)s has updated the widget layout": "%(senderName)s 已更新小挂件布局", + "Support": "支持", + "Your server does not support showing space hierarchies.": "您的服务器不支持显示空间层次结构。", + "This version of %(brand)s does not support searching encrypted messages": "当前版本的 %(brand)s 不支持搜索加密消息", + "This version of %(brand)s does not support viewing some encrypted files": "当前版本的 %(brand)s 不支持查看某些加密文件", + "Effects": "效果", + "Pakistan": "巴基斯坦", + "United Arab Emirates": "阿拉伯联合酋长国", + "Yemen": "也门", + "Ukraine": "乌克兰", + "Turkmenistan": "土库曼斯坦", + "Uganda": "乌干达", + "Turkey": "土耳其", + "U.S. Virgin Islands": "美属维尔京群岛", + "Taiwan": "台湾", + "Sweden": "瑞典", + "Spain": "西班牙", + "South Korea": "韩国", + "South Africa": "南非", + "Russia": "俄罗斯", + "Romania": "罗马尼亚", + "Philippines": "菲律宾", + "North Korea": "朝鲜", + "Norway": "挪威", + "New Zealand": "新西兰", + "Netherlands": "荷兰", + "Mexico": "墨西哥", + "Malaysia": "马来西亚", + "Macau": "澳门", + "Luxembourg": "卢森堡", + "Lebanon": "黎巴嫩", + "Lithuania": "立陶宛", + "Latvia": "拉脱维亚", + "Liechtenstein": "列支敦士登", + "Laos": "老挝", + "Libya": "利比亚", + "Liberia": "利比里亚", + "Japan": "日本", + "Jamaica": "牙买加", + "Italy": "意大利", + "Israel": "以色列", + "Ireland": "爱尔兰", + "Iraq": "伊拉克", + "Indonesia": "印度尼西亚", + "India": "印度", + "Iceland": "冰岛", + "Iran": "伊朗", + "Guatemala": "危地马拉", + "Guam": "关岛", + "Guadeloupe": "瓜德罗普", + "Grenada": "格林纳达", + "Greenland": "格陵兰", + "Greece": "希腊", + "Gibraltar": "直布罗陀", + "Ghana": "加纳", + "Germany": "德国", + "Georgia": "格鲁吉亚", + "Gambia": "冈比亚", + "Gabon": "加蓬", + "French Southern Territories": "法属南部领地", + "French Polynesia": "法属波利尼西亚", + "French Guiana": "法属圭亚那", + "France": "法国", + "Finland": "芬兰", + "Fiji": "斐济", + "Faroe Islands": "法罗群岛", + "Falkland Islands": "福克兰群岛", + "Ethiopia": "埃塞俄比亚", + "Estonia": "爱沙尼亚", + "Eritrea": "厄立特里亚", + "Equatorial Guinea": "赤道几内亚", + "El Salvador": "萨尔瓦多", + "Egypt": "埃及", + "Ecuador": "厄瓜多尔", + "Dominican Republic": "多明尼加共和国", + "Dominica": "多米尼加", + "Djibouti": "吉布提", + "Denmark": "丹麦", + "Côte d’Ivoire": "科特迪瓦", + "Czech Republic": "捷克共和国", + "Cyprus": "塞浦路斯", + "Curaçao": "库拉索", + "Cuba": "古巴", + "Croatia": "克罗地亚", + "Costa Rica": "哥斯达黎加" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 64dab74139..6932b1ba9a 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -25,7 +25,7 @@ "Click here to fix": "點擊這里修復", "Confirm password": "確認密碼", "Continue": "繼續", - "Create Room": "創建聊天室", + "Create Room": "建立聊天室", "Cryptography": "加密", "Current password": "當前密碼", "/ddg is not a command": "/ddg 不是一個命令", @@ -109,7 +109,7 @@ "Someone": "某人", "Submit": "提交", "Success": "成功", - "This email address is already in use": "這個電子郵件地址已被使用", + "This email address is already in use": "該電子郵件位址已被使用", "This email address was not found": "未找到此電子郵件地址", "The email address linked to your account must be entered.": "必須輸入和你帳號關聯的電子郵件地址。", "Unable to add email address": "無法新增電郵地址", @@ -259,7 +259,7 @@ "This room has no local addresses": "此房間沒有本機地址", "This room is not recognised.": "此聊天室不被認可。", "This doesn't appear to be a valid email address": "這似乎不是有效的電子郵件地址", - "This phone number is already in use": "這個電話號碼已在使用中", + "This phone number is already in use": "該電話號碼已被使用", "This room": "此房間", "This room is not accessible by remote Matrix servers": "此房間無法被遠端的 Matrix 伺服器存取", "To use it, just wait for autocomplete results to load and tab through them.": "要使用它,只要等待自動完成的結果載入並在它們上面按 Tab。", @@ -625,7 +625,7 @@ "Room Notification": "聊天室通知", "The information being sent to us to help make %(brand)s better includes:": "傳送給我們以協助改進 %(brand)s 的資訊包含了:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "這個頁面包含了可識別的資訊,如聊天室、使用者或群組 ID,這些資料會在傳到伺服器前被刪除。", - "The platform you're on": "您使用的平臺是", + "The platform you're on": "您使用的平台是", "The version of %(brand)s": "%(brand)s 版本", "Your language of choice": "您選擇的語言", "Which officially provided instance you are using, if any": "您正在使用的任何官方實體,如果有的話", @@ -1520,8 +1520,8 @@ "Create a private room": "建立私人聊天室", "Topic (optional)": "主題(選擇性)", "Make this room public": "讓聊天室公開", - "Hide advanced": "隱藏進階的", - "Show advanced": "顯示進階的", + "Hide advanced": "隱藏進階", + "Show advanced": "顯示進階", "Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)": "阻擋其他 matrix 伺服器上的使用加入此聊天室(此設定無法在之後變更!)", "Close dialog": "關閉對話框", "To continue you need to accept the terms of this service.": "要繼續,您必須同意本服務的條款。", @@ -1543,7 +1543,7 @@ "Click the link in the email you received to verify and then click continue again.": "點擊您收到的電子郵件中的連結以驗證然後再次點擊繼續。", "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "您將要移除 %(user)s 的 1 則訊息。這無法復原。您想要繼續嗎?", "Remove %(count)s messages|one": "移除 1 則訊息", - "Add Email Address": "新增電子郵件地址", + "Add Email Address": "新增電子郵件位址", "Add Phone Number": "新增電話號碼", "%(creator)s created and configured the room.": "%(creator)s 建立並設定了聊天室。", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "您應該在斷線前從身份識別伺服器 移除您的個人資料。不幸的是,身份識別伺服器 目前離線中或無法連線。", @@ -1847,7 +1847,7 @@ "Bridges": "橋接", "This user has not verified all of their sessions.": "此使用者尚未驗證他們的所有工作階段。", "You have verified this user. This user has verified all of their sessions.": "您已驗證此使用者。此使用者已驗證他們所有的工作階段。", - "Someone is using an unknown session": "某人正仔使用未知的工作階段", + "Someone is using an unknown session": "某人正在使用未知的工作階段", "Your key share request has been sent - please check your other sessions for key share requests.": "您的金鑰分享請求已傳送,請檢查您其他的工作階段以取得金鑰分享請求。", "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "金鑰分享請求已自動傳送到您其他的工作階段。如果您在您其他的工作階段上拒絕或忽略金鑰分享請求,點擊此處以再此請求此工作階段的金鑰。", "If your other sessions do not have the key for this message you will not be able to decrypt them.": "如果您的其他工作階段沒有此訊息的金鑰,您就無法解密它們。", @@ -2079,11 +2079,11 @@ "Sends a message as html, without interpreting it as markdown": "以 html 形式傳送訊息,不將其翻譯為 markdown", "Cancel replying to a message": "取消回覆訊息", "Sign in with SSO": "使用單一登入系統登入", - "Use Single Sign On to continue": "使用單一登入繼續", - "Confirm adding this email address by using Single Sign On to prove your identity.": "透過使用單一登入來證明您的身份以確認新增此電子郵件地址。", + "Use Single Sign On to continue": "使用單一登入來繼續", + "Confirm adding this email address by using Single Sign On to prove your identity.": "使用單一登入來證明身份,以確認新增該電子郵件位址。", "Single Sign On": "單一登入", "Confirm adding email": "確任新增電子郵件", - "Click the button below to confirm adding this email address.": "點擊下方按鈕以確認新增此電子郵件地址。", + "Click the button below to confirm adding this email address.": "點擊下方按鈕以確認新增此電子郵件位址。", "Confirm adding this phone number by using Single Sign On to prove your identity.": "透過使用單一登入來證明您的身份以確認新增此電話號碼。", "Confirm adding phone number": "確任新增電話號碼", "Click the button below to confirm adding this phone number.": "點擊下方按鈕以確認新增此電話號碼。", @@ -2742,7 +2742,7 @@ "Gibraltar": "直布羅陀", "Ghana": "迦納", "Germany": "德國", - "Georgia": "喬治亞", + "Georgia": "格魯吉亞", "Gambia": "甘比亞", "Gabon": "加彭", "French Southern Territories": "法屬南部領地", @@ -2760,11 +2760,11 @@ "El Salvador": "薩爾瓦多", "Egypt": "埃及", "Ecuador": "厄瓜多", - "Dominican Republic": "多明尼加", + "Dominican Republic": "多明尼加共和國", "Dominica": "多米尼克", "Djibouti": "吉布地", "Denmark": "丹麥", - "Côte d’Ivoire": "象牙海岸", + "Côte d’Ivoire": "科特迪瓦", "Czech Republic": "捷克", "Cyprus": "賽普勒斯", "Curaçao": "古拉索", @@ -3090,5 +3090,120 @@ "Setting ID": "設定 ID", "Failed to save settings": "儲存設定失敗", "Settings Explorer": "設定瀏覽程式", - "Show chat effects (animations when receiving e.g. confetti)": "顯示聊天效果(當收到如五彩紙屑時顯示動畫)" + "Show chat effects (animations when receiving e.g. confetti)": "顯示聊天效果(當收到如五彩紙屑時顯示動畫)", + "Original event source": "原始活動來源", + "Decrypted event source": "解密活動來源", + "We'll create rooms for each of them. You can add existing rooms after setup.": "我們將為每個專案建立聊天室。您可以在設定完成後新增既有的聊天室。", + "What projects are you working on?": "您正在從事哪些專案?", + "We'll create rooms for each topic.": "我們將為每個主題建立聊天室。", + "What are some things you want to discuss?": "您想討論什麼?", + "Inviting...": "邀請……", + "Invite by username": "透過使用者名稱邀請", + "Invite your teammates": "邀請您的隊友", + "Failed to invite the following users to your space: %(csvUsers)s": "無法邀請以下使用者加入您的空間:%(csvUsers)s", + "A private space for you and your teammates": "專為您與您的隊友設計的私人空間", + "Me and my teammates": "我與我的隊友", + "A private space just for you": "專為您設計的私人空間", + "Just Me": "只有我", + "Ensure the right people have access to the space.": "確定適合的人才能存取空間。", + "Who are you working with?": "您與誰一起工作?", + "Finish": "結束", + "At the moment only you can see it.": "目前只有您可以看見它。", + "Creating rooms...": "正在建立聊天室……", + "Skip for now": "現在跳過", + "Failed to create initial space rooms": "建立初始空間聊天室失敗", + "Room name": "聊天室名稱", + "Support": "支援", + "Random": "隨機", + "Welcome to ": "歡迎加入 ", + "Your private space ": "您的私人空間 ", + "Your public space ": "您的公開空間 ", + "You have been invited to ": "您被邀請到 ", + " invited you to ": " 邀請您到 ", + "%(count)s members|one": "%(count)s 位成員", + "%(count)s members|other": "%(count)s 位成員", + "Your server does not support showing space hierarchies.": "您的伺服器不支援顯示空間的層次結構。", + "Default Rooms": "預設聊天室", + "Add existing rooms & spaces": "新增既有聊天室與空間", + "Accept Invite": "接受邀請", + "Find a room...": "尋找聊天室……", + "Manage rooms": "管理聊天室", + "Promoted to users": "升級為使用者", + "Save changes": "儲存變更", + "You're in this room": "您在此聊天室中", + "You're in this space": "您在此空間中", + "No permissions": "無權限", + "Remove from Space": "從空間移除", + "Undo": "復原", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "未傳送您的訊息,因為此家伺服器已被其管理員封鎖。請聯絡您的服務管理員以繼續使用服務。", + "Are you sure you want to leave the space '%(spaceName)s'?": "您確定您要離開空間「%(spaceName)s」?", + "This space is not public. You will not be able to rejoin without an invite.": "此空間並非公開。在無邀請的情況下,您將無法重新加入。", + "Start audio stream": "開始音訊串流", + "Failed to start livestream": "開始直播串流失敗", + "Unable to start audio streaming.": "無法開始音訊串流。", + "Save Changes": "儲存變更", + "Saving...": "正在儲存……", + "View dev tools": "檢視開發者工具", + "Leave Space": "離開空間", + "Make this space private": "將此空間設為私人", + "Edit settings relating to your space.": "編輯關於您空間的設定。", + "Space settings": "空間設定", + "Failed to save space settings.": "無法儲存空間設定。", + "Invite someone using their name, username (like ) or share this space.": "使用某人的名字、使用者名稱(如 )邀請他們,或分享此空間。", + "Invite someone using their name, email address, username (like ) or share this space.": "使用某人的名字、電子郵件地址、使用者名稱(如 )邀請他們,或分享此空間。", + "Unnamed Space": "未命名空間", + "Invite to %(spaceName)s": "邀請至 %(spaceName)s", + "Failed to add rooms to space": "新增聊天室到空間失敗", + "Apply": "套用", + "Applying...": "正在套用……", + "Create a new room": "建立新聊天室", + "Don't want to add an existing room?": "不想新增既有的聊天室?", + "Spaces": "空間", + "Filter your rooms and spaces": "過濾您的聊天室與空間", + "Add existing spaces/rooms": "新增既有空間/聊天室", + "Space selection": "空間選取", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "如果您將自己降級,您將無法撤銷此變更,而且如果您是空間中的最後一個高權限使用者,將無法再取得這類權限。", + "Empty room": "空聊天室", + "Suggested Rooms": "建議的聊天室", + "Explore space rooms": "探索空間聊天室", + "You do not have permissions to add rooms to this space": "您無權在此空間中新增聊天室", + "Add existing room": "新增既有的聊天室", + "You do not have permissions to create new rooms in this space": "您無權在此空間中建立新聊天室", + "Send message": "傳送訊息", + "Invite to this space": "邀請至此空間", + "Your message was sent": "您的訊息已傳送", + "Encrypting your message...": "正在加密的您訊息……", + "Sending your message...": "正在傳送您的訊息……", + "Spell check dictionaries": "拼字檢查字典", + "Space options": "空間選項", + "Space Home": "空間首頁", + "New room": "新聊天室", + "Leave space": "離開空間", + "Invite people": "邀請夥伴", + "Share your public space": "分享您的公開空間", + "Invite members": "邀請成員", + "Invite by email or username": "透過電子郵件或使用者名稱邀請", + "Share invite link": "分享邀請連結", + "Click to copy": "點擊複製", + "Collapse space panel": "折疊空間面板", + "Expand space panel": "展開空間面板", + "Creating...": "正在建立……", + "You can change these at any point.": "您隨時可以更改它們。", + "Give it a photo, name and description to help you identify it.": "給它一張照片、名字與描述來協助您識別它。", + "Your private space": "您的私人空間", + "Your public space": "您的公開空間", + "You can change this later": "您之後仍可變更", + "Invite only, best for yourself or teams": "僅邀請,最適合您自己或團隊", + "Private": "私人", + "Open space for anyone, best for communities": "對所有人開放的空間,最適合社群", + "Public": "公開", + "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "空間是將聊天室與人們分組的新方法。要加入既有的空間,您需要邀請", + "Create a space": "建立空間", + "Delete": "刪除", + "Jump to the bottom of the timeline when you send a message": "傳送訊息時,跳到時間軸底部", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "空間原型。與社群、社群 v2 以及自訂標籤不相容。需要相容的家伺服器才能使用某些功能。", + "This homeserver has been blocked by it's administrator.": "此家伺服器已被其管理員封鎖。", + "This homeserver has been blocked by its administrator.": "此家伺服器已被其管理員封鎖。", + "You're already in a call with this person.": "您已與此人通話。", + "Already in call": "已在通話中" } diff --git a/src/index.js b/src/index.js index 008e15ad90..e360c04f4f 100644 --- a/src/index.js +++ b/src/index.js @@ -28,3 +28,7 @@ export function resetSkin() { export function getComponent(componentName) { return Skinner.getComponent(componentName); } + +// Import the js-sdk so the proper `request` object can be set. This does some +// magic with the browser injection to make all subsequent imports work fine. +import "matrix-js-sdk"; diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index fa263a2a55..2dcdb9e3a3 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -16,7 +16,8 @@ limitations under the License. import PlatformPeg from "../PlatformPeg"; import {MatrixClientPeg} from "../MatrixClientPeg"; -import {EventTimeline, RoomMember} from 'matrix-js-sdk'; +import {RoomMember} from 'matrix-js-sdk/src/models/room-member'; +import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; import {sleep} from "../utils/promise"; import SettingsStore from "../settings/SettingsStore"; import {EventEmitter} from "events"; diff --git a/src/indexing/EventIndexPeg.js b/src/indexing/EventIndexPeg.js index 443daa8f43..7004efc554 100644 --- a/src/indexing/EventIndexPeg.js +++ b/src/indexing/EventIndexPeg.js @@ -31,6 +31,7 @@ class EventIndexPeg { constructor() { this.index = null; this._supportIsInstalled = false; + this.error = null; } /** @@ -96,6 +97,7 @@ class EventIndexPeg { await index.init(); } catch (e) { console.log("EventIndex: Error initializing the event index", e); + this.error = e; return false; } diff --git a/src/rageshake/rageshake.js b/src/rageshake/rageshake.js index 8eb77bb3ae..b886f369df 100644 --- a/src/rageshake/rageshake.js +++ b/src/rageshake/rageshake.js @@ -434,15 +434,37 @@ function selectQuery(store, keyRange, resultMapper) { /** * Configure rage shaking support for sending bug reports. * Modifies globals. + * @param {boolean} setUpPersistence When true (default), the persistence will + * be set up immediately for the logs. * @return {Promise} Resolves when set up. */ -export function init() { +export function init(setUpPersistence = true) { if (global.mx_rage_initPromise) { return global.mx_rage_initPromise; } global.mx_rage_logger = new ConsoleLogger(); global.mx_rage_logger.monkeyPatch(window.console); + if (setUpPersistence) { + return tryInitStorage(); + } + + global.mx_rage_initPromise = Promise.resolve(); + return global.mx_rage_initPromise; +} + +/** + * Try to start up the rageshake storage for logs. If not possible (client unsupported) + * then this no-ops. + * @return {Promise} Resolves when complete. + */ +export function tryInitStorage() { + if (global.mx_rage_initStoragePromise) { + return global.mx_rage_initStoragePromise; + } + + console.log("Configuring rageshake persistence..."); + // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. let indexedDB; @@ -452,11 +474,11 @@ export function init() { if (indexedDB) { global.mx_rage_store = new IndexedDBLogStore(indexedDB, global.mx_rage_logger); - global.mx_rage_initPromise = global.mx_rage_store.connect(); - return global.mx_rage_initPromise; + global.mx_rage_initStoragePromise = global.mx_rage_store.connect(); + return global.mx_rage_initStoragePromise; } - global.mx_rage_initPromise = Promise.resolve(); - return global.mx_rage_initPromise; + global.mx_rage_initStoragePromise = Promise.resolve(); + return global.mx_rage_initStoragePromise; } export function flush() { diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 4f589ba49a..b38dee6e1a 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -128,6 +128,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new ReloadOnChangeController(), }, + "feature_voice_messages": { + isFeature: true, + displayName: _td("Send and receive voice messages (in development)"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_latex_maths": { isFeature: true, displayName: _td("Render LaTeX maths in messages"), diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index 8983380fec..bb45456f1e 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -22,19 +22,29 @@ import { User } from "matrix-js-sdk/src/models/user"; import { throttle } from "lodash"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { _t } from "../languageHandler"; +import {mediaFromMxc} from "../customisations/Media"; interface IState { displayName?: string; avatarUrl?: string; } +const KEY_DISPLAY_NAME = "mx_profile_displayname"; +const KEY_AVATAR_URL = "mx_profile_avatar_url"; + export class OwnProfileStore extends AsyncStoreWithClient { private static internalInstance = new OwnProfileStore(); private monitoredUser: User; private constructor() { - super(defaultDispatcher, {}); + // seed from localstorage because otherwise we won't get these values until a whole network + // round-trip after the client is ready, and we often load widgets in that time, and we'd + // and up passing them an incorrect display name + super(defaultDispatcher, { + displayName: window.localStorage.getItem(KEY_DISPLAY_NAME), + avatarUrl: window.localStorage.getItem(KEY_AVATAR_URL), + }); } public static get instance(): OwnProfileStore { @@ -72,8 +82,12 @@ export class OwnProfileStore extends AsyncStoreWithClient { */ public getHttpAvatarUrl(size = 0): string { if (!this.avatarMxc) return null; - const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through - return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); + const media = mediaFromMxc(this.avatarMxc); + if (!size || size <= 0) { + return media.srcHttp; + } else { + return media.getSquareThumbnailHttp(size); + } } protected async onNotReady() { @@ -110,6 +124,16 @@ export class OwnProfileStore extends AsyncStoreWithClient { // We specifically do not use the User object we stored for profile info as it // could easily be wrong (such as per-room instead of global profile). const profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getUserId()); + if (profileInfo.displayname) { + window.localStorage.setItem(KEY_DISPLAY_NAME, profileInfo.displayname); + } else { + window.localStorage.removeItem(KEY_DISPLAY_NAME); + } + if (profileInfo.avatar_url) { + window.localStorage.setItem(KEY_AVATAR_URL, profileInfo.avatar_url); + } else { + window.localStorage.removeItem(KEY_AVATAR_URL); + } await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url}); }; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index f4c0c1b15c..601c77cdf3 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -273,7 +273,10 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; try { - await retry(() => cli.joinRoom(address, payload.opts), NUM_JOIN_RETRY, (err) => { + await retry(() => cli.joinRoom(address, { + viaServers: payload.via_servers, + ...payload.opts, + }), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout then retry return err.httpStatus === 504; }); diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index d1abc68f4e..bcf95a82be 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -34,6 +34,7 @@ import {setHasDiff} from "../utils/sets"; import {objectDiff} from "../utils/objects"; import {arrayHasDiff} from "../utils/arrays"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; +import RoomViewStore from "./RoomViewStore"; type SpaceKey = string | symbol; @@ -118,23 +119,32 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (space) { - try { - const data: { - rooms: ISpaceSummaryRoom[]; - events: ISpaceSummaryEvent[]; - } = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, MAX_SUGGESTED_ROOMS); - if (this._activeSpace === space) { - this._suggestedRooms = data.rooms.filter(roomInfo => { - return roomInfo.room_type !== RoomType.Space && !this.matrixClient.getRoom(roomInfo.room_id); - }); - this.emit(SUGGESTED_ROOMS, this._suggestedRooms); - } - } catch (e) { - console.error(e); + const data = await this.fetchSuggestedRooms(space); + if (this._activeSpace === space) { + this._suggestedRooms = data.rooms.filter(roomInfo => { + return roomInfo.room_type !== RoomType.Space && !this.matrixClient.getRoom(roomInfo.room_id); + }); + this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } } } + public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS) => { + try { + const data: { + rooms: ISpaceSummaryRoom[]; + events: ISpaceSummaryEvent[]; + } = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit); + return data; + } catch (e) { + console.error(e); + } + return { + rooms: [], + events: [], + }; + }; + public addRoomToSpace(space: Room, roomId: string, via: string[], suggested = false, autoJoin = false) { return this.matrixClient.sendStateEvent(space.roomId, EventType.SpaceChild, { via, @@ -186,15 +196,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); }; - public rebuild = throttle(() => { // exported for tests - const visibleRooms = this.matrixClient.getVisibleRooms(); - - // Sort spaces by room ID to force the loop breaking to be deterministic - const spaces = sortBy(this.getSpaces(), space => space.roomId); - const unseenChildren = new Set([...visibleRooms, ...spaces]); + private rebuild = throttle(() => { + // get all most-upgraded rooms & spaces except spaces which have been left (historical) + const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { + return !r.isSpaceRoom() || r.getMyMembership() === "join"; + }); + const unseenChildren = new Set(visibleRooms); const backrefs = new EnhancedMap>(); + // Sort spaces by room ID to force the cycle breaking to be deterministic + const spaces = sortBy(visibleRooms.filter(r => r.isSpaceRoom()), space => space.roomId); + // TODO handle cleaning up links when a Space is removed spaces.forEach(space => { const children = this.getChildren(space.roomId); @@ -207,7 +220,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); - // untested algorithm to handle full-cycles + // somewhat algorithm to handle full-cycles const detachedNodes = new Set(spaces); const markTreeChildren = (rootSpace: Room, unseen: Set) => { @@ -356,6 +369,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onRoomsUpdate(); } + // if the user was looking at the room and then joined select that space + if (room.getMyMembership() === "join" && room.roomId === RoomViewStore.getRoomId()) { + this.setActiveSpace(room); + } + const numSuggestedRooms = this._suggestedRooms.length; this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId); if (numSuggestedRooms !== this._suggestedRooms.length) { @@ -385,7 +403,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent: MatrixEvent) => { if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) { // If the room was in favourites and now isn't or the opposite then update its position in the trees - if (!!ev.getContent()[DefaultTagID.Favourite] !== !!lastEvent.getContent()[DefaultTagID.Favourite]) { + const oldTags = lastEvent.getContent()?.tags; + const newTags = ev.getContent()?.tags; + if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { this.onRoomUpdate(room); } } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 3f415f946d..074c2e569d 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -655,6 +655,18 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (!algorithmTags) return [DefaultTagID.Untagged]; return algorithmTags; } + + /** + * Manually update a room with a given cause. This should only be used if the + * room list store would otherwise be incapable of doing the update itself. Note + * that this may race with the room list's regular operation. + * @param {Room} room The room to update. + * @param {RoomUpdateCause} cause The cause to update for. + */ + public async manualRoomUpdate(room: Room, cause: RoomUpdateCause) { + await this.handleRoomUpdate(room, cause); + this.updateFn.trigger(); + } } export default class RoomListStore { diff --git a/src/usercontent/index.js b/src/usercontent/index.js index 6ecd17dcd7..13f38cc31a 100644 --- a/src/usercontent/index.js +++ b/src/usercontent/index.js @@ -1,10 +1,8 @@ function remoteRender(event) { const data = event.data; - const img = document.createElement("img"); + const img = document.createElement("span"); // we'll mask it as an image img.id = "img"; - img.src = data.imgSrc; - img.style = data.imgStyle; const a = document.createElement("a"); a.id = "a"; @@ -16,6 +14,23 @@ function remoteRender(event) { a.appendChild(img); a.appendChild(document.createTextNode(data.textContent)); + // Apply image style after so we can steal the anchor's colour. + // Style copied from a rendered version of mx_MFileBody_download_icon + img.style = (data.imgStyle || "" + + "width: 12px; height: 12px;" + + "-webkit-mask-size: 12px;" + + "mask-size: 12px;" + + "-webkit-mask-position: center;" + + "mask-position: center;" + + "-webkit-mask-repeat: no-repeat;" + + "mask-repeat: no-repeat;" + + "display: inline-block;") + "" + + + // Always add these styles + `-webkit-mask-image: url('${data.imgSrc}');` + + `mask-image: url('${data.imgSrc}');` + + `background-color: ${a.style.color};`; + const body = document.body; // Don't display scrollbars if the link takes more than one line to display. body.style = "margin: 0px; overflow: hidden"; @@ -26,20 +41,8 @@ function remoteRender(event) { } } -function remoteSetTint(event) { - const data = event.data; - - const img = document.getElementById("img"); - img.src = data.imgSrc; - img.style = data.imgStyle; - - const a = document.getElementById("a"); - a.style = data.style; -} - window.onmessage = function(e) { if (e.origin === window.location.origin) { if (e.data.blob) remoteRender(e); - else remoteSetTint(e); } }; diff --git a/src/utils/AutoDiscoveryUtils.js b/src/utils/AutoDiscoveryUtils.js index 18b6451d3e..614aa4cea8 100644 --- a/src/utils/AutoDiscoveryUtils.js +++ b/src/utils/AutoDiscoveryUtils.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import {AutoDiscovery} from "matrix-js-sdk"; +import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; import {_t, _td, newTranslatableError} from "../languageHandler"; import {makeType} from "./TypeUtils"; import SdkConfig from '../SdkConfig'; diff --git a/src/utils/DecryptFile.js b/src/utils/DecryptFile.ts similarity index 74% rename from src/utils/DecryptFile.js rename to src/utils/DecryptFile.ts index d3625d614a..93cedbc707 100644 --- a/src/utils/DecryptFile.js +++ b/src/utils/DecryptFile.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd +Copyright 2016, 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,8 +16,8 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; -// Grab the client so that we can turn mxc:// URLs into https:// URLS. -import {MatrixClientPeg} from '../MatrixClientPeg'; +import {mediaFromContent} from "../customisations/Media"; +import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; // WARNING: We have to be very careful about what mime-types we allow into blobs, // as for performance reasons these are now rendered via URL.createObjectURL() @@ -54,48 +53,46 @@ import {MatrixClientPeg} from '../MatrixClientPeg'; // For the record, mime-types which must NEVER enter this list below include: // text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. -const ALLOWED_BLOB_MIMETYPES = { - 'image/jpeg': true, - 'image/gif': true, - 'image/png': true, +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', - 'video/mp4': true, - 'video/webm': true, - 'video/ogg': true, + 'video/mp4', + 'video/webm', + 'video/ogg', - 'audio/mp4': true, - 'audio/webm': true, - 'audio/aac': true, - 'audio/mpeg': true, - 'audio/ogg': true, - 'audio/wave': true, - 'audio/wav': true, - 'audio/x-wav': true, - 'audio/x-pn-wav': true, - 'audio/flac': true, - 'audio/x-flac': true, -}; + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; /** * Decrypt a file attached to a matrix event. - * @param {Object} file The json taken from the matrix event. + * @param {IEncryptedFile} file The json taken from the matrix event. * This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments} * as the encryption info object, so will also have the those keys in addition to * the keys below. - * @param {string} file.url An mxc:// URL for the encrypted file. - * @param {string} file.mimetype The MIME-type of the plaintext file. - * @returns {Promise} + * @returns {Promise} Resolves to a Blob of the file. */ -export function decryptFile(file) { - const url = MatrixClientPeg.get().mxcUrlToHttp(file.url); +export function decryptFile(file: IEncryptedFile): Promise { + const media = mediaFromContent({file}); // Download the encrypted file as an array buffer. - return Promise.resolve(fetch(url)).then(function(response) { + return media.downloadSource().then((response) => { return response.arrayBuffer(); - }).then(function(responseData) { + }).then((responseData) => { // Decrypt the array buffer using the information taken from // the event content. return encrypt.decryptAttachment(responseData, file); - }).then(function(dataArray) { + }).then((dataArray) => { // Turn the array into a Blob and give it the correct MIME-type. // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise @@ -103,11 +100,10 @@ export function decryptFile(file) { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { mimetype = 'application/octet-stream'; } - const blob = new Blob([dataArray], {type: mimetype}); - return blob; + return new Blob([dataArray], {type: mimetype}); }); } diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index 6558a11ed4..be21896417 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventStatus } from 'matrix-js-sdk'; +import { EventStatus } from 'matrix-js-sdk/src/models/event'; import {MatrixClientPeg} from '../MatrixClientPeg'; import shouldHideEvent from "../shouldHideEvent"; /** diff --git a/src/utils/IdentityServerUtils.js b/src/utils/IdentityServerUtils.js index 093d4eeabf..5ece308954 100644 --- a/src/utils/IdentityServerUtils.js +++ b/src/utils/IdentityServerUtils.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SERVICE_TYPES } from 'matrix-js-sdk'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; import SdkConfig from '../SdkConfig'; import {MatrixClientPeg} from '../MatrixClientPeg'; diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js index 63d3942b37..78f956b91b 100644 --- a/src/utils/MultiInviter.js +++ b/src/utils/MultiInviter.js @@ -111,17 +111,10 @@ export default class MultiInviter { } if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { - try { - const profile = await MatrixClientPeg.get().getProfileInfo(addr); - if (!profile) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("User has no profile"); - } - } catch (e) { - throw { - errcode: "RIOT.USER_NOT_FOUND", - error: "User does not have a profile or does not exist." - }; + const profile = await MatrixClientPeg.get().getProfileInfo(addr); + if (!profile) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("User has no profile"); } } @@ -171,7 +164,7 @@ export default class MultiInviter { this._doInvite(address, ignoreProfile).then(resolve, reject); }, 5000); return; - } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'RIOT.USER_NOT_FOUND'].includes(err.errcode)) { + } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { errorText = _t("User %(user_id)s does not exist", {user_id: address}); } else if (err.errcode === 'M_PROFILE_UNDISCLOSED') { errorText = _t("User %(user_id)s may or may not exist", {user_id: address}); @@ -212,7 +205,7 @@ export default class MultiInviter { if (Object.keys(this.errors).length > 0 && !this.groupId) { // There were problems inviting some people - see if we can invite them // without caring if they exist or not. - const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND', 'RIOT.USER_NOT_FOUND']; + const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); if (unknownProfileUsers.length > 0) { @@ -228,7 +221,7 @@ export default class MultiInviter { const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog"); console.log("Showing failed to invite dialog..."); - Modal.createTrackedDialog('Failed to invite the following users to the room', '', AskInviteAnywayDialog, { + Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}), onInviteAnyways: () => inviteUnknowns(), onGiveUp: () => { diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js index c90281bacf..23c27a2d1c 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Matrix from 'matrix-js-sdk'; import {LocalStorageCryptoStore} from 'matrix-js-sdk/src/crypto/store/localStorage-crypto-store'; import Analytics from '../Analytics'; +import {IndexedDBStore} from "matrix-js-sdk/src/store/indexeddb"; +import {IndexedDBCryptoStore} from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; const localStorage = window.localStorage; @@ -132,7 +133,7 @@ export async function checkConsistency() { async function checkSyncStore() { let exists = false; try { - exists = await Matrix.IndexedDBStore.exists( + exists = await IndexedDBStore.exists( indexedDB, SYNC_STORE_NAME, ); log(`Sync store using IndexedDB contains data? ${exists}`); @@ -148,7 +149,7 @@ async function checkSyncStore() { async function checkCryptoStore() { let exists = false; try { - exists = await Matrix.IndexedDBCryptoStore.exists( + exists = await IndexedDBCryptoStore.exists( indexedDB, CRYPTO_STORE_NAME, ); log(`Crypto store using IndexedDB contains data? ${exists}`); diff --git a/src/utils/createMatrixClient.js b/src/utils/createMatrixClient.js index c8ff35a584..f5e196d846 100644 --- a/src/utils/createMatrixClient.js +++ b/src/utils/createMatrixClient.js @@ -14,7 +14,10 @@ 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 {IndexedDBCryptoStore} from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; +import {WebStorageSessionStore} from "matrix-js-sdk/src/store/session/webstorage"; +import {IndexedDBStore} from "matrix-js-sdk/src/store/indexeddb"; const localStorage = window.localStorage; @@ -44,7 +47,7 @@ export default function createMatrixClient(opts) { }; if (indexedDB && localStorage) { - storeOpts.store = new Matrix.IndexedDBStore({ + storeOpts.store = new IndexedDBStore({ indexedDB: indexedDB, dbName: "riot-web-sync", localStorage: localStorage, @@ -53,18 +56,18 @@ export default function createMatrixClient(opts) { } if (localStorage) { - storeOpts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); + storeOpts.sessionStore = new WebStorageSessionStore(localStorage); } if (indexedDB) { - storeOpts.cryptoStore = new Matrix.IndexedDBCryptoStore( + storeOpts.cryptoStore = new IndexedDBCryptoStore( indexedDB, "matrix-js-sdk:crypto", ); } opts = Object.assign(storeOpts, opts); - return Matrix.createClient(opts); + return createClient(opts); } createMatrixClient.indexedDbWorkerScript = null; diff --git a/src/utils/pages.js b/src/utils/pages.ts similarity index 68% rename from src/utils/pages.js rename to src/utils/pages.ts index d63ca3f2c7..bae76be29d 100644 --- a/src/utils/pages.js +++ b/src/utils/pages.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +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. @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getHomePageUrl(appConfig) { +import { ConfigOptions } from "../SdkConfig"; + +export function getHomePageUrl(appConfig: ConfigOptions): string | null { const pagesConfig = appConfig.embeddedPages; - let pageUrl = null; - if (pagesConfig) { - pageUrl = pagesConfig.homeUrl; - } + let pageUrl = pagesConfig?.homeUrl; + if (!pageUrl) { // This is a deprecated config option for the home page // (despite the name, given we also now have a welcome @@ -29,3 +29,8 @@ export function getHomePageUrl(appConfig) { return pageUrl; } + +export function shouldUseLoginForWelcome(appConfig: ConfigOptions): boolean { + const pagesConfig = appConfig.embeddedPages; + return pagesConfig?.loginForWelcome === true; +} diff --git a/src/voice/VoiceRecorder.ts b/src/voice/VoiceRecorder.ts new file mode 100644 index 0000000000..06c0d939fc --- /dev/null +++ b/src/voice/VoiceRecorder.ts @@ -0,0 +1,180 @@ +/* +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 * as Recorder from 'opus-recorder'; +import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import CallMediaHandler from "../CallMediaHandler"; +import {SimpleObservable} from "matrix-widget-api"; + +const CHANNELS = 1; // stereo isn't important +const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. +const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. +const FREQ_SAMPLE_RATE = 4; // Target rate of frequency data (samples / sec). We don't need this super often. + +export interface IFrequencyPackage { + dbBars: Float32Array; + dbMin: number; + dbMax: number; + + // TODO: @@ TravisR: Generalize this for a timing package? +} + +export class VoiceRecorder { + private recorder: Recorder; + private recorderContext: AudioContext; + private recorderSource: MediaStreamAudioSourceNode; + private recorderStream: MediaStream; + private recorderFreqNode: AnalyserNode; + private buffer = new Uint8Array(0); + private mxc: string; + private recording = false; + private observable: SimpleObservable; + private freqTimerId: number; + + public constructor(private client: MatrixClient) { + } + + private async makeRecorder() { + this.recorderStream = await navigator.mediaDevices.getUserMedia({ + audio: { + // specify some audio settings so we're feeding the recorder with the + // best possible values. The browser will handle resampling for us. + sampleRate: SAMPLE_RATE, + channelCount: CHANNELS, + noiseSuppression: true, // browsers ignore constraints they can't honour + deviceId: CallMediaHandler.getAudioInput(), + }, + }); + this.recorderContext = new AudioContext({ + latencyHint: "interactive", + sampleRate: SAMPLE_RATE, // once again, the browser will resample for us + }); + this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); + this.recorderFreqNode = this.recorderContext.createAnalyser(); + this.recorderSource.connect(this.recorderFreqNode); + this.recorder = new Recorder({ + encoderPath, // magic from webpack + encoderSampleRate: SAMPLE_RATE, + encoderApplication: 2048, // voice (default is "audio") + streamPages: true, // this speeds up the encoding process by using CPU over time + encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder + numberOfChannels: CHANNELS, + sourceNode: this.recorderSource, + encoderBitRate: BITRATE, + + // We use low values for the following to ease CPU usage - the resulting waveform + // is indistinguishable for a voice message. Note that the underlying library will + // pick defaults which prefer the highest possible quality, CPU be damned. + encoderComplexity: 3, // 0-10, 10 is slow and high quality. + resampleQuality: 3, // 0-10, 10 is slow and high quality + }); + this.recorder.ondataavailable = (a: ArrayBuffer) => { + const buf = new Uint8Array(a); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + } + + public get frequencyData(): SimpleObservable { + if (!this.recording) throw new Error("No observable when not recording"); + return this.observable; + } + + public get isSupported(): boolean { + return !!Recorder.isRecordingSupported(); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get mxcUri(): string { + if (!this.mxc) { + throw new Error("Recording has not been uploaded yet"); + } + return this.mxc; + } + + public async start(): Promise { + if (this.mxc || this.hasRecording) { + throw new Error("Recording already prepared"); + } + if (this.recording) { + throw new Error("Recording already in progress"); + } + if (this.observable) { + this.observable.close(); + } + this.observable = new SimpleObservable(); + await this.makeRecorder(); + this.freqTimerId = setInterval(() => { + if (!this.recording) return; + const data = new Float32Array(this.recorderFreqNode.frequencyBinCount); + this.recorderFreqNode.getFloatFrequencyData(data); + this.observable.update({ + dbBars: data, + dbMin: this.recorderFreqNode.minDecibels, + dbMax: this.recorderFreqNode.maxDecibels, + }); + }, 1000 / FREQ_SAMPLE_RATE) as any as number; // XXX: Linter doesn't understand timer environment + await this.recorder.start(); + this.recording = true; + } + + public async stop(): Promise { + if (!this.recording) { + throw new Error("No recording to stop"); + } + + // Disconnect the source early to start shutting down resources + this.recorderSource.disconnect(); + await this.recorder.stop(); + + // close the context after the recorder so the recorder doesn't try to + // connect anything to the context (this would generate a warning) + await this.recorderContext.close(); + + // Now stop all the media tracks so we can release them back to the user/OS + this.recorderStream.getTracks().forEach(t => t.stop()); + + // Finally do our post-processing and clean up + clearInterval(this.freqTimerId); + this.recording = false; + await this.recorder.close(); + + return this.buffer; + } + + public async upload(): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.mxc) return this.mxc; + + this.mxc = await this.client.uploadContent(new Blob([this.buffer], { + type: "audio/ogg", + }), { + onlyContentUri: false, // to stop the warnings in the console + }).then(r => r['content_uri']); + return this.mxc; + } +} + +window.mxVoiceRecorder = VoiceRecorder; diff --git a/test/components/structures/GroupView-test.js b/test/components/structures/GroupView-test.js index fb942d2f7c..ee5d1b6912 100644 --- a/test/components/structures/GroupView-test.js +++ b/test/components/structures/GroupView-test.js @@ -262,7 +262,8 @@ describe('GroupView', function() { expect(longDescElement.innerHTML).toContain('
      '); expect(longDescElement.innerHTML).toContain('
    • And lists!
    • '); - const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl?width=800&height=600"; + const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl" + + "?width=800&height=600&method=scale"; expect(longDescElement.innerHTML).toContain(''); }); diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 2fd5bd6ad1..7347ff2658 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -116,6 +116,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + i*1000, mship: 'join', @@ -148,6 +149,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + i*1000, mship: 'join', @@ -193,6 +195,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + 1, mship: 'join', @@ -239,6 +242,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + 5, mship: 'invite', diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js index 6d26fa36e9..dd6febc7d7 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -50,6 +50,7 @@ describe('MemberEventListSummary', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, }); // Override random event ID to allow for equality tests against tiles from diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index a596825c09..0a6d47a72b 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -37,6 +37,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ @@ -61,6 +62,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ @@ -86,6 +88,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; }); @@ -139,6 +142,7 @@ describe("", () => { on: () => undefined, removeListener: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; }); @@ -284,6 +288,7 @@ describe("", () => { getAccountData: () => undefined, getUrlPreview: (url) => new Promise(() => {}), isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ diff --git a/test/setupTests.js b/test/setupTests.js index 9c2d16a8df..6d37d48987 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -2,3 +2,5 @@ import * as languageHandler from "../src/languageHandler"; languageHandler.setLanguage('en'); languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]); + +require('jest-fetch-mock').enableMocks(); diff --git a/test/test-utils.js b/test/test-utils.js index b6e0468d86..d259fcb95f 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -213,6 +213,7 @@ export function mkStubRoom(roomId = null) { rawDisplayName: 'Member', roomId: roomId, getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }), getMembersWithMembership: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]), @@ -242,6 +243,7 @@ export function mkStubRoom(roomId = null) { removeListener: jest.fn(), getDMInviter: jest.fn(), getAvatarUrl: () => 'mxc://avatar.url/room.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', }; } diff --git a/yarn.lock b/yarn.lock index f99ea5900d..1763a42e75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,6 +2589,13 @@ crc-32@^0.3.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" integrity sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14= +cross-fetch@^3.0.4: + version "3.0.6" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" + integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -4918,6 +4925,14 @@ jest-environment-node@^26.6.2: jest-mock "^26.6.2" jest-util "^26.6.2" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" @@ -5573,8 +5588,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "9.8.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fb73ab687826e4d05fb8b424ab013a771213f84f" + version "9.9.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd38fb9b4c349eb31feac14e806e710bf6431b72" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5835,6 +5850,11 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -6076,6 +6096,11 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +opus-recorder@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/opus-recorder/-/opus-recorder-8.0.3.tgz#f7b44f8f68500c9b96a15042a69f915fd9c1716d" + integrity sha512-8vXGiRwlJAavT9D3yYzukNVXQ8vEcKHcsQL/zXO24DQtJ0PLXvoPHNQPJrbMCdB4ypJgWDExvHF4JitQDL7dng== + os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -6448,6 +6473,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-polyfill@^8.1.3: + version "8.2.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.2.0.tgz#367394726da7561457aba2133c9ceefbd6267da0" + integrity sha512-k/TC0mIcPVF6yHhUvwAp7cvL6I2fFV7TzF1DuGPI8mBh4QQazf36xCKEHKTZKRysEoTQoQdKyP25J8MPJp7j5g== + promise@^7.0.3, promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"