Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/12740

 Conflicts:
	src/components/structures/TimelinePanel.js
	src/components/views/context_menus/MessageContextMenu.js
	src/components/views/right_panel/UserInfo.tsx
	src/dispatcher/actions.ts
This commit is contained in:
Michael Telatynski 2021-06-17 15:31:06 +01:00
commit f38cd38bd3
203 changed files with 7834 additions and 2996 deletions

View file

@ -18,7 +18,7 @@ module.exports = {
}, },
overrides: [{ overrides: [{
"files": ["src/**/*.{ts,tsx}"], "files": ["src/**/*.{ts,tsx}", "test/**/*.{ts,tsx}"],
"extends": ["matrix-org/ts"], "extends": ["matrix-org/ts"],
"rules": { "rules": {
// We're okay being explicit at the moment // We're okay being explicit at the moment

38
.github/workflows/develop.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Develop
on:
push:
branches: [develop]
pull_request:
branches: [develop]
jobs:
end-to-end:
runs-on: ubuntu-latest
container: vectorim/element-web-ci-e2etests-env:latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: End-to-End tests
run: ./scripts/ci/end-to-end-tests.sh
- name: Archive logs
uses: actions/upload-artifact@v2
with:
path: |
test/end-to-end-tests/logs/**/*
test/end-to-end-tests/synapse/installations/consent/homeserver.log
retention-days: 14
- name: Download previous benchmark data
uses: actions/cache@v1
with:
path: ./cache
key: ${{ runner.os }}-benchmark
- name: Store benchmark result
uses: matrix-org/github-action-benchmark@jsperfentry-1
with:
tool: 'jsperformanceentry'
output-file-path: test/end-to-end-tests/performance-entries.json
fail-on-alert: false
comment-on-alert: false
# Only temporary to monitor where failures occur
alert-comment-cc-users: '@gsouquet'
github-token: ${{ secrets.DEPLOY_GH_PAGES }}
auto-push: ${{ github.ref == 'refs/heads/develop' }}

View file

@ -1,3 +1,109 @@
Changes in [3.23.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0) (2021-06-07)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.23.0-rc.1...v3.23.0)
* Upgrade to JS SDK 11.2.0
* [Release] Fix notif panel timestamp padding
[\#6158](https://github.com/matrix-org/matrix-react-sdk/pull/6158)
Changes in [3.23.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.23.0-rc.1) (2021-06-01)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0...v3.23.0-rc.1)
* Upgrade to JS SDK 11.2.0-rc.1
* Translations update from Weblate
[\#6128](https://github.com/matrix-org/matrix-react-sdk/pull/6128)
* Fix all DMs wrongly appearing in room list when `m.direct` is changed
[\#6122](https://github.com/matrix-org/matrix-react-sdk/pull/6122)
* Update way of checking for registration disabled
[\#6123](https://github.com/matrix-org/matrix-react-sdk/pull/6123)
* Fix the ability to remove avatar from a space via settings
[\#6126](https://github.com/matrix-org/matrix-react-sdk/pull/6126)
* Switch to stable endpoint/fields for MSC2858
[\#6125](https://github.com/matrix-org/matrix-react-sdk/pull/6125)
* Clear stored editor state when canceling editing using a shortcut
[\#6117](https://github.com/matrix-org/matrix-react-sdk/pull/6117)
* Respect newlines in space topics
[\#6124](https://github.com/matrix-org/matrix-react-sdk/pull/6124)
* Add url param `defaultUsername` to prefill the login username field
[\#5674](https://github.com/matrix-org/matrix-react-sdk/pull/5674)
* Bump ws from 7.4.2 to 7.4.6
[\#6115](https://github.com/matrix-org/matrix-react-sdk/pull/6115)
* Sticky headers repositioning without layout trashing
[\#6110](https://github.com/matrix-org/matrix-react-sdk/pull/6110)
* Handle user_busy in voip calls
[\#6112](https://github.com/matrix-org/matrix-react-sdk/pull/6112)
* Avoid showing warning modals from the invite dialog after it unmounts
[\#6105](https://github.com/matrix-org/matrix-react-sdk/pull/6105)
* Fix misleading child counts in spaces
[\#6109](https://github.com/matrix-org/matrix-react-sdk/pull/6109)
* Close creation menu when expanding space panel via expand hierarchy
[\#6090](https://github.com/matrix-org/matrix-react-sdk/pull/6090)
* Prevent having duplicates in pending room state
[\#6108](https://github.com/matrix-org/matrix-react-sdk/pull/6108)
* Update reactions row on event decryption
[\#6106](https://github.com/matrix-org/matrix-react-sdk/pull/6106)
* Destroy playback instance on voice message unmount
[\#6101](https://github.com/matrix-org/matrix-react-sdk/pull/6101)
* Fix message preview not up to date
[\#6102](https://github.com/matrix-org/matrix-react-sdk/pull/6102)
* Convert some Flow typed files to TS (round 2)
[\#6076](https://github.com/matrix-org/matrix-react-sdk/pull/6076)
* Remove unused middlePanelResized event listener
[\#6086](https://github.com/matrix-org/matrix-react-sdk/pull/6086)
* Fix accessing currentState on an invalid joinedRoom
[\#6100](https://github.com/matrix-org/matrix-react-sdk/pull/6100)
* Remove Promise allSettled polyfill as js-sdk uses it directly
[\#6097](https://github.com/matrix-org/matrix-react-sdk/pull/6097)
* Prevent DecoratedRoomAvatar to update its state for the same value
[\#6099](https://github.com/matrix-org/matrix-react-sdk/pull/6099)
* Skip generatePreview if event is not part of the live timeline
[\#6098](https://github.com/matrix-org/matrix-react-sdk/pull/6098)
* fix sticky headers when results num get displayed
[\#6095](https://github.com/matrix-org/matrix-react-sdk/pull/6095)
* Improve addEventsToTimeline performance scoping WhoIsTypingTile::setState
[\#6094](https://github.com/matrix-org/matrix-react-sdk/pull/6094)
* Safeguards to prevent layout trashing for window dimensions
[\#6092](https://github.com/matrix-org/matrix-react-sdk/pull/6092)
* Use local room state to render space hierarchy if the room is known
[\#6089](https://github.com/matrix-org/matrix-react-sdk/pull/6089)
* Add spinner in UserMenu to list pending long running actions
[\#6085](https://github.com/matrix-org/matrix-react-sdk/pull/6085)
* Stop overscroll in Firefox Nightly for macOS
[\#6093](https://github.com/matrix-org/matrix-react-sdk/pull/6093)
* Move SettingsStore watchers/monitors over to ES6 maps for performance
[\#6063](https://github.com/matrix-org/matrix-react-sdk/pull/6063)
* Bump libolm version.
[\#6080](https://github.com/matrix-org/matrix-react-sdk/pull/6080)
* Improve styling of the message action bar
[\#6066](https://github.com/matrix-org/matrix-react-sdk/pull/6066)
* Improve explore rooms when no results are found
[\#6070](https://github.com/matrix-org/matrix-react-sdk/pull/6070)
* Remove logo spinner
[\#6078](https://github.com/matrix-org/matrix-react-sdk/pull/6078)
* Fix add reaction prompt showing even when user is not joined to room
[\#6073](https://github.com/matrix-org/matrix-react-sdk/pull/6073)
* Vectorize spinners
[\#5680](https://github.com/matrix-org/matrix-react-sdk/pull/5680)
* Fix handling of via servers for suggested rooms
[\#6077](https://github.com/matrix-org/matrix-react-sdk/pull/6077)
* Upgrade showChatEffects to room-level setting exposure
[\#6075](https://github.com/matrix-org/matrix-react-sdk/pull/6075)
* Delete RoomView dead code
[\#6071](https://github.com/matrix-org/matrix-react-sdk/pull/6071)
* Reduce noise in tests
[\#6074](https://github.com/matrix-org/matrix-react-sdk/pull/6074)
* Fix room name issues in right panel summary card
[\#6069](https://github.com/matrix-org/matrix-react-sdk/pull/6069)
* Cache normalized room name
[\#6072](https://github.com/matrix-org/matrix-react-sdk/pull/6072)
* Update MemberList to reflect changes for invite permission change
[\#6061](https://github.com/matrix-org/matrix-react-sdk/pull/6061)
* Delete RoomView dead code
[\#6065](https://github.com/matrix-org/matrix-react-sdk/pull/6065)
* Show subspace rooms count even if it is 0 for consistency
[\#6067](https://github.com/matrix-org/matrix-react-sdk/pull/6067)
Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24) Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.22.0", "version": "3.23.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -55,7 +55,6 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"blueimp-canvas-to-blob": "^3.28.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"cheerio": "^1.0.0-rc.9", "cheerio": "^1.0.0-rc.9",
@ -88,18 +87,16 @@
"png-chunks-extract": "^1.0.0", "png-chunks-extract": "^1.0.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"qs": "^6.9.6",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^16.14.0", "react": "^17.0.2",
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.14.0", "react-dom": "^17.0.2",
"react-focus-lock": "^2.5.0", "react-focus-lock": "^2.5.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-html": "^2.3.2", "sanitize-html": "^2.3.2",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"text-encoding-utf-8": "^1.0.2",
"url": "^0.11.0", "url": "^0.11.0",
"what-input": "^5.2.10", "what-input": "^5.2.10",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
@ -147,7 +144,7 @@
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"eslint": "7.18.0", "eslint": "7.18.0",
"eslint-config-matrix-org": "^0.2.0", "eslint-config-matrix-org": "^0.2.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
@ -160,9 +157,9 @@
"jest-environment-jsdom-sixteen": "^1.0.3", "jest-environment-jsdom-sixteen": "^1.0.3",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.3",
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^17.0.2",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"stylelint": "^13.9.0", "stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",

View file

@ -291,6 +291,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
.mx_Dialog_staticWrapper .mx_Dialog { .mx_Dialog_staticWrapper .mx_Dialog {
z-index: 4010; z-index: 4010;
contain: content;
} }
.mx_Dialog_background { .mx_Dialog_background {

View file

@ -76,6 +76,7 @@
@import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss";
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
@import "./views/dialogs/_FeedbackDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss";
@import "./views/dialogs/_ForwardDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_HostSignupDialog.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@ -179,6 +180,7 @@
@import "./views/messages/_common_CryptoEvent.scss"; @import "./views/messages/_common_CryptoEvent.scss";
@import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_BaseCard.scss";
@import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_EncryptionInfo.scss";
@import "./views/right_panel/_PinnedMessagesCard.scss";
@import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss";
@import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_UserInfo.scss";
@import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_VerificationPanel.scss";
@ -203,7 +205,6 @@
@import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NewRoomIntro.scss";
@import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss";

View file

@ -38,6 +38,7 @@ limitations under the License.
position: absolute; position: absolute;
font-size: $font-14px; font-size: $font-14px;
z-index: 5001; z-index: 5001;
contain: content;
} }
.mx_ContextualMenu_right { .mx_ContextualMenu_right {

View file

@ -25,6 +25,7 @@ $roomListCollapsedWidth: 68px;
// Create a row-based flexbox for the GroupFilterPanel and the room list // Create a row-based flexbox for the GroupFilterPanel and the room list
display: flex; display: flex;
contain: content;
.mx_LeftPanel_GroupFilterPanelContainer { .mx_LeftPanel_GroupFilterPanelContainer {
flex-grow: 0; flex-grow: 0;
@ -70,6 +71,7 @@ $roomListCollapsedWidth: 68px;
// aligned correctly. This is also a row-based flexbox. // aligned correctly. This is also a row-based flexbox.
display: flex; display: flex;
align-items: center; align-items: center;
contain: content;
&.mx_IndicatorScrollbar_leftOverflow { &.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 5%); mask-image: linear-gradient(90deg, transparent, black 5%);

View file

@ -82,7 +82,6 @@ limitations under the License.
color: $primary-fg-color; color: $primary-fg-color;
font-size: $font-12px; font-size: $font-12px;
display: inline; display: inline;
padding-left: 0px;
} }
.mx_NotificationPanel .mx_EventTile_senderDetails { .mx_NotificationPanel .mx_EventTile_senderDetails {
@ -103,6 +102,7 @@ limitations under the License.
visibility: visible; visibility: visible;
position: initial; position: initial;
display: inline; display: inline;
padding-left: 5px;
} }
.mx_NotificationPanel .mx_EventTile_line { .mx_NotificationPanel .mx_EventTile_line {

View file

@ -25,6 +25,7 @@ limitations under the License.
padding: 4px 0; padding: 4px 0;
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
contain: strict;
.mx_RoomView_MessageList { .mx_RoomView_MessageList {
padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above
@ -98,6 +99,48 @@ limitations under the License.
mask-position: center; mask-position: center;
} }
$dot-size: 8px;
$pulse-color: $pinned-unread-color;
.mx_RightPanel_pinnedMessagesButton {
&::before {
mask-image: url('$(res)/img/element-icons/room/pin.svg');
mask-position: center;
}
.mx_RightPanel_pinnedMessagesButton_unreadIndicator {
position: absolute;
right: 0;
top: 0;
margin: 4px;
width: $dot-size;
height: $dot-size;
border-radius: 50%;
transform: scale(1);
background: rgba($pulse-color, 1);
box-shadow: 0 0 0 0 rgba($pulse-color, 1);
animation: mx_RightPanel_indicator_pulse 2s infinite;
animation-iteration-count: 1;
}
}
@keyframes mx_RightPanel_indicator_pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba($pulse-color, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0);
}
}
.mx_RightPanel_headerButton_highlight { .mx_RightPanel_headerButton_highlight {
&::before { &::before {
background-color: $accent-color !important; background-color: $accent-color !important;

View file

@ -152,6 +152,7 @@ limitations under the License.
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
contain: content;
} }
.mx_RoomView_statusArea { .mx_RoomView_statusArea {
@ -237,6 +238,7 @@ hr.mx_RoomView_myReadMarker {
position: relative; position: relative;
top: -1px; top: -1px;
z-index: 1; z-index: 1;
will-change: width;
transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s;
width: 99%; width: 99%;
opacity: 1; opacity: 1;

View file

@ -21,5 +21,8 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
content-visibility: auto;
contain-intrinsic-size: 50px;
} }
} }

View file

@ -328,6 +328,8 @@ $SpaceRoomViewInnerWidth: 428px;
font-size: $font-15px; font-size: $font-15px;
margin-top: 12px; margin-top: 12px;
margin-bottom: 16px; margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
} }
> hr { > hr {
@ -364,6 +366,45 @@ $SpaceRoomViewInnerWidth: 428px;
} }
} }
.mx_SpaceRoomView_betaWarning {
padding: 12px 12px 12px 54px;
position: relative;
font-size: $font-15px;
line-height: $font-24px;
width: 432px;
border-radius: 8px;
background-color: $info-plinth-bg-color;
color: $secondary-fg-color;
box-sizing: border-box;
> h3 {
font-weight: $font-semi-bold;
font-size: inherit;
line-height: inherit;
margin: 0;
}
> p {
font-size: inherit;
line-height: inherit;
margin: 0;
}
&::before {
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
content: '';
width: 20px;
height: 20px;
position: absolute;
top: 14px;
left: 14px;
background-color: $secondary-fg-color;
}
}
.mx_SpaceRoomView_inviteTeammates { .mx_SpaceRoomView_inviteTeammates {
// XXX remove this when spaces leaves Beta // XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_inviteTeammates_betaDisclaimer { .mx_SpaceRoomView_inviteTeammates_betaDisclaimer {

View file

@ -16,6 +16,7 @@ limitations under the License.
.mx_DecoratedRoomAvatar, .mx_ExtraTile { .mx_DecoratedRoomAvatar, .mx_ExtraTile {
position: relative; position: relative;
contain: content;
&.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar { &.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar {
mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg'); mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg');

View file

@ -0,0 +1,159 @@
/*
Copyright 2021 Robin Townsend <robin@robin.town>
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_ForwardDialog {
width: 520px;
color: $primary-fg-color;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-height: 0;
height: 80vh;
> h3 {
margin: 0 0 6px;
color: $secondary-fg-color;
font-size: $font-12px;
font-weight: $font-semi-bold;
line-height: $font-15px;
}
> .mx_ForwardDialog_preview {
max-height: 30%;
flex-shrink: 0;
overflow: scroll;
div {
pointer-events: none;
}
.mx_EventTile_msgOption {
display: none;
}
// When forwarding messages from encrypted rooms, EventTile will complain
// that our preview is unencrypted, which doesn't actually matter
.mx_EventTile_e2eIcon_unencrypted {
display: none;
}
// We also hide download links to not encourage users to try interacting
.mx_MFileBody_download {
display: none;
}
}
> hr {
width: 100%;
border: none;
border-top: 1px solid $input-border-color;
margin: 12px 0;
}
> .mx_ForwardList {
display: contents;
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
}
.mx_ForwardList_content {
flex-grow: 1;
}
.mx_ForwardList_noResults {
display: block;
margin-top: 24px;
}
.mx_ForwardList_results {
&:not(:first-child) {
margin-top: 24px;
}
.mx_ForwardList_entry {
display: flex;
justify-content: space-between;
height: 32px;
padding: 6px;
border-radius: 8px;
&:hover {
background-color: $groupFilterPanel-bg-color;
}
.mx_ForwardList_roomButton {
display: flex;
margin-right: 12px;
min-width: 0;
.mx_DecoratedRoomAvatar {
margin-right: 12px;
}
.mx_ForwardList_entry_name {
font-size: $font-15px;
line-height: 30px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
}
.mx_ForwardList_sendButton {
position: relative;
&:not(.mx_ForwardList_canSend) .mx_ForwardList_sendLabel {
// Hide the "Send" label while preserving button size
visibility: hidden;
}
.mx_ForwardList_sendIcon, .mx_NotificationBadge {
position: absolute;
}
.mx_NotificationBadge {
// Match the failed to send indicator's color with the disabled button
background-color: $button-danger-disabled-fg-color;
}
&.mx_ForwardList_sending .mx_ForwardList_sendIcon {
background-color: $button-primary-bg-color;
mask-image: url('$(res)/img/element-icons/circle-sending.svg');
mask-position: center;
mask-repeat: no-repeat;
mask-size: 14px;
width: 14px;
height: 14px;
}
&.mx_ForwardList_sent .mx_ForwardList_sendIcon {
background-color: $button-primary-bg-color;
mask-image: url('$(res)/img/element-icons/circle-sent.svg');
mask-position: center;
mask-repeat: no-repeat;
mask-size: 14px;
width: 14px;
height: 14px;
}
}
}
}
}
}

View file

@ -17,6 +17,9 @@ limitations under the License.
.mx_InviteDialog_addressBar { .mx_InviteDialog_addressBar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar
// for the user section gets weird.
margin: 8px 45px 0 0;
.mx_InviteDialog_editor { .mx_InviteDialog_editor {
flex: 1; flex: 1;
@ -73,7 +76,7 @@ limitations under the License.
} }
.mx_InviteDialog_section { .mx_InviteDialog_section {
padding-bottom: 10px; padding-bottom: 4px;
h3 { h3 {
font-size: $font-12px; font-size: $font-12px;
@ -82,6 +85,14 @@ limitations under the License.
text-transform: uppercase; text-transform: uppercase;
} }
> p {
margin: 0;
}
> span {
color: $primary-fg-color;
}
.mx_InviteDialog_subname { .mx_InviteDialog_subname {
margin-bottom: 10px; margin-bottom: 10px;
margin-top: -10px; // HACK: Positioning with margins is bad margin-top: -10px; // HACK: Positioning with margins is bad
@ -90,6 +101,63 @@ limitations under the License.
} }
} }
.mx_InviteDialog_section_hidden_suggestions_disclaimer {
padding: 8px 0 16px 0;
font-size: $font-14px;
> span {
color: $primary-fg-color;
font-weight: 600;
}
> p {
margin: 0;
}
}
.mx_InviteDialog_footer {
border-top: 1px solid $input-border-color;
> h3 {
margin: 12px 0;
font-size: $font-12px;
color: $muted-fg-color;
font-weight: bold;
text-transform: uppercase;
}
.mx_InviteDialog_footer_link {
display: flex;
justify-content: space-between;
border-radius: 4px;
border: solid 1px $light-fg-color;
padding: 8px;
> a {
text-decoration: none;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
}
}
.mx_InviteDialog_footer_link_copy {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
> div {
mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color;
margin-left: 5px;
width: 20px;
height: 20px;
background-repeat: no-repeat;
}
}
}
.mx_InviteDialog_roomTile { .mx_InviteDialog_roomTile {
cursor: pointer; cursor: pointer;
padding: 5px 10px; padding: 5px 10px;
@ -142,6 +210,7 @@ limitations under the License.
.mx_InviteDialog_roomTile_nameStack { .mx_InviteDialog_roomTile_nameStack {
display: inline-block; display: inline-block;
overflow: hidden;
} }
.mx_InviteDialog_roomTile_name { .mx_InviteDialog_roomTile_name {
@ -157,6 +226,13 @@ limitations under the License.
margin-left: 7px; margin-left: 7px;
} }
.mx_InviteDialog_roomTile_name,
.mx_InviteDialog_roomTile_userId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_InviteDialog_roomTile_time { .mx_InviteDialog_roomTile_time {
text-align: right; text-align: right;
font-size: $font-12px; font-size: $font-12px;
@ -212,22 +288,29 @@ limitations under the License.
.mx_InviteDialog { .mx_InviteDialog {
// Prevent the dialog from jumping around randomly when elements change. // Prevent the dialog from jumping around randomly when elements change.
height: 590px; height: 600px;
padding-left: 20px; // the design wants some padding on the left padding-left: 20px; // the design wants some padding on the left
display: flex;
flex-direction: column;
.mx_InviteDialog_content {
overflow: hidden;
}
} }
.mx_InviteDialog_userSections { .mx_InviteDialog_userSections {
margin-top: 10px; margin-top: 4px;
overflow-y: auto; overflow-y: auto;
padding-right: 45px; padding: 0 45px 4px 0;
height: 455px; // mx_InviteDialog's height minus some for the upper elements height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements
} }
// Right margin for the design. We could apply this to the whole dialog, but then the scrollbar .mx_InviteDialog_hasFooter .mx_InviteDialog_userSections {
// for the user section gets weird. height: calc(100% - 175px);
.mx_InviteDialog_helpText, }
.mx_InviteDialog_addressBar {
margin-right: 45px; .mx_InviteDialog_helpText {
margin: 0;
} }
.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { .mx_InviteDialog_helpText .mx_AccessibleButton_kind_link {

View file

@ -50,7 +50,8 @@ limitations under the License.
margin-left: 20px; margin-left: 20px;
display: inherit; display: inherit;
} }
.mx_ShareDialog_matrixto_copy > div { .mx_ShareDialog_matrixto_copy::after {
content: "";
mask-image: url($copy-button-url); mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color; background-color: $message-action-bar-fg-color;
margin-left: 5px; margin-left: 5px;

View file

@ -22,6 +22,7 @@ limitations under the License.
} }
.mx_ImageView_image_wrapper { .mx_ImageView_image_wrapper {
pointer-events: initial;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -30,7 +31,6 @@ limitations under the License.
} }
.mx_ImageView_image { .mx_ImageView_image {
pointer-events: all;
flex-shrink: 0; flex-shrink: 0;
} }
@ -43,7 +43,7 @@ limitations under the License.
} }
.mx_ImageView_info_wrapper { .mx_ImageView_info_wrapper {
pointer-events: all; pointer-events: initial;
padding-left: 32px; padding-left: 32px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -63,7 +63,7 @@ limitations under the License.
.mx_ImageView_toolbar { .mx_ImageView_toolbar {
padding-right: 16px; padding-right: 16px;
pointer-events: all; pointer-events: initial;
display: flex; display: flex;
align-items: center; align-items: center;
} }

View file

@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_SenderProfile_name { .mx_SenderProfile_displayName {
font-weight: 600; font-weight: 600;
} }
.mx_SenderProfile_mxid {
font-weight: 600;
font-size: 1.1rem;
margin-left: 5px;
opacity: 0.5; // Match mx_TextualEvent
}

View file

@ -0,0 +1,90 @@
/*
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_PinnedMessagesCard {
padding-top: 0;
.mx_BaseCard_header {
text-align: center;
margin-top: 0;
border-bottom: 1px solid $menu-border-color;
> h2 {
font-weight: $font-semi-bold;
font-size: $font-18px;
margin: 8px 0;
}
.mx_BaseCard_close {
margin-right: 6px;
}
}
.mx_PinnedMessagesCard_empty {
display: flex;
height: 100%;
> div {
height: max-content;
text-align: center;
margin: auto 40px;
.mx_PinnedMessagesCard_MessageActionBar {
pointer-events: none;
display: flex;
height: 32px;
line-height: $font-24px;
border-radius: 8px;
background: $primary-bg-color;
border: 1px solid $input-border-color;
padding: 1px;
width: max-content;
margin: 0 auto;
box-sizing: border-box;
.mx_MessageActionBar_maskButton {
display: inline-block;
position: relative;
}
.mx_MessageActionBar_optionsButton {
background: $roomlist-button-bg-color;
border-radius: 6px;
z-index: 1;
&::after {
background-color: $primary-fg-color;
}
}
}
> h2 {
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
margin-top: 24px;
margin-bottom: 20px;
}
> span {
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
}
}
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
$left-gutter: 64px; $left-gutter: 64px;
$hover-select-border: 4px;
.mx_EventTile { .mx_EventTile {
max-width: 100%; max-width: 100%;
@ -85,12 +86,11 @@ $left-gutter: 64px;
} }
.mx_EventTile_isEditing .mx_MessageTimestamp { .mx_EventTile_isEditing .mx_MessageTimestamp {
visibility: hidden !important; visibility: hidden;
} }
.mx_EventTile .mx_MessageTimestamp { .mx_EventTile .mx_MessageTimestamp {
display: block; display: block;
visibility: hidden;
white-space: nowrap; white-space: nowrap;
left: 0px; left: 0px;
text-align: center; text-align: center;
@ -104,7 +104,7 @@ $left-gutter: 64px;
.mx_EventTile_line, .mx_EventTile_reply { .mx_EventTile_line, .mx_EventTile_reply {
position: relative; position: relative;
padding-left: $left-gutter; padding-left: $left-gutter;
border-radius: 4px; border-radius: 8px;
} }
.mx_RoomView_timeline_rr_enabled, .mx_RoomView_timeline_rr_enabled,
@ -142,27 +142,8 @@ $left-gutter: 64px;
line-height: 57px !important; line-height: 57px !important;
} }
.mx_MessagePanel_alwaysShowTimestamps .mx_MessageTimestamp {
visibility: visible;
}
.mx_EventTile_selected > div > a > .mx_MessageTimestamp { .mx_EventTile_selected > div > a > .mx_MessageTimestamp {
left: 3px; left: calc(-$hover-select-border);
width: auto;
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
// The first set is to handle the 'group layout' (default) and the second for the IRC layout
.mx_EventTile_last > div > a > .mx_MessageTimestamp,
.mx_EventTile:hover > div > a > .mx_MessageTimestamp,
.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp,
.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp,
.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp,
.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp,
.mx_IRCLayout .mx_ReplyThread .mx_EventTile > a > .mx_MessageTimestamp,
.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp,
.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp {
visibility: visible;
} }
.mx_EventTile:hover .mx_MessageActionBar, .mx_EventTile:hover .mx_MessageActionBar,
@ -177,7 +158,7 @@ $left-gutter: 64px;
*/ */
.mx_EventTile_selected > .mx_EventTile_line { .mx_EventTile_selected > .mx_EventTile_line {
border-left: $accent-color 4px solid; border-left: $accent-color 4px solid;
padding-left: 60px; padding-left: calc($left-gutter - $hover-select-border);
background-color: $event-selected-color; background-color: $event-selected-color;
} }
@ -190,8 +171,12 @@ $left-gutter: 64px;
} }
} }
.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line { .mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line {
padding-left: 78px; padding-left: calc($left-gutter + 18px - $hover-select-border);
} }
.mx_EventTile:hover .mx_EventTile_line, .mx_EventTile:hover .mx_EventTile_line,
@ -280,6 +265,7 @@ $left-gutter: 64px;
height: $font-14px; height: $font-14px;
width: $font-14px; width: $font-14px;
will-change: left, top;
transition: transition:
left var(--transition-short) ease-out, left var(--transition-short) ease-out,
top var(--transition-standard) ease-out; top var(--transition-standard) ease-out;
@ -426,7 +412,7 @@ $left-gutter: 64px;
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
padding-left: 60px; padding-left: calc($left-gutter - $hover-select-border);
} }
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line {
@ -444,7 +430,7 @@ $left-gutter: 64px;
.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line, .mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line { .mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
padding-left: 78px; padding-left: calc($left-gutter + 18px - $hover-select-border);
} }
/* End to end encryption stuff */ /* End to end encryption stuff */
@ -456,7 +442,7 @@ $left-gutter: 64px;
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
width: $MessageTimestamp_width_hover; left: calc(-$hover-select-border);
} }
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)

View file

@ -24,10 +24,6 @@ $left-gutter: 64px;
margin-left: $left-gutter; margin-left: $left-gutter;
} }
> .mx_EventTile_line {
padding-left: $left-gutter;
}
> .mx_EventTile_avatar { > .mx_EventTile_avatar {
position: absolute; position: absolute;
} }
@ -43,10 +39,6 @@ $left-gutter: 64px;
line-height: $font-22px; line-height: $font-22px;
} }
} }
.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
} }
/* Compact layout overrides */ /* Compact layout overrides */

View file

@ -115,8 +115,7 @@ $irc-line-height: $font-18px;
.mx_EventTile_line { .mx_EventTile_line {
.mx_EventTile_e2eIcon, .mx_EventTile_e2eIcon,
.mx_TextualEvent, .mx_TextualEvent,
.mx_MTextBody, .mx_MTextBody {
.mx_ReplyThread_wrapper_empty {
display: inline-block; display: inline-block;
} }
} }
@ -177,16 +176,13 @@ $irc-line-height: $font-18px;
.mx_SenderProfile_hover { .mx_SenderProfile_hover {
background-color: $primary-bg-color; background-color: $primary-bg-color;
overflow: hidden; overflow: hidden;
display: flex;
> span { > .mx_SenderProfile_displayName {
display: flex; overflow: hidden;
text-overflow: ellipsis;
> .mx_SenderProfile_name { min-width: var(--name-width);
overflow: hidden; text-align: end;
text-overflow: ellipsis;
min-width: var(--name-width);
text-align: end;
}
} }
} }
@ -211,7 +207,7 @@ $irc-line-height: $font-18px;
background: transparent; background: transparent;
> span { > span {
> .mx_SenderProfile_name { > .mx_SenderProfile_displayName {
min-width: inherit; min-width: inherit;
} }
} }

View file

@ -52,6 +52,7 @@ limitations under the License.
.mx_JumpToBottomButton_scrollDown { .mx_JumpToBottomButton_scrollDown {
position: relative; position: relative;
display: block;
height: 38px; height: 38px;
border-radius: 19px; border-radius: 19px;
box-sizing: border-box; box-sizing: border-box;

View file

@ -16,62 +16,91 @@ limitations under the License.
.mx_PinnedEventTile { .mx_PinnedEventTile {
min-height: 40px; min-height: 40px;
margin-bottom: 5px;
width: 100%; width: 100%;
border-radius: 5px; // for the hover padding: 0 4px 12px;
}
.mx_PinnedEventTile:hover { display: grid;
background-color: $event-selected-color; grid-template-areas:
} "avatar name remove"
"content content content"
"footer footer footer";
grid-template-rows: max-content auto max-content;
grid-template-columns: 24px auto 24px;
grid-row-gap: 12px;
grid-column-gap: 8px;
.mx_PinnedEventTile .mx_PinnedEventTile_sender, & + .mx_PinnedEventTile {
.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { padding: 12px 4px;
color: #868686; border-top: 1px solid $menu-border-color;
font-size: 0.8em; }
vertical-align: top;
display: inline-block;
padding-bottom: 3px;
}
.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { .mx_PinnedEventTile_senderAvatar {
padding-left: 15px; grid-area: avatar;
display: none; }
}
.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar { .mx_PinnedEventTile_sender {
float: left; grid-area: name;
margin-right: 10px; font-weight: $font-semi-bold;
} font-size: $font-15px;
line-height: $font-24px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_PinnedEventTile_actions { .mx_PinnedEventTile_unpinButton {
float: right; visibility: hidden;
margin-right: 10px; grid-area: remove;
display: none; position: relative;
} width: 24px;
height: 24px;
border-radius: 8px;
.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp { &:hover {
display: inline-block; background-color: $roomheader-addroom-bg-color;
} }
.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions { &::before {
display: block; content: "";
} position: absolute;
//top: 0;
//left: 0;
height: inherit;
width: inherit;
background: $secondary-fg-color;
mask-position: center;
mask-size: 8px;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/image-view/close.svg');
}
}
.mx_PinnedEventTile_unpinButton { .mx_PinnedEventTile_message {
display: inline-block; grid-area: content;
cursor: pointer; }
margin-left: 10px;
}
.mx_PinnedEventTile_gotoButton { .mx_PinnedEventTile_footer {
display: inline-block; grid-area: footer;
font-size: 0.7em; // Smaller text to avoid conflicting with the layout font-size: 10px;
} line-height: 12px;
.mx_PinnedEventTile_message { .mx_PinnedEventTile_timestamp {
margin-left: 50px; font-size: inherit;
position: relative; line-height: inherit;
top: 0; color: $secondary-fg-color;
left: 0; }
.mx_AccessibleButton_kind_link {
padding: 0;
margin-left: 12px;
font-size: inherit;
line-height: inherit;
}
}
&:hover {
.mx_PinnedEventTile_unpinButton {
visibility: visible;
}
}
} }

View file

@ -32,14 +32,14 @@ limitations under the License.
// first triggering the enter state with the newest breadcrumb off screen (-40px) then // first triggering the enter state with the newest breadcrumb off screen (-40px) then
// sliding it into view. // sliding it into view.
&.mx_RoomBreadcrumbs-enter { &.mx_RoomBreadcrumbs-enter {
margin-left: -40px; // 32px for the avatar, 8px for the margin transform: translateX(-40px); // 32px for the avatar, 8px for the margin
} }
&.mx_RoomBreadcrumbs-enter-active { &.mx_RoomBreadcrumbs-enter-active {
margin-left: 0; transform: translateX(0);
// Timing function is as-requested by design. // Timing function is as-requested by design.
// NOTE: The transition time MUST match the value passed to CSSTransition! // NOTE: The transition time MUST match the value passed to CSSTransition!
transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1); transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
} }
.mx_RoomBreadcrumbs_placeholder { .mx_RoomBreadcrumbs_placeholder {

View file

@ -277,24 +277,6 @@ limitations under the License.
margin-top: 18px; margin-top: 18px;
} }
.mx_RoomHeader_pinnedButton::before {
mask-image: url('$(res)/img/element-icons/room/pin.svg');
}
.mx_RoomHeader_pinsIndicator {
position: absolute;
right: 0;
bottom: 4px;
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $pinned-color;
}
.mx_RoomHeader_pinsIndicatorUnread {
background-color: $pinned-unread-color;
}
@media only screen and (max-width: 480px) { @media only screen and (max-width: 480px) {
.mx_RoomHeader_wrapper { .mx_RoomHeader_wrapper {
padding: 0; padding: 0;

View file

@ -61,6 +61,7 @@ limitations under the License.
&.mx_RoomSublist_headerContainer_sticky { &.mx_RoomSublist_headerContainer_sticky {
position: fixed; position: fixed;
height: 32px; // to match the header container height: 32px; // to match the header container
// width set by JS because of a compat issue between Firefox and Chrome
width: calc(100% - 15px); width: calc(100% - 15px);
} }
@ -197,6 +198,7 @@ limitations under the License.
// as the box model should be top aligned. Happens in both FF and Chromium // as the box model should be top aligned. Happens in both FF and Chromium
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-self: stretch;
mask-image: linear-gradient(0deg, transparent, black 4px); mask-image: linear-gradient(0deg, transparent, black 4px);
} }

View file

@ -19,6 +19,10 @@ limitations under the License.
margin-bottom: 4px; margin-bottom: 4px;
padding: 4px; padding: 4px;
contain: content; // Not strict as it will break when resizing a sublist vertically
height: 40px;
box-sizing: border-box;
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;

View file

@ -1,7 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="black"/> <path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="#737D8C"/>
<path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="black"/> <path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="#737D8C"/>
<path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="black"/> <path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="#737D8C"/>
<path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="black"/> <path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="#737D8C"/>
<path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="black"/> <path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="#737D8C"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1,015 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

@ -43,6 +43,7 @@ import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg"; import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance"; import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore";
declare global { declare global {
interface Window { interface Window {
@ -82,6 +83,7 @@ declare global {
mxEventIndexPeg: EventIndexPeg; mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor; mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any; mxPerformanceEntryNames: any;
mxUIStore: UIStore;
} }
interface Document { interface Document {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,52 +14,61 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ComponentType } from "react";
import * as sdk from './index'; import * as sdk from './index';
import PropTypes from 'prop-types';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import { IDialogProps } from "./components/views/dialogs/IDialogProps";
type AsyncImport<T> = { default: T };
interface IProps extends IDialogProps {
// A promise which resolves with the real component
prom: Promise<ComponentType | AsyncImport<ComponentType>>;
}
interface IState {
component?: ComponentType;
error?: Error;
}
/** /**
* Wrap an asynchronous loader function with a react component which shows a * Wrap an asynchronous loader function with a react component which shows a
* spinner until the real component loads. * spinner until the real component loads.
*/ */
export default class AsyncWrapper extends React.Component { export default class AsyncWrapper extends React.Component<IProps, IState> {
static propTypes = { private unmounted = false;
/** A promise which resolves with the real component
*/
prom: PropTypes.object.isRequired,
};
state = { public state = {
component: null, component: null,
error: null, error: null,
}; };
componentDidMount() { componentDidMount() {
this._unmounted = false;
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Starting load of AsyncWrapper for modal'); console.log('Starting load of AsyncWrapper for modal');
this.props.prom.then((result) => { this.props.prom.then((result) => {
if (this._unmounted) { if (this.unmounted) return;
return;
}
// Take the 'default' member if it's there, then we support // Take the 'default' member if it's there, then we support
// passing in just an import()ed module, since ES6 async import // passing in just an import()ed module, since ES6 async import
// always returns a module *namespace*. // always returns a module *namespace*.
const component = result.default ? result.default : result; const component = (result as AsyncImport<ComponentType>).default
this.setState({component}); ? (result as AsyncImport<ComponentType>).default
: result as ComponentType;
this.setState({ component });
}).catch((e) => { }).catch((e) => {
console.warn('AsyncWrapper promise failed', e); console.warn('AsyncWrapper promise failed', e);
this.setState({error: e}); this.setState({ error: e });
}); });
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this.unmounted = true;
} }
_onWrapperCancelClick = () => { private onWrapperCancelClick = () => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
@ -71,12 +79,10 @@ export default class AsyncWrapper extends React.Component {
} else if (this.state.error) { } else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <BaseDialog onFinished={this.props.onFinished} return <BaseDialog onFinished={this.props.onFinished} title={_t("Error")}>
title={_t("Error")} { _t("Unable to load! Check your network connectivity and try again.") }
>
{_t("Unable to load! Check your network connectivity and try again.")}
<DialogButtons primaryButton={_t("Dismiss")} <DialogButtons primaryButton={_t("Dismiss")}
onPrimaryButtonClick={this._onWrapperCancelClick} onPrimaryButtonClick={this.onWrapperCancelClick}
hasCancel={false} hasCancel={false}
/> />
</BaseDialog>; </BaseDialog>;

View file

@ -264,7 +264,7 @@ export default class CallHandler extends EventEmitter {
} }
public getSupportsVirtualRooms() { public getSupportsVirtualRooms() {
return this.supportsPstnProtocol; return this.supportsSipNativeVirtual;
} }
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> { public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
@ -462,6 +462,9 @@ export default class CallHandler extends EventEmitter {
if (call.hangupReason === CallErrorCode.UserHangup) { if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined"); title = _t("Call Declined");
description = _t("The other party declined the call."); description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) { } else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed"); title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these // XXX: full stop appended as some relic here, but these
@ -518,7 +521,9 @@ export default class CallHandler extends EventEmitter {
let newNativeAssertedIdentity = newAssertedIdentity; let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) { if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity); const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid; if (response.length && response[0].fields.lookup_success) {
newNativeAssertedIdentity = response[0].userid;
}
} }
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
@ -537,6 +542,7 @@ export default class CallHandler extends EventEmitter {
if (newMappedRoomId !== mappedRoomId) { if (newMappedRoomId !== mappedRoomId) {
this.removeCallForRoom(mappedRoomId); this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId; mappedRoomId = newMappedRoomId;
console.log("Moving call to room " + mappedRoomId);
this.calls.set(mappedRoomId, call); this.calls.set(mappedRoomId, call);
this.emit(CallHandlerEvent.CallChangeRoom, call); this.emit(CallHandlerEvent.CallChangeRoom, call);
} }
@ -602,6 +608,7 @@ export default class CallHandler extends EventEmitter {
} }
private removeCallForRoom(roomId: string) { private removeCallForRoom(roomId: string) {
console.log("Removing call for room ", roomId);
this.calls.delete(roomId); this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls); this.emit(CallHandlerEvent.CallsChanged, this.calls);
} }
@ -675,6 +682,7 @@ export default class CallHandler extends EventEmitter {
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = MatrixClientPeg.get().createCall(mappedRoomId); const call = MatrixClientPeg.get().createCall(mappedRoomId);
console.log("Adding call for room ", roomId);
this.calls.set(roomId, call); this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls); this.emit(CallHandlerEvent.CallsChanged, this.calls);
if (transferee) { if (transferee) {
@ -799,11 +807,15 @@ export default class CallHandler extends EventEmitter {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) { if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room console.log(
"Got incoming call for room " + mappedRoomId +
" but there's already a call for this room: ignoring",
);
return; return;
} }
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
console.log("Adding call for room ", mappedRoomId);
this.calls.set(mappedRoomId, call) this.calls.set(mappedRoomId, call)
this.emit(CallHandlerEvent.CallsChanged, this.calls); this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(call); this.setCallListeners(call);
@ -856,9 +868,43 @@ export default class CallHandler extends EventEmitter {
}); });
break; break;
} }
case Action.DialNumber:
this.dialNumber(payload.number);
break;
} }
} }
private async dialNumber(number: string) {
const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to look up phone number"),
description: _t("There was an error looking up the phone number"),
});
return;
}
const userId = results[0].userid;
// Now check to see if this is a virtual user, in which case we should find the
// native user
let nativeUserId;
if (this.getSupportsVirtualRooms()) {
const nativeLookupResults = await this.sipNativeLookup(userId);
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
} else {
nativeUserId = userId;
}
const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
}
setActiveCallRoomId(activeCallRoomId: string) { setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active"); logger.info("Setting call in room " + activeCallRoomId + " active");

View file

@ -28,8 +28,6 @@ import encrypt from "browser-encrypt-attachment";
import extractPngChunks from "png-chunks-extract"; import extractPngChunks from "png-chunks-extract";
import Spinner from "./components/views/elements/Spinner"; import Spinner from "./components/views/elements/Spinner";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
import CountlyAnalytics from "./CountlyAnalytics"; import CountlyAnalytics from "./CountlyAnalytics";
import { import {
@ -40,6 +38,7 @@ import {
UploadStartedPayload, UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload"; } from "./dispatcher/payloads/UploadPayload";
import {IUpload} from "./models/IUpload"; import {IUpload} from "./models/IUpload";
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
@ -208,12 +207,12 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
} }
let imageInfo; let imageInfo;
return loadImageElement(imageFile).then(function(r) { return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType); return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) { }).then((result) => {
imageInfo = result.info; imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) { }).then((result) => {
imageInfo.thumbnail_url = result.url; imageInfo.thumbnail_url = result.url;
imageInfo.thumbnail_file = result.file; imageInfo.thumbnail_file = result.file;
return imageInfo; return imageInfo;
@ -264,12 +263,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
const thumbnailType = "image/jpeg"; const thumbnailType = "image/jpeg";
let videoInfo; let videoInfo;
return loadVideoElement(videoFile).then(function(video) { return loadVideoElement(videoFile).then((video) => {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then(function(result) { }).then((result) => {
videoInfo = result.info; videoInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) { }).then((result) => {
videoInfo.thumbnail_url = result.url; videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file; videoInfo.thumbnail_file = result.file;
return videoInfo; return videoInfo;
@ -308,7 +307,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
* If the file is unencrypted then the object will have a "url" key. * If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key. * If the file is encrypted then the object will have a "file" key.
*/ */
function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any) { function uploadFile(
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
progressHandler?: any, // TODO: Types
): Promise<{url?: string, file?: any}> { // TODO: Types
let canceled = false; let canceled = false;
if (matrixClient.isRoomEncrypted(roomId)) { if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it. // If the room is encrypted then encrypt the file before uploading it.
@ -355,7 +359,7 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo
// If the attachment isn't encrypted then include the URL directly. // If the attachment isn't encrypted then include the URL directly.
return {"url": url}; return {"url": url};
}); });
promise1.abort = () => { (promise1 as any).abort = () => {
canceled = true; canceled = true;
MatrixClientPeg.get().cancelUpload(basePromise); MatrixClientPeg.get().cancelUpload(basePromise);
}; };
@ -367,7 +371,7 @@ export default class ContentMessages {
private inprogress: IUpload[] = []; private inprogress: IUpload[] = [];
private mediaConfig: IMediaConfig = null; private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
@ -441,7 +445,7 @@ export default class ContentMessages {
let uploadAll = false; let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending // Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in // to match the order the files were specified in
let promBefore = Promise.resolve(); let promBefore: Promise<any> = Promise.resolve();
for (let i = 0; i < okFiles.length; ++i) { for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i]; const file = okFiles[i];
if (!uploadAll) { if (!uploadAll) {

View file

@ -24,13 +24,6 @@ import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore"; import RoomViewStore from "./stores/RoomViewStore";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
const INACTIVITY_TIME = 20; // seconds const INACTIVITY_TIME = 20; // seconds
const HEARTBEAT_INTERVAL = 5_000; // ms const HEARTBEAT_INTERVAL = 5_000; // ms
const SESSION_UPDATE_INTERVAL = 60; // seconds const SESSION_UPDATE_INTERVAL = 60; // seconds
@ -816,7 +809,9 @@ export default class CountlyAnalytics {
window.addEventListener("mousemove", this.onUserActivity); window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity); window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity); window.addEventListener("keydown", this.onUserActivity);
window.addEventListener("scroll", this.onUserActivity); // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
window.addEventListener("scroll", this.onUserActivity, { passive: true });
this.activityIntervalId = setInterval(() => { this.activityIntervalId = setInterval(() => {
this.inactivityCounter++; this.inactivityCounter++;

View file

@ -31,12 +31,12 @@ interface IPasswordFlow {
} }
export enum IdentityProviderBrand { export enum IdentityProviderBrand {
Gitlab = "org.matrix.gitlab", Gitlab = "gitlab",
Github = "org.matrix.github", Github = "github",
Apple = "org.matrix.apple", Apple = "apple",
Google = "org.matrix.google", Google = "google",
Facebook = "org.matrix.facebook", Facebook = "facebook",
Twitter = "org.matrix.twitter", Twitter = "twitter",
} }
export interface IIdentityProvider { export interface IIdentityProvider {
@ -48,7 +48,8 @@ export interface IIdentityProvider {
export interface ISSOFlow { export interface ISSOFlow {
type: "m.login.sso" | "m.login.cas"; type: "m.login.sso" | "m.login.cas";
"org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858 // eslint-disable-next-line camelcase
identity_providers: IIdentityProvider[];
} }
export type LoginFlow = ISSOFlow | IPasswordFlow; export type LoginFlow = ISSOFlow | IPasswordFlow;

View file

@ -98,7 +98,7 @@ class Presence {
} }
try { try {
await MatrixClientPeg.get().setPresence(this.state); await MatrixClientPeg.get().setPresence({presence: this.state});
console.info("Presence:", newState); console.info("Presence:", newState);
} catch (err) { } catch (err) {
console.error("Failed to set presence:", err); console.error("Failed to set presence:", err);

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,35 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClientPeg} from './MatrixClientPeg'; import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClientPeg } from './MatrixClientPeg';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
export default class Resend { export default class Resend {
static resendUnsentEvents(room) { static resendUnsentEvents(room: Room): Promise<void[]> {
return Promise.all(room.getPendingEvents().filter(function(ev) { return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
}).map(function(event) { }).map(function(event: MatrixEvent) {
return Resend.resend(event); return Resend.resend(event);
})); }));
} }
static cancelUnsentEvents(room) { static cancelUnsentEvents(room: Room): void {
room.getPendingEvents().filter(function(ev) { room.getPendingEvents().filter(function(ev: MatrixEvent) {
return ev.status === EventStatus.NOT_SENT; return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) { }).forEach(function(event: MatrixEvent) {
Resend.removeFromQueue(event); Resend.removeFromQueue(event);
}); });
} }
static resend(event) { static resend(event: MatrixEvent): Promise<void> {
const room = MatrixClientPeg.get().getRoom(event.getRoomId()); const room = MatrixClientPeg.get().getRoom(event.getRoomId());
return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
dis.dispatch({ dis.dispatch({
action: 'message_sent', action: 'message_sent',
event: event, event: event,
}); });
}, function(err) { }, function(err: Error) {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')'); console.log('Resend got send failure: ' + err.name + '(' + err + ')');
@ -55,7 +56,7 @@ export default class Resend {
}); });
} }
static removeFromQueue(event) { static removeFromQueue(event: MatrixEvent): void {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -24,12 +24,12 @@ limitations under the License.
* A similar thing could also be achieved via `pushState` with a state object, * A similar thing could also be achieved via `pushState` with a state object,
* but keeping it separate like this seems easier in case we do want to extend. * but keeping it separate like this seems easier in case we do want to extend.
*/ */
const aliasToIDMap = new Map(); const aliasToIDMap = new Map<string, string>();
export function storeRoomAliasInCache(alias, id) { export function storeRoomAliasInCache(alias: string, id: string): void {
aliasToIDMap.set(alias, id); aliasToIDMap.set(alias, id);
} }
export function getCachedRoomIDForAlias(alias) { export function getCachedRoomIDForAlias(alias: string): string {
return aliasToIDMap.get(alias); return aliasToIDMap.get(alias);
} }

View file

@ -66,7 +66,7 @@ async function serverSideSearchProcess(term, roomId = undefined) {
highlights: [], highlights: [],
}; };
return client._processRoomEventsSearch(searchResult, result.response); return client.processRoomEventsSearch(searchResult, result.response);
} }
function compareEvents(a, b) { function compareEvents(a, b) {
@ -131,7 +131,7 @@ async function combinedSearch(searchTerm) {
}, },
}; };
const result = client._processRoomEventsSearch(emptyResult, response); const result = client.processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(result.results); restoreEncryptionInfo(result.results);
@ -185,7 +185,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
}, },
}; };
const processedResult = MatrixClientPeg.get()._processRoomEventsSearch(emptyResult, response); const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(processedResult.results); restoreEncryptionInfo(processedResult.results);
@ -210,7 +210,7 @@ async function localPagination(searchResult) {
}, },
}; };
const result = MatrixClientPeg.get()._processRoomEventsSearch(searchResult, response); const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0)); const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
@ -520,7 +520,7 @@ async function combinedPagination(searchResult) {
const oldResultCount = searchResult.results ? searchResult.results.length : 0; const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result. // Let the client process the combined result.
const result = client._processRoomEventsSearch(searchResult, response); const result = client.processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
const newResultCount = result.results.length - oldResultCount; const newResultCount = result.results.length - oldResultCount;

View file

@ -271,7 +271,7 @@ async function onSecretRequested(
} }
return key && encodeBase64(key); return key && encodeBase64(key);
} else if (name === "m.megolm_backup.v1") { } else if (name === "m.megolm_backup.v1") {
const key = await client._crypto.getSessionBackupPrivateKey(); const key = await client.crypto.getSessionBackupPrivateKey();
if (!key) { if (!key) {
console.log( console.log(
`session backup key requested by ${deviceId}, but not found in cache`, `session backup key requested by ${deviceId}, but not found in cache`,

View file

@ -150,6 +150,10 @@ function success(promise?: Promise<any>) {
return {promise}; return {promise};
} }
function successSync(value: any) {
return success(Promise.resolve(value));
}
/* Disable the "unexpected this" error for these commands - all of the run /* Disable the "unexpected this" error for these commands - all of the run
* functions are called with `this` bound to the Command instance. * functions are called with `this` bound to the Command instance.
*/ */
@ -160,7 +164,7 @@ export const Commands = [
args: '<message>', args: '<message>',
description: _td('Sends the given message as a spoiler'), description: _td('Sends the given message as a spoiler'),
runFn: function(roomId, message) { runFn: function(roomId, message) {
return success(ContentHelpers.makeHtmlMessage( return successSync(ContentHelpers.makeHtmlMessage(
message, message,
`<span data-mx-spoiler>${message}</span>`, `<span data-mx-spoiler>${message}</span>`,
)); ));
@ -176,7 +180,7 @@ export const Commands = [
if (args) { if (args) {
message = message + ' ' + args; message = message + ' ' + args;
} }
return success(ContentHelpers.makeTextMessage(message)); return successSync(ContentHelpers.makeTextMessage(message));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -189,7 +193,7 @@ export const Commands = [
if (args) { if (args) {
message = message + ' ' + args; message = message + ' ' + args;
} }
return success(ContentHelpers.makeTextMessage(message)); return successSync(ContentHelpers.makeTextMessage(message));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -202,7 +206,7 @@ export const Commands = [
if (args) { if (args) {
message = message + ' ' + args; message = message + ' ' + args;
} }
return success(ContentHelpers.makeTextMessage(message)); return successSync(ContentHelpers.makeTextMessage(message));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -215,7 +219,7 @@ export const Commands = [
if (args) { if (args) {
message = message + ' ' + args; message = message + ' ' + args;
} }
return success(ContentHelpers.makeTextMessage(message)); return successSync(ContentHelpers.makeTextMessage(message));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -224,7 +228,7 @@ export const Commands = [
args: '<message>', args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'), description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) { runFn: function(roomId, messages) {
return success(ContentHelpers.makeTextMessage(messages)); return successSync(ContentHelpers.makeTextMessage(messages));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -233,7 +237,7 @@ export const Commands = [
args: '<message>', args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'), description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) { runFn: function(roomId, messages) {
return success(ContentHelpers.makeHtmlMessage(messages, messages)); return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -978,7 +982,7 @@ export const Commands = [
args: '<message>', args: '<message>',
runFn: function(roomId, args) { runFn: function(roomId, args) {
if (!args) return reject(this.getUserId()); if (!args) return reject(this.getUserId());
return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
@ -988,7 +992,7 @@ export const Commands = [
args: '<message>', args: '<message>',
runFn: function(roomId, args) { runFn: function(roomId, args) {
if (!args) return reject(this.getUserId()); if (!args) return reject(this.getUserId());
return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),

View file

@ -103,7 +103,7 @@ export async function startTermsFlow(
// fetch the set of agreed policy URLs from account data // fetch the set of agreed policy URLs from account data
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
let agreedUrlSet; let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set(); agreedUrlSet = new Set();
} else { } else {

View file

@ -21,153 +21,161 @@ import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore"; import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";
function textForMemberEvent(ev) { // These functions are frequently used just to check whether an event has
// any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed.
function textForMemberEvent(ev): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender(); const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey(); const targetName = ev.target ? ev.target.name : ev.getStateKey();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const content = ev.getContent(); const content = ev.getContent();
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; const getReason = () => content.reason ? (_t('Reason') + ': ' + content.reason) : '';
switch (content.membership) { switch (content.membership) {
case 'invite': { case 'invite': {
const threePidContent = content.third_party_invite; const threePidContent = content.third_party_invite;
if (threePidContent) { if (threePidContent) {
if (threePidContent.display_name) { if (threePidContent.display_name) {
return _t('%(targetName)s accepted the invitation for %(displayName)s.', { return () => _t('%(targetName)s accepted the invitation for %(displayName)s.', {
targetName, targetName,
displayName: threePidContent.display_name, displayName: threePidContent.display_name,
}); });
} else { } else {
return _t('%(targetName)s accepted an invitation.', {targetName}); return () => _t('%(targetName)s accepted an invitation.', {targetName});
} }
} else { } else {
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); return () => _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
} }
} }
case 'ban': case 'ban':
return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; return () => _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + getReason();
case 'join': case 'join':
if (prevContent && prevContent.membership === 'join') { if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
oldDisplayName: prevContent.displayname, oldDisplayName: prevContent.displayname,
displayName: content.displayname, displayName: content.displayname,
}); });
} else if (!prevContent.displayname && content.displayname) { } else if (!prevContent.displayname && content.displayname) {
return _t('%(senderName)s set their display name to %(displayName)s.', { return () => _t('%(senderName)s set their display name to %(displayName)s.', {
senderName: ev.getSender(), senderName: ev.getSender(),
displayName: content.displayname, displayName: content.displayname,
}); });
} else if (prevContent.displayname && !content.displayname) { } else if (prevContent.displayname && !content.displayname) {
return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s).', {
senderName, senderName,
oldDisplayName: prevContent.displayname, oldDisplayName: prevContent.displayname,
}); });
} else if (prevContent.avatar_url && !content.avatar_url) { } else if (prevContent.avatar_url && !content.avatar_url) {
return _t('%(senderName)s removed their profile picture.', {senderName}); return () => _t('%(senderName)s removed their profile picture.', {senderName});
} else if (prevContent.avatar_url && content.avatar_url && } else if (prevContent.avatar_url && content.avatar_url &&
prevContent.avatar_url !== content.avatar_url) { prevContent.avatar_url !== content.avatar_url) {
return _t('%(senderName)s changed their profile picture.', {senderName}); return () => _t('%(senderName)s changed their profile picture.', {senderName});
} else if (!prevContent.avatar_url && content.avatar_url) { } else if (!prevContent.avatar_url && content.avatar_url) {
return _t('%(senderName)s set a profile picture.', {senderName}); return () => _t('%(senderName)s set a profile picture.', {senderName});
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if the Labs option is enabled // This is a null rejoin, it will only be visible if the Labs option is enabled
return _t("%(senderName)s made no change.", {senderName}); return () => _t("%(senderName)s made no change.", {senderName});
} else { } else {
return ""; return null;
} }
} else { } else {
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
return _t('%(targetName)s joined the room.', {targetName}); return () => _t('%(targetName)s joined the room.', {targetName});
} }
case 'leave': case 'leave':
if (ev.getSender() === ev.getStateKey()) { if (ev.getSender() === ev.getStateKey()) {
if (prevContent.membership === "invite") { if (prevContent.membership === "invite") {
return _t('%(targetName)s rejected the invitation.', {targetName}); return () => _t('%(targetName)s rejected the invitation.', {targetName});
} else { } else {
return _t('%(targetName)s left the room.', {targetName}); return () => _t('%(targetName)s left the room.', {targetName});
} }
} else if (prevContent.membership === "ban") { } else if (prevContent.membership === "ban") {
return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); return () => _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName});
} else if (prevContent.membership === "invite") { } else if (prevContent.membership === "invite") {
return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { return () => _t('%(senderName)s withdrew %(targetName)s\'s invitation.', {
senderName, senderName,
targetName, targetName,
}) + ' ' + reason; }) + ' ' + getReason();
} else if (prevContent.membership === "join") { } else if (prevContent.membership === "join") {
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; return () => _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + getReason();
} else { } else {
return ""; return null;
} }
} }
} }
function textForTopicEvent(ev) { function textForTopicEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName, senderDisplayName,
topic: ev.getContent().topic, topic: ev.getContent().topic,
}); });
} }
function textForRoomNameEvent(ev) { function textForRoomNameEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); return () => _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
} }
if (ev.getPrevContent().name) { if (ev.getPrevContent().name) {
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', { return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
senderDisplayName, senderDisplayName,
oldRoomName: ev.getPrevContent().name, oldRoomName: ev.getPrevContent().name,
newRoomName: ev.getContent().name, newRoomName: ev.getContent().name,
}); });
} }
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
senderDisplayName, senderDisplayName,
roomName: ev.getContent().name, roomName: ev.getContent().name,
}); });
} }
function textForTombstoneEvent(ev) { function textForTombstoneEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); return () => _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName});
} }
function textForJoinRulesEvent(ev) { function textForJoinRulesEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) { switch (ev.getContent().join_rule) {
case "public": case "public":
return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName}); return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', {
senderDisplayName,
});
case "invite": case "invite":
return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName}); return () => _t('%(senderDisplayName)s made the room invite only.', {
senderDisplayName,
});
default: default:
// The spec supports "knock" and "private", however nothing implements these. // The spec supports "knock" and "private", however nothing implements these.
return _t('%(senderDisplayName)s changed the join rule to %(rule)s', { return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
senderDisplayName, senderDisplayName,
rule: ev.getContent().join_rule, rule: ev.getContent().join_rule,
}); });
} }
} }
function textForGuestAccessEvent(ev) { function textForGuestAccessEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) { switch (ev.getContent().guest_access) {
case "can_join": case "can_join":
return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName}); return () => _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName});
case "forbidden": case "forbidden":
return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName}); return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName});
default: default:
// There's no other options we can expect, however just for safety's sake we'll do this. // There's no other options we can expect, however just for safety's sake we'll do this.
return _t('%(senderDisplayName)s changed guest access to %(rule)s', { return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', {
senderDisplayName, senderDisplayName,
rule: ev.getContent().guest_access, rule: ev.getContent().guest_access,
}); });
} }
} }
function textForRelatedGroupsEvent(ev) { function textForRelatedGroupsEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || []; const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || []; const prevGroups = ev.getPrevContent().groups || [];
@ -175,17 +183,17 @@ function textForRelatedGroupsEvent(ev) {
const removed = prevGroups.filter((g) => !groups.includes(g)); const removed = prevGroups.filter((g) => !groups.includes(g));
if (added.length && !removed.length) { if (added.length && !removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', { return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
senderDisplayName, senderDisplayName,
groups: added.join(', '), groups: added.join(', '),
}); });
} else if (!added.length && removed.length) { } else if (!added.length && removed.length) {
return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', { return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
senderDisplayName, senderDisplayName,
groups: removed.join(', '), groups: removed.join(', '),
}); });
} else if (added.length && removed.length) { } else if (added.length && removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' + return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
'%(oldGroups)s in this room.', { '%(oldGroups)s in this room.', {
senderDisplayName, senderDisplayName,
newGroups: added.join(', '), newGroups: added.join(', '),
@ -193,11 +201,11 @@ function textForRelatedGroupsEvent(ev) {
}); });
} else { } else {
// Don't bother rendering this change (because there were no changes) // Don't bother rendering this change (because there were no changes)
return ''; return null;
} }
} }
function textForServerACLEvent(ev) { function textForServerACLEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const current = ev.getContent(); const current = ev.getContent();
@ -207,11 +215,11 @@ function textForServerACLEvent(ev) {
allow_ip_literals: !(prevContent.allow_ip_literals === false), allow_ip_literals: !(prevContent.allow_ip_literals === false),
}; };
let text = ""; let getText = null;
if (prev.deny.length === 0 && prev.allow.length === 0) { if (prev.deny.length === 0 && prev.allow.length === 0) {
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
} else { } else {
text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
} }
if (!Array.isArray(current.allow)) { if (!Array.isArray(current.allow)) {
@ -220,24 +228,27 @@ function textForServerACLEvent(ev) {
// If we know for sure everyone is banned, mark the room as obliterated // If we know for sure everyone is banned, mark the room as obliterated
if (current.allow.length === 0) { if (current.allow.length === 0) {
return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); return () => getText() + " " +
_t("🎉 All servers are banned from participating! This room can no longer be used.");
} }
return text; return getText;
} }
function textForMessageEvent(ev) { function textForMessageEvent(ev): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); return () => {
let message = senderDisplayName + ': ' + ev.getContent().body; const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (ev.getContent().msgtype === "m.emote") { let message = senderDisplayName + ': ' + ev.getContent().body;
message = "* " + senderDisplayName + " " + message; if (ev.getContent().msgtype === "m.emote") {
} else if (ev.getContent().msgtype === "m.image") { message = "* " + senderDisplayName + " " + message;
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); } else if (ev.getContent().msgtype === "m.image") {
} message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
return message; }
return message;
};
} }
function textForCanonicalAliasEvent(ev) { function textForCanonicalAliasEvent(ev): () => string | null {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias; const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || []; const oldAltAliases = ev.getPrevContent().alt_aliases || [];
@ -248,96 +259,100 @@ function textForCanonicalAliasEvent(ev) {
if (!removedAltAliases.length && !addedAltAliases.length) { if (!removedAltAliases.length && !addedAltAliases.length) {
if (newAlias) { if (newAlias) {
return _t('%(senderName)s set the main address for this room to %(address)s.', { return () => _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName, senderName: senderName,
address: ev.getContent().alias, address: ev.getContent().alias,
}); });
} else if (oldAlias) { } else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', { return () => _t('%(senderName)s removed the main address for this room.', {
senderName: senderName, senderName: senderName,
}); });
} }
} else if (newAlias === oldAlias) { } else if (newAlias === oldAlias) {
if (addedAltAliases.length && !removedAltAliases.length) { if (addedAltAliases.length && !removedAltAliases.length) {
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', { return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
senderName: senderName, senderName: senderName,
addresses: addedAltAliases.join(", "), addresses: addedAltAliases.join(", "),
count: addedAltAliases.length, count: addedAltAliases.length,
}); });
} if (removedAltAliases.length && !addedAltAliases.length) { } if (removedAltAliases.length && !addedAltAliases.length) {
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', { return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
senderName: senderName, senderName: senderName,
addresses: removedAltAliases.join(", "), addresses: removedAltAliases.join(", "),
count: removedAltAliases.length, count: removedAltAliases.length,
}); });
} if (removedAltAliases.length && addedAltAliases.length) { } if (removedAltAliases.length && addedAltAliases.length) {
return _t('%(senderName)s changed the alternative addresses for this room.', { return () => _t('%(senderName)s changed the alternative addresses for this room.', {
senderName: senderName, senderName: senderName,
}); });
} }
} else { } else {
// both alias and alt_aliases where modified // both alias and alt_aliases where modified
return _t('%(senderName)s changed the main and alternative addresses for this room.', { return () => _t('%(senderName)s changed the main and alternative addresses for this room.', {
senderName: senderName, senderName: senderName,
}); });
} }
// in case there is no difference between the two events, // in case there is no difference between the two events,
// say something as we can't simply hide the tile from here // say something as we can't simply hide the tile from here
return _t('%(senderName)s changed the addresses for this room.', { return () => _t('%(senderName)s changed the addresses for this room.', {
senderName: senderName, senderName: senderName,
}); });
} }
function textForCallAnswerEvent(event) { function textForCallAnswerEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : _t('Someone'); return () => {
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported; const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported;
};
} }
function textForCallHangupEvent(event) { function textForCallHangupEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent(); const eventContent = event.getContent();
let reason = ""; let getReason = () => "";
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
reason = _t('(not supported by this browser)'); getReason = () => _t('(not supported by this browser)');
} else if (eventContent.reason) { } else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") { if (eventContent.reason === "ice_failed") {
// We couldn't establish a connection at all // We couldn't establish a connection at all
reason = _t('(could not connect media)'); getReason = () => _t('(could not connect media)');
} else if (eventContent.reason === "ice_timeout") { } else if (eventContent.reason === "ice_timeout") {
// We established a connection but it died // We established a connection but it died
reason = _t('(connection failed)'); getReason = () => _t('(connection failed)');
} else if (eventContent.reason === "user_media_failed") { } else if (eventContent.reason === "user_media_failed") {
// The other side couldn't open capture devices // The other side couldn't open capture devices
reason = _t("(their device couldn't start the camera / microphone)"); getReason = () => _t("(their device couldn't start the camera / microphone)");
} else if (eventContent.reason === "unknown_error") { } else if (eventContent.reason === "unknown_error") {
// An error code the other side doesn't have a way to express // An error code the other side doesn't have a way to express
// (as opposed to an error code they gave but we don't know about, // (as opposed to an error code they gave but we don't know about,
// in which case we show the error code) // in which case we show the error code)
reason = _t("(an error occurred)"); getReason = () => _t("(an error occurred)");
} else if (eventContent.reason === "invite_timeout") { } else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)'); getReason = () => _t('(no answer)');
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
// workaround for https://github.com/vector-im/element-web/issues/5178 // workaround for https://github.com/vector-im/element-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is // it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :( // interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623 // https://github.com/vector-im/riot-android/issues/2623
// Also the correct hangup code as of VoIP v1 (with underscore) // Also the correct hangup code as of VoIP v1 (with underscore)
reason = ''; getReason = () => '';
} else { } else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); getReason = () => _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
} }
} }
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; return () => _t('%(senderName)s ended the call.', {senderName: getSenderName()}) + ' ' + getReason();
} }
function textForCallRejectEvent(event) { function textForCallRejectEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : _t('Someone'); return () => {
return _t('%(senderName)s declined the call.', {senderName}); const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', {senderName});
};
} }
function textForCallInviteEvent(event) { function textForCallInviteEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event? // FIXME: Find a better way to determine this from the event?
let isVoice = true; let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp && if (event.getContent().offer && event.getContent().offer.sdp &&
@ -350,48 +365,55 @@ function textForCallInviteEvent(event) {
// can have a hard time translating those strings. In an effort to make translations easier // can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans. // and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) { if (isVoice && isSupported) {
return _t("%(senderName)s placed a voice call.", {senderName}); return () => _t("%(senderName)s placed a voice call.", {
senderName: getSenderName(),
});
} else if (isVoice && !isSupported) { } else if (isVoice && !isSupported) {
return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName}); return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
senderName: getSenderName(),
});
} else if (!isVoice && isSupported) { } else if (!isVoice && isSupported) {
return _t("%(senderName)s placed a video call.", {senderName}); return () => _t("%(senderName)s placed a video call.", {
senderName: getSenderName(),
});
} else if (!isVoice && !isSupported) { } else if (!isVoice && !isSupported) {
return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName}); return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
senderName: getSenderName(),
});
} }
} }
function textForThreePidInviteEvent(event) { function textForThreePidInviteEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) { if (!isValid3pidInvite(event)) {
const targetDisplayName = event.getPrevContent().display_name || _t("Someone"); return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
senderName, senderName,
targetDisplayName, targetDisplayName: event.getPrevContent().display_name || _t("Someone"),
}); });
} }
return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
senderName, senderName,
targetDisplayName: event.getContent().display_name, targetDisplayName: event.getContent().display_name,
}); });
} }
function textForHistoryVisibilityEvent(event) { function textForHistoryVisibilityEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) { switch (event.getContent().history_visibility) {
case 'invited': case 'invited':
return _t('%(senderName)s made future room history visible to all room members, ' return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they are invited.', {senderName}); + 'from the point they are invited.', {senderName});
case 'joined': case 'joined':
return _t('%(senderName)s made future room history visible to all room members, ' return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they joined.', {senderName}); + 'from the point they joined.', {senderName});
case 'shared': case 'shared':
return _t('%(senderName)s made future room history visible to all room members.', {senderName}); return () => _t('%(senderName)s made future room history visible to all room members.', {senderName});
case 'world_readable': case 'world_readable':
return _t('%(senderName)s made future room history visible to anyone.', {senderName}); return () => _t('%(senderName)s made future room history visible to anyone.', {senderName});
default: default:
return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
senderName, senderName,
visibility: event.getContent().history_visibility, visibility: event.getContent().history_visibility,
}); });
@ -399,11 +421,11 @@ function textForHistoryVisibilityEvent(event) {
} }
// Currently will only display a change if a user's power level is changed // Currently will only display a change if a user's power level is changed
function textForPowerEvent(event) { function textForPowerEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users || if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) { !event.getContent() || !event.getContent().users) {
return ''; return null;
} }
const userDefault = event.getContent().users_default || 0; const userDefault = event.getContent().users_default || 0;
// Construct set of userIds // Construct set of userIds
@ -418,38 +440,38 @@ function textForPowerEvent(event) {
if (users.indexOf(userId) === -1) users.push(userId); if (users.indexOf(userId) === -1) users.push(userId);
}, },
); );
const diff = []; const diffs = [];
// XXX: This is also surely broken for i18n
users.forEach((userId) => { users.forEach((userId) => {
// Previous power level // Previous power level
const from = event.getPrevContent().users[userId]; const from = event.getPrevContent().users[userId];
// Current power level // Current power level
const to = event.getContent().users[userId]; const to = event.getContent().users[userId];
if (to !== from) { if (to !== from) {
diff.push( diffs.push({ userId, from, to });
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId,
fromPowerLevel: Roles.textualPowerLevel(from, userDefault),
toPowerLevel: Roles.textualPowerLevel(to, userDefault),
}),
);
} }
}); });
if (!diff.length) { if (!diffs.length) {
return ''; return null;
} }
return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { // XXX: This is also surely broken for i18n
return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
senderName, senderName,
powerLevelDiffText: diff.join(", "), powerLevelDiffText: diffs.map(diff =>
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId: diff.userId,
fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault),
toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault),
}),
).join(", "),
}); });
} }
function textForPinnedEvent(event) { function textForPinnedEvent(event): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); return () => _t("%(senderName)s changed the pinned messages for the room.", {senderName});
} }
function textForWidgetEvent(event) { function textForWidgetEvent(event): () => string | null {
const senderName = event.getSender(); const senderName = event.getSender();
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
const {name, type, url} = event.getContent() || {}; const {name, type, url} = event.getContent() || {};
@ -464,27 +486,27 @@ function textForWidgetEvent(event) {
// equivalent to that condition. // equivalent to that condition.
if (url) { if (url) {
if (prevUrl) { if (prevUrl) {
return _t('%(widgetName)s widget modified by %(senderName)s', { return () => _t('%(widgetName)s widget modified by %(senderName)s', {
widgetName, senderName, widgetName, senderName,
}); });
} else { } else {
return _t('%(widgetName)s widget added by %(senderName)s', { return () => _t('%(widgetName)s widget added by %(senderName)s', {
widgetName, senderName, widgetName, senderName,
}); });
} }
} else { } else {
return _t('%(widgetName)s widget removed by %(senderName)s', { return () => _t('%(widgetName)s widget removed by %(senderName)s', {
widgetName, senderName, widgetName, senderName,
}); });
} }
} }
function textForWidgetLayoutEvent(event) { function textForWidgetLayoutEvent(event): () => string | null {
const senderName = event.sender?.name || event.getSender(); const senderName = event.sender?.name || event.getSender();
return _t("%(senderName)s has updated the widget layout", {senderName}); return () => _t("%(senderName)s has updated the widget layout", {senderName});
} }
function textForMjolnirEvent(event) { function textForMjolnirEvent(event): () => string | null {
const senderName = event.getSender(); const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent(); const {entity: prevEntity} = event.getPrevContent();
const {entity, recommendation, reason} = event.getContent(); const {entity, recommendation, reason} = event.getContent();
@ -492,74 +514,74 @@ function textForMjolnirEvent(event) {
// Rule removed // Rule removed
if (!entity) { if (!entity) {
if (USER_RULE_TYPES.includes(event.getType())) { if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning users matching %(glob)s", return () => _t("%(senderName)s removed the rule banning users matching %(glob)s",
{senderName, glob: prevEntity}); {senderName, glob: prevEntity});
} else if (ROOM_RULE_TYPES.includes(event.getType())) { } else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
{senderName, glob: prevEntity}); {senderName, glob: prevEntity});
} else if (SERVER_RULE_TYPES.includes(event.getType())) { } else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s removed the rule banning servers matching %(glob)s", return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s",
{senderName, glob: prevEntity}); {senderName, glob: prevEntity});
} }
// Unknown type. We'll say something, but we shouldn't end up here. // Unknown type. We'll say something, but we shouldn't end up here.
return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); return () => _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity});
} }
// Invalid rule // Invalid rule
if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, {senderName});
// Rule updated // Rule updated
if (entity === prevEntity) { if (entity === prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) { if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) { } else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) { } else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} }
// Unknown type. We'll say something but we shouldn't end up here. // Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} }
// New rule // New rule
if (!prevEntity) { if (!prevEntity) {
if (USER_RULE_TYPES.includes(event.getType())) { if (USER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} else if (ROOM_RULE_TYPES.includes(event.getType())) { } else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} else if (SERVER_RULE_TYPES.includes(event.getType())) { } else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} }
// Unknown type. We'll say something but we shouldn't end up here. // Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
{senderName, glob: entity, reason}); {senderName, glob: entity, reason});
} }
// else the entity !== prevEntity - count as a removal & add // else the entity !== prevEntity - count as a removal & add
if (USER_RULE_TYPES.includes(event.getType())) { if (USER_RULE_TYPES.includes(event.getType())) {
return _t( return () => _t(
"%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}, {senderName, oldGlob: prevEntity, newGlob: entity, reason},
); );
} else if (ROOM_RULE_TYPES.includes(event.getType())) { } else if (ROOM_RULE_TYPES.includes(event.getType())) {
return _t( return () => _t(
"%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}, {senderName, oldGlob: prevEntity, newGlob: entity, reason},
); );
} else if (SERVER_RULE_TYPES.includes(event.getType())) { } else if (SERVER_RULE_TYPES.includes(event.getType())) {
return _t( return () => _t(
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
"%(newGlob)s for %(reason)s", "%(newGlob)s for %(reason)s",
{senderName, oldGlob: prevEntity, newGlob: entity, reason}, {senderName, oldGlob: prevEntity, newGlob: entity, reason},
@ -567,11 +589,15 @@ function textForMjolnirEvent(event) {
} }
// Unknown type. We'll say something but we shouldn't end up here. // Unknown type. We'll say something but we shouldn't end up here.
return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
"for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason});
} }
const handlers = { interface IHandlers {
[type: string]: (ev: any) => (() => string | null);
}
const handlers: IHandlers = {
'm.room.message': textForMessageEvent, 'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent, 'm.call.invite': textForCallInviteEvent,
'm.call.answer': textForCallAnswerEvent, 'm.call.answer': textForCallAnswerEvent,
@ -579,7 +605,7 @@ const handlers = {
'm.call.reject': textForCallRejectEvent, 'm.call.reject': textForCallRejectEvent,
}; };
const stateHandlers = { const stateHandlers: IHandlers = {
'm.room.canonical_alias': textForCanonicalAliasEvent, 'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent, 'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent, 'm.room.topic': textForTopicEvent,
@ -604,8 +630,12 @@ for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent; stateHandlers[evType] = textForMjolnirEvent;
} }
export function textForEvent(ev) { export function hasText(ev): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
if (handler) return handler(ev); return Boolean(handler?.(ev));
return ''; }
export function textForEvent(ev): string {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev)?.() || '';
} }

