Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into export-conversations

This commit is contained in:
Jaiwanth 2021-06-24 18:32:52 +05:30
commit 544761329c
39 changed files with 757 additions and 472 deletions

View file

@ -80,7 +80,7 @@
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"matrix-js-sdk": "12.0.0", "matrix-js-sdk": "12.0.0",
"matrix-widget-api": "^0.1.0-beta.14", "matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
@ -126,7 +126,7 @@
"@sinonjs/fake-timers": "^7.0.2", "@sinonjs/fake-timers": "^7.0.2",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/diff-match-patch": "^1.0.5", "@types/diff-match-patch": "^1.0.32",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/linkifyjs": "^2.1.3", "@types/linkifyjs": "^2.1.3",
@ -175,7 +175,7 @@
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [
"<rootDir>/test/**/*-test.[jt]s" "<rootDir>/test/**/*-test.[jt]s?(x)"
], ],
"setupFiles": [ "setupFiles": [
"jest-canvas-mock" "jest-canvas-mock"

View file

@ -71,7 +71,7 @@ limitations under the License.
&::before { &::before {
background-color: #ffffff; background-color: #ffffff;
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-size: 90%; mask-size: 80%;
} }
&::after { &::after {

View file

@ -295,6 +295,7 @@ limitations under the License.
.mx_InviteDialog_content { .mx_InviteDialog_content {
overflow: hidden; overflow: hidden;
height: 100%;
} }
} }
@ -316,3 +317,42 @@ limitations under the License.
.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { .mx_InviteDialog_helpText .mx_AccessibleButton_kind_link {
padding: 0; padding: 0;
} }
.mx_InviteDialog_multiInviterError {
> h4 {
font-size: $font-15px;
line-height: $font-24px;
color: $secondary-fg-color;
font-weight: normal;
}
> div {
.mx_InviteDialog_multiInviterError_entry {
margin-bottom: 24px;
.mx_InviteDialog_multiInviterError_entry_userProfile {
.mx_InviteDialog_multiInviterError_entry_name {
margin-left: 6px;
font-size: $font-15px;
line-height: $font-24px;
font-weight: $font-semi-bold;
color: $primary-fg-color;
}
.mx_InviteDialog_multiInviterError_entry_userId {
margin-left: 6px;
font-size: $font-12px;
line-height: $font-15px;
color: $tertiary-fg-color;
}
}
.mx_InviteDialog_multiInviterError_entry_error {
margin-left: 32px;
font-size: $font-15px;
line-height: $font-24px;
color: $notice-primary-color;
}
}
}
}

View file

@ -21,7 +21,7 @@ limitations under the License.
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
&.mx_cryptoEvent_icon::after { &.mx_cryptoEvent_icon::after {

View file

@ -45,7 +45,7 @@ limitations under the License.
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
// transparent-looking border surrounding the shield for when overlain over avatars // transparent-looking border surrounding the shield for when overlain over avatars
@ -59,7 +59,7 @@ limitations under the License.
} }
// shrink the infill of the badge // shrink the infill of the badge
&::before { &::before {
mask-size: 65%; mask-size: 60%;
} }
} }

View file

@ -345,7 +345,7 @@ $hover-select-border: 4px;
mask-image: url('$(res)/img/e2e/normal.svg'); mask-image: url('$(res)/img/e2e/normal.svg');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: 90%; mask-size: 80%;
} }
} }

View file

@ -23,7 +23,7 @@ limitations under the License.
.mx_DialPad_button { .mx_DialPad_button {
width: 40px; width: 40px;
height: 40px; height: 40px;
background-color: $theme-button-bg-color; background-color: $dialpad-button-bg-color;
border-radius: 40px; border-radius: 40px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;

View file

@ -27,9 +27,22 @@ limitations under the License.
} }
.mx_DialPadContextMenu_dialled { .mx_DialPadContextMenu_dialled {
height: 1em; height: 1.5em;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
max-width: 150px;
border: none;
margin: 0px;
}
.mx_DialPadContextMenu_dialled input {
font-size: 18px;
font-weight: 600;
overflow: hidden;
max-width: 150px;
text-align: left;
direction: rtl;
padding: 8px 0px;
background-color: rgb(0, 0, 0, 0);
} }
.mx_DialPadContextMenu_dialPad { .mx_DialPadContextMenu_dialPad {

View file

@ -118,6 +118,9 @@ $voipcall-plinth-color: #394049;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #6F7882;
;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $bg-color; $roomlist-filter-active-bg-color: $bg-color;

View file

@ -114,6 +114,8 @@ $voipcall-plinth-color: #394049;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #6F7882;
;
$roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-filter-active-bg-color: $roomlist-button-bg-color;

View file

@ -181,6 +181,8 @@ $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$roomlist-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $roomlist-button-bg-color; $roomlist-filter-active-bg-color: $roomlist-button-bg-color;

View file

@ -173,6 +173,8 @@ $voipcall-plinth-color: #F4F6FA;
// ******************** // ********************
$theme-button-bg-color: #e3e8f0; $theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: #ffffff; $roomlist-filter-active-bg-color: #ffffff;

View file

@ -15,20 +15,8 @@ limitations under the License.
*/ */
declare module "diff-dom" { declare module "diff-dom" {
enum Action {
AddElement = "addElement",
AddTextElement = "addTextElement",
RemoveTextElement = "removeTextElement",
RemoveElement = "removeElement",
ReplaceElement = "replaceElement",
ModifyTextElement = "modifyTextElement",
AddAttribute = "addAttribute",
RemoveAttribute = "removeAttribute",
ModifyAttribute = "modifyAttribute",
}
export interface IDiff { export interface IDiff {
action: Action; action: string;
name: string; name: string;
text?: string; text?: string;
route: number[]; route: number[];

View file

@ -1,85 +0,0 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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 SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default {
hasAnyLabeledDevices: async function() {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => !!d.label);
},
getDevices: function() {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
const audiooutput = [];
const audioinput = [];
const videoinput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audiooutput.push(device); break;
case 'audioinput': audioinput.push(device); break;
case 'videoinput': videoinput.push(device); break;
}
});
// console.log("Loaded WebRTC Devices", mediaDevices);
return {
audiooutput,
audioinput,
videoinput,
};
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
},
loadDevices: function() {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
},
getAudioInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
},
getVideoInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
},
};

120
src/MediaDeviceHandler.ts Normal file
View file

