Merge branch 'develop' into improved-forwarding-ui

This commit is contained in:
Robin Townsend 2021-06-05 11:04:17 -04:00
commit a06306d560
91 changed files with 1240 additions and 1002 deletions

View file

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

View file

@ -180,6 +180,7 @@
@import "./views/messages/_common_CryptoEvent.scss";
@import "./views/right_panel/_BaseCard.scss";
@import "./views/right_panel/_EncryptionInfo.scss";
@import "./views/right_panel/_PinnedMessagesCard.scss";
@import "./views/right_panel/_RoomSummaryCard.scss";
@import "./views/right_panel/_UserInfo.scss";
@import "./views/right_panel/_VerificationPanel.scss";
@ -204,7 +205,6 @@
@import "./views/rooms/_NewRoomIntro.scss";
@import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss";

View file

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

View file

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

View file

@ -25,6 +25,7 @@ limitations under the License.
padding: 4px 0;
box-sizing: border-box;
height: 100%;
contain: strict;
.mx_RoomView_MessageList {
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;
}
$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 {
&::before {
background-color: $accent-color !important;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,35 @@
/*
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;
}
}
}

View file

@ -104,7 +104,7 @@ $left-gutter: 64px;
.mx_EventTile_line, .mx_EventTile_reply {
position: relative;
padding-left: $left-gutter;
border-radius: 4px;
border-radius: 8px;
}
.mx_RoomView_timeline_rr_enabled,
@ -280,6 +280,7 @@ $left-gutter: 64px;
height: $font-14px;
width: $font-14px;
will-change: left, top;
transition:
left var(--transition-short) ease-out,
top var(--transition-standard) ease-out;

View file

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

View file

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

View file

@ -16,62 +16,91 @@ limitations under the License.
.mx_PinnedEventTile {
min-height: 40px;
margin-bottom: 5px;
width: 100%;
border-radius: 5px; // for the hover
}
padding: 0 4px 12px;
.mx_PinnedEventTile:hover {
background-color: $event-selected-color;
}
display: grid;
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_timestamp {
color: #868686;
font-size: 0.8em;
vertical-align: top;
display: inline-block;
padding-bottom: 3px;
}
& + .mx_PinnedEventTile {
padding: 12px 4px;
border-top: 1px solid $menu-border-color;
}
.mx_PinnedEventTile .mx_PinnedEventTile_timestamp {
padding-left: 15px;
display: none;
}
.mx_PinnedEventTile_senderAvatar {
grid-area: avatar;
}
.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar {
float: left;
margin-right: 10px;
}
.mx_PinnedEventTile_sender {
grid-area: name;
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-24px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_PinnedEventTile_actions {
float: right;
margin-right: 10px;
display: none;
}
.mx_PinnedEventTile_unpinButton {
visibility: hidden;
grid-area: remove;
position: relative;
width: 24px;
height: 24px;
border-radius: 8px;
.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp {
display: inline-block;
}
&:hover {
background-color: $roomheader-addroom-bg-color;
}
.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions {
display: block;
}
&::before {
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 {
display: inline-block;
cursor: pointer;
margin-left: 10px;
}
.mx_PinnedEventTile_message {
grid-area: content;
}
.mx_PinnedEventTile_gotoButton {
display: inline-block;
font-size: 0.7em; // Smaller text to avoid conflicting with the layout
}
.mx_PinnedEventTile_footer {
grid-area: footer;
font-size: 10px;
line-height: 12px;
.mx_PinnedEventTile_message {
margin-left: 50px;
position: relative;
top: 0;
left: 0;
.mx_PinnedEventTile_timestamp {
font-size: inherit;
line-height: inherit;
color: $secondary-fg-color;
}
.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
// sliding it into view.
&.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 {
margin-left: 0;
transform: translateX(0);
// Timing function is as-requested by design.
// 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 {

View file

@ -277,24 +277,6 @@ limitations under the License.
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) {
.mx_RoomHeader_wrapper {
padding: 0;

View file

@ -198,6 +198,7 @@ limitations under the License.
// as the box model should be top aligned. Happens in both FF and Chromium
display: flex;
flex-direction: column;
align-self: stretch;
mask-image: linear-gradient(0deg, transparent, black 4px);
}

View file

@ -19,6 +19,10 @@ limitations under the License.
margin-bottom: 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
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">
<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="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="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="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" 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="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="#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="#737D8C"/>
<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="#737D8C"/>
</svg>

Before

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

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -264,7 +264,7 @@ export default class CallHandler extends EventEmitter {
}
public getSupportsVirtualRooms() {
return this.supportsPstnProtocol;
return this.supportsSipNativeVirtual;
}
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
@ -521,7 +521,9 @@ export default class CallHandler extends EventEmitter {
let newNativeAssertedIdentity = newAssertedIdentity;
if (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}`);
@ -802,7 +804,10 @@ export default class CallHandler extends EventEmitter {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
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;
}
@ -859,9 +864,43 @@ export default class CallHandler extends EventEmitter {
});
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) {
logger.info("Setting call in room " + activeCallRoomId + " active");

View file

@ -40,6 +40,7 @@ import {
UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload";
import {IUpload} from "./models/IUpload";
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -208,12 +209,12 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
}
let imageInfo;
return loadImageElement(imageFile).then(function(r) {
return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) {
}).then((result) => {
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) {
}).then((result) => {
imageInfo.thumbnail_url = result.url;
imageInfo.thumbnail_file = result.file;
return imageInfo;
@ -264,12 +265,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
const thumbnailType = "image/jpeg";
let videoInfo;
return loadVideoElement(videoFile).then(function(video) {
return loadVideoElement(videoFile).then((video) => {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then(function(result) {
}).then((result) => {
videoInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) {
}).then((result) => {
videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file;
return videoInfo;
@ -308,7 +309,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 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;
if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
@ -355,7 +361,7 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo
// If the attachment isn't encrypted then include the URL directly.
return {"url": url};
});
promise1.abort = () => {
(promise1 as any).abort = () => {
canceled = true;
MatrixClientPeg.get().cancelUpload(basePromise);
};
@ -367,7 +373,7 @@ export default class ContentMessages {
private inprogress: IUpload[] = [];
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 prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
@ -441,7 +447,7 @@ export default class ContentMessages {
let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending
// 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) {
const file = okFiles[i];
if (!uploadAll) {

View file

@ -816,7 +816,9 @@ export default class CountlyAnalytics {
window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", 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.inactivityCounter++;

View file

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

View file

@ -66,7 +66,7 @@ async function serverSideSearchProcess(term, roomId = undefined) {
highlights: [],
};
return client._processRoomEventsSearch(searchResult, result.response);
return client.processRoomEventsSearch(searchResult, result.response);
}
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.
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.
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.
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;
// 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.
const newResultCount = result.results.length - oldResultCount;

View file

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

View file

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

View file

@ -33,7 +33,7 @@ export default class VoipUserMapper {
private async userToVirtualUser(userId: string): Promise<string> {
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;
}
@ -82,14 +82,14 @@ export default class VoipUserMapper {
return Boolean(claimedNativeRoomId);
}
public async onNewInvitedRoom(invitedRoom: Room) {
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
if (result.length === 0) {
return true;
return;
}
if (result[0].fields.is_virtual) {

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

@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
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();
}
}

View file

@ -97,6 +97,9 @@ 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() {
@ -108,6 +111,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
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 {
@ -295,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;
this.handleStickyHeaders(list);
};
@ -459,7 +463,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// 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);
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);
if (event) events.push(event);
}

View file

@ -378,7 +378,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
const promisesList: Promise<any>[] = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// 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

View file

@ -121,6 +121,9 @@ export default class MessagePanel extends React.Component {
// callback which is called when the panel is scrolled.
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.
onFillRequest: PropTypes.func,
@ -645,39 +648,37 @@ export default class MessagePanel extends React.Component {
// use txnId as key if available so that we don't remount during sending
ret.push(
<li
key={mxEv.getTxnId() || eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<TileErrorBoundary mxEvent={mxEv}>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/>
</TileErrorBoundary>
</li>,
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
<EventTile
as="li"
data-scroll-tokens={scrollToken}
ref={this._collectEventNode.bind(this, eventId)}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/>
</TileErrorBoundary>,
);
return ret;
@ -779,7 +780,7 @@ export default class MessagePanel extends React.Component {
}
_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
@ -885,6 +886,7 @@ export default class MessagePanel extends React.Component {
ref={this._scrollPanel}
className={className}
onScroll={this.props.onScroll}
onUserScroll={this.props.onUserScroll}
onResize={this.onResize}
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,29 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from "prop-types";
import React from "react";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import { MatrixClientPeg } from "../../MatrixClientPeg";
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
*/
@replaceableComponent("structures.NotificationPanel")
class NotificationPanel extends React.Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
export default class NotificationPanel extends React.PureComponent<IProps> {
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">
<h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications.')}</p>
@ -47,6 +41,7 @@ class NotificationPanel extends React.Component {
let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = (
<TimelinePanel
manageReadReceipts={false}
@ -59,7 +54,7 @@ class NotificationPanel extends React.Component {
);
} else {
console.error("No notifTimelineSet available!");
content = <Loader />;
content = <Spinner />;
}
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
@ -67,5 +62,3 @@ class NotificationPanel extends React.Component {
</BaseCard>;
}
}
export default NotificationPanel;

View file

@ -1,6 +1,6 @@
/*
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");
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 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 RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import {
RightPanelPhases,
RIGHT_PANEL_PHASES_NO_ARGS,
RIGHT_PANEL_SPACE_PHASES,
RightPanelPhases,
} from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions";
import { Action } from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
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")
export default class RightPanel extends React.Component {
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
};
}
export default class RightPanel extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string;
constructor(props, context) {
super(props, context);
this.state = {
...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
phase: this._getPhaseFromProps(),
phase: this.getPhaseFromProps(),
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();
}, 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.
_getUserForPanel() {
private getUserForPanel() {
if (this.state && this.state.member) return this.state.member;
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
return this.props.user || lastParams['member'];
}
// gets the current phase from the props and also maybe the store
_getPhaseFromProps() {
private getPhaseFromProps() {
const rps = RightPanelStore.getSharedInstance();
const userForPanel = this._getUserForPanel();
const userForPanel = this.getUserForPanel();
if (this.props.groupId) {
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
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);
const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
this.initGroupStore(this.props.groupId);
}
componentWillUnmount() {
@ -126,61 +148,47 @@ export default class RightPanel extends React.Component {
if (this.context) {
this.context.removeListener("RoomState.members", this.onRoomStateMember);
}
this._unregisterGroupStore(this.props.groupId);
this.unregisterGroupStore();
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
this.unregisterGroupStore();
this.initGroupStore(newProps.groupId);
}
}
_initGroupStore(groupId) {
private initGroupStore(groupId: string) {
if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
}
_unregisterGroupStore() {
private unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
}
onGroupStoreUpdated() {
private onGroupStoreUpdated = () => {
this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
}
};
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RightPanelPhases.GroupMemberList,
});
});
}
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
}
onRoomStateMember(ev, state, member) {
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}
// redraw the badge on the membership list
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 &&
member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level)
this._delayedUpdate();
this.delayedUpdate();
}
}
};
onAction(payload) {
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.AfterRightPanelPhaseChange) {
this.setState({
phase: payload.phase,
@ -194,9 +202,9 @@ export default class RightPanel extends React.Component {
space: payload.space,
});
}
}
};
onClose = () => {
private onClose = () => {
// 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
// of the app and is generally a bit silly.
@ -224,16 +232,6 @@ export default class RightPanel extends React.Component {
};
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 />;
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}
groupId={this.props.groupId}
key={this.state.member.userId}
phase={this.state.phase}
onClose={this.onClose} />;
break;
@ -299,6 +298,12 @@ export default class RightPanel extends React.Component {
panel = <NotificationPanel onClose={this.onClose} />;
break;
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;

View file

@ -46,7 +46,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout";
import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
@ -54,7 +54,6 @@ import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
@ -62,7 +61,6 @@ import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import SearchBar from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common";
@ -81,7 +79,8 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { omit } from 'lodash';
import UIStore from "../../stores/UIStore";
const DEBUG = false;
@ -154,7 +153,6 @@ export interface IState {
canPeek: boolean;
showApps: boolean;
isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
showRightPanel: boolean;
// error object, as from the matrix client/server API
@ -174,6 +172,7 @@ export interface IState {
statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: {
version: string;
needsUpgrade: boolean;
@ -231,7 +230,6 @@ export default class RoomView extends React.Component<IProps, IState> {
canPeek: false,
showApps: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
joining: false,
@ -325,7 +323,6 @@ export default class RoomView extends React.Component<IProps, IState> {
replyToEvent: RoomViewStore.getQuotingEvent(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(),
};
@ -526,7 +523,20 @@ export default class RoomView extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
const hasPropsDiff = objectHasDiff(this.props, nextProps);
// React only shallow comparison and we only want to trigger
// a component re-render if a room requires an upgrade
const newUpgradeRecommendation = nextState.upgradeRecommendation || {}
const state = omit(this.state, ['upgradeRecommendation']);
const newState = omit(nextState, ['upgradeRecommendation'])
const hasStateDiff =
objectHasDiff(state, newState) ||
(newUpgradeRecommendation.needsUpgrade === true)
return hasPropsDiff || hasStateDiff;
}
componentDidUpdate() {
@ -639,6 +649,17 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.layoutWatcherRef);
}
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
});
}
}
private onLayoutChange = () => {
this.setState({
layout: SettingsStore.getValue("layout"),
@ -809,7 +830,7 @@ export default class RoomView extends React.Component<IProps, IState> {
};
private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
@ -1374,13 +1395,6 @@ export default class RoomView extends React.Component<IProps, IState> {
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) => {
dis.dispatch({
action: 'place_call',
@ -1485,7 +1499,6 @@ export default class RoomView extends React.Component<IProps, IState> {
private onSearchClick = () => {
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
};
@ -1498,8 +1511,10 @@ export default class RoomView extends React.Component<IProps, IState> {
// jump down to the bottom of this room, where new events are arriving
private jumpToLiveTimeline = () => {
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
});
};
// jump up to wherever our read marker is
@ -1807,8 +1822,6 @@ export default class RoomView extends React.Component<IProps, IState> {
/>;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
} else if (this.state.showingPinned) {
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (myMembership !== "join") {
// 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.
@ -1960,6 +1973,7 @@ export default class RoomView extends React.Component<IProps, IState> {
eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll}
onUserScroll={this.onUserScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview}
className={messagePanelClassNames}
@ -1986,6 +2000,7 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>);
}
@ -2022,7 +2037,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}

View file

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

View file

@ -36,7 +36,6 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays";
@ -94,6 +93,9 @@ class TimelinePanel extends React.Component {
// callback which is called when the panel is scrolled.
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.
onReadMarkerUpdated: PropTypes.func,
@ -258,37 +260,15 @@ class TimelinePanel extends React.Component {
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 +
" (was " + this.props.eventId + ")");
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() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
@ -1456,6 +1436,7 @@ class TimelinePanel extends React.Component {
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}

View file

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

View file

@ -61,7 +61,7 @@ interface IProps {
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
}): string;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -28,9 +28,10 @@ import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MenuItem } from "../../structures/ContextMenu";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
export function canCancel(eventStatus) {
@ -83,7 +84,7 @@ export default class MessageContextMenu extends React.Component {
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& 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
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
@ -93,7 +94,7 @@ export default class MessageContextMenu extends React.Component {
_isPinned() {
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;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
@ -166,25 +167,23 @@ export default class MessageContextMenu extends React.Component {
};
onPinClick = () => {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
.catch((e) => {
// Intercept the Event Not Found error and fall through the promise chain with no event.
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 room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
const cli = MatrixClientPeg.get();
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
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();
};

View file

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

View file

@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent<IExplorerProps> {
render() {
const cli = this.context;
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();
return (<div>

View file

@ -49,6 +49,7 @@ import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -578,7 +579,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
members.sort((a, b) => {
if (a.score === b.score) {
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;

View file

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

View file

@ -75,7 +75,7 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
label={tooltip || title}
yOffset={yOffset}
alignment={alignment}
/> : <div />;
/> : null;
return (
<AccessibleButton
{...props}

View file

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

View file

@ -214,7 +214,7 @@ export default class ReplyThread extends React.Component {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />;
return null;
}
return <ReplyThread
parentEv={parentEv}
@ -269,36 +269,27 @@ export default class ReplyThread extends React.Component {
const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return;
if (ev) {
const loadedEv = await this.getNextEvent(ev);
this.setState({
events: [ev],
}, this.loadNextEvent);
loadedEv,
loading: false,
});
} else {
this.setState({err: true});
}
}
async loadNextEvent() {
if (this.unmounted) return;
const ev = this.state.events[0];
const inReplyToEventId = ReplyThread.getParentEventId(ev);
if (!inReplyToEventId) {
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 getNextEvent(ev) {
try {
const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
} catch (e) {
return null;
}
}
@ -326,13 +317,18 @@ export default class ReplyThread extends React.Component {
this.initialize();
}
onQuoteClick() {
async onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
if (events.length > 0) {
loadedEv = await this.getNextEvent(events[0]);
}
this.setState({
loadedEv: null,
loadedEv,
events,
}, this.loadNextEvent);
});
dis.fire(Action.FocusComposer);
}

View file

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

View file

@ -31,21 +31,23 @@ export default class SenderProfile extends React.Component {
static contextType = MatrixClientContext;
state = {
userGroups: null,
relatedGroups: [],
};
constructor(props) {
super(props);
const senderId = this.props.mxEvent.getSender();
this.state = {
userGroups: FlairStore.cachedPublicisedGroups(senderId) || [],
relatedGroups: [],
};
}
componentDidMount() {
this.unmounted = false;
this._updateRelatedGroups();
FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
).then((userGroups) => {
if (this.unmounted) return;
this.setState({userGroups});
});
if (this.state.userGroups.length === 0) {
this.getPublicisedGroups();
}
this.context.on('RoomState.events', this.onRoomStateEvents);
}
@ -55,6 +57,15 @@ export default class SenderProfile extends React.Component {
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 => {
if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId()
@ -93,10 +104,10 @@ export default class SenderProfile extends React.Component {
const {msgtype} = mxEvent.getContent();
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 flair = null;
if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups,
@ -110,19 +121,12 @@ 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 (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover">
{ nameFlair }
</div>
<div className="mx_SenderProfile mx_SenderProfile_hover" dir="auto" onClick={this.props.onClick}>
<span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem }
</span>
{ flair }
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,176 @@
/*
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} />
));
} else {
content = <div className="mx_RightPanel_empty mx_PinnedMessagesCard_empty">
<h2>{_t("Youre all caught up")}</h2>
<p>{_t("You have no visible notifications.")}</p>
</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.
*/
import React from 'react';
import {_t} from '../../../languageHandler';
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton';
import HeaderButtons, {HeaderKind} from './HeaderButtons';
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions";
import {ActionPayload} from "../../../dispatcher/payloads";
import HeaderButtons, { HeaderKind } from './HeaderButtons';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { Action } from "../../../dispatcher/actions";
import { ActionPayload } from "../../../dispatcher/payloads";
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 = [
RightPanelPhases.RoomSummary,
@ -38,9 +42,35 @@ const ROOM_INFO_PHASES = [
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")
export default class RoomHeaderButtons extends HeaderButtons {
constructor(props) {
export default class RoomHeaderButtons extends HeaderButtons<IProps> {
constructor(props: IProps) {
super(props, HeaderKind.Room);
}
@ -80,24 +110,32 @@ export default class RoomHeaderButtons extends HeaderButtons {
this.setPhase(RightPanelPhases.NotificationPanel);
};
private onPinnedMessagesClicked = () => {
// This toggles for us, if needed
this.setPhase(RightPanelPhases.PinnedMessages);
};
public renderButtons() {
return [
return <>
<PinnedMessagesHeaderButton
room={this.props.room}
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
onClick={this.onPinnedMessagesClicked}
/>
<HeaderButton
key="notifsButton"
name="notifsButton"
title={_t('Notifications')}
isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
onClick={this.onNotificationsClicked}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,
/>
<HeaderButton
key="roomSummaryButton"
name="roomSummaryButton"
title={_t('Room Info')}
isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
onClick={this.onRoomSummaryClicked}
analytics={['Right Panel', 'Room Summary Button', 'click']}
/>,
];
/>
</>;
}
}

View file

@ -17,18 +17,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import {MatrixClient} from 'matrix-js-sdk/src/client';
import {RoomMember} from 'matrix-js-sdk/src/models/room-member';
import {User} from 'matrix-js-sdk/src/models/user';
import {Room} from 'matrix-js-sdk/src/models/room';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
import {MatrixEvent} from 'matrix-js-sdk/src/models/event';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { User } from 'matrix-js-sdk/src/models/user';
import { Room } from 'matrix-js-sdk/src/models/room';
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
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 Modal from '../../../Modal';
import {_t} from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
@ -39,18 +40,18 @@ import MultiInviter from "../../../utils/MultiInviter";
import GroupStore from "../../../stores/GroupStore";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {textualPowerLevel} from '../../../Roles';
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { textualPowerLevel } from '../../../Roles';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import {useAsyncMemo} from '../../../hooks/useAsyncMemo';
import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification';
import {Action} from "../../../dispatcher/actions";
import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification';
import { Action } from "../../../dispatcher/actions";
import { USER_SECURITY_TAB } from "../dialogs/UserSettingsDialog";
import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard";
import {E2EStatus} from "../../../utils/ShieldUtils";
import { E2EStatus } from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
import PowerSelector from "../elements/PowerSelector";
@ -65,7 +66,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {mediaFromMxc} from "../../../customisations/Media";
import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore";
export interface IDevice {
@ -514,9 +515,6 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
} else {
setPowerLevels({});
}
return () => {
setPowerLevels({});
};
}, [room]);
useEventEmitter(cli, "RoomState.events", update);
@ -1530,21 +1528,16 @@ interface IProps {
user: Member;
groupId?: string;
room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo;
phase: RightPanelPhases.RoomMemberInfo
| RightPanelPhases.GroupMemberInfo
| RightPanelPhases.SpaceMemberInfo
| RightPanelPhases.EncryptionPanel;
onClose(): void;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
}
interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> {
user: Member;
groupId: void;
room: Room;
phase: RightPanelPhases.EncryptionPanel;
onClose(): void;
}
type Props = IProps | IPropsWithEncryptionPanel;
const UserInfo: React.FC<Props> = ({
const UserInfo: React.FC<IProps> = ({
user,
groupId,
room,

View file

@ -277,6 +277,12 @@ interface IProps {
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
// Symbol of the root node
as?: string
// whether or not to always show timestamps
alwaysShowTimestamps?: boolean
}
interface IState {
@ -291,12 +297,15 @@ interface IState {
previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations;
hover: boolean;
}
@replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private ref: React.RefObject<unknown>;
private tile = React.createRef();
private replyThread = React.createRef();
@ -322,6 +331,8 @@ export default class EventTile extends React.Component<IProps, IState> {
previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(),
hover: false,
};
// don't do RR animations until we are mounted
@ -333,6 +344,8 @@ export default class EventTile extends React.Component<IProps, IState> {
// to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all.
this.isListeningForReceipts = false;
this.ref = React.createRef();
}
/**
@ -631,7 +644,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars" />);
return null;
}
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
@ -640,6 +653,11 @@ export default class EventTile extends React.Component<IProps, IState> {
let left = 0;
const receipts = this.props.readReceipts || [];
if (receipts.length === 0) {
return null;
}
for (let i = 0; i < receipts.length; ++i) {
const receipt = receipts[i];
@ -690,10 +708,14 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
return <span className="mx_EventTile_readAvatars">
{ remText }
{ avatars }
</span>;
return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
{ remText }
{ avatars }
</span>
</div>
)
}
onSenderProfileClick = event => {
@ -953,7 +975,8 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange}
/> : undefined;
const timestamp = this.props.mxEvent.getTs() ?
const showTimestamp = this.props.mxEvent.getTs() && (this.props.alwaysShowTimestamps || this.state.hover);
const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
const keyRequestHelpText =
@ -1016,11 +1039,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let msgOption;
if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars();
msgOption = (
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
);
msgOption = readAvatars;
}
switch (this.props.tileShape) {
@ -1124,11 +1143,20 @@ export default class EventTile extends React.Component<IProps, IState> {
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true">
{ ircTimestamp }
{ sender }
{ ircPadlock }
<div className="mx_EventTile_line">
React.createElement(this.props.as || "div", {
"ref": this.ref,
"className": classes,
"tabIndex": -1,
"aria-live": ariaLive,
"aria-atomic": "true",
"data-scroll-tokens": this.props["data-scroll-tokens"],
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, [
ircTimestamp,
sender,
ircPadlock,
<div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp }
{ groupPadlock }
{ thread }
@ -1145,16 +1173,12 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo }
{ reactionsRow }
{ actionBar }
</div>
{msgOption}
{
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
// the need for further z-indexing chaos)
}
{ avatar }
</div>
);
</div>,
msgOption,
avatar,
])
)
}
}
}
@ -1316,11 +1340,15 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
tooltip = <Tooltip className="mx_EventTile_readAvatars_receiptTooltip" label={label} yOffset={20} />;
}
return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
</span>
</span>;
return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
</span>
</span>
</div>
);
}
}

View file

@ -238,6 +238,8 @@ export default class MemberList extends React.Component {
member.user = cli.getUser(member.userId);
}
member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, "");
// XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0
});
@ -252,6 +254,8 @@ export default class MemberList extends React.Component {
m.membership === 'join' || m.membership === 'invite'
);
});
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true });
filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers;
}
@ -351,13 +355,7 @@ export default class MemberList extends React.Component {
}
// Fourth by name (alphabetical)
const nameA = (memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name).replace(SORT_REGEX, "");
const nameB = (memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name).replace(SORT_REGEX, "");
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB, {
ignorePunctuation: true,
sensitivity: "base",
});
return this.collator.compare(memberA.sortName, memberB.sortName);
};
onSearchQueryChanged = searchQuery => {
@ -422,7 +420,7 @@ export default class MemberList extends React.Component {
} else {
// Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(m)} />;
onClick={() => this._onPending3pidInviteClick(m)} />;
}
});
}
@ -484,10 +482,10 @@ export default class MemberList extends React.Component {
if (this._getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
}
const footer = (
@ -520,9 +518,9 @@ export default class MemberList extends React.Component {
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined} />
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined} />
{ invitedHeader }
{ invitedSection }
</div>

View file

@ -1,111 +0,0 @@
/*
Copyright 2017 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import {formatFullDate} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PinnedEventTile")
export default class PinnedEventTile extends React.Component {
static propTypes = {
mxRoom: PropTypes.object.isRequired,
mxEvent: PropTypes.object.isRequired,
onUnpinned: PropTypes.func,
};
onTileClicked = () => {
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
};
onUnpinClicked = () => {
const pinnedEvents = this.props.mxRoom.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
// Nothing to do: already unpinned
if (this.props.onUnpinned) this.props.onUnpinned();
} else {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(this.props.mxEvent.getId());
if (index !== -1) {
pinned.splice(index, 1);
MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '')
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
} else if (this.props.onUnpinned) this.props.onUnpinned();
}
};
_canUnpin() {
return this.props.mxRoom.currentState.mayClientSendStateEvent('m.room.pinned_events', MatrixClientPeg.get());
}
render() {
const sender = this.props.mxEvent.getSender();
// Get the latest sender profile rather than historical
const senderProfile = this.props.mxRoom.getMember(sender);
const avatarSize = 40;
let unpinButton = null;
if (this._canUnpin()) {
unpinButton = (
<AccessibleButton onClick={this.onUnpinClicked} className="mx_PinnedEventTile_unpinButton">
<img src={require("../../../../res/img/cancel-red.svg")} width="8" height="8" alt={_t('Unpin Message')} title={_t('Unpin Message')} />
</AccessibleButton>
);
}
return (
<div className="mx_PinnedEventTile">
<div className="mx_PinnedEventTile_actions">
<AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
{ _t("Jump to message") }
</AccessibleButton>
{ unpinButton }
</div>
<span className="mx_PinnedEventTile_senderAvatar">
<MemberAvatar member={senderProfile} width={avatarSize} height={avatarSize} fallbackUserId={sender} />
</span>
<span className="mx_PinnedEventTile_sender">
{ senderProfile ? senderProfile.name : sender }
</span>
<span className="mx_PinnedEventTile_timestamp">
{ formatFullDate(new Date(this.props.mxEvent.getTs())) }
</span>
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.mxEvent}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
</div>
);
}
}

View file

@ -0,0 +1,104 @@
/*
Copyright 2017 Travis Ralston
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps {
room: Room;
event: MatrixEvent;
onUnpinClicked?(): void;
}
const AVATAR_SIZE = 24;
@replaceableComponent("views.rooms.PinnedEventTile")
export default class PinnedEventTile extends React.Component<IProps> {
public static contextType = MatrixClientContext;
private onTileClicked = () => {
dis.dispatch({
action: 'view_room',
event_id: this.props.event.getId(),
highlighted: true,
room_id: this.props.event.getRoomId(),
});
};
render() {
const sender = this.props.event.getSender();
const senderProfile = this.props.room.getMember(sender);
let unpinButton = null;
if (this.props.onUnpinClicked) {
unpinButton = (
<AccessibleTooltipButton
onClick={this.props.onUnpinClicked}
className="mx_PinnedEventTile_unpinButton"
title={_t("Unpin")}
/>
);
}
return <div className="mx_PinnedEventTile">
<MemberAvatar
className="mx_PinnedEventTile_senderAvatar"
member={senderProfile}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
fallbackUserId={sender}
/>
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
{ senderProfile?.name || sender }
</span>
{ unpinButton }
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.event}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
<div className="mx_PinnedEventTile_footer">
<span className="mx_PinnedEventTile_timestamp">
{ formatDate(new Date(this.props.event.getTs())) }
</span>
<AccessibleButton onClick={this.onTileClicked} kind="link">
{ _t("View message") }
</AccessibleButton>
</div>
</div>;
}
}

View file

@ -1,145 +0,0 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import PinnedEventTile from "./PinnedEventTile";
import { _t } from '../../../languageHandler';
import PinningUtils from "../../../utils/PinningUtils";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PinnedEventsPanel")
export default class PinnedEventsPanel extends React.Component {
static propTypes = {
// The Room from the js-sdk we're going to show pinned events for
room: PropTypes.object.isRequired,
onCancelClick: PropTypes.func,
};
state = {
loading: true,
};
componentDidMount() {
this._updatePinnedMessages();
MatrixClientPeg.get().on("RoomState.events", this._onStateEvent);
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent);
}
}
_onStateEvent = ev => {
if (ev.getRoomId() === this.props.room.roomId && ev.getType() === "m.room.pinned_events") {
this._updatePinnedMessages();
}
};
_updatePinnedMessages = () => {
const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
this.setState({ loading: false, pinned: [] });
} else {
const promises = [];
const cli = MatrixClientPeg.get();
pinnedEvents.getContent().pinned.map((eventId) => {
promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(
(timeline) => {
const event = timeline.getEvents().find((e) => e.getId() === eventId);
return {eventId, timeline, event};
}).catch((err) => {
console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
console.error(err);
return null; // return lack of context to avoid unhandled errors
}));
});
Promise.all(promises).then((contexts) => {
// Filter out the messages before we try to render them
const pinned = contexts.filter((context) => PinningUtils.isPinnable(context.event));
this.setState({ loading: false, pinned });
});
}
this._updateReadState();
};
_updateReadState() {
const pinnedEvents = this.props.room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinnedEvents) return; // nothing to read
let readStateEvents = [];
const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
if (readPinsEvent && readPinsEvent.getContent()) {
readStateEvents = readPinsEvent.getContent().event_ids || [];
}
if (!readStateEvents.includes(pinnedEvents.getId())) {
readStateEvents.push(pinnedEvents.getId());
// Only keep the last 10 event IDs to avoid infinite growth
readStateEvents = readStateEvents.reverse().splice(0, 10).reverse();
MatrixClientPeg.get().setRoomAccountData(this.props.room.roomId, "im.vector.room.read_pins", {
event_ids: readStateEvents,
});
}
}
_getPinnedTiles() {
if (this.state.pinned.length === 0) {
return (<div>{ _t("No pinned messages.") }</div>);
}
return this.state.pinned.map((context) => {
return (
<PinnedEventTile
key={context.event.getId()}
mxRoom={this.props.room}
mxEvent={context.event}
onUnpinned={this._updatePinnedMessages}
/>
);
});
}
render() {
let tiles = <div>{ _t("Loading...") }</div>;
if (this.state && !this.state.loading) {
tiles = this._getPinnedTiles();
}
return (
<div className="mx_PinnedEventsPanel">
<div className="mx_PinnedEventsPanel_body">
<AccessibleButton className="mx_PinnedEventsPanel_cancel" onClick={this.props.onCancelClick}>
<img className="mx_filterFlipColor" src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
<h3 className="mx_PinnedEventsPanel_header">{ _t("Pinned Messages") }</h3>
{ tiles }
</div>
</div>
);
}
}

View file

@ -19,7 +19,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
@ -29,8 +29,8 @@ import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import {PlaceCallType} from "../../../CallHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { PlaceCallType } from "../../../CallHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.RoomHeader")
export default class RoomHeader extends React.Component {
@ -39,7 +39,6 @@ export default class RoomHeader extends React.Component {
oobData: PropTypes.object,
inRoom: PropTypes.bool,
onSettingsClick: PropTypes.func,
onPinnedClick: PropTypes.func,
onSearchClick: PropTypes.func,
onLeaveClick: PropTypes.func,
e2eStatus: PropTypes.string,
@ -56,14 +55,12 @@ export default class RoomHeader extends React.Component {
componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("Room.accountData", this._onRoomAccountData);
}
componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents);
cli.removeListener("Room.accountData", this._onRoomAccountData);
}
}
@ -76,47 +73,13 @@ export default class RoomHeader extends React.Component {
this._rateLimitedUpdate();
};
_onRoomAccountData = (event, room) => {
if (!this.props.room || room.roomId !== this.props.room.roomId) return;
if (event.getType() !== "im.vector.room.read_pins") return;
this._rateLimitedUpdate();
};
_rateLimitedUpdate = new RateLimitedFunc(function() {
/* eslint-disable babel/no-invalid-this */
this.forceUpdate();
}, 500);
_hasUnreadPins() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
if (currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0) {
return false; // no pins == nothing to read
}
const readPinsEvent = this.props.room.getAccountData("im.vector.room.read_pins");
if (readPinsEvent && readPinsEvent.getContent()) {
const readStateEvents = readPinsEvent.getContent().event_ids || [];
if (readStateEvents) {
return !readStateEvents.includes(currentPinEvent.getId());
}
}
// There's pins, and we haven't read any of them
return true;
}
_hasPins() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
return !(currentPinEvent.getContent().pinned && currentPinEvent.getContent().pinned.length <= 0);
}
render() {
let searchStatus = null;
let pinnedEventsButton = null;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
@ -173,24 +136,6 @@ export default class RoomHeader extends React.Component {
/>;
}
if (this.props.onPinnedClick && SettingsStore.getValue('feature_pinning')) {
let pinsIndicator = null;
if (this._hasUnreadPins()) {
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator mx_RoomHeader_pinsIndicatorUnread" />);
} else if (this._hasPins()) {
pinsIndicator = (<div className="mx_RoomHeader_pinsIndicator" />);
}
pinnedEventsButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_pinnedButton"
onClick={this.props.onPinnedClick}
title={_t("Pinned Messages")}
>
{ pinsIndicator }
</AccessibleTooltipButton>;
}
let forgetButton;
if (this.props.onForgetClick) {
forgetButton =
@ -240,7 +185,6 @@ export default class RoomHeader extends React.Component {
<div className="mx_RoomHeader_buttons">
{ videoCallButton }
{ voiceCallButton }
{ pinnedEventsButton }
{ forgetButton }
{ appsButton }
{ searchButton }
@ -256,7 +200,7 @@ export default class RoomHeader extends React.Component {
{ name }
{ topicElement }
{ rightRow }
<RoomHeaderButtons />
<RoomHeaderButtons room={this.props.room} />
</div>
</div>
);

View file

@ -105,6 +105,7 @@ interface IState {
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private tilesRef = createRef<HTMLDivElement>();
private dispatcherRef: string;
private layout: ListLayout;
private heightAtStart: number;
@ -246,11 +247,15 @@ export default class RoomSublist extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
// 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.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent);
}
private onListsUpdated = () => {
@ -755,7 +760,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
);
}
private onScrollPrevent(e: React.UIEvent<HTMLDivElement>) {
private onScrollPrevent(e: Event) {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/element-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
@ -884,7 +889,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
className="mx_RoomSublist_resizeBox"
enable={handles}
>
<div className="mx_RoomSublist_tiles" onScroll={this.onScrollPrevent}>
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
{showNButton}

View file

@ -43,12 +43,10 @@ export default class SimpleRoomHeader extends React.Component {
}
return (
<div className="mx_RoomHeader" >
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title }
</div>
<div className="mx_RoomHeader mx_RoomHeader_wrapper" >
<div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title }
</div>
</div>
);

View file

@ -25,6 +25,7 @@ import Timer from '../../../utils/Timer';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { compare } from "../../../utils/strings";
interface IProps {
// the room this statusbar is representing.
@ -207,14 +208,14 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
usersTyping = usersTyping.concat(stoppedUsersOnTimer);
// sort them so the typing members don't change order when
// moved to delayedStopTypingTimers
usersTyping.sort((a, b) => a.name.localeCompare(b.name));
usersTyping.sort((a, b) => compare(a.name, b.name));
const typingString = WhoIsTyping.whoIsTypingString(
usersTyping,
this.props.whoIsTypingLimit,
);
if (!typingString) {
return (<div className="mx_WhoIsTypingTile_empty" />);
return null;
}
return (

View file

@ -79,8 +79,8 @@ export default class CrossSigningPanel extends React.PureComponent {
async _getUpdatedStatus() {
const cli = MatrixClientPeg.get();
const pkCache = cli.getCrossSigningCacheCallbacks();
const crossSigning = cli._crypto._crossSigningInfo;
const secretStorage = cli._crypto._secretStorage;
const crossSigning = cli.crypto._crossSigningInfo;
const secretStorage = cli.crypto._secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const masterPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("master"));

View file

@ -131,10 +131,10 @@ export default class SecureBackupPanel extends React.PureComponent {
async _getUpdatedDiagnostics() {
const cli = MatrixClientPeg.get();
const secretStorage = cli._crypto._secretStorage;
const secretStorage = cli.crypto._secretStorage;
const backupKeyStored = !!(await cli.isKeyBackupKeyStored());
const backupKeyFromCache = await cli._crypto.getSessionBackupPrivateKey();
const backupKeyFromCache = await cli.crypto.getSessionBackupPrivateKey();
const backupKeyCached = !!(backupKeyFromCache);
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
const secretStorageKeyInAccount = await secretStorage.hasKey();

View file

@ -25,6 +25,7 @@ import {EventType} from "matrix-js-sdk/src/@types/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { compare } from "../../../../../utils/strings";
const plEventsToLabels = {
// These will be translated for us later.
@ -312,7 +313,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
// comparator for sorting PL users lexicographically on PL descending, MXID ascending. (case-insensitive)
const comparator = (a, b) => {
const plDiff = userLevels[b.key] - userLevels[a.key];
return plDiff !== 0 ? plDiff : a.key.toLocaleLowerCase().localeCompare(b.key.toLocaleLowerCase());
return plDiff !== 0 ? plDiff : compare(a.key.toLocaleLowerCase(), b.key.toLocaleLowerCase());
};
privilegedUsers.sort(comparator);

View file

@ -35,9 +35,10 @@ import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import {UIFeature} from "../../../../../settings/UIFeature";
import {Layout} from "../../../../../settings/Layout";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { UIFeature } from "../../../../../settings/UIFeature";
import { Layout } from "../../../../../settings/Layout";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { compare } from "../../../../../utils/strings";
interface IProps {
}
@ -295,7 +296,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
const customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => compare(a.name, b.name));
const orderedThemes = [...builtInThemes, ...customThemes];
return (
<div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection">

View file

@ -15,17 +15,14 @@ limitations under the License.
*/
import * as React from "react";
import { ensureDMExists } from "../../../createRoom";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import dis from '../../../dispatcher/dispatcher';
import Modal from "../../../Modal";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import CallHandler from "../../../CallHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { DialNumberPayload } from "../../../dispatcher/payloads/DialNumberPayload";
import { Action } from "../../../dispatcher/actions";
interface IProps {
onFinished: (boolean) => void;
@ -67,21 +64,11 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
}
onDialPress = async () => {
const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
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"),
});
}
const userId = results[0].userid;
const roomId = await ensureDMExists(MatrixClientPeg.get(), userId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
const payload: DialNumberPayload = {
action: Action.DialNumber,
number: this.state.value,
};
dis.dispatch(payload);
this.props.onFinished(true);
}

View file

@ -16,8 +16,8 @@ limitations under the License.
import { createContext } from "react";
import {IState} from "../components/structures/RoomView";
import {Layout} from "../settings/Layout";
import { IState } from "../components/structures/RoomView";
import { Layout } from "../settings/Layout";
const RoomContext = createContext<IState>({
roomLoading: true,
@ -31,7 +31,6 @@ const RoomContext = createContext<IState>({
canPeek: false,
showApps: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: true,
joining: false,

View file

@ -100,6 +100,12 @@ export enum Action {
*/
OpenDialPad = "open_dial_pad",
/**
* Dial the phone number in the payload
* payload: DialNumberPayload
*/
DialNumber = "dial_number",
/**
* Fired when CallHandler has checked for PSTN protocol support
* payload: none

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 Travis Ralston
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.
@ -14,24 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_PinnedEventsPanel {
border-top: 1px solid $primary-hairline-color;
}
import { ActionPayload } from "../payloads";
import { Action } from "../actions";
.mx_PinnedEventsPanel_body {
max-height: 300px;
overflow-y: auto;
padding-bottom: 15px;
}
.mx_PinnedEventsPanel_header {
margin: 0;
padding-top: 8px;
padding-bottom: 15px;
}
.mx_PinnedEventsPanel_cancel {
margin: 12px;
float: right;
display: inline-block;
export interface DialNumberPayload extends ActionPayload {
action: Action.DialNumber;
number: string;
}

View file

@ -14,14 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {useState, useEffect, DependencyList} from 'react';
import { useState, useEffect, DependencyList } from 'react';
type Fn<T> = () => Promise<T>;
export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T): T => {
const [value, setValue] = useState<T>(initialValue);
useEffect(() => {
fn().then(setValue);
let discard = false;
fn().then(v => {
if (!discard) {
setValue(v);
}
});
return () => {
discard = true;
};
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return value;
};

View file

@ -63,6 +63,8 @@
"Already in call": "Already in call",
"You're already in a call with this person.": "You're already in a call with this person.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",
"Permission Required": "Permission Required",
@ -898,8 +900,6 @@
"Fill Screen": "Fill Screen",
"Return to call": "Return to call",
"%(name)s on hold": "%(name)s on hold",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Dial pad": "Dial pad",
"Unknown caller": "Unknown caller",
"Incoming voice call": "Incoming voice call",
@ -1509,11 +1509,8 @@
"Invite to just this room": "Invite to just this room",
"Add a photo, so people can easily spot your room.": "Add a photo, so people can easily spot your room.",
"This is the start of <roomName/>.": "This is the start of <roomName/>.",
"No pinned messages.": "No pinned messages.",
"Loading...": "Loading...",
"Pinned Messages": "Pinned Messages",
"Unpin Message": "Unpin Message",
"Jump to message": "Jump to message",
"Unpin": "Unpin",
"View message": "View message",
"%(duration)ss": "%(duration)ss",
"%(duration)sm": "%(duration)sm",
"%(duration)sh": "%(duration)sh",
@ -1719,9 +1716,11 @@
"The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to",
"Yours, or the other users internet connection": "Yours, or the other users internet connection",
"Yours, or the other users session": "Yours, or the other users session",
"Youre all caught up": "Youre all caught up",
"You have no visible notifications.": "You have no visible notifications.",
"Pinned messages": "Pinned messages",
"Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin": "Unpin",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Set my room layout for everyone": "Set my room layout for everyone",
@ -1897,6 +1896,7 @@
"Add rooms to this community": "Add rooms to this community",
"Filter community rooms": "Filter community rooms",
"Something went wrong when trying to get your communities.": "Something went wrong when trying to get your communities.",
"Loading...": "Loading...",
"Display your community flair in rooms configured to show it.": "Display your community flair in rooms configured to show it.",
"You're not currently a member of any communities.": "You're not currently a member of any communities.",
"Frequently Used": "Frequently Used",
@ -1951,7 +1951,6 @@
"Rotate Right": "Rotate Right",
"Download": "Download",
"Information": "Information",
"View message": "View message",
"Language Dropdown": "Language Dropdown",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
@ -2472,6 +2471,7 @@
"Unable to reject invite": "Unable to reject invite",
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
"Forward Message": "Forward Message",
"Unpin Message": "Unpin Message",
"Pin Message": "Pin Message",
"Unhide Preview": "Unhide Preview",
"Share Permalink": "Share Permalink",
@ -2634,8 +2634,6 @@
"Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"Communities are changing to Spaces": "Communities are changing to Spaces",
"Youre all caught up": "Youre all caught up",
"You have no visible notifications.": "You have no visible notifications.",
"%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.",
"%(brand)s failed to get the public room list.": "%(brand)s failed to get the public room list.",
"The homeserver may be unavailable or overloaded.": "The homeserver may be unavailable or overloaded.",

View file

@ -453,7 +453,7 @@ export default class EventIndex extends EventEmitter {
let res;
try {
res = await client._createMessagesRequest(
res = await client.createMessagesRequest(
checkpoint.roomId, checkpoint.token, this._eventsPerCrawl,
checkpoint.direction);
} catch (e) {

View file

@ -28,6 +28,7 @@ import WidgetUtils from "../utils/WidgetUtils";
import {MatrixClientPeg} from "../MatrixClientPeg";
import SettingsStore from "../settings/SettingsStore";
import url from 'url';
import { compare } from "../utils/strings";
const KIND_PREFERENCE = [
// Ordered: first is most preferred, last is least preferred.
@ -152,7 +153,7 @@ export class IntegrationManagers {
if (kind === Kind.Account) {
// Order by state_keys (IDs)
managers.sort((a, b) => a.id.localeCompare(b.id));
managers.sort((a, b) => compare(a.id, b.id));
}
ordered.push(...managers);

View file

@ -92,8 +92,8 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true) {
body.append('cross_signing_key', client.getCrossSigningId());
// add cross-signing status information
const crossSigning = client._crypto._crossSigningInfo;
const secretStorage = client._crypto._secretStorage;
const crossSigning = client.crypto._crossSigningInfo;
const secretStorage = client.crypto._secretStorage;
body.append("cross_signing_ready", String(await client.isCrossSigningReady()));
body.append("cross_signing_supported_by_hs",
@ -114,7 +114,7 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true) {
body.append("secret_storage_key_in_account", String(!!(await secretStorage.hasKey())));
body.append("session_backup_key_in_secret_storage", String(!!(await client.isKeyBackupKeyStored())));
const sessionBackupKeyFromCache = await client._crypto.getSessionBackupPrivateKey();
const sessionBackupKeyFromCache = await client.crypto.getSessionBackupPrivateKey();
body.append("session_backup_key_cached", String(!!sessionBackupKeyFromCache));
body.append("session_backup_key_well_formed", String(sessionBackupKeyFromCache instanceof Uint8Array));
}

View file

@ -601,10 +601,6 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Enable widget screenshots on supported widgets'),
default: false,
},
"PinnedEvents.isOpen": {
supportedLevels: [SettingLevel.ROOM_DEVICE],
default: false,
},
"promptBeforeInviteUnknownUsers": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Prompt before sending invites to potentially invalid matrix IDs'),

View file

@ -126,7 +126,7 @@ export class CommunityPrototypeStore extends AsyncStoreWithClient<IState> {
if (membership === EffectiveMembership.Invite) {
try {
const path = utils.encodeUri("/rooms/$roomId/group_info", {$roomId: room.roomId});
const profile = await this.matrixClient._http.authedRequest(
const profile = await this.matrixClient.http.authedRequest(
undefined, "GET", path,
undefined, undefined,
{prefix: "/_matrix/client/unstable/im.vector.custom"});

View file

@ -65,6 +65,10 @@ class FlairStore extends EventEmitter {
delete this._userGroups[userId];
}
cachedPublicisedGroups(userId) {
return this._userGroups[userId];
}
getPublicisedGroupsCached(matrixClient, userId) {
if (this._userGroups[userId]) {
return Promise.resolve(this._userGroups[userId]);

View file

@ -24,6 +24,7 @@ export enum RightPanelPhases {
EncryptionPanel = 'EncryptionPanel',
RoomSummary = 'RoomSummary',
Widget = 'Widget',
PinnedMessages = "PinnedMessages",
Room3pidMemberInfo = 'Room3pidMemberInfo',
// Group stuff
@ -43,6 +44,7 @@ export enum RightPanelPhases {
export const RIGHT_PANEL_PHASES_NO_ARGS = [
RightPanelPhases.RoomSummary,
RightPanelPhases.NotificationPanel,
RightPanelPhases.PinnedMessages,
RightPanelPhases.FilePanel,
RightPanelPhases.RoomMemberList,
RightPanelPhases.GroupMemberList,

View file

@ -196,7 +196,7 @@ export class SetupEncryptionStore extends EventEmitter {
this.phase = PHASE_FINISHED;
this.emit("update");
// async - ask other clients for keys, if necessary
MatrixClientPeg.get()._crypto.cancelAndResendAllOutgoingKeyRequests();
MatrixClientPeg.get().crypto.cancelAndResendAllOutgoingKeyRequests();
}
async _setActiveVerificationRequest(request) {

View file

@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room";
import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm";
import { compare } from "../../../../utils/strings";
/**
* Sorts rooms according to the browser's determination of alphabetic.
@ -24,7 +25,7 @@ import { IAlgorithm } from "./IAlgorithm";
export class AlphabeticAlgorithm implements IAlgorithm {
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
return rooms.sort((a, b) => {
return a.name.localeCompare(b.name);
return compare(a.name, b.name);
});
}
}

View file

@ -25,6 +25,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SettingLevel } from "../../settings/SettingLevel";
import { arrayFastClone } from "../../utils/arrays";
import { UPDATE_EVENT } from "../AsyncStore";
import { compare } from "../../utils/strings";
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
@ -240,7 +241,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
if (orderA === orderB) {
// We just need a tiebreak
return a.id.localeCompare(b.id);
return compare(a.id, b.id);
}
return orderA - orderB;

View file

@ -73,3 +73,14 @@ export function copyNode(ref: Element): boolean {
selectText(ref);
return document.execCommand('copy');
}
const collator = new Intl.Collator();
/**
* Performant language-sensitive string comparison
* @param a the first string to compare
* @param b the second string to compare
*/
export function compare(a: string, b: string): number {
return collator.compare(a, b);
}

View file

@ -23,8 +23,10 @@ import dis from '../src/dispatcher/dispatcher';
import { CallEvent, CallState } from 'matrix-js-sdk/src/webrtc/call';
import DMRoomMap from '../src/utils/DMRoomMap';
import EventEmitter from 'events';
import { Action } from '../src/dispatcher/actions';
import SdkConfig from '../src/SdkConfig';
import { ActionPayload } from '../src/dispatcher/payloads';
import { Actions } from '../src/notifications/types';
import { Action } from '../src/dispatcher/actions';
const REAL_ROOM_ID = '$room1:example.org';
const MAPPED_ROOM_ID = '$room2:example.org';
@ -75,6 +77,18 @@ class FakeCall extends EventEmitter {
}
}
function untilDispatch(waitForAction: string): Promise<ActionPayload> {
let dispatchHandle;
return new Promise<ActionPayload>(resolve => {
dispatchHandle = dis.register(payload => {
if (payload.action === waitForAction) {
dis.unregister(dispatchHandle);
resolve(payload);
}
});
});
}
describe('CallHandler', () => {
let dmRoomMap;
let callHandler;
@ -94,6 +108,21 @@ describe('CallHandler', () => {
callHandler = new CallHandler();
callHandler.start();
const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org');
const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org');
const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org');
MatrixClientPeg.get().getRoom = roomId => {
switch (roomId) {
case REAL_ROOM_ID:
return realRoom;
case MAPPED_ROOM_ID:
return mappedRoom;
case MAPPED_ROOM_ID_2:
return mappedRoom2;
}
};
dmRoomMap = {
getUserIdForRoomId: roomId => {
if (roomId === REAL_ROOM_ID) {
@ -134,38 +163,34 @@ describe('CallHandler', () => {
SdkConfig.unset();
});
it('should look up the correct user and open the room when a phone number is dialled', async () => {
MatrixClientPeg.get().getThirdpartyUser = jest.fn().mockResolvedValue([{
userid: '@user2:example.org',
protocol: "im.vector.protocol.sip_native",
fields: {
is_native: true,
lookup_success: true,
},
}]);
dis.dispatch({
action: Action.DialNumber,
number: '01818118181',
}, true);
const viewRoomPayload = await untilDispatch('view_room');
expect(viewRoomPayload.room_id).toEqual(MAPPED_ROOM_ID);
});
it('should move calls between rooms when remote asserted identity changes', async () => {
const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org');
const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org');
const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org');
MatrixClientPeg.get().getRoom = roomId => {
switch (roomId) {
case REAL_ROOM_ID:
return realRoom;
case MAPPED_ROOM_ID:
return mappedRoom;
case MAPPED_ROOM_ID_2:
return mappedRoom2;
}
};
dis.dispatch({
action: 'place_call',
type: PlaceCallType.Voice,
room_id: REAL_ROOM_ID,
}, true);
let dispatchHandle;
// wait for the call to be set up
await new Promise<void>(resolve => {
dispatchHandle = dis.register(payload => {
if (payload.action === 'call_state') {
resolve();
}
});
});
dis.unregister(dispatchHandle);
await untilDispatch('call_state');
// should start off in the actual room ID it's in at the protocol level
expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBe(fakeCall);

View file

@ -309,7 +309,7 @@ describe('MessagePanel', function() {
const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container');
// it should follow the <li> which wraps the event tile for event 4
const eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode;
const eventContainer = ReactDOM.findDOMNode(tiles[4]);
expect(rm.previousSibling).toEqual(eventContainer);
});
@ -365,7 +365,7 @@ describe('MessagePanel', function() {
const tiles = TestUtils.scryRenderedComponentsWithType(
mp, sdk.getComponent('rooms.EventTile'));
const tileContainers = tiles.map(function(t) {
return ReactDOM.findDOMNode(t).parentNode;
return ReactDOM.findDOMNode(t);
});
// find the <li> which wraps the read marker
@ -460,7 +460,7 @@ describe('MessagePanel', function() {
/>,
);
const Dates = res.find(sdk.getComponent('messages.DateSeparator'));
expect(Dates.length).toEqual(1);
});
});

View file

@ -9,6 +9,8 @@ import sdk from '../../../skinned-sdk';
import {Room, RoomMember, User} from 'matrix-js-sdk';
import { compare } from "../../../../src/utils/strings";
function generateRoomId() {
return '!' + Math.random().toString().slice(2, 10) + ':domain';
}
@ -173,7 +175,7 @@ describe('MemberList', () => {
if (!groupChange) {
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
const nameCompare = nameB.localeCompare(nameA);
const nameCompare = compare(nameB, nameA);
console.log("Comparing name");
expect(nameCompare).toBeGreaterThanOrEqual(0);
} else {

View file

@ -90,7 +90,7 @@ export function createTestClient() {
}),
// Used by various internal bits we aren't concerned with (yet)
_sessionStore: {
sessionStore: {
store: {
getItem: jest.fn(),
},