View file

@ -24,7 +24,9 @@ import { Room } from 'matrix-js-sdk/src/models/room';
// is sip virtual: there could be others in the future. // is sip virtual: there could be others in the future.
export default class VoipUserMapper { export default class VoipUserMapper {
private virtualRoomIdCache = new Set<string>(); // We store mappings of virtual -> native room IDs here until the local echo for the
// account data arrives.
private virtualToNativeRoomIdCache = new Map<string, string>();
public static sharedInstance(): VoipUserMapper { public static sharedInstance(): VoipUserMapper {
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper(); if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
@ -33,7 +35,7 @@ export default class VoipUserMapper {
private async userToVirtualUser(userId: string): Promise<string> { private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
if (results.length === 0) return null; if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid; return results[0].userid;
} }
@ -49,10 +51,20 @@ export default class VoipUserMapper {
native_room: roomId, native_room: roomId,
}); });
this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId);
return virtualRoomId; return virtualRoomId;
} }
public nativeRoomForVirtualRoom(roomId: string): string { public nativeRoomForVirtualRoom(roomId: string): string {
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
if (cachedNativeRoomId) {
console.log(
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
);
return cachedNativeRoomId;
}
const virtualRoom = MatrixClientPeg.get().getRoom(roomId); const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
if (!virtualRoom) return null; if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
@ -67,7 +79,7 @@ export default class VoipUserMapper {
public isVirtualRoom(room: Room): boolean { public isVirtualRoom(room: Room): boolean {
if (this.nativeRoomForVirtualRoom(room.roomId)) return true; if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
if (this.virtualRoomIdCache.has(room.roomId)) return true; if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
// also look in the create event for the claimed native room ID, which is the only // also look in the create event for the claimed native room ID, which is the only
// way we can recognise a virtual room we've created when it first arrives down // way we can recognise a virtual room we've created when it first arrives down
@ -82,14 +94,14 @@ export default class VoipUserMapper {
return Boolean(claimedNativeRoomId); return Boolean(claimedNativeRoomId);
} }
public async onNewInvitedRoom(invitedRoom: Room) { public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter(); const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
if (result.length === 0) { if (result.length === 0) {
return true; return;
} }
if (result[0].fields.is_virtual) { if (result[0].fields.is_virtual) {
@ -110,7 +122,7 @@ export default class VoipUserMapper {
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer // also put this room in the virtual room ID cache so isVirtualRoom return the right answer
// in however long it takes for the echo of setAccountData to come down the sync // in however long it takes for the echo of setAccountData to come down the sync
this.virtualRoomIdCache.add(invitedRoom.roomId); this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
} }
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import dis from '../dispatcher/dispatcher'; import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import dis from "../dispatcher/dispatcher";
import {ActionPayload} from "../dispatcher/payloads";
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events // TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
// become dispatches in the same place. // become dispatches in the same place.
@ -27,7 +33,7 @@ import dis from '../dispatcher/dispatcher';
* @param {string} prevState the previous sync state. * @param {string} prevState the previous sync state.
* @returns {Object} an action of type MatrixActions.sync. * @returns {Object} an action of type MatrixActions.sync.
*/ */
function createSyncAction(matrixClient, state, prevState) { function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload {
return { return {
action: 'MatrixActions.sync', action: 'MatrixActions.sync',
state, state,
@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) {
* @param {MatrixEvent} accountDataEvent the account data event. * @param {MatrixEvent} accountDataEvent the account data event.
* @returns {AccountDataAction} an action of type MatrixActions.accountData. * @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/ */
function createAccountDataAction(matrixClient, accountDataEvent) { function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload {
return { return {
action: 'MatrixActions.accountData', action: 'MatrixActions.accountData',
event: accountDataEvent, event: accountDataEvent,
@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
* @param {Room} room the room where account data was changed * @param {Room} room the room where account data was changed
* @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData. * @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData.
*/ */
function createRoomAccountDataAction(matrixClient, accountDataEvent, room) { function createRoomAccountDataAction(
matrixClient: MatrixClient,
accountDataEvent: MatrixEvent,
room: Room,
): ActionPayload {
return { return {
action: 'MatrixActions.Room.accountData', action: 'MatrixActions.Room.accountData',
event: accountDataEvent, event: accountDataEvent,
@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) {
* @param {Room} room the Room that was stored. * @param {Room} room the Room that was stored.
* @returns {RoomAction} an action of type `MatrixActions.Room`. * @returns {RoomAction} an action of type `MatrixActions.Room`.
*/ */
function createRoomAction(matrixClient, room) { function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload {
return { action: 'MatrixActions.Room', room }; return { action: 'MatrixActions.Room', room };
} }
@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) {
* @param {Room} room the Room whose tags were changed. * @param {Room} room the Room whose tags were changed.
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/ */
function createRoomTagsAction(matrixClient, roomTagsEvent, room) { function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload {
return { action: 'MatrixActions.Room.tags', room }; return { action: 'MatrixActions.Room.tags', room };
} }
@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
* @param {Room} room the room the receipt happened in. * @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt. * @returns {Object} an action of type MatrixActions.Room.receipt.
*/ */
function createRoomReceiptAction(matrixClient, event, room) { function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload {
return { return {
action: 'MatrixActions.Room.receipt', action: 'MatrixActions.Room.receipt',
event, event,
@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) {
* @param {EventTimeline} data.timeline the timeline being altered. * @param {EventTimeline} data.timeline the timeline being altered.
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
*/ */
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { function createRoomTimelineAction(
matrixClient: MatrixClient,
timelineEvent: MatrixEvent,
room: Room,
toStartOfTimeline: boolean,
removed: boolean,
data: {
liveEvent: boolean;
timeline: EventTimeline;
},
): ActionPayload {
return { return {
action: 'MatrixActions.Room.timeline', action: 'MatrixActions.Room.timeline',
event: timelineEvent, event: timelineEvent,
@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi
* @param {string} oldMembership the previous membership, can be null. * @param {string} oldMembership the previous membership, can be null.
* @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`. * @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`.
*/ */
function createSelfMembershipAction(matrixClient, room, membership, oldMembership) { function createSelfMembershipAction(
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership}; matrixClient: MatrixClient,
room: Room,
membership: string,
oldMembership: string,
): ActionPayload {
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership };
} }
/** /**
@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi
* @param {MatrixEvent} event the matrix event that was decrypted. * @param {MatrixEvent} event the matrix event that was decrypted.
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
*/ */
function createEventDecryptedAction(matrixClient, event) { function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload {
return { action: 'MatrixActions.Event.decrypted', event }; return { action: 'MatrixActions.Event.decrypted', event };
} }
type Listener = () => void;
type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload;
// A list of callbacks to call to unregister all listeners added
let matrixClientListenersStop: Listener[] = [];
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void {
const listener: Listener = (...args) => {
const payload = actionCreator(matrixClient, ...args);
if (payload) {
dis.dispatch(payload, true);
}
};
matrixClient.on(eventName, listener);
matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
}
/** /**
* This object is responsible for dispatching actions when certain events are emitted by * This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient. * the given MatrixClient.
*/ */
export default { export default {
// A list of callbacks to call to unregister all listeners added
_matrixClientListenersStop: [],
/** /**
* Start listening to certain events from the MatrixClient and dispatch actions when * Start listening to certain events from the MatrixClient and dispatch actions when
* they are emitted. * they are emitted.
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from * @param {MatrixClient} matrixClient the MatrixClient to listen to events from
*/ */
start(matrixClient) { start(matrixClient: MatrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction); addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction); addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction); addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
},
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => {
const payload = actionCreator(matrixClient, ...args);
if (payload) {
dis.dispatch(payload, true);
}
};
matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
}, },
/** /**
* Stop listening to events. * Stop listening to events.
*/ */
stop() { stop() {
this._matrixClientListenersStop.forEach((stopListener) => stopListener()); matrixClientListenersStop.forEach((stopListener) => stopListener());
matrixClientListenersStop = [];
}, },
}; };

View file

@ -1,51 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectContainerRef = this._collectContainerRef.bind(this);
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
}
}
getScrollTop() {
return this.containerRef.scrollTop;
}
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface IProps {
className?: string;
onScroll?: () => void;
onWheel?: () => void;
style?: React.CSSProperties
tabIndex?: number,
wrappedRef?: (ref: HTMLDivElement) => void;
}
export default class AutoHideScrollbar extends React.Component<IProps> {
private containerRef: React.RefObject<HTMLDivElement> = React.createRef();
public componentDidMount() {
if (this.containerRef.current && this.props.onScroll) {
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true });
}
if (this.props.wrappedRef) {
this.props.wrappedRef(this.containerRef.current);
}
}
public componentWillUnmount() {
if (this.containerRef.current && this.props.onScroll) {
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
}
}
public getScrollTop(): number {
return this.containerRef.current.scrollTop;
}
public render() {
return (<div
ref={this.containerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -24,13 +24,16 @@ import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {} interface IProps {
onClick?(): void;
}
interface IState {} interface IState {}
@replaceableComponent("structures.HostSignupAction") @replaceableComponent("structures.HostSignupAction")
export default class HostSignupAction extends React.PureComponent<IProps, IState> { export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => { private openDialog = async () => {
this.props.onClick?.();
await HostSignupStore.instance.setHostSignupActive(true); await HostSignupStore.instance.setHostSignupActive(true);
} }

View file

@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
_collectScroller(scroller) { _collectScroller(scroller) {
if (scroller && !this._scrollElement) { if (scroller && !this._scrollElement) {
this._scrollElement = scroller; this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow); // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow(); this.checkOverflow();
} }
} }

View file

@ -67,6 +67,7 @@ const cssClasses = [
@replaceableComponent("structures.LeftPanel") @replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> { export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string; private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string; private bgImageWatcherRef: string;
@ -93,6 +94,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}); });
} }
public componentDidMount() {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
}
public componentWillUnmount() { public componentWillUnmount() {
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef); SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef); SettingsStore.unwatchSetting(this.bgImageWatcherRef);
@ -100,6 +109,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevState.activeSpace !== this.state.activeSpace) {
this.refreshStickyHeaders();
}
} }
private updateActiveSpace = (activeSpace: Room) => { private updateActiveSpace = (activeSpace: Room) => {
@ -245,10 +263,24 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.add("mx_RoomSublist_headerContainer_sticky"); header.classList.add("mx_RoomSublist_headerContainer_sticky");
} }
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
if (listDimensions) {
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = listDimensions.width - headerRightMargin;
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
}
} else if (!style.stickyTop && !style.stickyBottom) { } else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky"); header.classList.remove("mx_RoomSublist_headerContainer_sticky");
} }
if (header.style.width) {
header.style.removeProperty('width');
}
} }
} }
@ -267,7 +299,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
} }
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => { private onScroll = (ev: Event) => {
const list = ev.target as HTMLDivElement; const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list); this.handleStickyHeaders(list);
}; };
@ -407,6 +439,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
activeSpace={this.state.activeSpace} activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
/>; />;
const containerClasses = classNames({ const containerClasses = classNames({
@ -420,7 +454,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
); );
return ( return (
<div className={containerClasses}> <div className={containerClasses} ref={this.ref}>
{leftLeftPanel} {leftLeftPanel}
<aside className="mx_LeftPanel_roomListContainer"> <aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
@ -430,7 +464,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<div className="mx_LeftPanel_roomListWrapper"> <div className="mx_LeftPanel_roomListWrapper">
<div <div
className={roomListClasses} className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef} ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to // Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order. // overflow:scroll;, so force it out of tab order.

View file

@ -358,7 +358,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) { for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0); const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId); const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) events.push(event); if (event) events.push(event);
} }

View file

@ -232,6 +232,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private accountPasswordTimer?: NodeJS.Timeout; private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean; private focusComposer: boolean;
private subTitleStatus: string; private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView: React.RefObject<LoggedInViewType>; private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any; private readonly dispatcherRef: any;
@ -277,6 +278,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
} }
this.prevWindowWidth = UIStore.instance.windowWidth || 1000;
UIStore.instance.on(UI_EVENTS.Resize, this.handleResize); UIStore.instance.on(UI_EVENTS.Resize, this.handleResize);
this.pageChanging = false; this.pageChanging = false;
@ -376,7 +378,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn(); this.onLoggedIn();
} }
const promisesList = [this.firstSyncPromise.promise]; const promisesList: Promise<any>[] = [this.firstSyncPromise.promise];
if (cryptoEnabled) { if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we // wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account // know whether or not we have keys set up on this account
@ -1821,13 +1823,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const LHS_THRESHOLD = 1000; const LHS_THRESHOLD = 1000;
const width = UIStore.instance.windowWidth; const width = UIStore.instance.windowWidth;
if (width <= LHS_THRESHOLD && !this.state.collapseLhs) { if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (width > LHS_THRESHOLD && this.state.collapseLhs) {
dis.dispatch({ action: 'show_left_panel' }); dis.dispatch({ action: 'show_left_panel' });
} }
if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
this.prevWindowWidth = width;
this.state.resizeNotifier.notifyWindowResized(); this.state.resizeNotifier.notifyWindowResized();
}; };
@ -1949,6 +1953,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Create and start the client // Create and start the client
await Lifecycle.setLoggedIn(credentials); await Lifecycle.setLoggedIn(credentials);
await this.postLoginSetup(); await this.postLoginSetup();
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
}; };
@ -2087,6 +2092,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
{...this.getServerProperties()} {...this.getServerProperties()}
/> />
); );