@ -0,0 +1,120 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events';
interface IMediaDevices {
audioOutput: Array<MediaDeviceInfo>;
audioInput: Array<MediaDeviceInfo>;
videoInput: Array<MediaDeviceInfo>;
}
export enum MediaDeviceHandlerEvent {
AudioOutputChanged = "audio_output_changed",
}
export default class MediaDeviceHandler extends EventEmitter {
private static internalInstance;
public static get instance(): MediaDeviceHandler {
if (!MediaDeviceHandler.internalInstance) {
MediaDeviceHandler.internalInstance = new MediaDeviceHandler();
}
return MediaDeviceHandler.internalInstance;
}
public static async hasAnyLabeledDevices(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => Boolean(d.label));
}
public static async getDevices(): Promise<IMediaDevices> {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioOutput = [];
const audioInput = [];
const videoInput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audioOutput.push(device); break;
case 'audioinput': audioInput.push(device); break;
case 'videoinput': videoInput.push(device); break;
}
});
return { audioOutput, audioInput, videoInput };
} catch (error) {
console.warn('Unable to refresh WebRTC Devices: ', error);
}
}
/**
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
*/
public static loadDevices(): void {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId);
}
public setAudioOutput(deviceId: string): void {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId);
}
/**
* This will not change the device that a potential call uses. The call will
* need to be ended and started again for this change to take effect
* @param {string} deviceId
*/
public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId);
}
public static getAudioOutput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
}
public static getAudioInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
}
public static getVideoInput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
}
}

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,15 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {MatrixClientPeg} from './MatrixClientPeg'; import { Room } from "matrix-js-sdk/src/models/room";
import MultiInviter from './utils/MultiInviter'; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixClientPeg } from './MatrixClientPeg';
import MultiInviter, { CompletionStates } from './utils/MultiInviter';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './'; import * as sdk from './';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog";
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore";
import BaseAvatar from "./components/views/avatars/BaseAvatar";
import { mediaFromMxc } from "./customisations/Media";
export interface IInviteResult {
states: CompletionStates;
inviter: MultiInviter;
}
/** /**
* Invites multiple addresses to a room * Invites multiple addresses to a room
@ -32,15 +41,15 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
* no option to cancel. * no option to cancel.
* *
* @param {string} roomId The ID of the room to invite to * @param {string} roomId The ID of the room to invite to
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @returns {Promise} Promise * @returns {Promise} Promise
*/ */
export function inviteMultipleToRoom(roomId, addrs) { export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId); const inviter = new MultiInviter(roomId);
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
} }
export function showStartChatInviteDialog(initialText) { export function showStartChatInviteDialog(initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it. // This dialog handles the room creation internally - we don't need to worry about it.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
@ -49,7 +58,7 @@ export function showStartChatInviteDialog(initialText) {
); );
} }
export function showRoomInviteDialog(roomId, initialText = "") { export function showRoomInviteDialog(roomId: string, initialText = ""): void {
// This dialog handles the room creation internally - we don't need to worry about it. // This dialog handles the room creation internally - we don't need to worry about it.
Modal.createTrackedDialog( Modal.createTrackedDialog(
"Invite Users", "", InviteDialog, { "Invite Users", "", InviteDialog, {
@ -61,14 +70,14 @@ export function showRoomInviteDialog(roomId, initialText = "") {
); );
} }
export function showCommunityRoomInviteDialog(roomId, communityName) { export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
); );
} }
export function showCommunityInviteDialog(communityId) { export function showCommunityInviteDialog(communityId: string): void {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) { if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId); const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
@ -83,7 +92,7 @@ export function showCommunityInviteDialog(communityId) {
* @param {MatrixEvent} event The event to check * @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise * @returns {boolean} True if valid, false otherwise
*/ */
export function isValid3pidInvite(event) { export function isValid3pidInvite(event: MatrixEvent): boolean {
if (!event || event.getType() !== "m.room.third_party_invite") return false; if (!event || event.getType() !== "m.room.third_party_invite") return false;
// any events without these keys are not valid 3pid invites, so we ignore them // any events without these keys are not valid 3pid invites, so we ignore them
@ -96,7 +105,7 @@ export function isValid3pidInvite(event) {
return true; return true;
} }
export function inviteUsersToRoom(roomId, userIds) { export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter); showAnyInviteErrors(result.states, room, result.inviter);
@ -110,9 +119,14 @@ export function inviteUsersToRoom(roomId, userIds) {
}); });
} }
export function showAnyInviteErrors(addrs, room, inviter) { export function showAnyInviteErrors(
states: CompletionStates,
room: Room,
inviter: MultiInviter,
userMap?: Map<string, Member>,
): boolean {
// Show user any errors // Show user any errors
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); const failedUsers = Object.keys(states).filter(a => states[a] === 'error');
if (failedUsers.length === 1 && inviter.fatal) { if (failedUsers.length === 1 && inviter.fatal) {
// Just get the first message because there was a fatal problem on the first // Just get the first message because there was a fatal problem on the first
// user. This usually means that no other users were attempted, making it // user. This usually means that no other users were attempted, making it
@ -126,19 +140,47 @@ export function showAnyInviteErrors(addrs, room, inviter) {
} else { } else {
const errorList = []; const errorList = [];
for (const addr of failedUsers) { for (const addr of failedUsers) {
if (addrs[addr] === "error") { if (states[addr] === "error") {
const reason = inviter.getErrorText(addr); const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason); errorList.push(addr + ": " + reason);
} }
} }
const cli = MatrixClientPeg.get();
if (errorList.length > 0) { if (errorList.length > 0) {
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution // React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>; const description = <div className="mx_InviteDialog_multiInviterError">
<h4>{ _t("We sent the others, but the below people couldn't be invited to <RoomName/>", {}, {
RoomName: () => <b>{ room.name }</b>,
}) }</h4>
<div>
{ failedUsers.map(addr => {
const user = userMap?.get(addr) || cli.getUser(addr);
const name = (user as Member).name || (user as User).rawDisplayName;
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
return <div key={addr} className="mx_InviteDialog_multiInviterError_entry">
<div className="mx_InviteDialog_multiInviterError_entry_userProfile">
<BaseAvatar
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null}
name={name}
idName={user.userId}
width={24}
height={24}
/>
<span className="mx_InviteDialog_multiInviterError_entry_name">{ name }</span>
<span className="mx_InviteDialog_multiInviterError_entry_userId">{ user.userId }</span>
</div>
<div className="mx_InviteDialog_multiInviterError_entry_error">
{ inviter.getErrorText(addr) }
</div>
</div>;
}) }
</div>
</div>;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), title: _t("Some invites couldn't be sent"),
description, description,
}); });
return false; return false;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const emailRegex = /^\S+@\S+\.\S+$/; import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\S+:\S+$/; const mxUserIdRegex = /^@\S+:\S+$/;
const mxRoomIdRegex = /^!\S+:\S+$/; const mxRoomIdRegex = /^!\S+:\S+$/;
import PropTypes from 'prop-types'; export const addressTypes = ['mx-user-id', 'mx-room-id', 'email'];
export const addressTypes = [
'mx-user-id', 'mx-room-id', 'email', export enum AddressType {
]; Email = "email",
MatrixUserId = "mx-user-id",
MatrixRoomId = "mx-room-id",
}
// PropType definition for an object describing // PropType definition for an object describing
// an address that can be invited to a room (which // an address that can be invited to a room (which
@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({
isKnown: PropTypes.bool, isKnown: PropTypes.bool,
}); });
export function getAddressType(inputText) { export function getAddressType(inputText: string): AddressType | null {
const isEmailAddress = emailRegex.test(inputText); if (emailRegex.test(inputText)) {
const isUserId = mxUserIdRegex.test(inputText); return AddressType.Email;
const isRoomId = mxRoomIdRegex.test(inputText); } else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
// sanity check the input for user IDs } else if (mxRoomIdRegex.test(inputText)) {
if (isEmailAddress) { return AddressType.MatrixRoomId;
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
} else { } else {
return null; return null;
} }

View file

@ -22,7 +22,7 @@ import { MatrixClient } from 'matrix-js-sdk/src/client';
import {Key} from '../../Keyboard'; import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler'; import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager'; import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index'; import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
@ -167,7 +167,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// stash the MatrixClient in case we log out before we are unmounted // stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient; this._matrixClient = this.props.matrixClient;
CallMediaHandler.loadDevices(); MediaDeviceHandler.loadDevices();
fixupColorFonts(); fixupColorFonts();

View file

@ -48,7 +48,7 @@ import createRoom, {IOpts} from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler'; import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController"; import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js"; import { startAnyRegistrationFlow } from "../../Registration";
import { messageForSyncError } from '../../utils/ErrorUtils'; import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";

View file

@ -18,6 +18,7 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import Dialpad from '../voip/DialPad'; import Dialpad from '../voip/DialPad';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -44,13 +45,21 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.setState({value: this.state.value + digit}); this.setState({value: this.state.value + digit});
} }
onChange = (ev) => {
this.setState({value: ev.target.value});
}
render() { render() {
return <ContextMenu {...this.props}> return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header"> <div className="mx_DialPadContextMenu_header">
<div> <div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span> <span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div> </div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div> <Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div> </div>
<div className="mx_DialPadContextMenu_horizSep" /> <div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad"> <div className="mx_DialPadContextMenu_dialPad">

View file

@ -179,7 +179,7 @@ export default class MessageContextMenu extends React.Component {
pinnedIds.push(eventId); pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, { cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [ event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids, ...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []),
eventId, eventId,
], ],
}); });

View file

@ -39,6 +39,9 @@ import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher"; import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -204,6 +207,17 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
setSelectedToAdd(new Set(selectedToAdd)); setSelectedToAdd(new Set(selectedToAdd));
} : null; } : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <div className="mx_AddExistingToSpace"> return <div className="mx_AddExistingToSpace">
<SearchBox <SearchBox
className="mx_textinput_icon mx_textinput_search" className="mx_textinput_icon mx_textinput_search"
@ -216,16 +230,21 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3> <h3>{ _t("Rooms") }</h3>
{ rooms.map(room => { <TruncatedList
return <Entry truncateAt={truncateAt}
key={room.roomId} createOverflowElement={overflowTile}
room={room} getChildren={(start, end) => rooms.slice(start, end).map(room =>
checked={selectedToAdd.has(room)} <Entry
onChange={onChange ? (checked) => { key={room.roomId}
onChange(checked, room); room={room}
} : null} checked={selectedToAdd.has(room)}
/>; onChange={onChange ? (checked) => {
}) } onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div> </div>
) : undefined } ) : undefined }

View file

@ -24,7 +24,7 @@ import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js'; import { addressTypes, getAddressType } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore'; import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email'; import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient'; import IdentityAuthClient from '../../../IdentityAuthClient';