View file

@ -19,17 +19,17 @@ limitations under the License.
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils'; import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index'; import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg'; import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore'; import SettingsStore from '../../settings/SettingsStore';
import RoomContext from "../../contexts/RoomContext";
import {Layout, LayoutPropType} from "../../settings/Layout"; import {Layout, LayoutPropType} from "../../settings/Layout";
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile"; import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent"; import {hasText} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap"; import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro"; import NewRoomIntro from "../views/rooms/NewRoomIntro";
@ -121,6 +121,9 @@ export default class MessagePanel extends React.Component {
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: PropTypes.func, onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when more content is needed. // callback which is called when more content is needed.
onFillRequest: PropTypes.func, onFillRequest: PropTypes.func,
@ -149,6 +152,8 @@ export default class MessagePanel extends React.Component {
enableFlair: PropTypes.bool, enableFlair: PropTypes.bool,
}; };
static contextType = RoomContext;
constructor(props) { constructor(props) {
super(props); super(props);
@ -378,7 +383,7 @@ export default class MessagePanel extends React.Component {
// Always show highlighted event // Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true; if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv); return !shouldHideEvent(mxEv, this.context);
} }
_readMarkerForEvent(eventId, isLastEvent) { _readMarkerForEvent(eventId, isLastEvent) {
@ -613,10 +618,6 @@ export default class MessagePanel extends React.Component {
const eventId = mxEv.getId(); const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId); const highlight = (eventId === this.props.highlightedEventId);
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
const scrollToken = mxEv.status ? undefined : eventId;
const readReceipts = this._readReceiptsByEvent[eventId]; const readReceipts = this._readReceiptsByEvent[eventId];
let isLastSuccessful = false; let isLastSuccessful = false;
@ -645,39 +646,36 @@ export default class MessagePanel extends React.Component {
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
<li <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
key={mxEv.getTxnId() || eventId} <EventTile
ref={this._collectEventNode.bind(this, eventId)} as="li"
data-scroll-tokens={scrollToken} ref={this._collectEventNode.bind(this, eventId)}
> alwaysShowTimestamps={this.props.alwaysShowTimestamps}
<TileErrorBoundary mxEvent={mxEv}> mxEvent={mxEv}
<EventTile continuation={continuation}
mxEvent={mxEv} isRedacted={mxEv.isRedacted()}
continuation={continuation} replacingEventId={mxEv.replacingEventId()}
isRedacted={mxEv.isRedacted()} editState={isEditing && this.props.editState}
replacingEventId={mxEv.replacingEventId()} onHeightChanged={this._onHeightChanged}
editState={isEditing && this.props.editState} readReceipts={readReceipts}
onHeightChanged={this._onHeightChanged} readReceiptMap={this._readReceiptMap}
readReceipts={readReceipts} showUrlPreview={this.props.showUrlPreview}
readReceiptMap={this._readReceiptMap} checkUnmounting={this._isUnmounting}
showUrlPreview={this.props.showUrlPreview} eventSendStatus={mxEv.getAssociatedStatus()}
checkUnmounting={this._isUnmounting} tileShape={this.props.tileShape}
eventSendStatus={mxEv.getAssociatedStatus()} isTwelveHour={this.props.isTwelveHour}
tileShape={this.props.tileShape} permalinkCreator={this.props.permalinkCreator}
isTwelveHour={this.props.isTwelveHour} last={last}
permalinkCreator={this.props.permalinkCreator} lastInSection={willWantDateSeparator}
last={last} lastSuccessful={isLastSuccessful}
lastInSection={willWantDateSeparator} isSelectedEvent={highlight}
lastSuccessful={isLastSuccessful} getRelationsForEvent={this.props.getRelationsForEvent}
isSelectedEvent={highlight} showReactions={this.props.showReactions}
getRelationsForEvent={this.props.getRelationsForEvent} layout={this.props.layout}
showReactions={this.props.showReactions} enableFlair={this.props.enableFlair}
layout={this.props.layout} showReadReceipts={this.props.showReadReceipts}
enableFlair={this.props.enableFlair} />
showReadReceipts={this.props.showReadReceipts} </TileErrorBoundary>,
/>
</TileErrorBoundary>
</li>,
); );
return ret; return ret;
@ -779,7 +777,7 @@ export default class MessagePanel extends React.Component {
} }
_collectEventNode = (eventId, node) => { _collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node; this.eventNodes[eventId] = node?.ref?.current;
} }
// once dynamic content in the events load, make the scrollPanel check the // once dynamic content in the events load, make the scrollPanel check the
@ -853,13 +851,6 @@ export default class MessagePanel extends React.Component {
const style = this.props.hidden ? { display: 'none' } : {}; const style = this.props.hidden ? { display: 'none' } : {};
const className = classNames(
this.props.className,
{
"mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
},
);
let whoIsTyping; let whoIsTyping;
if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) { if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) {
whoIsTyping = (<WhoIsTypingTile whoIsTyping = (<WhoIsTypingTile
@ -883,8 +874,9 @@ export default class MessagePanel extends React.Component {
<ErrorBoundary> <ErrorBoundary>
<ScrollPanel <ScrollPanel
ref={this._scrollPanel} ref={this._scrollPanel}
className={className} className={this.props.className}
onScroll={this.props.onScroll} onScroll={this.props.onScroll}
onUserScroll={this.props.onUserScroll}
onResize={this.onResize} onResize={this.onResize}
onFillRequest={this.props.onFillRequest} onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest} onUnfillRequest={this.props.onUnfillRequest}
@ -1175,11 +1167,8 @@ class MemberGrouper {
add(ev) { add(ev) {
if (ev.getType() === 'm.room.member') { if (ev.getType() === 'm.room.member') {
// We'll just double check that it's worth our time to do so, through an // We can ignore any events that don't actually have a message to display
// ugly hack. If textForEvent returns something, we should group it for if (!hasText(ev)) return;
// rendering but if it doesn't then we'll exclude it.
const renderText = textForEvent(ev);
if (!renderText || renderText.trim().length === 0) return; // quietly ignore
} }
this.readMarker = this.readMarker || this.panel._readMarkerForEvent( this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
ev.getId(), ev.getId(),

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,29 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import PropTypes from "prop-types";
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
import BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
interface IProps {
onClose(): void;
}
/* /*
* Component which shows the global notification list using a TimelinePanel * Component which shows the global notification list using a TimelinePanel
*/ */
@replaceableComponent("structures.NotificationPanel") @replaceableComponent("structures.NotificationPanel")
class NotificationPanel extends React.Component { export default class NotificationPanel extends React.PureComponent<IProps> {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
render() { render() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty"> const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2> <h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications.')}</p> <p>{_t('You have no visible notifications.')}</p>
@ -47,6 +41,7 @@ class NotificationPanel extends React.Component {
let content; let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) { if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = ( content = (
<TimelinePanel <TimelinePanel
manageReadReceipts={false} manageReadReceipts={false}
@ -55,11 +50,12 @@ class NotificationPanel extends React.Component {
showUrlPreview={false} showUrlPreview={false}
tileShape="notif" tileShape="notif"
empty={emptyState} empty={emptyState}
alwaysShowTimestamps={true}
/> />
); );
} else { } else {
console.error("No notifTimelineSet available!"); console.error("No notifTimelineSet available!");
content = <Loader />; content = <Spinner />;
} }
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer> return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
@ -67,5 +63,3 @@ class NotificationPanel extends React.Component {
</BaseCard>; </BaseCard>;
} }
} }
export default NotificationPanel;

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015 - 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,70 +16,92 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { Room } from "matrix-js-sdk/src/models/room";
import {Room} from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import RateLimitedFunc from '../../ratelimitedfunc'; import RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore'; import GroupStore from '../../stores/GroupStore';
import { import {
RightPanelPhases,
RIGHT_PANEL_PHASES_NO_ARGS, RIGHT_PANEL_PHASES_NO_ARGS,
RIGHT_PANEL_SPACE_PHASES, RIGHT_PANEL_SPACE_PHASES,
RightPanelPhases,
} from "../../stores/RightPanelStorePhases"; } from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { ActionPayload } from "../../dispatcher/payloads";
import MemberList from "../views/rooms/MemberList";
import GroupMemberList from "../views/groups/GroupMemberList";
import GroupRoomList from "../views/groups/GroupRoomList";
import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
}
interface IState {
phase: RightPanelPhases;
isUserPrivilegedInGroup?: boolean;
member?: RoomMember;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
space?: Room;
widgetId?: string;
groupRoomId?: string;
groupId?: string;
event: MatrixEvent;
}
@replaceableComponent("structures.RightPanel") @replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component { export default class RightPanel extends React.Component<IProps, IState> {
static get propTypes() {
return {
room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set
groupId: PropTypes.string, // if showing panels for a given group, this is set
user: PropTypes.object, // used if we know the user ahead of opening the panel
};
}
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
...RightPanelStore.getSharedInstance().roomPanelPhaseParams, ...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
phase: this._getPhaseFromProps(), phase: this.getPhaseFromProps(),
isUserPrivilegedInGroup: null, isUserPrivilegedInGroup: null,
member: this._getUserForPanel(), member: this.getUserForPanel(),
}; };
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
this._delayedUpdate = new RateLimitedFunc(() => { this.delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate(); this.forceUpdate();
}, 500); }, 500);
} }
// Helper function to split out the logic for _getPhaseFromProps() and the constructor // Helper function to split out the logic for getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor. // as both are called at the same time in the constructor.
_getUserForPanel() { private getUserForPanel() {
if (this.state && this.state.member) return this.state.member; if (this.state && this.state.member) return this.state.member;
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
return this.props.user || lastParams['member']; return this.props.user || lastParams['member'];
} }
// gets the current phase from the props and also maybe the store // gets the current phase from the props and also maybe the store
_getPhaseFromProps() { private getPhaseFromProps() {
const rps = RightPanelStore.getSharedInstance(); const rps = RightPanelStore.getSharedInstance();
const userForPanel = this._getUserForPanel(); const userForPanel = this.getUserForPanel();
if (this.props.groupId) { if (this.props.groupId) {
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList}); dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList});
@ -118,7 +140,7 @@ export default class RightPanel extends React.Component {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
const cli = this.context; const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId); this.initGroupStore(this.props.groupId);
} }
componentWillUnmount() { componentWillUnmount() {
@ -126,61 +148,47 @@ export default class RightPanel extends React.Component {
if (this.context) { if (this.context) {
this.context.removeListener("RoomState.members", this.onRoomStateMember); this.context.removeListener("RoomState.members", this.onRoomStateMember);
} }
this._unregisterGroupStore(this.props.groupId); this.unregisterGroupStore();
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.groupId !== this.props.groupId) { if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId); this.unregisterGroupStore();
this._initGroupStore(newProps.groupId); this.initGroupStore(newProps.groupId);
} }
} }
_initGroupStore(groupId) { private initGroupStore(groupId: string) {
if (!groupId) return; if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated); GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
} }
_unregisterGroupStore() { private unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated); GroupStore.unregisterListener(this.onGroupStoreUpdated);
} }
onGroupStoreUpdated() { private onGroupStoreUpdated = () => {
this.setState({ this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId), isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
}); });
} };
onInviteToGroupButtonClick() { private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RightPanelPhases.GroupMemberList,
});
});
}
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
}
onRoomStateMember(ev, state, member) {
if (!this.props.room || member.roomId !== this.props.room.roomId) { if (!this.props.room || member.roomId !== this.props.room.roomId) {
return; return;
} }
// redraw the badge on the membership list // redraw the badge on the membership list
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
this._delayedUpdate(); this.delayedUpdate();
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
member.userId === this.state.member.userId) { member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level) // refresh the member info (e.g. new power level)
this._delayedUpdate(); this.delayedUpdate();
} }
} };
onAction(payload) { private onAction = (payload: ActionPayload) => {
if (payload.action === Action.AfterRightPanelPhaseChange) { if (payload.action === Action.AfterRightPanelPhaseChange) {
this.setState({ this.setState({
phase: payload.phase, phase: payload.phase,
@ -194,9 +202,9 @@ export default class RightPanel extends React.Component {
space: payload.space, space: payload.space,
}); });
} }
} };
onClose = () => { private onClose = () => {
// XXX: There are three different ways of 'closing' this panel depending on what state // XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest // things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly. // of the app and is generally a bit silly.
@ -224,16 +232,6 @@ export default class RightPanel extends React.Component {
}; };
render() { render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
let panel = <div />; let panel = <div />;
const roomId = this.props.room ? this.props.room.roomId : undefined; const roomId = this.props.room ? this.props.room.roomId : undefined;
@ -285,6 +283,7 @@ export default class RightPanel extends React.Component {
user={this.state.member} user={this.state.member}
groupId={this.props.groupId} groupId={this.props.groupId}
key={this.state.member.userId} key={this.state.member.userId}
phase={this.state.phase}
onClose={this.onClose} />; onClose={this.onClose} />;
break; break;
@ -299,6 +298,12 @@ export default class RightPanel extends React.Component {
panel = <NotificationPanel onClose={this.onClose} />; panel = <NotificationPanel onClose={this.onClose} />;
break; break;
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.FilePanel: case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />; panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break; break;

View file

@ -41,7 +41,7 @@ export function getUnsentMessages(room) {
} }
@replaceableComponent("structures.RoomStatusBar") @replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.Component { export default class RoomStatusBar extends React.PureComponent {
static propTypes = { static propTypes = {
// the room this statusbar is representing. // the room this statusbar is representing.
room: PropTypes.object.isRequired, room: PropTypes.object.isRequired,

View file

@ -46,7 +46,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile"; import { haveTileForEvent } from "../views/rooms/EventTile";
@ -54,16 +54,13 @@ import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg"; import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel"; import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary"; import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import ForwardMessage from "../views/rooms/ForwardMessage";
import SearchBar from "../views/rooms/SearchBar"; import SearchBar from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common"; import { XOR } from "../../@types/common";
@ -82,7 +79,7 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects"; import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView"; import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore"; import UIStore from "../../stores/UIStore";
const DEBUG = false; const DEBUG = false;
@ -137,7 +134,6 @@ export interface IState {
// Whether to highlight the event scrolled to // Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean; isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent;
numUnreadMessages: number; numUnreadMessages: number;
draggingFile: boolean; draggingFile: boolean;
searching: boolean; searching: boolean;
@ -156,8 +152,6 @@ export interface IState {
canPeek: boolean; canPeek: boolean;
showApps: boolean; showApps: boolean;
isPeeking: boolean; isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
showRightPanel: boolean; showRightPanel: boolean;
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -176,6 +170,7 @@ export interface IState {
statusBarVisible: boolean; statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us. // We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion() // This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: { upgradeRecommendation?: {
version: string; version: string;
needsUpgrade: boolean; needsUpgrade: boolean;
@ -184,6 +179,12 @@ export interface IState {
canReact: boolean; canReact: boolean;
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
lowBandwidth: boolean;
showReadReceipts: boolean;
showRedactions: boolean;
showJoinLeaves: boolean;
showAvatarChanges: boolean;
showDisplaynameChanges: boolean;
matrixClientIsReady: boolean; matrixClientIsReady: boolean;
showUrlPreview?: boolean; showUrlPreview?: boolean;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
@ -201,8 +202,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription; private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription;
private readonly showReadReceiptsWatchRef: string; private settingWatchers: string[];
private readonly layoutWatcherRef: string;
private unmounted = false; private unmounted = false;
private permalinkCreators: Record<string, RoomPermalinkCreator> = {}; private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
@ -233,8 +233,6 @@ export default class RoomView extends React.Component<IProps, IState> {
canPeek: false, canPeek: false,
showApps: false, showApps: false,
isPeeking: false, isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
joining: false, joining: false,
atEndOfLiveTimeline: true, atEndOfLiveTimeline: true,
@ -244,6 +242,12 @@ export default class RoomView extends React.Component<IProps, IState> {
canReact: false, canReact: false,
canReply: false, canReply: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0, dragCounter: 0,
}; };
@ -270,9 +274,14 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, this.settingWatchers = [
this.onReadReceiptsChange); SettingsStore.watchSetting("layout", null, () =>
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange); this.setState({ layout: SettingsStore.getValue("layout") }),
),
SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
),
];
} }
private onWidgetStoreUpdate = () => { private onWidgetStoreUpdate = () => {
@ -325,14 +334,45 @@ export default class RoomView extends React.Component<IProps, IState> {
initialEventId: RoomViewStore.getInitialEventId(), initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(), replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client // we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
showRedactions: SettingsStore.getValue("showRedactions", roomId),
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(), wasContextSwitch: RoomViewStore.getWasContextSwitch(),
}; };
// Add watchers for each of the settings we just looked up
this.settingWatchers = this.settingWatchers.concat([
SettingsStore.watchSetting("showReadReceipts", null, () =>
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}),
),
SettingsStore.watchSetting("showRedactions", null, () =>
this.setState({
showRedactions: SettingsStore.getValue("showRedactions", roomId),
}),
),
SettingsStore.watchSetting("showJoinLeaves", null, () =>
this.setState({
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
}),
),
SettingsStore.watchSetting("showAvatarChanges", null, () =>
this.setState({
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
}),
),
SettingsStore.watchSetting("showDisplaynameChanges", null, () =>
this.setState({
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
}),
),
]);
if (!initial && this.state.shouldPeek && !newState.shouldPeek) { if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
// Stop peeking because we have joined this room now // Stop peeking because we have joined this room now
this.context.stopPeeking(); this.context.stopPeeking();
@ -529,7 +569,16 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); const hasPropsDiff = objectHasDiff(this.props, nextProps);
const { upgradeRecommendation, ...state } = this.state;
const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState;
const hasStateDiff =
newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade ||
objectHasDiff(state, newState);
return hasPropsDiff || hasStateDiff;
} }
componentDidUpdate() { componentDidUpdate() {
@ -628,10 +677,6 @@ export default class RoomView extends React.Component<IProps, IState> {
); );
} }
if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
}
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the rate_limited_funcs
this.updateRoomMembers.cancelPendingCall(); this.updateRoomMembers.cancelPendingCall();
@ -639,14 +684,22 @@ export default class RoomView extends React.Component<IProps, IState> {
// console.log("Tinter.tint from RoomView.unmount"); // console.log("Tinter.tint from RoomView.unmount");
// Tinter.tint(); // reset colourscheme // Tinter.tint(); // reset colourscheme
SettingsStore.unwatchSetting(this.layoutWatcherRef); for (const watcher of this.settingWatchers) {
SettingsStore.unwatchSetting(watcher);
}
} }
private onLayoutChange = () => { private onUserScroll = () => {
this.setState({ if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
layout: SettingsStore.getValue("layout"), dis.dispatch({
}); action: 'view_room',
}; room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
replyingToEvent: this.state.replyToEvent,
});
}
}
private onRightPanelStoreUpdate = () => { private onRightPanelStoreUpdate = () => {
this.setState({ this.setState({
@ -798,7 +851,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change // no change
} else if (!shouldHideEvent(ev)) { } else if (!shouldHideEvent(ev, this.state)) {
this.setState((state, props) => { this.setState((state, props) => {
return {numUnreadMessages: state.numUnreadMessages + 1}; return {numUnreadMessages: state.numUnreadMessages + 1};
}); });
@ -812,7 +865,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private onEvent = (ev) => { private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev); this.handleEffects(ev);
}; };
@ -1377,13 +1430,6 @@ export default class RoomView extends React.Component<IProps, IState> {
return ret; return ret;
} }
private onPinnedClick = () => {
const nowShowingPinned = !this.state.showingPinned;
const roomId = this.state.room.roomId;
this.setState({showingPinned: nowShowingPinned, searching: false});
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
};
private onCallPlaced = (type: PlaceCallType) => { private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
@ -1396,18 +1442,6 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({ action: "open_room_settings" }); dis.dispatch({ action: "open_room_settings" });
}; };
private onCancelClick = () => {
console.log("updateTint from onCancelClick");
this.updateTint();
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
event: null,
});
}
dis.fire(Action.FocusComposer);
};
private onAppsClick = () => { private onAppsClick = () => {
dis.dispatch({ dis.dispatch({
action: "appsDrawer", action: "appsDrawer",
@ -1500,7 +1534,6 @@ export default class RoomView extends React.Component<IProps, IState> {
private onSearchClick = () => { private onSearchClick = () => {
this.setState({ this.setState({
searching: !this.state.searching, searching: !this.state.searching,
showingPinned: false,
}); });
}; };
@ -1513,8 +1546,19 @@ export default class RoomView extends React.Component<IProps, IState> {
// jump down to the bottom of this room, where new events are arriving // jump down to the bottom of this room, where new events are arriving
private jumpToLiveTimeline = () => { private jumpToLiveTimeline = () => {
this.messagePanel.jumpToLiveTimeline(); if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.fire(Action.FocusComposer); // If we were viewing a highlighted event, firing view_room without
// an event will take care of both clearing the URL fragment and
// jumping to the bottom
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
});
} else {
// Otherwise we have to jump manually
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
}
}; };
// jump up to wherever our read marker is // jump up to wherever our read marker is
@ -1590,29 +1634,45 @@ export default class RoomView extends React.Component<IProps, IState> {
let auxPanelMaxHeight = UIStore.instance.windowHeight - let auxPanelMaxHeight = UIStore.instance.windowHeight -
(54 + // height of RoomHeader (54 + // height of RoomHeader
36 + // height of the status area 36 + // height of the status area
51 + // minimum height of the message compmoser 51 + // minimum height of the message composer
120); // amount of desired scrollback 120); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely // but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) {
this.setState({ auxPanelMaxHeight });
}
}; };
private onStatusBarVisible = () => { private onStatusBarVisible = () => {
if (this.unmounted) return; if (this.unmounted || this.state.statusBarVisible) return;
this.setState({ this.setState({ statusBarVisible: true });
statusBarVisible: true,
});
}; };
private onStatusBarHidden = () => { private onStatusBarHidden = () => {
// This is currently not desired as it is annoying if it keeps expanding and collapsing // This is currently not desired as it is annoying if it keeps expanding and collapsing
if (this.unmounted) return; if (this.unmounted || !this.state.statusBarVisible) return;
this.setState({ this.setState({ statusBarVisible: false });
statusBarVisible: false, };
});
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
private handleScrollKey = ev => {
let panel;
if (this.searchResultsPanel.current) {
panel = this.searchResultsPanel.current;
} else if (this.messagePanel) {
panel = this.messagePanel;
}
if (panel) {
panel.handleScrollKey(ev);
}
}; };
/** /**
@ -1813,11 +1873,7 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null; let aux = null;
let previewBar; let previewBar;
let hideCancel = false; if (this.state.searching) {
if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
aux = <SearchBar aux = <SearchBar
searchInProgress={this.state.searchInProgress} searchInProgress={this.state.searchInProgress}
onCancelClick={this.onCancelSearchClick} onCancelClick={this.onCancelSearchClick}
@ -1826,10 +1882,6 @@ export default class RoomView extends React.Component<IProps, IState> {
/>; />;
} else if (showRoomUpgradeBar) { } else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />; aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (myMembership !== "join") { } else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it. // We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it. // We may have a 3rd party invite to it.
@ -1838,7 +1890,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inviterName = this.props.oobData.inviterName; inviterName = this.props.oobData.inviterName;
} }
const invitedEmail = this.props.threepidInvite?.toEmail; const invitedEmail = this.props.threepidInvite?.toEmail;
hideCancel = true;
previewBar = ( previewBar = (
<RoomPreviewBar <RoomPreviewBar
onJoinClick={this.onJoinButtonClicked} onJoinClick={this.onJoinButtonClicked}
@ -1956,11 +2007,8 @@ export default class RoomView extends React.Component<IProps, IState> {
hideMessagePanel = true; hideMessagePanel = true;
} }
const shouldHighlight = this.state.isInitialEventHighlighted;
let highlightedEventId = null; let highlightedEventId = null;
if (this.state.forwardingEvent) { if (this.state.isInitialEventHighlighted) {
highlightedEventId = this.state.forwardingEvent.getId();
} else if (shouldHighlight) {
highlightedEventId = this.state.initialEventId; highlightedEventId = this.state.initialEventId;
} }
@ -1985,6 +2033,7 @@ export default class RoomView extends React.Component<IProps, IState> {
eventId={this.state.initialEventId} eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset} eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onUserScroll={this.onUserScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar} onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview} showUrlPreview = {this.state.showUrlPreview}
className={messagePanelClassNames} className={messagePanelClassNames}
@ -2011,6 +2060,7 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0} highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>); />);
} }
@ -2047,8 +2097,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inRoom={myMembership === 'join'} inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick} onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}

View file

@ -133,6 +133,10 @@ export default class ScrollPanel extends React.Component {
*/ */
onScroll: PropTypes.func, onScroll: PropTypes.func,
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll: PropTypes.func,
/* className: classnames to add to the top-level div /* className: classnames to add to the top-level div
*/ */
className: PropTypes.string, className: PropTypes.string,
@ -535,21 +539,29 @@ export default class ScrollPanel extends React.Component {
* @param {object} ev the keyboard event * @param {object} ev the keyboard event
*/ */
handleScrollKey = ev => { handleScrollKey = ev => {
let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev); const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) { switch (roomAction) {
case RoomAction.ScrollUp: case RoomAction.ScrollUp:
this.scrollRelative(-1); this.scrollRelative(-1);
isScrolling = true;
break; break;
case RoomAction.RoomScrollDown: case RoomAction.RoomScrollDown:
this.scrollRelative(1); this.scrollRelative(1);
isScrolling = true;
break; break;
case RoomAction.JumpToFirstMessage: case RoomAction.JumpToFirstMessage:
this.scrollToTop(); this.scrollToTop();
isScrolling = true;
break; break;
case RoomAction.JumpToLatestMessage: case RoomAction.JumpToLatestMessage:
this.scrollToBottom(); this.scrollToBottom();
isScrolling = true;
break; break;
} }
if (isScrolling && this.props.onUserScroll) {
this.props.onUserScroll(ev);
}
}; };
/* Scroll the panel to bring the DOM node with the scroll token /* Scroll the panel to bring the DOM node with the scroll token
@ -888,9 +900,8 @@ export default class ScrollPanel extends React.Component {
<AutoHideScrollbar <AutoHideScrollbar
wrappedRef={this._collectScroll} wrappedRef={this._collectScroll}
onScroll={this.onScroll} onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} onWheel={this.props.onUserScroll}
style={this.props.style} className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
>
{ this.props.fixedChildren } { this.props.fixedChildren }
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list"> <ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">

View file

@ -319,7 +319,7 @@ export const HierarchyLevel = ({
key={roomId} key={roomId}
room={rooms.get(roomId)} room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || []) numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length} .filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)} selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin) => {
@ -437,7 +437,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
let content; let content;
if (roomsMap) { if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr; let countsStr;
@ -520,6 +520,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
setError("Failed to update some suggestions. Try again later"); setError("Failed to update some suggestions. Try again later");
} }
setSaving(false); setSaving(false);
setSelected(new Map());
}} }}
kind="primary_outline" kind="primary_outline"
disabled={disabled} disabled={disabled}

View file

@ -28,7 +28,7 @@ import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner"; import InlineSpinner from "../views/elements/InlineSpinner";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers"; import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom"; import createRoom, {IOpts} from "../../createRoom";
import Field from "../views/elements/Field"; import Field from "../views/elements/Field";
import {useEventEmitter} from "../../hooks/useEventEmitter"; import {useEventEmitter} from "../../hooks/useEventEmitter";
import withValidation from "../views/elements/Validation"; import withValidation from "../views/elements/Validation";
@ -65,6 +65,7 @@ import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal"; import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import { Preset } from "matrix-js-sdk/src/@types/partials";
interface IProps { interface IProps {
space: Room; space: Room;
@ -417,9 +418,13 @@ const SpaceLanding = ({ space }) => {
{ inviteButton } { inviteButton }
{ settingsButton } { settingsButton }
</div> </div>
<div className="mx_SpaceRoomView_landing_topic"> <RoomTopic room={space}>
<RoomTopic room={space} /> {(topic, ref) => (
</div> <div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
</div>
)}
</RoomTopic>
<SpaceFeedbackPrompt /> <SpaceFeedbackPrompt />
<hr /> <hr />
@ -437,7 +442,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const [error, setError] = useState(""); const [error, setError] = useState("");
const numFields = 3; const numFields = 3;
const placeholders = [_t("General"), _t("Random"), _t("Support")]; const placeholders = [_t("General"), _t("Random"), _t("Support")];
// TODO vary default prefills for "Just Me" spaces
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((_, i) => { const fields = new Array(numFields).fill(0).map((_, i) => {
const name = "roomName" + i; const name = "roomName" + i;
@ -584,6 +588,10 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
<h3>{ _t("Me and my teammates") }</h3> <h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div> <div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton> </AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
</div>
<SpaceFeedbackPrompt /> <SpaceFeedbackPrompt />
</div>; </div>;
}; };

View file

@ -26,6 +26,7 @@ import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity"; import UserActivity from "../../UserActivity";
import Modal from "../../Modal"; import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
@ -36,7 +37,6 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../utils/EditorStateTransfer';
import { haveTileForEvent } from "../views/rooms/EventTile"; import { haveTileForEvent } from "../views/rooms/EventTile";
import { UIFeature } from "../../settings/UIFeature"; import { UIFeature } from "../../settings/UIFeature";
import { objectHasDiff } from "../../utils/objects";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays"; import { arrayFastClone } from "../../utils/arrays";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
@ -95,6 +95,9 @@ class TimelinePanel extends React.Component {
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: PropTypes.func, onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when the read-up-to mark is updated. // callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func, onReadMarkerUpdated: PropTypes.func,
@ -119,8 +122,13 @@ class TimelinePanel extends React.Component {
// which layout to use // which layout to use
layout: LayoutPropType, layout: LayoutPropType,
// whether to always show timestamps for an event
alwaysShowTimestamps: PropTypes.bool,
} }
static contextType = RoomContext;
// a map from room id to read marker event timestamp // a map from room id to read marker event timestamp
static roomReadMarkerTsMap = {}; static roomReadMarkerTsMap = {};
@ -259,37 +267,15 @@ class TimelinePanel extends React.Component {
console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
} }
if (newProps.eventId != this.props.eventId) { const differentEventId = newProps.eventId != this.props.eventId;
const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId;
if (differentEventId || differentHighlightedEventId) {
console.log("TimelinePanel switching to eventId " + newProps.eventId + console.log("TimelinePanel switching to eventId " + newProps.eventId +
" (was " + this.props.eventId + ")"); " (was " + this.props.eventId + ")");
return this._initTimeline(newProps); return this._initTimeline(newProps);
} }
} }
shouldComponentUpdate(nextProps, nextState) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
}
componentWillUnmount() { componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending // set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results. // promises can use to throw away their results.
@ -1327,7 +1313,7 @@ class TimelinePanel extends React.Component {
const shouldIgnore = !!ev.status || // local echo const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev); const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) { if (isWithoutTile || !node) {
// don't start counting if the event should be ignored, // don't start counting if the event should be ignored,
@ -1478,10 +1464,11 @@ class TimelinePanel extends React.Component {
ourUserId={MatrixClientPeg.get().credentials.userId} ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom} stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest} onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest} onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour} isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps} alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps}
className={this.props.className} className={this.props.className}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}

View file

@ -55,6 +55,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const totalCount = this.state.toasts.length; const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1; const isStacked = totalCount > 1;
let toast; let toast;
let containerClasses;
if (totalCount !== 0) { if (totalCount !== 0) {
const topToast = this.state.toasts[0]; const topToast = this.state.toasts[0];
const {title, icon, key, component, className, props} = topToast; const {title, icon, key, component, className, props} = topToast;
@ -79,16 +80,17 @@ export default class ToastContainer extends React.Component<{}, IState> {
</div> </div>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div> <div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>); </div>);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
} }
return toast
const containerClasses = classNames("mx_ToastContainer", { ? (
"mx_ToastContainer_stacked": isStacked, <div className={containerClasses} role="alert">
}); {toast}
</div>
return ( )
<div className={containerClasses} role="alert"> : null;
{toast}
</div>
);
} }
} }

View file

@ -69,7 +69,7 @@ interface IState {
contextMenuPosition: PartialDOMRect; contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean; isDarkTheme: boolean;
selectedSpace?: Room; selectedSpace?: Room;
pendingRoomJoin: string[] pendingRoomJoin: Set<string>;
} }
@replaceableComponent("structures.UserMenu") @replaceableComponent("structures.UserMenu")
@ -86,7 +86,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.state = { this.state = {
contextMenuPosition: null, contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(), isDarkTheme: this.isUserOnDarkTheme(),
pendingRoomJoin: [], pendingRoomJoin: new Set<string>(),
}; };
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -106,6 +106,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -117,6 +118,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
} }
private onTagStoreUpdate = () => { private onTagStoreUpdate = () => {
@ -168,30 +174,21 @@ export default class UserMenu extends React.Component<IProps, IState> {
} }
}; };
private addPendingJoinRoom(roomId) { private addPendingJoinRoom(roomId: string): void {
this.setState({ this.setState({
pendingRoomJoin: [ pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
...this.state.pendingRoomJoin, .add(roomId),
roomId,
],
}); });
} }
private removePendingJoinRoom(roomId) { private removePendingJoinRoom(roomId: string): void {
const newPendingRoomJoin = this.state.pendingRoomJoin.filter(pendingJoinRoomId => { if (this.state.pendingRoomJoin.delete(roomId)) {
return pendingJoinRoomId !== roomId;
});
if (newPendingRoomJoin.length !== this.state.pendingRoomJoin.length) {
this.setState({ this.setState({
pendingRoomJoin: newPendingRoomJoin, pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
}) })
} }
} }
get hasPendingActions(): boolean {
return this.state.pendingRoomJoin.length > 0;
}
private onOpenMenuClick = (ev: React.MouseEvent) => { private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -369,9 +366,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const mxDomain = MatrixClientPeg.get().getDomain(); const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.domains || validDomains.length > 0) { if (!hostSignupConfig.domains || validDomains.length > 0) {
topSection = <div onClick={this.onCloseMenu}> topSection = <HostSignupAction onClick={this.onCloseMenu} />;
<HostSignupAction />
</div>;
} }
} }
} }
@ -653,11 +648,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
/> />
</span> </span>
{name} {name}
{this.hasPendingActions && ( {this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner> <InlineSpinner>
<TooltipButton helpText={_t( <TooltipButton helpText={_t(
"Currently joining %(count)s rooms", "Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.length }, { count: this.state.pendingRoomJoin.size },
)} /> )} />
</InlineSpinner> </InlineSpinner>
)} )}

View file

@ -59,6 +59,7 @@ interface IProps {
fallbackHsUrl?: string; fallbackHsUrl?: string;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string; fragmentAfterLogin?: string;
defaultUsername?: string;
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - The object returned by the login API // - The object returned by the login API
@ -119,7 +120,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flows: null, flows: null,
username: "", username: props.defaultUsername? props.defaultUsername: '',
phoneCountry: null, phoneCountry: null,
phoneNumber: "", phoneNumber: "",

View file

@ -61,7 +61,7 @@ interface IProps {
is_url?: string; is_url?: string;
session_id: string; session_id: string;
/* eslint-enable camelcase */ /* eslint-enable camelcase */
}): void; }): string;
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick(): void; onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void; onServerConfigChange(config: ValidatedServerConfig): void;
@ -223,7 +223,8 @@ export default class Registration extends React.Component<IProps, IState> {
this.setState({ this.setState({
flows: e.data.flows, flows: e.data.flows,
}); });
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { } else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") {
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
// At this point registration is pretty much disabled, but before we do that let's // At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send // quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out. // the user off to the login page to figure their account out.
@ -467,7 +468,7 @@ export default class Registration extends React.Component<IProps, IState> {
let ssoSection; let ssoSection;
if (this.state.ssoFlow) { if (this.state.ssoFlow) {
let continueWithSection; let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || []; const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text // when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) { if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context // i18n: ssoButtons is a placeholder to help translators understand context

View file

@ -22,6 +22,7 @@ import classNames from 'classnames';
import * as AvatarLogic from '../../../Avatar'; import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units"; import {toPx} from "../../../utils/units";
@ -44,12 +45,12 @@ interface IProps {
className?: string; className?: string;
} }
const calculateUrls = (url, urls) => { const calculateUrls = (url, urls, lowBandwidth) => {
// work out the full set of urls to try to load. This is formed like so: // work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ] // imageUrls: [ props.url, ...props.urls ]
let _urls = []; let _urls = [];
if (!SettingsStore.getValue("lowBandwidth")) { if (!lowBandwidth) {
_urls = urls || []; _urls = urls || [];
if (url) { if (url) {
@ -63,7 +64,13 @@ const calculateUrls = (url, urls) => {
}; };
const useImageUrl = ({url, urls}): [string, () => void] => { const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls)); // Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext ?
roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0); const [urlsIndex, setIndex] = useState<number>(0);
const onError = useCallback(() => { const onError = useCallback(() => {
@ -71,7 +78,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setUrls(calculateUrls(url, urls)); setUrls(calculateUrls(url, urls, lowBandwidth));
setIndex(0); setIndex(0);
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps

View file

@ -31,6 +31,8 @@ import { isContentActionable } from '../../../utils/EventUtils';
import { MenuItem } from "../../structures/ContextMenu"; import { MenuItem } from "../../structures/ContextMenu";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
@ -84,7 +86,7 @@ export default class MessageContextMenu extends React.Component {
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl && this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption; && this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false; if (!SettingsStore.getValue("feature_pinning")) canPin = false;
@ -94,7 +96,7 @@ export default class MessageContextMenu extends React.Component {
_isPinned() { _isPinned() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
if (!pinnedEvent) return false; if (!pinnedEvent) return false;
const content = pinnedEvent.getContent(); const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
@ -158,34 +160,32 @@ export default class MessageContextMenu extends React.Component {
}; };
onForwardClick = () => { onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog(); Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
dis.dispatch({ matrixClient: MatrixClientPeg.get(),
action: 'forward_event',
event: this.props.mxEvent, event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
}); });
this.closeMenu(); this.closeMenu();
}; };
onPinClick = () => { onPinClick = () => {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') const cli = MatrixClientPeg.get();
.catch((e) => { const room = cli.getRoom(this.props.mxEvent.getRoomId());
// Intercept the Event Not Found error and fall through the promise chain with no event. const eventId = this.props.mxEvent.getId();
if (e.errcode === "M_NOT_FOUND") return null;
throw e;
})
.then((event) => {
const eventIds = (event ? event.pinned : []) || [];
if (!eventIds.includes(this.props.mxEvent.getId())) {
// Not pinned - add
eventIds.push(this.props.mxEvent.getId());
} else {
// Pinned - remove
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
}
const cli = MatrixClientPeg.get(); const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids,
eventId,
],
}); });
}
cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
this.closeMenu(); this.closeMenu();
}; };

View file

@ -40,6 +40,8 @@ interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
showUnpin?: boolean; showUnpin?: boolean;
// override delete handler // override delete handler
onDeleteClick?(): void; onDeleteClick?(): void;
// override edit handler
onEditClick?(): void;
} }
const WidgetContextMenu: React.FC<IProps> = ({ const WidgetContextMenu: React.FC<IProps> = ({
@ -47,6 +49,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
app, app,
userWidget, userWidget,
onDeleteClick, onDeleteClick,
onEditClick,
showUnpin, showUnpin,
...props ...props
}) => { }) => {
@ -89,12 +92,16 @@ const WidgetContextMenu: React.FC<IProps> = ({
let editButton; let editButton;
if (canModify && WidgetUtils.isManagedByManager(app)) { if (canModify && WidgetUtils.isManagedByManager(app)) {
const onEditClick = () => { const _onEditClick = () => {
WidgetUtils.editWidget(room, app); if (onEditClick) {
onEditClick();
} else {
WidgetUtils.editWidget(room, app);
}
onFinished(); onFinished();
}; };
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />; editButton = <IconizedContextMenuOption onClick={_onEditClick} label={_t("Edit")} />;
} }
let snapshotButton; let snapshotButton;
@ -116,24 +123,29 @@ const WidgetContextMenu: React.FC<IProps> = ({
let deleteButton; let deleteButton;
if (onDeleteClick || canModify) { if (onDeleteClick || canModify) {
const onDeleteClickDefault = () => { const _onDeleteClick = () => {
// Show delete confirmation dialog if (onDeleteClick) {
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { onDeleteClick();
title: _t("Delete Widget"), } else {
description: _t( // Show delete confirmation dialog
"Deleting a widget removes it for all users in this room." + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
" Are you sure you want to delete this widget?"), title: _t("Delete Widget"),
button: _t("Delete widget"), description: _t(
onFinished: (confirmed) => { "Deleting a widget removes it for all users in this room." +
if (!confirmed) return; " Are you sure you want to delete this widget?"),
WidgetUtils.setRoomWidget(roomId, app.id); button: _t("Delete widget"),
}, onFinished: (confirmed) => {
}); if (!confirmed) return;
WidgetUtils.setRoomWidget(roomId, app.id);
},
});
}
onFinished(); onFinished();
}; };
deleteButton = <IconizedContextMenuOption deleteButton = <IconizedContextMenuOption
onClick={onDeleteClick || onDeleteClickDefault} onClick={_onDeleteClick}
label={userWidget ? _t("Remove") : _t("Remove for everyone")} label={userWidget ? _t("Remove") : _t("Remove for everyone")}
/>; />;
} }

View file

@ -212,7 +212,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
autoComplete={true} autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace"> <AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3> <h3>{ _t("Rooms") }</h3>

View file

@ -15,22 +15,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react"; import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import {Room} from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import withValidation, {IFieldState} from '../elements/Validation'; import withValidation, { IFieldState } from '../elements/Validation';
import {_t} from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom"; import { IOpts, privateShouldBeEncrypted } from "../../../createRoom";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field from "../elements/Field"; import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField"; import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog"; import BaseDialog from "../dialogs/BaseDialog";
import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
interface IProps { interface IProps {
defaultPublic?: boolean; defaultPublic?: boolean;
@ -72,7 +73,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
canChangeEncryption: true, canChangeEncryption: true,
}; };
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat)
.then(isForced => this.setState({ canChangeEncryption: !isForced })); .then(isForced => this.setState({ canChangeEncryption: !isForced }));
} }

View file

@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent<IExplorerProps> {
render() { render() {
const cli = this.context; const cli = this.context;
const room = this.props.room; const room = this.props.room;
const inRoomChannel = cli._crypto._inRoomVerificationRequests; const inRoomChannel = cli.crypto._inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div> return (<div>

View file

@ -0,0 +1,248 @@
/*
Copyright 2021 Robin Townsend <robin@robin.town>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useState, useEffect} from "react";
import classnames from "classnames";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import {useSettingValue, useFeatureEnabled} from "../../../hooks/useSettings";
import {UIFeature} from "../../../settings/UIFeature";
import {Layout} from "../../../settings/Layout";
import {IDialogProps} from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import {avatarUrlForUser} from "../../../Avatar";
import EventTile from "../rooms/EventTile";
import SearchBox from "../../structures/SearchBox";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {Alignment} from '../elements/Tooltip';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
const AVATAR_SIZE = 30;
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
// The event to forward
event: MatrixEvent;
// We need a permalink creator for the source room to pass through to EventTile
// in case the event is a reply (even though the user can't get at the link)
permalinkCreator: RoomPermalinkCreator;
}
interface IEntryProps {
room: Room;
event: MatrixEvent;
matrixClient: MatrixClient;
onFinished(success: boolean): void;
}
enum SendState {
CanSend,
Sending,
Sent,
Failed,
}
const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinished }) => {
const [sendState, setSendState] = useState<SendState>(SendState.CanSend);
const jumpToRoom = () => {
dis.dispatch({
action: "view_room",
room_id: room.roomId,
});
onFinished(true);
};
const send = async () => {
setSendState(SendState.Sending);
try {
await cli.sendEvent(room.roomId, event.getType(), event.getContent());
setSendState(SendState.Sent);
} catch (e) {
setSendState(SendState.Failed);
}
};
let className;
let disabled = false;
let title;
let icon;
if (sendState === SendState.CanSend) {
className = "mx_ForwardList_canSend";
if (room.maySendMessage()) {
title = _t("Send");
} else {
disabled = true;
title = _t("You don't have permission to do this");
}
} else if (sendState === SendState.Sending) {
className = "mx_ForwardList_sending";
disabled = true;
title = _t("Sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
} else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent";
disabled = true;
title = _t("Sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
} else {
className = "mx_ForwardList_sendFailed";
disabled = true;
title = _t("Failed to send");
icon = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
return <div className="mx_ForwardList_entry">
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
onClick={jumpToRoom}
title={_t("Open link")}
yOffset={-20}
alignment={Alignment.Top}
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
<span className="mx_ForwardList_entry_name">{ room.name }</span>
</AccessibleTooltipButton>
<AccessibleTooltipButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}
className={`mx_ForwardList_sendButton ${className}`}
onClick={send}
disabled={disabled}
title={title}
yOffset={-20}
alignment={Alignment.Top}
>
<div className="mx_ForwardList_sendLabel">{ _t("Send") }</div>
{ icon }
</AccessibleTooltipButton>
</div>;
};
const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => {
const userId = cli.getUserId();
const [profileInfo, setProfileInfo] = useState<any>({});
useEffect(() => {
cli.getProfileInfo(userId).then(info => setProfileInfo(info));
}, [cli, userId]);
// For the message preview we fake the sender as ourselves
const mockEvent = new MatrixEvent({
type: "m.room.message",
sender: userId,
content: event.getContent(),
unsigned: {
age: 97,
},
event_id: "$9999999999999999999999999999999999999999999",
room_id: event.getRoomId(),
});
mockEvent.sender = {
name: profileInfo.displayname || userId,
rawDisplayName: profileInfo.displayname,
userId,
getAvatarUrl: (..._) => {
return avatarUrlForUser(
{ avatarUrl: profileInfo.avatar_url },
AVATAR_SIZE, AVATAR_SIZE, "crop",
);
},
getMxcAvatarUrl: () => profileInfo.avatar_url,
};
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const spacesEnabled = useFeatureEnabled("feature_spaces");
const flairEnabled = useFeatureEnabled(UIFeature.Flair);
const previewLayout = useSettingValue<Layout>("layout");
let rooms = useMemo(() => sortRooms(
cli.getVisibleRooms().filter(
room => room.getMyMembership() === "join" &&
!(spacesEnabled && room.isSpaceRoom()),
),
), [cli, spacesEnabled]);
if (lcQuery) {
rooms = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
}).match(lcQuery);
}
return <BaseDialog
title={_t("Forward message")}
className="mx_ForwardDialog"
contentId="mx_ForwardList"
onFinished={onFinished}
fixedWidth={false}
>
<h3>{ _t("Message preview") }</h3>
<div className={classnames("mx_ForwardDialog_preview", {
"mx_IRCLayout": previewLayout == Layout.IRC,
"mx_GroupLayout": previewLayout == Layout.Group,
})}>
<EventTile
mxEvent={mockEvent}
layout={previewLayout}
enableFlair={flairEnabled}
permalinkCreator={permalinkCreator}
as="div"
/>
</div>
<hr />
<div className="mx_ForwardList" id="mx_ForwardList">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search for rooms or people")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? (
<div className="mx_ForwardList_results">
{ rooms.map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
) }
</div>
) : <span className="mx_ForwardList_noResults">
{ _t("No results") }
</span> }
</AutoHideScrollbar>
</div>
</BaseDialog>;
};
export default ForwardDialog;

View file

@ -15,5 +15,5 @@ limitations under the License.
*/ */
export interface IDialogProps { export interface IDialogProps {
onFinished: (bool) => void; onFinished(...args: any): void;
} }

View file

@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {createRef} from 'react'; import React, { createRef } from 'react';
import classNames from 'classnames';
import {_t, _td} from "../../../languageHandler"; import {_t, _td} from "../../../languageHandler";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
@ -31,7 +33,6 @@ import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize"; import {humanizeTime} from "../../../utils/humanize";
import createRoom, { import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
IInvite3PID,
} from "../../../createRoom"; } from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
@ -47,10 +48,25 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media"; import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress"; import {getAddressType} from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
import { IInvite3PID } from "matrix-js-sdk/src/@types/requests";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { copyPlaintext, selectText } from "../../../utils/strings";
import * as ContextMenu from "../../structures/ContextMenu";
import { toRightOf } from "../../structures/ContextMenu";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface IRecentUser {
userId: string,
user: RoomMember,
lastActive: number,
}
export const KIND_DM = "dm"; export const KIND_DM = "dm";
export const KIND_INVITE = "invite"; export const KIND_INVITE = "invite";
export const KIND_CALL_TRANSFER = "call_transfer"; export const KIND_CALL_TRANSFER = "call_transfer";
@ -61,43 +77,41 @@ const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is c
// This is the interface that is expected by various components in this file. It is a bit // This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support // awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses. // for 3PIDs/email addresses.
// abstract class Member {
// XXX: We should use TypeScript interfaces instead of this weird "abstract" class.
class Member {
/** /**
* The display name of this Member. For users this should be their profile's display * The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email). * name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/ */
get name(): string { throw new Error("Member class not implemented"); } public abstract get name(): string;
/** /**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should * The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email). * be the 3PID address (email).
*/ */
get userId(): string { throw new Error("Member class not implemented"); } public abstract get userId(): string;
/** /**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's * Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be null. * avatar MXC URL or null if none set. For 3PIDs this should always be null.
*/ */
getMxcAvatarUrl(): string { throw new Error("Member class not implemented"); } public abstract getMxcAvatarUrl(): string;
} }
class DirectoryMember extends Member { class DirectoryMember extends Member {
_userId: string; private readonly _userId: string;
_displayName: string; private readonly displayName: string;
_avatarUrl: string; private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
super(); super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name; this.displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url; this.avatarUrl = userDirResult.avatar_url;
} }
// These next class members are for the Member interface // These next class members are for the Member interface
get name(): string { get name(): string {
return this._displayName || this._userId; return this.displayName || this._userId;
} }
get userId(): string { get userId(): string {
@ -105,32 +119,32 @@ class DirectoryMember extends Member {
} }
getMxcAvatarUrl(): string { getMxcAvatarUrl(): string {
return this._avatarUrl; return this.avatarUrl;
} }
} }
class ThreepidMember extends Member { class ThreepidMember extends Member {
_id: string; private readonly id: string;
constructor(id: string) { constructor(id: string) {
super(); super();
this._id = id; this.id = id;
} }
// This is a getter that would be falsey on all other implementations. Until we have // This is a getter that would be falsey on all other implementations. Until we have
// better type support in the react-sdk we can use this trick to determine the kind // better type support in the react-sdk we can use this trick to determine the kind
// of 3PID we're dealing with, if any. // of 3PID we're dealing with, if any.
get isEmail(): boolean { get isEmail(): boolean {
return this._id.includes('@'); return this.id.includes('@');
} }
// These next class members are for the Member interface // These next class members are for the Member interface
get name(): string { get name(): string {
return this._id; return this.id;
} }
get userId(): string { get userId(): string {
return this._id; return this.id;
} }
getMxcAvatarUrl(): string { getMxcAvatarUrl(): string {
@ -140,11 +154,11 @@ class ThreepidMember extends Member {
interface IDMUserTileProps { interface IDMUserTileProps {
member: RoomMember; member: RoomMember;
onRemove: (RoomMember) => any; onRemove(member: RoomMember): void;
} }
class DMUserTile extends React.PureComponent<IDMUserTileProps> { class DMUserTile extends React.PureComponent<IDMUserTileProps> {
_onRemove = (e) => { private onRemove = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -153,9 +167,6 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
}; };
render() { render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const avatarSize = 20; const avatarSize = 20;
const avatar = this.props.member.isEmail const avatar = this.props.member.isEmail
? <img ? <img
@ -177,7 +188,7 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
closeButton = ( closeButton = (
<AccessibleButton <AccessibleButton
className='mx_InviteDialog_userTile_remove' className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove} onClick={this.onRemove}
> >
<img src={require("../../../../res/img/icon-pill-remove.svg")} <img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8} alt={_t('Remove')} width={8} height={8}
@ -201,13 +212,13 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
interface IDMRoomTileProps { interface IDMRoomTileProps {
member: RoomMember; member: RoomMember;
lastActiveTs: number; lastActiveTs: number;
onToggle: (RoomMember) => any; onToggle(member: RoomMember): void;
highlightWord: string; highlightWord: string;
isSelected: boolean; isSelected: boolean;
} }
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> { class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
_onClick = (e) => { private onClick = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -215,7 +226,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
this.props.onToggle(this.props.member); this.props.onToggle(this.props.member);
}; };
_highlightName(str: string) { private highlightName(str: string) {
if (!this.props.highlightWord) return str; if (!this.props.highlightWord) return str;
// We convert things to lowercase for index searching, but pull substrings from // We convert things to lowercase for index searching, but pull substrings from
@ -252,8 +263,6 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
} }
render() { render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
let timestamp = null; let timestamp = null;
if (this.props.lastActiveTs) { if (this.props.lastActiveTs) {
const humanTs = humanizeTime(this.props.lastActiveTs); const humanTs = humanizeTime(this.props.lastActiveTs);
@ -291,13 +300,13 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const caption = this.props.member.isEmail const caption = this.props.member.isEmail
? _t("Invite by email") ? _t("Invite by email")
: this._highlightName(this.props.member.userId); : this.highlightName(this.props.member.userId);
return ( return (
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}> <div className='mx_InviteDialog_roomTile' onClick={this.onClick}>
{stackedAvatar} {stackedAvatar}
<span className="mx_InviteDialog_roomTile_nameStack"> <span className="mx_InviteDialog_roomTile_nameStack">
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div> <div className='mx_InviteDialog_roomTile_name'>{this.highlightName(this.props.member.name)}</div>
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div> <div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
</span> </span>
{timestamp} {timestamp}
@ -308,7 +317,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
interface IInviteDialogProps { interface IInviteDialogProps {
// Takes an array of user IDs/emails to invite. // Takes an array of user IDs/emails to invite.
onFinished: (toInvite?: string[]) => any; onFinished: (toInvite?: string[]) => void;
// The kind of invite being performed. Assumed to be KIND_DM if // The kind of invite being performed. Assumed to be KIND_DM if
// not provided. // not provided.
@ -349,8 +358,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
initialText: "", initialText: "",
}; };
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser private closeCopiedTooltip: () => void;
_editorRef: any = null; private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>();
private unmounted = false;
constructor(props) { constructor(props) {
super(props); super(props);
@ -378,7 +389,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
filterText: this.props.initialText, filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited), recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN, numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited), suggestions: this.buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN, numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], serverResultsMixin: [],
threepidResultsMixin: [], threepidResultsMixin: [],
@ -390,21 +401,26 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
busy: false, busy: false,
errorText: null, errorText: null,
}; };
this._editorRef = createRef();
} }
componentDidMount() { componentDidMount() {
if (this.props.initialText) { if (this.props.initialText) {
this._updateSuggestions(this.props.initialText); this.updateSuggestions(this.props.initialText);
} }
} }
componentWillUnmount() {
this.unmounted = true;
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape or clicking X really quickly
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private onConsultFirstChange = (ev) => { private onConsultFirstChange = (ev) => {
this.setState({consultFirst: ev.target.checked}); this.setState({consultFirst: ev.target.checked});
} }
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] { public static buildRecents(excludedTargetIds: Set<string>): IRecentUser[] {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
@ -467,7 +483,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return recents; return recents;
} }
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] { private buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
const maxConsideredMembers = 200; const maxConsideredMembers = 200;
const joinedRooms = MatrixClientPeg.get().getRooms() const joinedRooms = MatrixClientPeg.get().getRooms()
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers); .filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
@ -574,7 +590,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
members.sort((a, b) => { members.sort((a, b) => {
if (a.score === b.score) { if (a.score === b.score) {
if (a.numRooms === b.numRooms) { if (a.numRooms === b.numRooms) {
return a.member.userId.localeCompare(b.member.userId); return compare(a.member.userId, b.member.userId);
} }
return b.numRooms - a.numRooms; return b.numRooms - a.numRooms;
@ -585,7 +601,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member})); return members.map(m => ({userId: m.member.userId, user: m.member}));
} }
_shouldAbortAfterInviteError(result): boolean { private shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) { if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result); console.log("Failed to invite users: ", result);
@ -600,7 +616,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return false; return false;
} }
_convertFilter(): Member[] { private convertFilter(): Member[] {
// Check to see if there's anything to convert first // Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || []; if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || [];
@ -617,10 +633,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return newTargets; return newTargets;
} }
_startDm = async () => { private startDm = async () => {
this.setState({busy: true}); this.setState({busy: true});
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible. // Check if there is already a DM with these people and reuse it if possible.
@ -694,11 +710,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_inviteUsers = async () => { private inviteUsers = async () => {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true}); this.setState({busy: true});
this._convertFilter(); this.convertFilter();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -715,7 +731,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try { try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds) const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished(); this.props.onFinished();
} }
@ -749,9 +765,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_transferCall = async () => { private transferCall = async () => {
this._convertFilter(); this.convertFilter();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
if (targetIds.length > 1) { if (targetIds.length > 1) {
this.setState({ this.setState({
@ -790,26 +806,26 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_onKeyDown = (e) => { private onKeyDown = (e) => {
if (this.state.busy) return; if (this.state.busy) return;
const value = e.target.value.trim(); const value = e.target.value.trim();
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey; const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
// when the field is empty and the user hits backspace remove the right-most target // when the field is empty and the user hits backspace remove the right-most target
e.preventDefault(); e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]); this.removeMember(this.state.targets[this.state.targets.length - 1]);
} else if (value && e.key === Key.ENTER && !hasModifiers) { } else if (value && e.key === Key.ENTER && !hasModifiers) {
// when the user hits enter with something in their field try to convert it // when the user hits enter with something in their field try to convert it
e.preventDefault(); e.preventDefault();
this._convertFilter(); this.convertFilter();
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) { } else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
// when the user hits space and their input looks like an e-mail/MXID then try to convert it // when the user hits space and their input looks like an e-mail/MXID then try to convert it
e.preventDefault(); e.preventDefault();
this._convertFilter(); this.convertFilter();
} }
}; };
_updateSuggestions = async (term) => { private updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) { if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make // Discard the results - we were probably too slow on the server-side to make
@ -918,30 +934,30 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_updateFilter = (e) => { private updateFilter = (e) => {
const term = e.target.value; const term = e.target.value;
this.setState({filterText: term}); this.setState({filterText: term});
// Debounce server lookups to reduce spam. We don't clear the existing server // Debounce server lookups to reduce spam. We don't clear the existing server
// results because they might still be vaguely accurate, likewise for races which // results because they might still be vaguely accurate, likewise for races which
// could happen here. // could happen here.
if (this._debounceTimer) { if (this.debounceTimer) {
clearTimeout(this._debounceTimer); clearTimeout(this.debounceTimer);
} }
this._debounceTimer = setTimeout(() => { this.debounceTimer = setTimeout(() => {
this._updateSuggestions(term); this.updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some) }, 150); // 150ms debounce (human reaction time + some)
}; };
_showMoreRecents = () => { private showMoreRecents = () => {
this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN}); this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN});
}; };
_showMoreSuggestions = () => { private showMoreSuggestions = () => {
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN});
}; };
_toggleMember = (member: Member) => { private toggleMember = (member: Member) => {
if (!this.state.busy) { if (!this.state.busy) {
let filterText = this.state.filterText; let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation const targets = this.state.targets.map(t => t); // cheap clone for mutation
@ -954,13 +970,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
this.setState({targets, filterText}); this.setState({targets, filterText});
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
} }
}; };
_removeMember = (member: Member) => { private removeMember = (member: Member) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member); const idx = targets.indexOf(member);
if (idx >= 0) { if (idx >= 0) {
@ -968,12 +984,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets}); this.setState({targets});
} }
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
}; };
_onPaste = async (e) => { private onPaste = async (e) => {
if (this.state.filterText) { if (this.state.filterText) {
// if the user has already typed something, just let them // if the user has already typed something, just let them
// paste normally. // paste normally.
@ -1027,6 +1043,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
failed.push(address); failed.push(address);
} }
} }
if (this.unmounted) return;
if (failed.length > 0) { if (failed.length > 0) {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
@ -1043,17 +1060,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets: [...this.state.targets, ...toAdd]}); this.setState({targets: [...this.state.targets, ...toAdd]});
}; };
_onClickInputArea = (e) => { private onClickInputArea = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
}; };
_onUseDefaultIdentityServerClick = (e) => { private onUseDefaultIdentityServerClick = (e) => {
e.preventDefault(); e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms. // Update the IS in account data. Actually using it may trigger terms.
@ -1062,21 +1079,21 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({canUseIdentityServer: true, tryingIdentityServer: false}); this.setState({canUseIdentityServer: true, tryingIdentityServer: false});
}; };
_onManageSettingsClick = (e) => { private onManageSettingsClick = (e) => {
e.preventDefault(); e.preventDefault();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
this.props.onFinished(); this.props.onFinished();
}; };
_onCommunityInviteClick = (e) => { private onCommunityInviteClick = (e) => {
this.props.onFinished(); this.props.onFinished();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
}; };
_renderSection(kind: "recents"|"suggestions") { private renderSection(kind: "recents"|"suggestions") {
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions; let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const showMoreFn = kind === 'recents' ? this.showMoreRecents.bind(this) : this.showMoreSuggestions.bind(this);
const lastActive = (m) => kind === 'recents' ? m.lastActive : null; const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
let sectionSubname = null; let sectionSubname = null;
@ -1156,7 +1173,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
member={r.user} member={r.user}
lastActiveTs={lastActive(r)} lastActiveTs={lastActive(r)}
key={r.userId} key={r.userId}
onToggle={this._toggleMember} onToggle={this.toggleMember}
highlightWord={this.state.filterText} highlightWord={this.state.filterText}
isSelected={this.state.targets.some(t => t.userId === r.userId)} isSelected={this.state.targets.some(t => t.userId === r.userId)}
/> />
@ -1171,32 +1188,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
); );
} }
_renderEditor() { private renderEditor() {
const targets = this.state.targets.map(t => ( const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} /> <DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} />
)); ));
const input = ( const input = (
<input <input
type="text" type="text"
onKeyDown={this._onKeyDown} onKeyDown={this.onKeyDown}
onChange={this._updateFilter} onChange={this.updateFilter}
value={this.state.filterText} value={this.state.filterText}
ref={this._editorRef} ref={this.editorRef}
onPaste={this._onPaste} onPaste={this.onPaste}
autoFocus={true} autoFocus={true}
disabled={this.state.busy} disabled={this.state.busy}
autoComplete="off" autoComplete="off"
/> />
); );
return ( return (
<div className='mx_InviteDialog_editor' onClick={this._onClickInputArea}> <div className='mx_InviteDialog_editor' onClick={this.onClickInputArea}>
{targets} {targets}
{input} {input}
</div> </div>
); );
} }
_renderIdentityServerWarning() { private renderIdentityServerWarning() {
if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer || if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer ||
!SettingsStore.getValue(UIFeature.IdentityServer) !SettingsStore.getValue(UIFeature.IdentityServer)
) { ) {
@ -1214,8 +1231,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
}, },
{ {
default: sub => <a href="#" onClick={this._onUseDefaultIdentityServerClick}>{sub}</a>, default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>, settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
}, },
)}</div> )}</div>
); );
@ -1225,13 +1242,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
"Use an identity server to invite by email. " + "Use an identity server to invite by email. " +
"Manage in <settings>Settings</settings>.", "Manage in <settings>Settings</settings>.",
{}, { {}, {
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>, settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
}, },
)}</div> )}</div>
); );
} }
} }
private async onLinkClick(e) {
e.preventDefault();
selectText(e.target);
}
private onCopyClick = async e => {
e.preventDefault();
const target = e.target; // copy target before we go async and React throws it away
const successful = await copyPlaintext(makeUserPermalink(MatrixClientPeg.get().getUserId()));
const buttonRect = target.getBoundingClientRect();
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t("Copied!") : _t("Failed to copy"),
});
// Drop a reference to this close handler for componentWillUnmount
this.closeCopiedTooltip = target.onmouseleave = close;
};
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@ -1242,12 +1278,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
spinner = <Spinner w={20} h={20} />; spinner = <Spinner w={20} h={20} />;
} }
let title; let title;
let helpText; let helpText;
let buttonText; let buttonText;
let goButtonFn; let goButtonFn;
let consultSection; let extraSection;
let footer;
let keySharingWarning = <span />; let keySharingWarning = <span />;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
@ -1298,7 +1334,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return ( return (
<AccessibleButton <AccessibleButton
kind="link" kind="link"
onClick={this._onCommunityInviteClick} onClick={this.onCommunityInviteClick}
>{sub}</AccessibleButton> >{sub}</AccessibleButton>
); );
}, },
@ -1309,7 +1345,27 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</React.Fragment>; </React.Fragment>;
} }
buttonText = _t("Go"); buttonText = _t("Go");
goButtonFn = this._startDm; goButtonFn = this.startDm;
extraSection = <div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{ _t("Some suggestions may be hidden for privacy.") }</span>
<p>{ _t("If you can't see who youre looking for, send them your invite link below.") }</p>
</div>;
const link = makeUserPermalink(MatrixClientPeg.get().getUserId());
footer = <div className="mx_InviteDialog_footer">
<h3>{ _t("Or send invite link") }</h3>
<div className="mx_InviteDialog_footer_link">
<a href={link} onClick={this.onLinkClick}>
{ link }
</a>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onCopyClick}
className="mx_InviteDialog_footer_link_copy"
>
<div />
</AccessibleTooltipButton>
</div>
</div>
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
@ -1348,7 +1404,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}); });
buttonText = _t("Invite"); buttonText = _t("Invite");
goButtonFn = this._inviteUsers; goButtonFn = this.inviteUsers;
if (cli.isRoomEncrypted(this.props.roomId)) { if (cli.isRoomEncrypted(this.props.roomId)) {
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
@ -1370,8 +1426,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} else if (this.props.kind === KIND_CALL_TRANSFER) { } else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer"); title = _t("Transfer");
buttonText = _t("Transfer"); buttonText = _t("Transfer");
goButtonFn = this._transferCall; goButtonFn = this.transferCall;
consultSection = <div> footer = <div>
<label> <label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} /> <input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("Consult first")} {_t("Consult first")}
@ -1385,7 +1441,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|| (this.state.filterText && this.state.filterText.includes('@')); || (this.state.filterText && this.state.filterText.includes('@'));
return ( return (
<BaseDialog <BaseDialog
className='mx_InviteDialog' className={classNames("mx_InviteDialog", {
mx_InviteDialog_hasFooter: !!footer,
})}
hasCancel={true} hasCancel={true}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={title} title={title}
@ -1393,7 +1451,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<div className='mx_InviteDialog_content'> <div className='mx_InviteDialog_content'>
<p className='mx_InviteDialog_helpText'>{helpText}</p> <p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'> <div className='mx_InviteDialog_addressBar'>
{this._renderEditor()} {this.renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'> <div className='mx_InviteDialog_buttonAndSpinner'>
<AccessibleButton <AccessibleButton
kind="primary" kind="primary"
@ -1407,13 +1465,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</div> </div>
</div> </div>
{keySharingWarning} {keySharingWarning}
{this._renderIdentityServerWarning()} {this.renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div> <div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'> <div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')} {this.renderSection('recents')}
{this._renderSection('suggestions')} {this.renderSection('suggestions')}
{extraSection}
</div> </div>
{consultSection} {footer}
</div> </div>
</BaseDialog> </BaseDialog>
); );

View file

@ -159,7 +159,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent {
stickyBottom={false} stickyBottom={false}
startAtBottom={false} startAtBottom={false}
> >
<ul className="mx_MessageEditHistoryDialog_edits mx_MessagePanel_alwaysShowTimestamps">{this._renderEdits()}</ul> <ul className="mx_MessageEditHistoryDialog_edits">{this._renderEdits()}</ul>
</ScrollPanel>); </ScrollPanel>);
} }
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import TabbedView, {Tab} from "../../structures/TabbedView"; import TabbedView, {Tab} from "../../structures/TabbedView";
import {_t, _td} from "../../../languageHandler"; import {_t, _td} from "../../../languageHandler";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab"; import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
@ -39,31 +38,36 @@ export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB"; export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB"; export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
interface IProps {
roomId: string;
onFinished: (success: boolean) => void;
initialTabId?: string;
}
@replaceableComponent("views.dialogs.RoomSettingsDialog") @replaceableComponent("views.dialogs.RoomSettingsDialog")
export default class RoomSettingsDialog extends React.Component { export default class RoomSettingsDialog extends React.Component<IProps> {
static propTypes = { private dispatcherRef: string;
roomId: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};
componentDidMount() { public componentDidMount() {
this._dispatcherRef = dis.register(this._onAction); this.dispatcherRef = dis.register(this.onAction);
} }
componentWillUnmount() { public componentWillUnmount() {
if (this._dispatcherRef) dis.unregister(this._dispatcherRef); if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
} }
_onAction = (payload) => { private onAction = (payload): void => {
// When view changes below us, close the room settings // When view changes below us, close the room settings
// whilst the modal is open this can only be triggered when someone hits Leave Room // whilst the modal is open this can only be triggered when someone hits Leave Room
if (payload.action === 'view_home_page') { if (payload.action === 'view_home_page') {
this.props.onFinished(); this.props.onFinished(true);
} }
}; };
_getTabs() { private getTabs(): Tab[] {
const tabs = []; const tabs: Tab[] = [];
tabs.push(new Tab( tabs.push(new Tab(
ROOM_GENERAL_TAB, ROOM_GENERAL_TAB,
@ -123,7 +127,10 @@ export default class RoomSettingsDialog extends React.Component {
title={_t("Room Settings - %(roomName)s", {roomName})} title={_t("Room Settings - %(roomName)s", {roomName})}
> >
<div className='mx_SettingsDialog_content'> <div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} /> <TabbedView
tabs={this.getTabs()}
initialTabId={this.props.initialTabId}
/>
</div> </div>
</BaseDialog> </BaseDialog>
); );

View file

@ -73,9 +73,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
const promises = []; const promises = [];
if (avatarChanged) { if (avatarChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, { if (newAvatar) {
url: await cli.uploadContent(newAvatar), promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
}, "")); url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
} }
if (nameChanged) { if (nameChanged) {

View file

@ -39,6 +39,7 @@ import { SettingLevel } from "../../../settings/SettingLevel";
import TextInputDialog from "../dialogs/TextInputDialog"; import TextInputDialog from "../dialogs/TextInputDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { compare } from "../../../utils/strings";
export const ALL_ROOMS = Symbol("ALL_ROOMS"); export const ALL_ROOMS = Symbol("ALL_ROOMS");
@ -187,7 +188,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
protocolsList.forEach(({instances=[]}) => { protocolsList.forEach(({instances=[]}) => {
[...instances].sort((b, a) => { [...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc); return compare(a.desc, b.desc);
}).forEach(({desc, instance_id: instanceId}) => { }).forEach(({desc, instance_id: instanceId}) => {
entries.push( entries.push(
<MenuItemRadio <MenuItemRadio

View file

@ -19,7 +19,7 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import Tooltip from './Tooltip'; import Tooltip, {Alignment} from './Tooltip';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> { interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
@ -28,6 +28,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
tooltipClassName?: string; tooltipClassName?: string;
forceHide?: boolean; forceHide?: boolean;
yOffset?: number; yOffset?: number;
alignment?: Alignment;
} }
interface IState { interface IState {
@ -66,14 +67,15 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
render() { render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props; const {title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, ...props} = this.props;
const tip = this.state.hover ? <Tooltip const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container" className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)} tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title} label={tooltip || title}
yOffset={yOffset} yOffset={yOffset}
/> : <div />; alignment={alignment}
/> : null;
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}

View file

@ -47,9 +47,14 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement // The key used for PersistedElement
this._persistKey = getPersistKey(this.props.app.id); this._persistKey = getPersistKey(this.props.app.id);
this._sgWidget = new StopGapWidget(this.props); try {
this._sgWidget.on("preparing", this._onWidgetPrepared); this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("ready", this._onWidgetReady); this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
}
this.iframe = null; // ref to the iframe (callback style) this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props); this.state = this._getNewState(props);
@ -97,7 +102,7 @@ export default class AppTile extends React.Component {
// Force the widget to be non-persistent (able to be deleted/forgotten) // Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop(); if (this._sgWidget) this._sgWidget.stop();
} }
this.setState({ hasPermissionToLoad }); this.setState({ hasPermissionToLoad });
@ -117,7 +122,7 @@ export default class AppTile extends React.Component {
componentDidMount() { componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load // Only fetch IM token on mount if we're showing and have permission to load
if (this.state.hasPermissionToLoad) { if (this._sgWidget && this.state.hasPermissionToLoad) {
this._startWidget(); this._startWidget();
} }
@ -146,10 +151,15 @@ export default class AppTile extends React.Component {
if (this._sgWidget) { if (this._sgWidget) {
this._sgWidget.stop(); this._sgWidget.stop();
} }
this._sgWidget = new StopGapWidget(newProps); try {
this._sgWidget.on("preparing", this._onWidgetPrepared); this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("ready", this._onWidgetReady); this._sgWidget.on("preparing", this._onWidgetPrepared);
this._startWidget(); this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
}
} }
_startWidget() { _startWidget() {
@ -161,7 +171,7 @@ export default class AppTile extends React.Component {
_iframeRefChange = (ref) => { _iframeRefChange = (ref) => {
this.iframe = ref; this.iframe = ref;
if (ref) { if (ref) {
this._sgWidget.start(ref); if (this._sgWidget) this._sgWidget.start(ref);
} else { } else {
this._resetWidget(this.props); this._resetWidget(this.props);
} }
@ -209,7 +219,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop({forceDestroy: true}); if (this._sgWidget) this._sgWidget.stop({forceDestroy: true});
} }
_onWidgetPrepared = () => { _onWidgetPrepared = () => {
@ -340,7 +350,13 @@ export default class AppTile extends React.Component {
<Spinner message={_t("Loading...")} /> <Spinner message={_t("Loading...")} />
</div> </div>
); );
if (!this.state.hasPermissionToLoad) { if (this._sgWidget === null) {
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("Error loading Widget")} />
</div>
);
} else if (!this.state.hasPermissionToLoad) {
// only possible for room widgets, can assert this.props.room here // only possible for room widgets, can assert this.props.room here
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = ( appTileBody = (
@ -364,7 +380,7 @@ export default class AppTile extends React.Component {
if (this.isMixedContent()) { if (this.isMixedContent()) {
appTileBody = ( appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}> <div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg="Error - Mixed content" /> <AppWarning errorMsg={_t("Error - Mixed content")} />
</div> </div>
); );
} else { } else {
@ -417,6 +433,8 @@ export default class AppTile extends React.Component {
onFinished={this._closeContextMenu} onFinished={this._closeContextMenu}
showUnpin={!this.props.userWidget} showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget} userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
onDeleteClick={this.props.onDeleteClick}
/> />
); );
} }

View file

@ -63,9 +63,9 @@ const EventListSummary: React.FC<IProps> = ({
// If we are only given few events then just pass them through // If we are only given few events then just pass them through
if (events.length < threshold) { if (events.length < threshold) {
return ( return (
<div className="mx_EventListSummary" data-scroll-tokens={eventIds}> <li className="mx_EventListSummary" data-scroll-tokens={eventIds}>
{ children } { children }
</div> </li>
); );
} }

View file

@ -128,6 +128,7 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
mxEvent={event} mxEvent={event}
layout={this.props.layout} layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)} enableFlair={SettingsStore.getValue(UIFeature.Flair)}
as="div"
/> />
</div>; </div>;
} }

View file

@ -116,7 +116,7 @@ export default class Flair extends React.Component {
render() { render() {
if (this.state.profiles.length === 0) { if (this.state.profiles.length === 0) {
return <span className="mx_Flair" />; return null;
} }
const avatars = this.state.profiles.map((profile, index) => { const avatars = this.state.profiles.map((profile, index) => {
return <FlairAvatar key={index} groupProfile={profile} />; return <FlairAvatar key={index} groupProfile={profile} />;

View file

@ -19,20 +19,20 @@ limitations under the License.
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "./AccessibleTooltipButton"; import AccessibleTooltipButton from "./AccessibleTooltipButton";
import {Key} from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import FocusLock from "react-focus-lock"; import FocusLock from "react-focus-lock";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import MessageContextMenu from "../context_menus/MessageContextMenu"; import MessageContextMenu from "../context_menus/MessageContextMenu";
import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu'; import { aboveLeftOf, ContextMenu } from '../../structures/ContextMenu';
import MessageTimestamp from "../messages/MessageTimestamp"; import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {formatFullDate} from "../../../DateUtils"; import { formatFullDate } from "../../../DateUtils";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse"; import { normalizeWheelEvent } from "../../../utils/Mouse";
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -95,8 +95,6 @@ export default class ImageView extends React.Component<IProps, IState> {
private initX = 0; private initX = 0;
private initY = 0; private initY = 0;
private lastX = 0;
private lastY = 0;
private previousX = 0; private previousX = 0;
private previousY = 0; private previousY = 0;
@ -105,23 +103,35 @@ export default class ImageView extends React.Component<IProps, IState> {
// needs to be passive in order to work with Chromium // needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false }); this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes // We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.calculateZoom); window.addEventListener("resize", this.recalculateZoom);
// After the image loads for the first time we want to calculate the zoom // After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom); this.image.current.addEventListener("load", this.recalculateZoom);
} }
componentWillUnmount() { componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel); this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.calculateZoom); window.removeEventListener("resize", this.recalculateZoom);
this.image.current.removeEventListener("load", this.calculateZoom); this.image.current.removeEventListener("load", this.recalculateZoom);
} }
private calculateZoom = () => { private recalculateZoom = () => {
this.setZoomAndRotation();
}
private setZoomAndRotation = (inputRotation?: number) => {
const image = this.image.current; const image = this.image.current;
const imageWrapper = this.imageWrapper.current; const imageWrapper = this.imageWrapper.current;
const zoomX = imageWrapper.clientWidth / image.naturalWidth; const rotation = inputRotation || this.state.rotation;
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
const imageIsNotFlipped = rotation % 180 === 0;
// If the image is rotated take it into account
const width = imageIsNotFlipped ? image.naturalWidth : image.naturalHeight;
const height = imageIsNotFlipped ? image.naturalHeight : image.naturalWidth;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
// If the image is smaller in both dimensions set its the zoom to 1 to // If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size // display it in its original size
@ -130,6 +140,7 @@ export default class ImageView extends React.Component<IProps, IState> {
zoom: 1, zoom: 1,
minZoom: 1, minZoom: 1,
maxZoom: 1, maxZoom: 1,
rotation: rotation,
}); });
return; return;
} }
@ -138,10 +149,14 @@ export default class ImageView extends React.Component<IProps, IState> {
// image by default // image by default
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE; const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom}); // If zoom is smaller than minZoom don't go below that value
const zoom = (this.state.zoom <= this.state.minZoom) ? minZoom : this.state.zoom;
this.setState({ this.setState({
minZoom: minZoom, minZoom: minZoom,
maxZoom: 1, maxZoom: 1,
rotation: rotation,
zoom: zoom,
}); });
} }
@ -157,7 +172,7 @@ export default class ImageView extends React.Component<IProps, IState> {
return; return;
} }
if (newZoom >= this.state.maxZoom) { if (newZoom >= this.state.maxZoom) {
this.setState({zoom: this.state.maxZoom}); this.setState({ zoom: this.state.maxZoom });
return; return;
} }
@ -170,7 +185,7 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev); const { deltaY } = normalizeWheelEvent(ev);
this.zoom(-(deltaY * ZOOM_COEFFICIENT)); this.zoom(-(deltaY * ZOOM_COEFFICIENT));
}; };
@ -192,14 +207,12 @@ export default class ImageView extends React.Component<IProps, IState> {
private onRotateCounterClockwiseClick = () => { private onRotateCounterClockwiseClick = () => {
const cur = this.state.rotation; const cur = this.state.rotation;
const rotationDegrees = cur - 90; this.setZoomAndRotation(cur - 90);
this.setState({ rotation: rotationDegrees });
}; };
private onRotateClockwiseClick = () => { private onRotateClockwiseClick = () => {
const cur = this.state.rotation; const cur = this.state.rotation;
const rotationDegrees = cur + 90; this.setZoomAndRotation(cur + 90);
this.setState({ rotation: rotationDegrees });
}; };
private onDownloadClick = () => { private onDownloadClick = () => {
@ -246,15 +259,15 @@ export default class ImageView extends React.Component<IProps, IState> {
// Zoom in if we are completely zoomed out // Zoom in if we are completely zoomed out
if (this.state.zoom === this.state.minZoom) { if (this.state.zoom === this.state.minZoom) {
this.setState({zoom: this.state.maxZoom}); this.setState({ zoom: this.state.maxZoom });
return; return;
} }
this.setState({moving: true}); this.setState({ moving: true });
this.previousX = this.state.translationX; this.previousX = this.state.translationX;
this.previousY = this.state.translationY; this.previousY = this.state.translationY;
this.initX = ev.pageX - this.lastX; this.initX = ev.pageX - this.state.translationX;
this.initY = ev.pageY - this.lastY; this.initY = ev.pageY - this.state.translationY;
}; };
private onMoving = (ev: React.MouseEvent) => { private onMoving = (ev: React.MouseEvent) => {
@ -263,11 +276,9 @@ export default class ImageView extends React.Component<IProps, IState> {
if (!this.state.moving) return; if (!this.state.moving) return;
this.lastX = ev.pageX - this.initX;
this.lastY = ev.pageY - this.initY;
this.setState({ this.setState({
translationX: this.lastX, translationX: ev.pageX - this.initX,
translationY: this.lastY, translationY: ev.pageY - this.initY,
}); });
}; };
@ -283,8 +294,10 @@ export default class ImageView extends React.Component<IProps, IState> {
translationX: 0, translationX: 0,
translationY: 0, translationY: 0,
}); });
this.initX = 0;
this.initY = 0;
} }
this.setState({moving: false}); this.setState({ moving: false });
}; };
private renderContextMenu() { private renderContextMenu() {
@ -355,7 +368,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const sender = ( const sender = (
<div className="mx_ImageView_info_sender"> <div className="mx_ImageView_info_sender">
{senderName} { senderName }
</div> </div>
); );
const messageTimestamp = ( const messageTimestamp = (
@ -382,10 +395,10 @@ export default class ImageView extends React.Component<IProps, IState> {
info = ( info = (
<div className="mx_ImageView_info_wrapper"> <div className="mx_ImageView_info_wrapper">
{avatar} { avatar }
<div className="mx_ImageView_info"> <div className="mx_ImageView_info">
{sender} { sender }
{messageTimestamp} { messageTimestamp }
</div> </div>
</div> </div>
); );
@ -425,7 +438,7 @@ export default class ImageView extends React.Component<IProps, IState> {
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn" className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")} title={_t("Zoom in")}
onClick={ this.onZoomInClick }> onClick={this.onZoomInClick}>
</AccessibleTooltipButton> </AccessibleTooltipButton>
); );
} }
@ -441,7 +454,7 @@ export default class ImageView extends React.Component<IProps, IState> {
ref={this.focusLock} ref={this.focusLock}
> >
<div className="mx_ImageView_panel"> <div className="mx_ImageView_panel">
{info} { info }
<div className="mx_ImageView_toolbar"> <div className="mx_ImageView_toolbar">
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_rotateCCW" className="mx_ImageView_button mx_ImageView_button_rotateCCW"
@ -453,25 +466,30 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("Rotate Right")} title={_t("Rotate Right")}
onClick={this.onRotateClockwiseClick}> onClick={this.onRotateClockwiseClick}>
</AccessibleTooltipButton> </AccessibleTooltipButton>
{zoomOutButton} { zoomOutButton }
{zoomInButton} { zoomInButton }
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")} title={_t("Download")}
onClick={ this.onDownloadClick }> onClick={ this.onDownloadClick }>
</AccessibleTooltipButton> </AccessibleTooltipButton>
{contextMenuButton} { contextMenuButton }
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_close" className="mx_ImageView_button mx_ImageView_button_close"
title={_t("Close")} title={_t("Close")}
onClick={ this.props.onFinished }> onClick={ this.props.onFinished }>
</AccessibleTooltipButton> </AccessibleTooltipButton>
{this.renderContextMenu()} { this.renderContextMenu() }
</div> </div>
</div> </div>
<div <div
className="mx_ImageView_image_wrapper" className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}> ref={this.imageWrapper}
onMouseDown={this.props.onFinished}
onMouseMove={this.onMoving}
onMouseUp={this.onEndMoving}
onMouseLeave={this.onEndMoving}
>
<img <img
src={this.props.src} src={this.props.src}
title={this.props.name} title={this.props.name}
@ -480,9 +498,6 @@ export default class ImageView extends React.Component<IProps, IState> {
className="mx_ImageView_image" className="mx_ImageView_image"
draggable={true} draggable={true}
onMouseDown={this.onStartMoving} onMouseDown={this.onStartMoving}
onMouseMove={this.onMoving}
onMouseUp={this.onEndMoving}
onMouseLeave={this.onEndMoving}
/> />
</div> </div>
</FocusLock> </FocusLock>

View file

@ -46,6 +46,8 @@ export default class ReplyThread extends React.Component {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired, permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use. // Specifies which layout to use.
layout: LayoutPropType, layout: LayoutPropType,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
}; };
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
@ -212,9 +214,9 @@ export default class ReplyThread extends React.Component {
}; };
} }
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) { static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) {
if (!ReplyThread.getParentEventId(parentEv)) { if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />; return null;
} }
return <ReplyThread return <ReplyThread
parentEv={parentEv} parentEv={parentEv}
@ -222,6 +224,7 @@ export default class ReplyThread extends React.Component {
ref={ref} ref={ref}
permalinkCreator={permalinkCreator} permalinkCreator={permalinkCreator}
layout={layout} layout={layout}
alwaysShowTimestamps={alwaysShowTimestamps}
/>; />;
} }
@ -269,40 +272,32 @@ export default class ReplyThread extends React.Component {
const {parentEv} = this.props; const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return; if (this.unmounted) return;
if (ev) { if (ev) {
const loadedEv = await this.getNextEvent(ev);
this.setState({ this.setState({
events: [ev], events: [ev],
}, this.loadNextEvent); loadedEv,
loading: false,
});
} else { } else {
this.setState({err: true}); this.setState({err: true});
} }
} }
async loadNextEvent() { async getNextEvent(ev) {
if (this.unmounted) return; try {
const ev = this.state.events[0]; const inReplyToEventId = ReplyThread.getParentEventId(ev);
const inReplyToEventId = ReplyThread.getParentEventId(ev); return await this.getEvent(inReplyToEventId);
} catch (e) {
if (!inReplyToEventId) { return null;
this.setState({
loading: false,
});
return;
}
const loadedEv = await this.getEvent(inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else {
this.setState({err: true});
} }
} }
async getEvent(eventId) { async getEvent(eventId) {
if (!eventId) return null;
const event = this.room.findEventById(eventId); const event = this.room.findEventById(eventId);
if (event) return event; if (event) return event;
@ -326,13 +321,18 @@ export default class ReplyThread extends React.Component {
this.initialize(); this.initialize();
} }
onQuoteClick() { async onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
if (events.length > 0) {
loadedEv = await this.getNextEvent(events[0]);
}
this.setState({ this.setState({
loadedEv: null, loadedEv,
events, events,
}, this.loadNextEvent); });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }
@ -390,8 +390,10 @@ export default class ReplyThread extends React.Component {
isRedacted={ev.isRedacted()} isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout} layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)} enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()} replacingEventId={ev.replacingEventId()}
as="div"
/> />
</blockquote>; </blockquote>;
}); });