View file

@ -40,6 +40,9 @@ import NotificationBadge from "../rooms/NotificationBadge";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher"; import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
const AVATAR_SIZE = 30; const AVATAR_SIZE = 30;
@ -196,6 +199,17 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}).match(lcQuery); }).match(lcQuery);
} }
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <BaseDialog return <BaseDialog
title={_t("Forward message")} title={_t("Forward message")}
className="mx_ForwardDialog" className="mx_ForwardDialog"
@ -228,15 +242,20 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
<AutoHideScrollbar className="mx_ForwardList_content"> <AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_ForwardList_results"> <div className="mx_ForwardList_results">
{ rooms.map(room => <TruncatedList
<Entry truncateAt={truncateAt}
key={room.roomId} createOverflowElement={overflowTile}
room={room} getChildren={(start, end) => rooms.slice(start, end).map(room =>
event={event} <Entry
matrixClient={cli} key={room.roomId}
onFinished={onFinished} room={room}
/>, event={event}
) } matrixClient={cli}
onFinished={onFinished}
/>,
)}
getChildCount={() => rooms.length}
/>
</div> </div>
) : <span className="mx_ForwardList_noResults"> ) : <span className="mx_ForwardList_noResults">
{ _t("No results") } { _t("No results") }

View file

@ -17,37 +17,45 @@ limitations under the License.
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import {_t, _td} from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import * as Email from "../../../email"; import * as Email from "../../../email";
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
import {abbreviateUrl} from "../../../utils/UrlUtils"; import { abbreviateUrl } from "../../../utils/UrlUtils";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient"; import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize"; import { humanizeTime } from "../../../utils/humanize";
import createRoom, { import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, canEncryptToAllUsers,
ensureDMExists,
findDMForUser,
privateShouldBeEncrypted,
} from "../../../createRoom"; } from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {
import {Key} from "../../../Keyboard"; IInviteResult,
import {Action} from "../../../dispatcher/actions"; inviteMultipleToRoom,
import {DefaultTagID} from "../../../stores/room-list/models"; showAnyInviteErrors,
showCommunityInviteDialog,
} from "../../../RoomInvite";
import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions";
import { DefaultTagID } from "../../../stores/room-list/models";
import RoomListStore from "../../../stores/room-list/RoomListStore"; import RoomListStore from "../../../stores/room-list/RoomListStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress"; import { getAddressType } from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings'; import { compare } from '../../../utils/strings';
@ -74,10 +82,10 @@ export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
// This is the interface that is expected by various components in this file. It is a bit // This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// awkward because it also matches the RoomMember class from the js-sdk with some extra support // It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses. // for 3PIDs/email addresses.
abstract class Member { export abstract class Member {
/** /**
* The display name of this Member. For users this should be their profile's display * The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email). * name or user ID if none set. For 3PIDs this should be the 3PID address (email).
@ -102,7 +110,8 @@ class DirectoryMember extends Member {
private readonly displayName: string; private readonly displayName: string;
private readonly avatarUrl: string; private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { // eslint-disable-next-line camelcase
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) {
super(); super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
this.displayName = userDirResult.display_name; this.displayName = userDirResult.display_name;
@ -601,19 +610,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member})); return members.map(m => ({userId: m.member.userId, user: m.member}));
} }
private shouldAbortAfterInviteError(result): boolean { private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); this.setState({ busy: false });
if (failedUsers.length > 0) { const userMap = new Map<string, Member>(this.state.targets.map(member => [member.userId, member]));
console.log("Failed to invite users: ", result); return !showAnyInviteErrors(result.states, room, result.inviter, userMap);
this.setState({
busy: false,
errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
});
return true; // abort
}
return false;
} }
private convertFilter(): Member[] { private convertFilter(): Member[] {
@ -731,7 +731,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try { try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds) const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too if (!this.shouldAbortAfterInviteError(result, room)) { // handles setting error message too
this.props.onFinished(); this.props.onFinished();
} }

View file

@ -63,7 +63,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef(); private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = { state: IState = {
disabledButtonIds: [], disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter(b => b.disabled)
.map(b => b.id),
}; };
constructor(props) { constructor(props) {

View file

@ -20,9 +20,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js'; import { UserAddressType } from '../../../UserAddress';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
@replaceableComponent("views.elements.AddressTile") @replaceableComponent("views.elements.AddressTile")
export default class AddressTile extends React.Component { export default class AddressTile extends React.Component {

View file

@ -16,31 +16,29 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TruncatedList") interface IProps {
export default class TruncatedList extends React.Component { // The number of elements to show before truncating. If negative, no truncation is done.
static propTypes = { truncateAt?: number;
// The number of elements to show before truncating. If negative, no truncation is done. // The className to apply to the wrapping div
truncateAt: PropTypes.number, className?: string;
// The className to apply to the wrapping div // A function that returns the children to be rendered into the element.
className: PropTypes.string, // The start element is included, the end is not (as in `slice`).
// A function that returns the children to be rendered into the element. // If omitted, the React child elements will be used. This parameter can be used
// function getChildren(start: number, end: number): Array<React.Node> // to avoid creating unnecessary React elements.
// The start element is included, the end is not (as in `slice`). getChildren?: (start: number, end: number) => Array<React.ReactNode>;
// If omitted, the React child elements will be used. This parameter can be used // A function that should return the total number of child element available.
// to avoid creating unnecessary React elements. // Required if getChildren is supplied.
getChildren: PropTypes.func, getChildCount?: () => number;
// A function that should return the total number of child element available. // A function which will be invoked when an overflow element is required.
// Required if getChildren is supplied. // This will be inserted after the children.
getChildCount: PropTypes.func, createOverflowElement?: (overflowCount: number, totalCount: number) => React.ReactNode;
// A function which will be invoked when an overflow element is required. }
// This will be inserted after the children.
createOverflowElement: PropTypes.func,
};
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component<IProps> {
static defaultProps ={ static defaultProps ={
truncateAt: 2, truncateAt: 2,
createOverflowElement(overflowCount, totalCount) { createOverflowElement(overflowCount, totalCount) {
@ -50,7 +48,7 @@ export default class TruncatedList extends React.Component {
}, },
}; };
_getChildren(start, end) { private getChildren(start: number, end: number): Array<React.ReactNode> {
if (this.props.getChildren && this.props.getChildCount) { if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildren(start, end); return this.props.getChildren(start, end);
} else { } else {
@ -63,7 +61,7 @@ export default class TruncatedList extends React.Component {
} }
} }
_getChildCount() { private getChildCount(): number {
if (this.props.getChildren && this.props.getChildCount) { if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildCount(); return this.props.getChildCount();
} else { } else {
@ -73,10 +71,10 @@ export default class TruncatedList extends React.Component {
} }
} }
render() { public render() {
let overflowNode = null; let overflowNode = null;
const totalChildren = this._getChildCount(); const totalChildren = this.getChildCount();
let upperBound = totalChildren; let upperBound = totalChildren;
if (this.props.truncateAt >= 0) { if (this.props.truncateAt >= 0) {
const overflowCount = totalChildren - this.props.truncateAt; const overflowCount = totalChildren - this.props.truncateAt;
@ -87,7 +85,7 @@ export default class TruncatedList extends React.Component {
upperBound = this.props.truncateAt; upperBound = this.props.truncateAt;
} }
} }
const childNodes = this._getChildren(0, upperBound); const childNodes = this.getChildren(0, upperBound);
return ( return (
<div className={this.props.className}> <div className={this.props.className}>

View file

@ -15,7 +15,7 @@
*/ */
import React from 'react'; import React from 'react';
import Flair from '../elements/Flair.js'; import Flair from '../elements/Flair';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -20,17 +21,28 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import {isValid3pidInvite} from "../../../RoomInvite"; import { isValid3pidInvite } from "../../../RoomInvite";
import rate_limited_func from "../../../ratelimitedfunc"; import rateLimitedFunction from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from "../../../index"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard"; import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import { User } from "matrix-js-sdk/src/models/user";
import TruncatedList from '../elements/TruncatedList';
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -40,41 +52,59 @@ const SHOW_MORE_INCREMENT = 100;
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ // matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g; const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
interface IProps {
roomId: string;
onClose(): void;
}
interface IState {
loading: boolean;
members: Array<RoomMember>;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
searchQuery: string;
}
@replaceableComponent("views.rooms.MemberList") @replaceableComponent("views.rooms.MemberList")
export default class MemberList extends React.Component { export default class MemberList extends React.Component<IProps, IState> {
private showPresence = true;
private mounted = false;
private collator: Intl.Collator;
private sortNames = new Map<RoomMember, string>(); // RoomMember -> sortName
constructor(props) { constructor(props) {
super(props); super(props);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) { if (cli.hasLazyLoadMembersEnabled()) {
// show an empty list // show an empty list
this.state = this._getMembersState([]); this.state = this.getMembersState([]);
} else { } else {
this.state = this._getMembersState(this.roomMembers()); this.state = this.getMembersState(this.roomMembers());
} }
cli.on("Room", this.onRoom); // invites & joining after peek cli.on("Room", this.onRoom); // invites & joining after peek
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"]; const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = MatrixClientPeg.get().baseUrl; const hsUrl = MatrixClientPeg.get().baseUrl;
this._showPresence = true; this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
this._showPresence = enablePresenceByHsUrl[hsUrl];
}
} }
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillMount() { UNSAFE_componentWillMount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
this._mounted = true; this.mounted = true;
if (cli.hasLazyLoadMembersEnabled()) { if (cli.hasLazyLoadMembersEnabled()) {
this._showMembersAccordingToMembershipWithLL(); this.showMembersAccordingToMembershipWithLL();
cli.on("Room.myMembership", this.onMyMembership); cli.on("Room.myMembership", this.onMyMembership);
} else { } else {
this._listenForMembersChanges(); this.listenForMembersChanges();
} }
} }
_listenForMembersChanges() { private listenForMembersChanges(): void {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomState.members", this.onRoomStateMember);
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
@ -89,7 +119,7 @@ export default class MemberList extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
this._mounted = false; this.mounted = false;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember); cli.removeListener("RoomState.members", this.onRoomStateMember);
@ -103,7 +133,7 @@ export default class MemberList extends React.Component {
} }
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the rate_limited_funcs
this._updateList.cancelPendingCall(); this.updateList.cancelPendingCall();
} }
/** /**
@ -111,7 +141,7 @@ export default class MemberList extends React.Component {
* show a spinner and load the members if the user is joined, * show a spinner and load the members if the user is joined,
* or show the members available so far if the user is invited * or show the members available so far if the user is invited
*/ */
async _showMembersAccordingToMembershipWithLL() { private async showMembersAccordingToMembershipWithLL(): Promise<void> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) { if (cli.hasLazyLoadMembersEnabled()) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -122,31 +152,31 @@ export default class MemberList extends React.Component {
try { try {
await room.loadMembersIfNeeded(); await room.loadMembersIfNeeded();
} catch (ex) {/* already logged in RoomView */} } catch (ex) {/* already logged in RoomView */}
if (this._mounted) { if (this.mounted) {
this.setState(this._getMembersState(this.roomMembers())); this.setState(this.getMembersState(this.roomMembers()));
this._listenForMembersChanges(); this.listenForMembersChanges();
} }
} else { } else {
// show the members we already have loaded // show the members we already have loaded
this.setState(this._getMembersState(this.roomMembers())); this.setState(this.getMembersState(this.roomMembers()));
} }
} }
} }
get canInvite() { private get canInvite(): boolean {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
return room && room.canInvite(cli.getUserId()); return room && room.canInvite(cli.getUserId());
} }
_getMembersState(members) { private getMembersState(members: Array<RoomMember>): IState {
// set the state after determining _showPresence to make sure it's // set the state after determining showPresence to make sure it's
// taken into account while rerendering // taken into account while rendering
return { return {
loading: false, loading: false,
members: members, members: members,
filteredJoinedMembers: this._filterMembers(members, 'join'), filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this._filterMembers(members, 'invite'), filteredInvitedMembers: this.filterMembers(members, 'invite'),
canInvite: this.canInvite, canInvite: this.canInvite,
// ideally we'd size this to the page height, but // ideally we'd size this to the page height, but
@ -157,72 +187,72 @@ export default class MemberList extends React.Component {
}; };
} }
onUserPresenceChange = (event, user) => { private onUserPresenceChange = (event: MatrixEvent, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the // Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile // member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener. // ever attaching their own listener.
const tile = this.refs[user.userId]; const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`); // console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) { if (tile) {
this._updateList(); // reorder the membership list this.updateList(); // reorder the membership list
} }
}; };
onRoom = room => { private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) { if (room.roomId !== this.props.roomId) {
return; return;
} }
// We listen for room events because when we accept an invite // We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state // we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list. // before refreshing the member list else we get a stale list.
this._showMembersAccordingToMembershipWithLL(); this.showMembersAccordingToMembershipWithLL();
}; };
onMyMembership = (room, membership, oldMembership) => { private onMyMembership = (room: Room, membership: string, oldMembership: string): void => {
if (room.roomId === this.props.roomId && membership === "join") { if (room.roomId === this.props.roomId && membership === "join") {
this._showMembersAccordingToMembershipWithLL(); this.showMembersAccordingToMembershipWithLL();
} }
}; };
onRoomStateMember = (ev, state, member) => { private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) { if (member.roomId !== this.props.roomId) {
return; return;
} }
this._updateList(); this.updateList();
}; };
onRoomMemberName = (ev, member) => { private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) { if (member.roomId !== this.props.roomId) {
return; return;
} }
this._updateList(); this.updateList();
}; };
onRoomStateEvent = (event, state) => { private onRoomStateEvent = (event: MatrixEvent, state: RoomState): void => {
if (event.getRoomId() === this.props.roomId && if (event.getRoomId() === this.props.roomId &&
event.getType() === "m.room.third_party_invite") { event.getType() === "m.room.third_party_invite") {
this._updateList(); this.updateList();
} }
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite }); if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
}; };
_updateList = rate_limited_func(() => { private updateList = rateLimitedFunction(() => {
this._updateListNow(); this.updateListNow();
}, 500); }, 500);
_updateListNow() { private updateListNow(): void {
// console.log("Updating memberlist"); const members = this.roomMembers()
const newState = {
this.setState({
loading: false, loading: false,
members: this.roomMembers(), members: members,
}; filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery),
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery); filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery),
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery); });
this.setState(newState);
} }
getMembersWithUser() { private getMembersWithUser(): Array<RoomMember> {
if (!this.props.roomId) return []; if (!this.props.roomId) return [];
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
@ -230,15 +260,18 @@ export default class MemberList extends React.Component {
const allMembers = Object.values(room.currentState.members); const allMembers = Object.values(room.currentState.members);
allMembers.forEach(function(member) { allMembers.forEach((member) => {
// work around a race where you might have a room member object // work around a race where you might have a room member object
// before the user object exists. This may or may not cause // before the user object exists. This may or may not cause
// https://github.com/vector-im/vector-web/issues/186 // https://github.com/vector-im/vector-web/issues/186
if (member.user === null) { if (!member.user) {
member.user = cli.getUser(member.userId); member.user = cli.getUser(member.userId);
} }
member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""); this.sortNames.set(
member,
(member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""),
);
// XXX: this user may have no lastPresenceTs value! // XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0 // the right solution here is to fix the race rather than leave it as 0
@ -247,7 +280,7 @@ export default class MemberList extends React.Component {
return allMembers; return allMembers;
} }
roomMembers() { private roomMembers(): Array<RoomMember> {
const allMembers = this.getMembersWithUser(); const allMembers = this.getMembersWithUser();
const filteredAndSortedMembers = allMembers.filter((m) => { const filteredAndSortedMembers = allMembers.filter((m) => {
return ( return (
@ -255,23 +288,21 @@ export default class MemberList extends React.Component {
); );
}); });
const language = SettingsStore.getValue("language"); const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true }); this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false });
filteredAndSortedMembers.sort(this.memberSort); filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers; return filteredAndSortedMembers;
} }
_createOverflowTileJoined = (overflowCount, totalCount) => { private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreJoinedMemberList); return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
}; };
_createOverflowTileInvited = (overflowCount, totalCount) => { private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreInvitedMemberList); return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
}; };
_createOverflowTile = (overflowCount, totalCount, onClick) => { private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element=> {
// For now we'll pretend this is any entity. It should probably be a separate tile. // For now we'll pretend this is any entity. It should probably be a separate tile.
const EntityTile = sdk.getComponent("rooms.EntityTile");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const text = _t("and %(count)s others...", { count: overflowCount }); const text = _t("and %(count)s others...", { count: overflowCount });
return ( return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={ <EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
@ -281,31 +312,48 @@ export default class MemberList extends React.Component {
); );
}; };
_showMoreJoinedMemberList = () => { private showMoreJoinedMemberList = (): void => {
this.setState({ this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT, truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
}); });
}; };
_showMoreInvitedMemberList = () => { private showMoreInvitedMemberList = (): void => {
this.setState({ this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT, truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
}); });
}; };
memberString(member) { /**
* SHOULD ONLY BE USED BY TESTS
*/
public memberString(member: RoomMember): string {
if (!member) { if (!member) {
return "(null)"; return "(null)";
} else { } else {
const u = member.user; const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")"; return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
} }
} }
// returns negative if a comes before b, // returns negative if a comes before b,
// returns 0 if a and b are equivalent in ordering // returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b. // returns positive if a comes after b.
memberSort = (memberA, memberB) => { private memberSort = (memberA: RoomMember, memberB: RoomMember): number => {
// order by presence, with "active now" first. // order by presence, with "active now" first.
// ...and then by power level // ...and then by power level
// ...and then by last active // ...and then by last active
@ -325,7 +373,7 @@ export default class MemberList extends React.Component {
if (!userA && userB) return 1; if (!userA && userB) return 1;
// First by presence // First by presence
if (this._showPresence) { if (this.showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p; const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => { const presenceIndex = p => {
const order = ['active', 'online', 'offline']; const order = ['active', 'online', 'offline'];
@ -349,31 +397,31 @@ export default class MemberList extends React.Component {
} }
// Third by last active // Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) { if (this.showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning"); // console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs(); return userB.getLastActiveTs() - userA.getLastActiveTs();
} }
// Fourth by name (alphabetical) // Fourth by name (alphabetical)
return this.collator.compare(memberA.sortName, memberB.sortName); return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB));
}; };
onSearchQueryChanged = searchQuery => { private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({ this.setState({
searchQuery, searchQuery,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery), filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery), filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
}); });
}; };
_onPending3pidInviteClick = inviteEvent => { private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({ dis.dispatch({
action: 'view_3pid_invite', action: 'view_3pid_invite',
event: inviteEvent, event: inviteEvent,
}); });
}; };
_filterMembers(members, membership, query) { private filterMembers(members: Array<RoomMember>, membership: string, query?: string): Array<RoomMember> {
return members.filter((m) => { return members.filter((m) => {
if (query) { if (query) {
query = query.toLowerCase(); query = query.toLowerCase();
@ -389,7 +437,7 @@ export default class MemberList extends React.Component {
}); });
} }
_getPending3PidInvites() { private getPending3PidInvites(): Array<MatrixEvent> {
// include 3pid invites (m.room.third_party_invite) state events. // include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so // The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the // we shouldn't add them if the 3pid invite state key (token) is in the
@ -409,42 +457,40 @@ export default class MemberList extends React.Component {
} }
} }
_makeMemberTiles(members) { private makeMemberTiles(members: Array<RoomMember | MatrixEvent>) {
const MemberTile = sdk.getComponent("rooms.MemberTile");
const EntityTile = sdk.getComponent("rooms.EntityTile");
return members.map((m) => { return members.map((m) => {
if (m.userId) { if (m instanceof RoomMember) {
// Is a Matrix invite // Is a Matrix invite
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this._showPresence} />; return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
} else { } else {
// Is a 3pid invite // Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true} return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(m)} />; onClick={() => this.onPending3pidInviteClick(m)} />;
} }
}); });
} }
_getChildrenJoined = (start, end) => this._makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end)); private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end))
_getChildCountJoined = () => this.state.filteredJoinedMembers.length;
_getChildrenInvited = (start, end) => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this._getPending3PidInvites());
}
return this._makeMemberTiles(targets.slice(start, end));
}; };
_getChildCountInvited = () => { private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
return this.state.filteredInvitedMembers.length + (this._getPending3PidInvites() || []).length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
} }
render() { render() {
if (this.state.loading) { if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <BaseCard return <BaseCard
className="mx_MemberList" className="mx_MemberList"
onClose={this.props.onClose} onClose={this.props.onClose}
@ -454,9 +500,6 @@ export default class MemberList extends React.Component {
</BaseCard>; </BaseCard>;
} }
const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
let inviteButton; let inviteButton;
@ -470,22 +513,30 @@ export default class MemberList extends React.Component {
inviteButtonText = _t("Invite to this space"); inviteButtonText = _t("Invite to this space");
} }
const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); inviteButton = (
inviteButton = <AccessibleButton
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!this.state.canInvite}> className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<span>{ inviteButtonText }</span> <span>{ inviteButtonText }</span>
</AccessibleButton>; </AccessibleButton>
);
} }
let invitedHeader; let invitedHeader;
let invitedSection; let invitedSection;
if (this._getChildCountInvited() > 0) { if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>; invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited} invitedSection = (
createOverflowElement={this._createOverflowTileInvited} <TruncatedList
getChildren={this._getChildrenInvited} className="mx_MemberList_section mx_MemberList_invited"
getChildCount={this._getChildCountInvited} truncateAt={this.state.truncateAtInvited}
/>; createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
} }
const footer = ( const footer = (
@ -517,17 +568,19 @@ export default class MemberList extends React.Component {
previousPhase={previousPhase} previousPhase={previousPhase}
> >
<div className="mx_MemberList_wrapper"> <div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined} <TruncatedList
createOverflowElement={this._createOverflowTileJoined} className="mx_MemberList_section mx_MemberList_joined"
getChildren={this._getChildrenJoined} truncateAt={this.state.truncateAtJoined}
getChildCount={this._getChildCountJoined} /> createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined} />
{ invitedHeader } { invitedHeader }
{ invitedSection } { invitedSection }
</div> </div>
</BaseCard>; </BaseCard>;
} }
onInviteButtonClick = () => { onInviteButtonClick = (): void => {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'}); dis.dispatch({action: 'require_registration'});
return; return;

View file

@ -30,7 +30,7 @@ import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event"; import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler"; import MediaDeviceHandler from "../../../MediaDeviceHandler";
interface IProps { interface IProps {
room: Room; room: Room;
@ -129,8 +129,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might // Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried. // change between this and recording, but at least we will have tried.
try { try {
const devices = await CallMediaHandler.getDevices(); const devices = await MediaDeviceHandler.getDevices();
if (!devices?.['audioinput']?.length) { if (!devices?.['audioInput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, { Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"), title: _t("No microphone found"),
description: <> description: <>

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import {_t} from "../../../../../languageHandler"; import {_t} from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import CallMediaHandler from "../../../../../CallMediaHandler"; import MediaDeviceHandler from "../../../../../MediaDeviceHandler";
import Field from "../../../elements/Field"; import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
@ -41,7 +41,7 @@ export default class VoiceUserSettingsTab extends React.Component {
} }
async componentDidMount() { async componentDidMount() {
const canSeeDeviceLabels = await CallMediaHandler.hasAnyLabeledDevices(); const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) { if (canSeeDeviceLabels) {
this._refreshMediaDevices(); this._refreshMediaDevices();
} }
@ -49,10 +49,10 @@ export default class VoiceUserSettingsTab extends React.Component {
_refreshMediaDevices = async (stream) => { _refreshMediaDevices = async (stream) => {
this.setState({ this.setState({
mediaDevices: await CallMediaHandler.getDevices(), mediaDevices: await MediaDeviceHandler.getDevices(),
activeAudioOutput: CallMediaHandler.getAudioOutput(), activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
activeAudioInput: CallMediaHandler.getAudioInput(), activeAudioInput: MediaDeviceHandler.getAudioInput(),
activeVideoInput: CallMediaHandler.getVideoInput(), activeVideoInput: MediaDeviceHandler.getVideoInput(),
}); });
if (stream) { if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again) // kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
@ -100,21 +100,21 @@ export default class VoiceUserSettingsTab extends React.Component {
}; };
_setAudioOutput = (e) => { _setAudioOutput = (e) => {
CallMediaHandler.setAudioOutput(e.target.value); MediaDeviceHandler.instance.setAudioOutput(e.target.value);
this.setState({ this.setState({
activeAudioOutput: e.target.value, activeAudioOutput: e.target.value,
}); });
}; };
_setAudioInput = (e) => { _setAudioInput = (e) => {
CallMediaHandler.setAudioInput(e.target.value); MediaDeviceHandler.instance.setAudioInput(e.target.value);
this.setState({ this.setState({
activeAudioInput: e.target.value, activeAudioInput: e.target.value,
}); });
}; };
_setVideoInput = (e) => { _setVideoInput = (e) => {
CallMediaHandler.setVideoInput(e.target.value); MediaDeviceHandler.instance.setVideoInput(e.target.value);
this.setState({ this.setState({
activeVideoInput: e.target.value, activeVideoInput: e.target.value,
}); });
@ -171,7 +171,7 @@ export default class VoiceUserSettingsTab extends React.Component {
} }
}; };
const audioOutputs = this.state.mediaDevices.audiooutput.slice(0); const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
if (audioOutputs.length > 0) { if (audioOutputs.length > 0) {
const defaultDevice = getDefaultDevice(audioOutputs); const defaultDevice = getDefaultDevice(audioOutputs);
speakerDropdown = ( speakerDropdown = (
@ -183,7 +183,7 @@ export default class VoiceUserSettingsTab extends React.Component {
); );
} }
const audioInputs = this.state.mediaDevices.audioinput.slice(0); const audioInputs = this.state.mediaDevices.audioInput.slice(0);
if (audioInputs.length > 0) { if (audioInputs.length > 0) {
const defaultDevice = getDefaultDevice(audioInputs); const defaultDevice = getDefaultDevice(audioInputs);
microphoneDropdown = ( microphoneDropdown = (
@ -195,7 +195,7 @@ export default class VoiceUserSettingsTab extends React.Component {
); );
} }
const videoInputs = this.state.mediaDevices.videoinput.slice(0); const videoInputs = this.state.mediaDevices.videoInput.slice(0);
if (videoInputs.length > 0) { if (videoInputs.length > 0) {
const defaultDevice = getDefaultDevice(videoInputs); const defaultDevice = getDefaultDevice(videoInputs);
webcamDropdown = ( webcamDropdown = (

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler"; import MediaDeviceHandler, { MediaDeviceHandlerEvent } from "../../../MediaDeviceHandler";
interface IProps { interface IProps {
feed: CallFeed, feed: CallFeed,
@ -27,19 +27,25 @@ export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>(); private element = createRef<HTMLAudioElement>();
componentDidMount() { componentDidMount() {
MediaDeviceHandler.instance.addListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia(); this.playMedia();
} }
componentWillUnmount() { componentWillUnmount() {
MediaDeviceHandler.instance.removeListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia(); this.stopMedia();
} }
private playMedia() { private onAudioOutputChanged = (audioOutput: string) => {
const element = this.element.current; const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) { if (audioOutput) {
try { try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where // This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
@ -52,7 +58,11 @@ export default class AudioFeed extends React.Component<IProps> {
logger.warn("Couldn't set requested audio output device: using default", e); logger.warn("Couldn't set requested audio output device: using default", e);
} }
} }
}
private playMedia() {
const element = this.element.current;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
element.muted = false; element.muted = false;
element.srcObject = this.props.feed.stream; element.srcObject = this.props.feed.stream;
element.autoplay = true; element.autoplay = true;

View file

@ -397,7 +397,8 @@
"Failed to invite": "Failed to invite", "Failed to invite": "Failed to invite",
"Operation failed": "Operation failed", "Operation failed": "Operation failed",
"Failed to invite users to the room:": "Failed to invite users to the room:", "Failed to invite users to the room:": "Failed to invite users to the room:",
"Failed to invite the following users to the %(roomName)s room:": "Failed to invite the following users to the %(roomName)s room:", "We sent the others, but the below people couldn't be invited to <RoomName/>": "We sent the others, but the below people couldn't be invited to <RoomName/>",
"Some invites couldn't be sent": "Some invites couldn't be sent",
"You need to be logged in.": "You need to be logged in.", "You need to be logged in.": "You need to be logged in.",
"You need to be able to invite users to do that.": "You need to be able to invite users to do that.", "You need to be able to invite users to do that.": "You need to be able to invite users to do that.",
"Unable to create widget.": "Unable to create widget.", "Unable to create widget.": "Unable to create widget.",
@ -2287,7 +2288,6 @@
"Confirm to continue": "Confirm to continue", "Confirm to continue": "Confirm to continue",
"Click the button below to confirm your identity.": "Click the button below to confirm your identity.", "Click the button below to confirm your identity.": "Click the button below to confirm your identity.",
"Invite by email": "Invite by email", "Invite by email": "Invite by email",
"Failed to invite the following users to chat: %(csvUsers)s": "Failed to invite the following users to chat: %(csvUsers)s",
"We couldn't create your DM.": "We couldn't create your DM.", "We couldn't create your DM.": "We couldn't create your DM.",
"Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.", "Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.",
"We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.", "We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.",

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch'; import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
import { Action, DiffDOM, IDiff } from "diff-dom"; import { DiffDOM, IDiff } from "diff-dom";
import { IContent } from "matrix-js-sdk/src/models/event"; import { IContent } from "matrix-js-sdk/src/models/event";
import { bodyToHtml, checkBlockNode, IOptsReturnString } from "../HtmlUtils"; import { bodyToHtml, checkBlockNode, IOptsReturnString } from "../HtmlUtils";
@ -149,7 +149,7 @@ function stringAsTextNode(string: string): Text {
function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void { function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void {
const {refNode, refParentNode} = findRefNodes(originalRootNode, diff.route); const {refNode, refParentNode} = findRefNodes(originalRootNode, diff.route);
switch (diff.action) { switch (diff.action) {
case Action.ReplaceElement: { case "replaceElement": {
const container = document.createElement("span"); const container = document.createElement("span");
const delNode = wrapDeletion(diffTreeToDOM(diff.oldValue)); const delNode = wrapDeletion(diffTreeToDOM(diff.oldValue));
const insNode = wrapInsertion(diffTreeToDOM(diff.newValue)); const insNode = wrapInsertion(diffTreeToDOM(diff.newValue));
@ -158,17 +158,17 @@ function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatc
refNode.parentNode.replaceChild(container, refNode); refNode.parentNode.replaceChild(container, refNode);
break; break;
} }
case Action.RemoveTextElement: { case "removeTextElement": {
const delNode = wrapDeletion(stringAsTextNode(diff.value)); const delNode = wrapDeletion(stringAsTextNode(diff.value));
refNode.parentNode.replaceChild(delNode, refNode); refNode.parentNode.replaceChild(delNode, refNode);
break; break;
} }
case Action.RemoveElement: { case "removeElement": {
const delNode = wrapDeletion(diffTreeToDOM(diff.element)); const delNode = wrapDeletion(diffTreeToDOM(diff.element));
refNode.parentNode.replaceChild(delNode, refNode); refNode.parentNode.replaceChild(delNode, refNode);
break; break;
} }
case Action.ModifyTextElement: { case "modifyTextElement": {
const textDiffs = diffMathPatch.diff_main(diff.oldValue, diff.newValue); const textDiffs = diffMathPatch.diff_main(diff.oldValue, diff.newValue);
diffMathPatch.diff_cleanupSemantic(textDiffs); diffMathPatch.diff_cleanupSemantic(textDiffs);
const container = document.createElement("span"); const container = document.createElement("span");
@ -184,12 +184,12 @@ function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatc
refNode.parentNode.replaceChild(container, refNode); refNode.parentNode.replaceChild(container, refNode);
break; break;
} }
case Action.AddElement: { case "addElement": {
const insNode = wrapInsertion(diffTreeToDOM(diff.element)); const insNode = wrapInsertion(diffTreeToDOM(diff.element));
insertBefore(refParentNode, refNode, insNode); insertBefore(refParentNode, refNode, insNode);
break; break;
} }
case Action.AddTextElement: { case "addTextElement": {
// XXX: sometimes diffDOM says insert a newline when there shouldn't be one // XXX: sometimes diffDOM says insert a newline when there shouldn't be one
// but we must insert the node anyway so that we don't break the route child IDs. // but we must insert the node anyway so that we don't break the route child IDs.
// See https://github.com/fiduswriter/diffDOM/issues/100 // See https://github.com/fiduswriter/diffDOM/issues/100
@ -199,9 +199,9 @@ function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatc
} }
// e.g. when changing a the href of a link, // e.g. when changing a the href of a link,
// show the link with old href as removed and with the new href as added // show the link with old href as removed and with the new href as added
case Action.RemoveAttribute: case "removeAttribute":
case Action.AddAttribute: case "addAttribute":
case Action.ModifyAttribute: { case "modifyAttribute": {
const delNode = wrapDeletion(refNode.cloneNode(true)); const delNode = wrapDeletion(refNode.cloneNode(true));
const updatedNode = refNode.cloneNode(true) as HTMLElement; const updatedNode = refNode.cloneNode(true) as HTMLElement;
if (diff.action === "addAttribute" || diff.action === "modifyAttribute") { if (diff.action === "addAttribute" || diff.action === "modifyAttribute") {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,23 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClientPeg} from '../MatrixClientPeg'; import { MatrixError } from "matrix-js-sdk/src/http-api";
import {getAddressType} from '../UserAddress';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { AddressType, getAddressType } from '../UserAddress';
import GroupStore from '../stores/GroupStore'; import GroupStore from '../stores/GroupStore';
import {_t} from "../languageHandler"; import { _t } from "../languageHandler";
import * as sdk from "../index";
import Modal from "../Modal"; import Modal from "../Modal";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import {defer} from "./promise"; import { defer, IDeferred } from "./promise";
import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog";
export enum InviteState {
Invited = "invited",
Error = "error",
}
interface IError {
errorText: string;
errcode: string;
}
const UNKNOWN_PROFILE_ERRORS = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND'];
export type CompletionStates = Record<string, InviteState>;
/** /**
* Invites multiple addresses to a room or group, handling rate limiting from the server * Invites multiple addresses to a room or group, handling rate limiting from the server
*/ */
export default class MultiInviter { export default class MultiInviter {
private readonly roomId?: string;
private readonly groupId?: string;
private canceled = false;
private addresses: string[] = [];
private busy = false;
private _fatal = false;
private completionStates: CompletionStates = {}; // State of each address (invited or error)
private errors: Record<string, IError> = {}; // { address: {errorText, errcode} }
private deferred: IDeferred<CompletionStates> = null;
private reason: string = null;
/** /**
* @param {string} targetId The ID of the room or group to invite to * @param {string} targetId The ID of the room or group to invite to
*/ */
constructor(targetId) { constructor(targetId: string) {
if (targetId[0] === '+') { if (targetId[0] === '+') {
this.roomId = null; this.roomId = null;
this.groupId = targetId; this.groupId = targetId;
@ -39,41 +66,38 @@ export default class MultiInviter {
this.roomId = targetId; this.roomId = targetId;
this.groupId = null; this.groupId = null;
} }
}
this.canceled = false; public get fatal() {
this.addrs = []; return this._fatal;
this.busy = false;
this.completionStates = {}; // State of each address (invited or error)
this.errors = {}; // { address: {errorText, errcode} }
this.deferred = null;
} }
/** /**
* Invite users to this room. This may only be called once per * Invite users to this room. This may only be called once per
* instance of the class. * instance of the class.
* *
* @param {array} addrs Array of addresses to invite * @param {array} addresses Array of addresses to invite
* @param {string} reason Reason for inviting (optional) * @param {string} reason Reason for inviting (optional)
* @returns {Promise} Resolved when all invitations in the queue are complete * @returns {Promise} Resolved when all invitations in the queue are complete
*/ */
invite(addrs, reason) { public invite(addresses, reason?: string): Promise<CompletionStates> {
if (this.addrs.length > 0) { if (this.addresses.length > 0) {
throw new Error("Already inviting/invited"); throw new Error("Already inviting/invited");
} }
this.addrs.push(...addrs); this.addresses.push(...addresses);
this.reason = reason; this.reason = reason;
for (const addr of this.addrs) { for (const addr of this.addresses) {
if (getAddressType(addr) === null) { if (getAddressType(addr) === null) {
this.completionStates[addr] = 'error'; this.completionStates[addr] = InviteState.Error;
this.errors[addr] = { this.errors[addr] = {
errcode: 'M_INVALID', errcode: 'M_INVALID',
errorText: _t('Unrecognised address'), errorText: _t('Unrecognised address'),
}; };
} }
} }
this.deferred = defer(); this.deferred = defer<CompletionStates>();
this._inviteMore(0); this.inviteMore(0);
return this.deferred.promise; return this.deferred.promise;
} }
@ -81,33 +105,36 @@ export default class MultiInviter {
/** /**
* Stops inviting. Causes promises returned by invite() to be rejected. * Stops inviting. Causes promises returned by invite() to be rejected.
*/ */
cancel() { public cancel(): void {
if (!this.busy) return; if (!this.busy) return;
this._canceled = true; this.canceled = true;
this.deferred.reject(new Error('canceled')); this.deferred.reject(new Error('canceled'));
} }
getCompletionState(addr) { public getCompletionState(addr: string): InviteState {
return this.completionStates[addr]; return this.completionStates[addr];
} }
getErrorText(addr) { public getErrorText(addr: string): string {
return this.errors[addr] ? this.errors[addr].errorText : null; return this.errors[addr] ? this.errors[addr].errorText : null;
} }
async _inviteToRoom(roomId, addr, ignoreProfile) { private async inviteToRoom(roomId: string, addr: string, ignoreProfile = false): Promise<{}> {
const addrType = getAddressType(addr); const addrType = getAddressType(addr);
if (addrType === 'email') { if (addrType === AddressType.Email) {
return MatrixClientPeg.get().inviteByEmail(roomId, addr); return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType === 'mx-user-id') { } else if (addrType === AddressType.MatrixUserId) {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) throw new Error("Room not found"); if (!room) throw new Error("Room not found");
const member = room.getMember(addr); const member = room.getMember(addr);
if (member && ['join', 'invite'].includes(member.membership)) { if (member && ['join', 'invite'].includes(member.membership)) {
throw {errcode: "RIOT.ALREADY_IN_ROOM", error: "Member already invited"}; throw new new MatrixError({
errcode: "RIOT.ALREADY_IN_ROOM",
error: "Member already invited",
});
} }
if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) { if (!ignoreProfile && SettingsStore.getValue("promptBeforeInviteUnknownUsers", this.roomId)) {
@ -124,28 +151,28 @@ export default class MultiInviter {
} }
} }
_doInvite(address, ignoreProfile) { private doInvite(address: string, ignoreProfile = false): Promise<void> {
return new Promise((resolve, reject) => { return new Promise<void>((resolve, reject) => {
console.log(`Inviting ${address}`); console.log(`Inviting ${address}`);
let doInvite; let doInvite;
if (this.groupId !== null) { if (this.groupId !== null) {
doInvite = GroupStore.inviteUserToGroup(this.groupId, address); doInvite = GroupStore.inviteUserToGroup(this.groupId, address);
} else { } else {
doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile); doInvite = this.inviteToRoom(this.roomId, address, ignoreProfile);
} }
doInvite.then(() => { doInvite.then(() => {
if (this._canceled) { if (this.canceled) {
return; return;
} }
this.completionStates[address] = 'invited'; this.completionStates[address] = InviteState.Invited;
delete this.errors[address]; delete this.errors[address];
resolve(); resolve();
}).catch((err) => { }).catch((err) => {
if (this._canceled) { if (this.canceled) {
return; return;
} }
@ -161,7 +188,7 @@ export default class MultiInviter {
} else if (err.errcode === 'M_LIMIT_EXCEEDED') { } else if (err.errcode === 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again // we're being throttled so wait a bit & try again
setTimeout(() => { setTimeout(() => {
this._doInvite(address, ignoreProfile).then(resolve, reject); this.doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000); }, 5000);
return; return;
} else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) {
@ -171,7 +198,7 @@ export default class MultiInviter {
} else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) { } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) {
// Invite without the profile check // Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`); console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this._doInvite(address, true).then(resolve, reject); this.doInvite(address, true).then(resolve, reject);
} else if (err.errcode === "M_BAD_STATE") { } else if (err.errcode === "M_BAD_STATE") {
errorText = _t("The user must be unbanned before they can be invited."); errorText = _t("The user must be unbanned before they can be invited.");
} else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") { } else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") {
@ -180,14 +207,14 @@ export default class MultiInviter {
errorText = _t('Unknown server error'); errorText = _t('Unknown server error');
} }
this.completionStates[address] = 'error'; this.completionStates[address] = InviteState.Error;
this.errors[address] = {errorText, errcode: err.errcode}; this.errors[address] = { errorText, errcode: err.errcode };
this.busy = !fatal; this.busy = !fatal;
this.fatal = fatal; this._fatal = fatal;
if (fatal) { if (fatal) {
reject(); reject(err);
} else { } else {
resolve(); resolve();
} }
@ -195,22 +222,22 @@ export default class MultiInviter {
}); });
} }
_inviteMore(nextIndex, ignoreProfile) { private inviteMore(nextIndex: number, ignoreProfile = false): void {
if (this._canceled) { if (this.canceled) {
return; return;
} }
if (nextIndex === this.addrs.length) { if (nextIndex === this.addresses.length) {
this.busy = false; this.busy = false;
if (Object.keys(this.errors).length > 0 && !this.groupId) { if (Object.keys(this.errors).length > 0 && !this.groupId) {
// There were problems inviting some people - see if we can invite them // There were problems inviting some people - see if we can invite them
// without caring if they exist or not. // without caring if they exist or not.
const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND']; const unknownProfileUsers = Object.keys(this.errors)
const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); .filter(a => UNKNOWN_PROFILE_ERRORS.includes(this.errors[a].errcode));
if (unknownProfileUsers.length > 0) { if (unknownProfileUsers.length > 0) {
const inviteUnknowns = () => { const inviteUnknowns = () => {
const promises = unknownProfileUsers.map(u => this._doInvite(u, true)); const promises = unknownProfileUsers.map(u => this.doInvite(u, true));
Promise.all(promises).then(() => this.deferred.resolve(this.completionStates)); Promise.all(promises).then(() => this.deferred.resolve(this.completionStates));
}; };
@ -219,15 +246,17 @@ export default class MultiInviter {
return; return;
} }
const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog");
console.log("Showing failed to invite dialog..."); console.log("Showing failed to invite dialog...");
Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, { Modal.createTrackedDialog('Failed to invite', '', AskInviteAnywayDialog, {
unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}), unknownProfileUsers: unknownProfileUsers.map(u => ({
userId: u,
errorText: this.errors[u].errorText,
})),
onInviteAnyways: () => inviteUnknowns(), onInviteAnyways: () => inviteUnknowns(),
onGiveUp: () => { onGiveUp: () => {
// Fake all the completion states because we already warned the user // Fake all the completion states because we already warned the user
for (const addr of unknownProfileUsers) { for (const addr of unknownProfileUsers) {
this.completionStates[addr] = 'invited'; this.completionStates[addr] = InviteState.Invited;
} }
this.deferred.resolve(this.completionStates); this.deferred.resolve(this.completionStates);
}, },
@ -239,25 +268,25 @@ export default class MultiInviter {
return; return;
} }
const addr = this.addrs[nextIndex]; const addr = this.addresses[nextIndex];
// don't try to invite it if it's an invalid address // don't try to invite it if it's an invalid address
// (it will already be marked as an error though, // (it will already be marked as an error though,
// so no need to do so again) // so no need to do so again)
if (getAddressType(addr) === null) { if (getAddressType(addr) === null) {
this._inviteMore(nextIndex + 1); this.inviteMore(nextIndex + 1);
return; return;
} }
// don't re-invite (there's no way in the UI to do this, but // don't re-invite (there's no way in the UI to do this, but
// for sanity's sake) // for sanity's sake)
if (this.completionStates[addr] === 'invited') { if (this.completionStates[addr] === InviteState.Invited) {
this._inviteMore(nextIndex + 1); this.inviteMore(nextIndex + 1);
return; return;
} }
this._doInvite(addr, ignoreProfile).then(() => { this.doInvite(addr, ignoreProfile).then(() => {
this._inviteMore(nextIndex + 1, ignoreProfile); this.inviteMore(nextIndex + 1, ignoreProfile);
}).catch(() => this.deferred.resolve(this.completionStates)); }).catch(() => this.deferred.resolve(this.completionStates));
} }
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import * as Recorder from 'opus-recorder'; import * as Recorder from 'opus-recorder';
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixClient} from "matrix-js-sdk/src/client";
import CallMediaHandler from "../CallMediaHandler"; import MediaDeviceHandler from "../MediaDeviceHandler";
import {SimpleObservable} from "matrix-widget-api"; import {SimpleObservable} from "matrix-widget-api";
import {clamp, percentageOf, percentageWithin} from "../utils/numbers"; import {clamp, percentageOf, percentageWithin} from "../utils/numbers";
import EventEmitter from "events"; import EventEmitter from "events";
@ -97,7 +97,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
audio: { audio: {
channelCount: CHANNELS, channelCount: CHANNELS,
noiseSuppression: true, // browsers ignore constraints they can't honour noiseSuppression: true, // browsers ignore constraints they can't honour
deviceId: CallMediaHandler.getAudioInput(), deviceId: MediaDeviceHandler.getAudioInput(),
}, },
}); });
this.recorderContext = createAudioContext({ this.recorderContext = createAudioContext({

View file

@ -1,21 +1,36 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 React from 'react';
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as TestUtils from '../../../test-utils'; import * as TestUtils from '../../../test-utils';
import {MatrixClientPeg} from '../../../../src/MatrixClientPeg';
import sdk from '../../../skinned-sdk'; import sdk from '../../../skinned-sdk';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import {Room, RoomMember, User} from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { User } from "matrix-js-sdk/src/models/user";
import { compare } from "../../../../src/utils/strings"; import { compare } from "../../../../src/utils/strings";
import MemberList from "../../../../src/components/views/rooms/MemberList";
function generateRoomId() { function generateRoomId() {
return '!' + Math.random().toString().slice(2, 10) + ':domain'; return '!' + Math.random().toString().slice(2, 10) + ':domain';
} }
describe('MemberList', () => { describe('MemberList', () => {
function createRoom(opts) { function createRoom(opts) {
const room = new Room(generateRoomId(), null, client.getUserId()); const room = new Room(generateRoomId(), null, client.getUserId());
@ -97,13 +112,19 @@ describe('MemberList', () => {
memberListRoom.currentState.members[member.userId] = member; memberListRoom.currentState.members[member.userId] = member;
} }
const MemberList = sdk.getComponent('views.rooms.MemberList');
const WrappedMemberList = TestUtils.wrapInMatrixClientContext(MemberList); const WrappedMemberList = TestUtils.wrapInMatrixClientContext(MemberList);
const gatherWrappedRef = (r) => { const gatherWrappedRef = (r) => {
memberList = r; memberList = r;
}; };
root = ReactDOM.render(<WrappedMemberList roomId={memberListRoom.roomId} root = ReactDOM.render(
wrappedRef={gatherWrappedRef} />, parentDiv); (
<WrappedMemberList
roomId={memberListRoom.roomId}
wrappedRef={gatherWrappedRef}
/>
),
parentDiv,
);
}); });
afterEach((done) => { afterEach((done) => {
@ -213,8 +234,8 @@ describe('MemberList', () => {
}); });
// Bypass all the event listeners and skip to the good part // Bypass all the event listeners and skip to the good part
memberList._showPresence = enablePresence; memberList.showPresence = enablePresence;
memberList._updateListNow(); memberList.updateListNow();
const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile);
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
@ -225,7 +246,7 @@ describe('MemberList', () => {
// Bypass all the event listeners and skip to the good part // Bypass all the event listeners and skip to the good part
memberList._showPresence = enablePresence; memberList._showPresence = enablePresence;
memberList._updateListNow(); memberList.updateListNow();
const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile);
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
@ -254,8 +275,8 @@ describe('MemberList', () => {
}); });
// Bypass all the event listeners and skip to the good part // Bypass all the event listeners and skip to the good part
memberList._showPresence = enablePresence; memberList.showPresence = enablePresence;
memberList._updateListNow(); memberList.updateListNow();
const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile);
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
@ -273,8 +294,8 @@ describe('MemberList', () => {
}); });
// Bypass all the event listeners and skip to the good part // Bypass all the event listeners and skip to the good part
memberList._showPresence = enablePresence; memberList.showPresence = enablePresence;
memberList._updateListNow(); memberList.updateListNow();
const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile); const tiles = ReactTestUtils.scryRenderedComponentsWithType(root, MemberTile);
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence); expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);