View file

@ -112,7 +112,7 @@ interface IProps {
const MAX_PER_ROW = 6; const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => { const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow["org.matrix.msc2858.identity_providers"] || []; const providers = flow.identity_providers || [];
if (providers.length < 2) { if (providers.length < 2) {
return <div className="mx_SSOButtons"> return <div className="mx_SSOButtons">
<SSOButton <SSOButton

View file

@ -70,7 +70,10 @@ export default class Tooltip extends React.Component<IProps> {
this.tooltipContainer = document.createElement("div"); this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_Tooltip_wrapper"; this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer); document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this.renderTooltip, true); window.addEventListener('scroll', this.renderTooltip, {
passive: true,
capture: true,
});
this.parent = ReactDOM.findDOMNode(this).parentNode as Element; this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
@ -85,7 +88,9 @@ export default class Tooltip extends React.Component<IProps> {
public componentWillUnmount() { public componentWillUnmount() {
ReactDOM.unmountComponentAtNode(this.tooltipContainer); ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer); document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this.renderTooltip, true); window.removeEventListener('scroll', this.renderTooltip, {
capture: true,
});
} }
private updatePosition(style: CSSProperties) { private updatePosition(style: CSSProperties) {

View file

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {forwardRef, ReactNode} from "react"; import React, {forwardRef, ReactNode, ReactChildren} from "react";
import classNames from "classnames"; import classNames from "classnames";
interface IProps { interface IProps {
className: string; className: string;
title: string; title: string;
subtitle?: ReactNode; subtitle?: ReactNode;
children?: ReactChildren;
} }
const EventTileBubble = forwardRef<HTMLDivElement, IProps>(({ className, title, subtitle, children }, ref) => { const EventTileBubble = forwardRef<HTMLDivElement, IProps>(({ className, title, subtitle, children }, ref) => {

View file

@ -16,20 +16,19 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { formatFullDate, formatTime, formatFullTime } from '../../../DateUtils';
import {formatFullDate, formatTime, formatFullTime} from '../../../DateUtils'; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
ts: number;
showTwelveHour?: boolean;
showFullDate?: boolean;
showSeconds?: boolean;
}
@replaceableComponent("views.messages.MessageTimestamp") @replaceableComponent("views.messages.MessageTimestamp")
export default class MessageTimestamp extends React.Component { export default class MessageTimestamp extends React.Component<IProps> {
static propTypes = { public render() {
ts: PropTypes.number.isRequired,
showTwelveHour: PropTypes.bool,
showFullDate: PropTypes.bool,
showSeconds: PropTypes.bool,
};
render() {
const date = new Date(this.props.ts); const date = new Date(this.props.ts);
let timestamp; let timestamp;
if (this.props.showFullDate) { if (this.props.showFullDate) {
@ -41,7 +40,11 @@ export default class MessageTimestamp extends React.Component {
} }
return ( return (
<span className="mx_MessageTimestamp" title={formatFullDate(date, this.props.showTwelveHour)} aria-hidden={true}> <span
className="mx_MessageTimestamp"
title={formatFullDate(date, this.props.showTwelveHour)}
aria-hidden={true}
>
{timestamp} {timestamp}
</span> </span>
); );

View file

@ -81,19 +81,39 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
this.state = { this.state = {
myReactions: this.getMyReactions(), myReactions: this.getMyReactions(),
showAll: false, showAll: false,
}; };
} }
componentDidUpdate(prevProps) { componentDidMount() {
const { mxEvent, reactions } = this.props;
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
mxEvent.once("Event.decrypted", this.onDecrypted);
}
if (reactions) {
reactions.on("Relations.add", this.onReactionsChange);
reactions.on("Relations.remove", this.onReactionsChange);
reactions.on("Relations.redaction", this.onReactionsChange);
}
}
componentWillUnmount() {
const { mxEvent, reactions } = this.props;
mxEvent.off("Event.decrypted", this.onDecrypted);
if (reactions) {
reactions.off("Relations.add", this.onReactionsChange);
reactions.off("Relations.remove", this.onReactionsChange);
reactions.off("Relations.redaction", this.onReactionsChange);
}
}
componentDidUpdate(prevProps: IProps) {
if (prevProps.reactions !== this.props.reactions) { if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange); this.props.reactions.on("Relations.remove", this.onReactionsChange);
@ -102,24 +122,12 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
} }
} }
componentWillUnmount() { private onDecrypted = () => {
if (this.props.reactions) { // Decryption changes whether the event is actionable
this.props.reactions.removeListener( this.forceUpdate();
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
} }
onReactionsChange = () => { private onReactionsChange = () => {
// TODO: Call `onHeightChanged` as needed // TODO: Call `onHeightChanged` as needed
this.setState({ this.setState({
myReactions: this.getMyReactions(), myReactions: this.getMyReactions(),
@ -130,7 +138,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
this.forceUpdate(); this.forceUpdate();
} }
getMyReactions() { private getMyReactions() {
const reactions = this.props.reactions; const reactions = this.props.reactions;
if (!reactions) { if (!reactions) {
return null; return null;
@ -143,7 +151,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
return [...myReactions.values()]; return [...myReactions.values()];
} }
onShowAllClick = () => { private onShowAllClick = () => {
this.setState({ this.setState({
showAll: true, showAll: true,
}); });

View file

@ -15,37 +15,47 @@
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import Flair from '../elements/Flair.js'; import Flair from '../elements/Flair.js';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import {getUserNameColorClass} from '../../../utils/FormattingUtils'; import {getUserNameColorClass} from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import MatrixEvent from "matrix-js-sdk/src/models/event";
interface IProps {
mxEvent: MatrixEvent;
onClick(): void;
enableFlair: boolean;
}
interface IState {
userGroups;
relatedGroups;
}
@replaceableComponent("views.messages.SenderProfile") @replaceableComponent("views.messages.SenderProfile")
export default class SenderProfile extends React.Component { export default class SenderProfile extends React.Component<IProps, IState> {
static propTypes = {
mxEvent: PropTypes.object.isRequired, // event whose sender we're showing
onClick: PropTypes.func,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted: boolean;
state = { constructor(props: IProps) {
userGroups: null, super(props)
relatedGroups: [], const senderId = this.props.mxEvent.getSender();
};
this.state = {
userGroups: FlairStore.cachedPublicisedGroups(senderId) || [],
relatedGroups: [],
};
}
componentDidMount() { componentDidMount() {
this.unmounted = false; this.unmounted = false;
this._updateRelatedGroups(); this._updateRelatedGroups();
FlairStore.getPublicisedGroupsCached( if (this.state.userGroups.length === 0) {
this.context, this.props.mxEvent.getSender(), this.getPublicisedGroups();
).then((userGroups) => { }
if (this.unmounted) return;
this.setState({userGroups});
});
this.context.on('RoomState.events', this.onRoomStateEvents); this.context.on('RoomState.events', this.onRoomStateEvents);
} }
@ -55,6 +65,15 @@ export default class SenderProfile extends React.Component {
this.context.removeListener('RoomState.events', this.onRoomStateEvents); this.context.removeListener('RoomState.events', this.onRoomStateEvents);
} }
async getPublicisedGroups() {
if (!this.unmounted) {
const userGroups = await FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
);
this.setState({userGroups});
}
}
onRoomStateEvents = event => { onRoomStateEvents = event => {
if (event.getType() === 'm.room.related_groups' && if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId() event.getRoomId() === this.props.mxEvent.getRoomId()
@ -89,14 +108,26 @@ export default class SenderProfile extends React.Component {
render() { render() {
const {mxEvent} = this.props; const {mxEvent} = this.props;
const colorClass = getUserNameColorClass(mxEvent.getSender()); const colorClass = getUserNameColorClass(mxEvent.getSender());
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const {msgtype} = mxEvent.getContent(); const {msgtype} = mxEvent.getContent();
const disambiguate = mxEvent.sender?.disambiguate;
const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || "";
const mxid = mxEvent.sender?.userId || mxEvent.getSender() || "";
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return <span />; // emote message must include the name so don't duplicate it return null; // emote message must include the name so don't duplicate it
} }
let flair = <div />; let mxidElement;
if (disambiguate) {
mxidElement = (
<span className="mx_SenderProfile_mxid">
{ mxid }
</span>
);
}
let flair;
if (this.props.enableFlair) { if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups( const displayedGroups = this._getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups, this.state.userGroups, this.state.relatedGroups,
@ -108,21 +139,13 @@ export default class SenderProfile extends React.Component {
/>; />;
} }
const nameElem = name || '';
// Name + flair
const nameFlair = <span>
<span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem }
</span>
{ flair }
</span>;
return ( return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}> <div className="mx_SenderProfile mx_SenderProfile_hover" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover"> <span className={`mx_SenderProfile_displayName ${colorClass}`}>
{ nameFlair } { displayName }
</div> </span>
{ mxidElement }
{ flair }
</div> </div>
); );
} }

View file

@ -280,15 +280,15 @@ export default class TextualBody extends React.Component {
// pass only the first child which is the event tile otherwise this recurses on edited events // pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this._content.current]); let links = this.findLinks([this._content.current]);
if (links.length) { if (links.length) {
// de-dup the links (but preserve ordering) // de-duplicate the links after stripping hashes as they don't affect the preview
const seen = new Set(); // using a set here maintains the order
links = links.filter((link) => { links = Array.from(new Set(links.map(link => {
if (seen.has(link)) return false; const url = new URL(link);
seen.add(link); url.hash = "";
return true; return url.toString();
}); })));
this.setState({ links: links }); this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) { if (global.localStorage) {

View file

@ -21,12 +21,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton'; import HeaderButton from './HeaderButton';
import HeaderButtons, {HeaderKind} from './HeaderButtons'; import HeaderButtons, { HeaderKind } from './HeaderButtons';
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import {ActionPayload} from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import {ViewUserPayload} from "../../../dispatcher/payloads/ViewUserPayload"; import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
const GROUP_PHASES = [ const GROUP_PHASES = [
RightPanelPhases.GroupMemberInfo, RightPanelPhases.GroupMemberInfo,
@ -84,19 +84,21 @@ export default class GroupHeaderButtons extends HeaderButtons {
}; };
renderButtons() { renderButtons() {
return [ return <>
<HeaderButton key="groupMembersButton" name="groupMembersButton" <HeaderButton
name="groupMembersButton"
title={_t('Members')} title={_t('Members')}
isHighlighted={this.isPhase(GROUP_PHASES)} isHighlighted={this.isPhase(GROUP_PHASES)}
onClick={this.onMembersClicked} onClick={this.onMembersClicked}
analytics={['Right Panel', 'Group Member List Button', 'click']} analytics={['Right Panel', 'Group Member List Button', 'click']}
/>, />
<HeaderButton key="roomsButton" name="roomsButton" <HeaderButton
name="roomsButton"
title={_t('Rooms')} title={_t('Rooms')}
isHighlighted={this.isPhase(ROOM_PHASES)} isHighlighted={this.isPhase(ROOM_PHASES)}
onClick={this.onRoomsClicked} onClick={this.onRoomsClicked}
analytics={['Right Panel', 'Group Room List Button', 'click']} analytics={['Right Panel', 'Group Room List Button', 'click']}
/>, />
]; </>;
} }
} }

View file

@ -22,15 +22,13 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Analytics from '../../../Analytics'; import Analytics from '../../../Analytics';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
// Whether this button is highlighted // Whether this button is highlighted
isHighlighted: boolean; isHighlighted: boolean;
// click handler // click handler
onClick: () => void; onClick: () => void;
// The badge to display above the icon
badge?: React.ReactNode;
// The parameters to track the click event // The parameters to track the click event
analytics: Parameters<typeof Analytics.trackEvent>; analytics: Parameters<typeof Analytics.trackEvent>;
@ -40,31 +38,29 @@ interface IProps {
title: string; title: string;
} }
// TODO: replace this, the composer buttons and the right panel buttons with a unified // TODO: replace this, the composer buttons and the right panel buttons with a unified representation
// representation
@replaceableComponent("views.right_panel.HeaderButton") @replaceableComponent("views.right_panel.HeaderButton")
export default class HeaderButton extends React.Component<IProps> { export default class HeaderButton extends React.Component<IProps> {
constructor(props: IProps) { private onClick = () => {
super(props);
this.onClick = this.onClick.bind(this);
}
private onClick() {
Analytics.trackEvent(...this.props.analytics); Analytics.trackEvent(...this.props.analytics);
this.props.onClick(); this.props.onClick();
} };
public render() { public render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {isHighlighted, onClick, analytics, name, title, ...props} = this.props;
const classes = classNames({ const classes = classNames({
mx_RightPanel_headerButton: true, mx_RightPanel_headerButton: true,
mx_RightPanel_headerButton_highlight: this.props.isHighlighted, mx_RightPanel_headerButton_highlight: isHighlighted,
[`mx_RightPanel_${this.props.name}`]: true, [`mx_RightPanel_${name}`]: true,
}); });
return <AccessibleTooltipButton return <AccessibleTooltipButton
aria-selected={this.props.isHighlighted} {...props}
aria-selected={isHighlighted}
role="tab" role="tab"
title={this.props.title} title={title}
className={classes} className={classes}
onClick={this.onClick} onClick={this.onClick}
/>; />;

View file

@ -21,14 +21,14 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import RightPanelStore from "../../../stores/RightPanelStore"; import RightPanelStore from "../../../stores/RightPanelStore";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from '../../../dispatcher/actions'; import { Action } from '../../../dispatcher/actions';
import { import {
SetRightPanelPhasePayload, SetRightPanelPhasePayload,
SetRightPanelPhaseRefireParams, SetRightPanelPhaseRefireParams,
} from '../../../dispatcher/payloads/SetRightPanelPhasePayload'; } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import {EventSubscription} from "fbemitter"; import type { EventSubscription } from "fbemitter";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
export enum HeaderKind { export enum HeaderKind {
Room = "room", Room = "room",
@ -43,11 +43,11 @@ interface IState {
interface IProps {} interface IProps {}
@replaceableComponent("views.right_panel.HeaderButtons") @replaceableComponent("views.right_panel.HeaderButtons")
export default abstract class HeaderButtons extends React.Component<IProps, IState> { export default abstract class HeaderButtons<P = {}> extends React.Component<IProps & P, IState> {
private storeToken: EventSubscription; private storeToken: EventSubscription;
private dispatcherRef: string; private dispatcherRef: string;
constructor(props: IProps, kind: HeaderKind) { constructor(props: IProps & P, kind: HeaderKind) {
super(props); super(props);
const rps = RightPanelStore.getSharedInstance(); const rps = RightPanelStore.getSharedInstance();
@ -95,7 +95,7 @@ export default abstract class HeaderButtons extends React.Component<IProps, ISta
} }
// XXX: Make renderButtons a prop // XXX: Make renderButtons a prop
public abstract renderButtons(): JSX.Element[]; public abstract renderButtons(): JSX.Element;
public render() { public render() {
return <div className="mx_HeaderButtons"> return <div className="mx_HeaderButtons">

View file

@ -0,0 +1,188 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useState} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../languageHandler";
import BaseCard from "./BaseCard";
import Spinner from "../elements/Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile";
interface IProps {
room: Room;
onClose(): void;
}
export const usePinnedEvents = (room: Room): string[] => {
const [pinnedEvents, setPinnedEvents] = useState<string[]>([]);
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
setPinnedEvents(room.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned || []);
}, [room]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setPinnedEvents([]);
};
}, [update]);
return pinnedEvents;
};
export const ReadPinsEventId = "im.vector.room.read_pins";
export const useReadPinnedEvents = (room: Room): Set<string> => {
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== ReadPinsEventId) return;
const readPins = room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids;
setReadPinnedEvents(new Set(readPins || []));
}, [room]);
useEventEmitter(room, "Room.accountData", update);
useEffect(() => {
update();
return () => {
setReadPinnedEvents(new Set());
};
}, [update]);
return readPinnedEvents;
};
const useRoomState = <T extends any>(room: Room, mapper: (state: RoomState) => T): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);
const update = useCallback(() => {
if (!room) return;
setValue(mapper(room.currentState));
}, [room, mapper]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setValue(undefined);
};
}, [update]);
return value;
};
const PinnedMessagesCard = ({ room, onClose }: IProps) => {
const cli = useContext(MatrixClientContext);
const canUnpin = useRoomState(room, state => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));
const pinnedEventIds = usePinnedEvents(room);
const readPinnedEvents = useReadPinnedEvents(room);
useEffect(() => {
const newlyRead = pinnedEventIds.filter(id => !readPinnedEvents.has(id));
if (newlyRead.length > 0) {
// clear out any read pinned events which no longer are pinned
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: pinnedEventIds,
});
}
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
const pinnedEvents = useAsyncMemo(() => {
const promises = pinnedEventIds.map(async eventId => {
const timelineSet = room.getUnfilteredTimelineSet();
const localEvent = timelineSet?.getTimelineForEvent(eventId)?.getEvents().find(e => e.getId() === eventId);
if (localEvent) return localEvent;
try {
const evJson = await cli.fetchRoomEvent(room.roomId, eventId);
const event = new MatrixEvent(evJson);
if (event.isEncrypted()) {
await cli.decryptEventIfNeeded(event); // TODO await?
}
if (event && PinningUtils.isPinnable(event)) {
return event;
}
} catch (err) {
console.error("Error looking up pinned event " + eventId + " in room " + room.roomId);
console.error(err);
}
return null;
});
return Promise.all(promises);
}, [cli, room, pinnedEventIds], null);
let content;
if (!pinnedEvents) {
content = <Spinner />;
} else if (pinnedEvents.length > 0) {
let onUnpinClicked;
if (canUnpin) {
onUnpinClicked = async (event: MatrixEvent) => {
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
if (pinnedEvents?.getContent()?.pinned) {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(event.getId());
if (index !== -1) {
pinned.splice(index, 1);
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
}
}
};
}
// show them in reverse, with latest pinned at the top
content = pinnedEvents.filter(Boolean).reverse().map(ev => (
<PinnedEventTile key={ev.getId()} room={room} event={ev} onUnpinClicked={() => onUnpinClicked(ev)} />
));
} else {
content = <div className="mx_PinnedMessagesCard_empty">
<div>
{ /* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */ }
<div className="mx_PinnedMessagesCard_MessageActionBar">
<div className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton" />
<div className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" />
<div className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton" />
</div>
<h2>{ _t("Nothing pinned, yet") }</h2>
{ _t("If you have permissions, open the menu on any message and select " +
"<b>Pin</b> to stick them here.", {}, {
b: sub => <b>{ sub }</b>,
}) }
</div>
</div>;
}
return <BaseCard
header={<h2>{ _t("Pinned messages") }</h2>}
className="mx_PinnedMessagesCard"
onClose={onClose}
>
{ content }
</BaseCard>;
};
export default PinnedMessagesCard;

View file

@ -18,15 +18,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {_t} from '../../../languageHandler'; import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton'; import HeaderButton from './HeaderButton';
import HeaderButtons, {HeaderKind} from './HeaderButtons'; import HeaderButtons, { HeaderKind } from './HeaderButtons';
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import {ActionPayload} from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import RightPanelStore from "../../../stores/RightPanelStore"; import RightPanelStore from "../../../stores/RightPanelStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { useSettingValue } from "../../../hooks/useSettings";
import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
const ROOM_INFO_PHASES = [ const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary, RightPanelPhases.RoomSummary,
@ -38,9 +42,35 @@ const ROOM_INFO_PHASES = [
RightPanelPhases.Room3pidMemberInfo, RightPanelPhases.Room3pidMemberInfo,
]; ];
const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
const pinningEnabled = useSettingValue("feature_pinning");
const pinnedEvents = usePinnedEvents(pinningEnabled && room);
const readPinnedEvents = useReadPinnedEvents(pinningEnabled && room);
if (!pinningEnabled) return null;
let unreadIndicator;
if (pinnedEvents.some(id => !readPinnedEvents.has(id))) {
unreadIndicator = <div className="mx_RightPanel_pinnedMessagesButton_unreadIndicator" />;
}
return <HeaderButton
name="pinnedMessagesButton"
title={_t("Pinned messages")}
isHighlighted={isHighlighted}
onClick={onClick}
analytics={["Right Panel", "Pinned Messages Button", "click"]}
>
{ unreadIndicator }
</HeaderButton>;
};
interface IProps {
room?: Room;
}
@replaceableComponent("views.right_panel.RoomHeaderButtons") @replaceableComponent("views.right_panel.RoomHeaderButtons")
export default class RoomHeaderButtons extends HeaderButtons { export default class RoomHeaderButtons extends HeaderButtons<IProps> {
constructor(props) { constructor(props: IProps) {
super(props, HeaderKind.Room); super(props, HeaderKind.Room);
} }
@ -80,24 +110,32 @@ export default class RoomHeaderButtons extends HeaderButtons {
this.setPhase(RightPanelPhases.NotificationPanel); this.setPhase(RightPanelPhases.NotificationPanel);
}; };
private onPinnedMessagesClicked = () => {
// This toggles for us, if needed
this.setPhase(RightPanelPhases.PinnedMessages);
};
public renderButtons() { public renderButtons() {
return [ return <>
<PinnedMessagesHeaderButton
room={this.props.room}
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
onClick={this.onPinnedMessagesClicked}
/>
<HeaderButton <HeaderButton
key="notifsButton"
name="notifsButton" name="notifsButton"
title={_t('Notifications')} title={_t('Notifications')}
isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)} isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
onClick={this.onNotificationsClicked} onClick={this.onNotificationsClicked}
analytics={['Right Panel', 'Notification List Button', 'click']} analytics={['Right Panel', 'Notification List Button', 'click']}
/>, />
<HeaderButton <HeaderButton
key="roomSummaryButton"
name="roomSummaryButton" name="roomSummaryButton"
title={_t('Room Info')} title={_t('Room Info')}
isHighlighted={this.isPhase(ROOM_INFO_PHASES)} isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
onClick={this.onRoomSummaryClicked} onClick={this.onRoomSummaryClicked}
analytics={['Right Panel', 'Room Summary Button', 'click']} analytics={['Right Panel', 'Room Summary Button', 'click']}
/>, />
]; </>;
} }
} }

View file

@ -25,6 +25,7 @@ import { User } from 'matrix-js-sdk/src/models/user';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
@ -515,9 +516,6 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
} else { } else {
setPowerLevels({}); setPowerLevels({});
} }
return () => {
setPowerLevels({});
};
}, [room]); }, [room]);
useEventEmitter(cli, "RoomState.events", update); useEventEmitter(cli, "RoomState.events", update);
@ -1531,21 +1529,16 @@ interface IProps {
user: Member; user: Member;
groupId?: string; groupId?: string;
room?: Room; room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo; phase: RightPanelPhases.RoomMemberInfo
| RightPanelPhases.GroupMemberInfo
| RightPanelPhases.SpaceMemberInfo
| RightPanelPhases.EncryptionPanel;
onClose(): void; onClose(): void;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
} }
interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> { const UserInfo: React.FC<IProps> = ({
user: Member;
groupId: void;
room: Room;
phase: RightPanelPhases.EncryptionPanel;
onClose(): void;
}
type Props = IProps | IPropsWithEncryptionPanel;
const UserInfo: React.FC<Props> = ({
user, user,
groupId, groupId,
room, room,

View file

@ -82,13 +82,6 @@ export default class AppsDrawer extends React.Component {
this.props.resizeNotifier.off("isResizing", this.onIsResizing); this.props.resizeNotifier.off("isResizing", this.onIsResizing);
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(newProps) {
// Room has changed probably, update apps
this._updateApps();
}
onIsResizing = (resizing) => { onIsResizing = (resizing) => {
// This one is the vertical, ie. change height of apps drawer // This one is the vertical, ie. change height of apps drawer
this.setState({ resizingVertical: resizing }); this.setState({ resizingVertical: resizing });
@ -141,7 +134,10 @@ export default class AppsDrawer extends React.Component {
_getAppsHash = (apps) => apps.map(app => app.id).join("~"); _getAppsHash = (apps) => apps.map(app => app.id).join("~");
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) { if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps
this._updateApps();
} else if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) {
this._loadResizerPreferences(); this._loadResizerPreferences();
} }
} }

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