View file

@ -1486,7 +1486,7 @@
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
"@types/diff-match-patch@^1.0.5": "@types/diff-match-patch@^1.0.32":
version "1.0.32" version "1.0.32"
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f" resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f"
integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A== integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A==
@ -5777,10 +5777,10 @@ matrix-react-test-utils@^0.2.3:
"@babel/traverse" "^7.13.17" "@babel/traverse" "^7.13.17"
walk "^2.3.14" walk "^2.3.14"
matrix-widget-api@^0.1.0-beta.14: matrix-widget-api@^0.1.0-beta.15:
version "0.1.0-beta.14" version "0.1.0-beta.15"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.14.tgz#e38beed71c5ebd62c1ac1d79ef262d7150b42c70" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.15.tgz#b02511f93fe1a3634868b6e246d736107f182745"
integrity sha512-5tC6LO1vCblKg/Hfzf5U1eHPz1nHUZIobAm3gkEKV5vpYPgRpr8KdkLiGB78VZid0tB17CVtAb4VKI8CQ3lhAQ== integrity sha512-sWmtb8ZarSbHVbk5ni7IHBR9jOh7m1+5R4soky0fEO9VKl+MN7skT0+qNux3J9WuUAu2D80dZW9xPUT9cxfxbg==
dependencies: dependencies:
"@types/events" "^3.0.0" "@types/events" "^3.0.0"
events "^3.2.0" events "^3.2.0"