Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into spaces-jump-to-room

This commit is contained in:
Jaiwanth 2021-06-29 14:31:08 +05:30
commit 746b11b24d
471 changed files with 19932 additions and 8649 deletions

38
src/@types/diff-dom.ts Normal file
View file

@ -0,0 +1,38 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
declare module "diff-dom" {
export interface IDiff {
action: string;
name: string;
text?: string;
route: number[];
value: string;
element: unknown;
oldValue: string;
newValue: string;
}
interface IOpts {
}
export class DiffDOM {
public constructor(opts?: IOpts);
public apply(tree: unknown, diffs: IDiff[]): unknown;
public undo(tree: unknown, diffs: IDiff[]): unknown;
public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
}
}

View file

@ -42,6 +42,9 @@ import {SpaceStoreClass} from "../stores/SpaceStore";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
declare global {
interface Window {
@ -52,6 +55,9 @@ declare global {
init: () => Promise<void>;
};
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
@ -76,6 +82,10 @@ declare global {
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any;
mxUIStore: UIStore;
mxSetupEncryptionStore?: SetupEncryptionStore;
}
interface Document {
@ -103,19 +113,6 @@ declare global {
usageDetails?: {[key: string]: number};
}
export interface ISettledFulfilled<T> {
status: "fulfilled";
value: T;
}
export interface ISettledRejected {
status: "rejected";
reason: any;
}
interface PromiseConstructor {
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
}
interface HTMLAudioElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them

View file

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

View file

@ -14,18 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {User} from "matrix-js-sdk/src/models/user";
import {Room} from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import DMRoomMap from './utils/DMRoomMap';
import {mediaFromMxc} from "./customisations/Media";
import { mediaFromMxc } from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore";
export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
export function avatarUrlForMember(
member: RoomMember,
width: number,
height: number,
resizeMethod: ResizeMethod,
): string {
let url: string;
if (member?.getMxcAvatarUrl()) {
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
@ -39,7 +43,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
return url;
}
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
export function avatarUrlForUser(
user: Pick<User, "avatarUrl">,
width: number,
height: number,
resizeMethod?: ResizeMethod,
): string | null {
if (!user.avatarUrl) return null;
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}

View file

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

View file

@ -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");
},
};

View file

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

View file

@ -22,13 +22,7 @@ import SdkConfig from './SdkConfig';
import {MatrixClientPeg} from "./MatrixClientPeg";
import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
import { Action } from "./dispatcher/actions";
const INACTIVITY_TIME = 20; // seconds
const HEARTBEAT_INTERVAL = 5_000; // ms
@ -265,7 +259,7 @@ interface ICreateRoomEvent extends IEvent {
}
interface IJoinRoomEvent extends IEvent {
key: "join_room";
key: Action.JoinRoom;
dur: number; // how long it took to join (until remote echo)
segmentation: {
room_id: string; // hashed
@ -684,7 +678,9 @@ export default class CountlyAnalytics {
}
private getOrientation = (): Orientation => {
return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait;
return window.matchMedia("(orientation: landscape)").matches
? Orientation.Landscape
: Orientation.Portrait
};
private reportOrientation = () => {
@ -813,7 +809,9 @@ export default class CountlyAnalytics {
window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity);
window.addEventListener("scroll", this.onUserActivity);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
window.addEventListener("scroll", this.onUserActivity, { passive: true });
this.activityIntervalId = setInterval(() => {
this.inactivityCounter++;
@ -858,7 +856,7 @@ export default class CountlyAnalytics {
}
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
this.track<IJoinRoomEvent>("join_room", { type }, roomId, {
this.track<IJoinRoomEvent>(Action.JoinRoom, { type }, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime,
});
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,34 +14,40 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
export class DecryptionFailure {
constructor(failedEventId, errorCode) {
this.failedEventId = failedEventId;
this.errorCode = errorCode;
public readonly ts: number;
constructor(public readonly failedEventId: string, public readonly errorCode: string) {
this.ts = Date.now();
}
}
type TrackingFn = (count: number, trackedErrCode: string) => void;
type ErrCodeMapFn = (errcode: string) => string;
export class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
// is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
// are accumulated in `failureCounts`.
failures = [];
public failures: DecryptionFailure[] = [];
// A histogram of the number of failures that will be tracked at the next tracking
// interval, split by failure error code.
failureCounts = {
public failureCounts: Record<string, number> = {
// [errorCode]: 42
};
// Event IDs of failures that were tracked previously
trackedEventHashMap = {
public trackedEventHashMap: Record<string, boolean> = {
// [eventId]: true
};
// Set to an interval ID when `start` is called
checkInterval = null;
trackInterval = null;
public checkInterval: NodeJS.Timeout = null;
public trackInterval: NodeJS.Timeout = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000;
@ -67,7 +73,7 @@ export class DecryptionFailureTracker {
* @param {function?} errorCodeMapFn The function used to map error codes to the
* trackedErrorCode. If not provided, the `.code` of errors will be used.
*/
constructor(fn, errorCodeMapFn) {
constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) {
if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function');
}
@ -75,9 +81,6 @@ export class DecryptionFailureTracker {
if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
}
this._trackDecryptionFailure = fn;
this._mapErrorCode = errorCodeMapFn;
}
// loadTrackedEventHashMap() {
@ -88,7 +91,7 @@ export class DecryptionFailureTracker {
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
// }
eventDecrypted(e, err) {
public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void {
if (err) {
this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
} else {
@ -97,18 +100,18 @@ export class DecryptionFailureTracker {
}
}
addDecryptionFailure(failure) {
public addDecryptionFailure(failure: DecryptionFailure): void {
this.failures.push(failure);
}
removeDecryptionFailuresForEvent(e) {
public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
}
/**
* Start checking for and tracking failures.
*/
start() {
public start(): void {
this.checkInterval = setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
@ -123,7 +126,7 @@ export class DecryptionFailureTracker {
/**
* Clear state and stop checking for and tracking failures.
*/
stop() {
public stop(): void {
clearInterval(this.checkInterval);
clearInterval(this.trackInterval);
@ -132,11 +135,11 @@ export class DecryptionFailureTracker {
}
/**
* Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be
* Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be
* tracked. Only mark one failure per event ID.
* @param {number} nowTs the timestamp that represents the time now.
*/
checkFailures(nowTs) {
public checkFailures(nowTs: number): void {
const failuresGivenGrace = [];
const failuresNotReady = [];
while (this.failures.length > 0) {
@ -175,10 +178,10 @@ export class DecryptionFailureTracker {
const dedupedFailures = dedupedFailuresMap.values();
this._aggregateFailures(dedupedFailures);
this.aggregateFailures(dedupedFailures);
}
_aggregateFailures(failures) {
private aggregateFailures(failures: DecryptionFailure[]): void {
for (const failure of failures) {
const errorCode = failure.errorCode;
this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1;
@ -189,12 +192,12 @@ export class DecryptionFailureTracker {
* If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the number of failures that should be tracked.
*/
trackFailures() {
public trackFailures(): void {
for (const errorCode of Object.keys(this.failureCounts)) {
if (this.failureCounts[errorCode] > 0) {
const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode;
const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode;
this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode);
this.fn(this.failureCounts[errorCode], trackedErrorCode);
this.failureCounts[errorCode] = 0;
}
}

View file

@ -21,7 +21,6 @@ import MultiInviter from './utils/MultiInviter';
import { _t } from './languageHandler';
import {MatrixClientPeg} from './MatrixClientPeg';
import GroupStore from './stores/GroupStore';
import {allSettled} from "./utils/promise";
import StyledCheckbox from './components/views/elements/StyledCheckbox';
export function showGroupInviteDialog(groupId) {
@ -120,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) {
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get();
const errorList = [];
return allSettled(addrs.map((addr) => {
return Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroup(groupId, addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); })

View file

@ -17,11 +17,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import sanitizeHtml from 'sanitize-html';
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import cheerio from 'cheerio';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
@ -29,13 +28,15 @@ import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import katex from 'katex';
import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio';
import { IContent } from 'matrix-js-sdk/src/models/event';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
import linkifyMatrix from './linkify-matrix';
import SettingsStore from './settings/SettingsStore';
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
import {mediaFromMxc} from "./customisations/Media";
import { mediaFromMxc } from "./customisations/Media";
linkifyMatrix(linkify);
@ -66,7 +67,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'
* need emojification.
* unicodeToImage uses this function.
*/
function mightContainEmoji(str: string) {
function mightContainEmoji(str: string): boolean {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
@ -76,7 +77,7 @@ function mightContainEmoji(str: string) {
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
export function unicodeToShortcode(char: string) {
export function unicodeToShortcode(char: string): string {
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
@ -87,7 +88,7 @@ export function unicodeToShortcode(char: string) {
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
export function shortcodeToUnicode(shortcode: string) {
export function shortcodeToUnicode(shortcode: string): string {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
@ -124,13 +125,13 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
export function sanitizedHtmlNode(insaneHtml: string) {
export function sanitizedHtmlNode(insaneHtml: string): ReactNode {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
export function getHtmlText(insaneHtml: string) {
export function getHtmlText(insaneHtml: string): string {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
@ -148,7 +149,7 @@ export function getHtmlText(insaneHtml: string) {
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl: string) {
export function isUrlPermitted(inputUrl: string): boolean {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
@ -351,13 +352,6 @@ class HtmlHighlighter extends BaseHighlighter<string> {
}
}
interface IContent {
format?: string;
// eslint-disable-next-line camelcase
formatted_body?: string;
body: string;
}
interface IOpts {
highlightLink?: string;
disableBigEmoji?: boolean;
@ -367,6 +361,14 @@ interface IOpts {
ref?: React.Ref<any>;
}
export interface IOptsReturnNode extends IOpts {
returnString: false;
}
export interface IOptsReturnString extends IOpts {
returnString: true;
}
/* turn a matrix event body into html
*
* content: 'content' of the MatrixEvent
@ -380,6 +382,8 @@ interface IOpts {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode;
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
@ -422,8 +426,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody,
{ _useHtmlParser2: true, decodeEntities: false })
const phtml = cheerio.load(safeBody, {
// @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
// @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
@ -431,6 +439,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{
throwOnError: false,
// @ts-ignore - `e` can be an Element, not just a Node
displayMode: e.name == 'div',
output: "htmlAndMathml",
});
@ -496,7 +505,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
export function linkifyString(str: string, options = linkifyMatrix.options) {
export function linkifyString(str: string, options = linkifyMatrix.options): string {
return _linkifyString(str, options);
}
@ -507,7 +516,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement {
return _linkifyElement(element, options);
}
@ -518,7 +527,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
@ -529,7 +538,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri
* @param {Node} node
* @returns {bool}
*/
export function checkBlockNode(node: Node) {
export function checkBlockNode(node: Node): boolean {
switch (node.nodeName) {
case "H1":
case "H2":

View file

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

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

@ -385,7 +385,7 @@ export class ModalManager {
</div>
);
ReactDOM.render(dialog, ModalManager.getOrCreateContainer());
setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()));
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());

View file

@ -68,7 +68,7 @@ export const Notifier = {
// or not
pendingEncryptedEventIds: [],
notificationMessageForEvent: function(ev: MatrixEvent) {
notificationMessageForEvent: function(ev: MatrixEvent): string {
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
return typehandlers[ev.getContent().msgtype](ev);
}
@ -331,6 +331,8 @@ export const Notifier = {
if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
MatrixClientPeg.get().decryptEventIfNeeded(ev);
// If it's an encrypted event and the type is still 'm.room.encrypted',
// it hasn't yet been decrypted, so wait until it is.
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {

View file

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

View file

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

View file

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

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,15 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {MatrixClientPeg} from './MatrixClientPeg';
import MultiInviter from './utils/MultiInviter';
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
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 * as sdk from './';
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 {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
@ -32,15 +41,15 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
* no option to cancel.
*
* @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
*/
export function inviteMultipleToRoom(roomId, addrs) {
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
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.
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
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.
Modal.createTrackedDialog(
"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(
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
);
}
export function showCommunityInviteDialog(communityId) {
export function showCommunityInviteDialog(communityId: string): void {
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
if (chat) {
const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
@ -83,7 +92,7 @@ export function showCommunityInviteDialog(communityId) {
* @param {MatrixEvent} event The event to check
* @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;
// any events without these keys are not valid 3pid invites, so we ignore them
@ -96,7 +105,7 @@ export function isValid3pidInvite(event) {
return true;
}
export function inviteUsersToRoom(roomId, userIds) {
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId);
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
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) {
// 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
@ -126,19 +140,47 @@ export function showAnyInviteErrors(addrs, room, inviter) {
} else {
const errorList = [];
for (const addr of failedUsers) {
if (addrs[addr] === "error") {
if (states[addr] === "error") {
const reason = inviter.getErrorText(addr);
errorList.push(addr + ": " + reason);
}
}
const cli = MatrixClientPeg.get();
if (errorList.length > 0) {
// 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");
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
title: _t("Some invites couldn't be sent"),
description,
});
return false;

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from './MatrixClientPeg';
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from './MatrixClientPeg';
/**
* Given a room object, return the alias we should use for it,
@ -25,11 +27,11 @@ import {MatrixClientPeg} from './MatrixClientPeg';
* @param {Object} room The room object
* @returns {string} A display alias for the given room
*/
export function getDisplayAliasForRoom(room) {
export function getDisplayAliasForRoom(room: Room): string {
return room.getCanonicalAlias() || room.getAltAliases()[0];
}
export function looksLikeDirectMessageRoom(room, myUserId) {
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
const myMembership = room.getMyMembership();
const me = room.getMember(myUserId);
@ -48,7 +50,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) {
return false;
}
export function guessAndSetDMRoom(room, isDirect) {
export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
let newTarget;
if (isDirect) {
const guessedUserId = guessDMRoomTargetId(
@ -70,7 +72,7 @@ export function guessAndSetDMRoom(room, isDirect) {
this room as a DM room
* @returns {object} A promise
*/
export function setDMRoom(roomId, userId) {
export function setDMRoom(roomId: string, userId: string): Promise<void> {
if (MatrixClientPeg.get().isGuest()) {
return Promise.resolve();
}
@ -114,7 +116,7 @@ export function setDMRoom(roomId, userId) {
* @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room, myUserId) {
function guessDMRoomTargetId(room: Room, myUserId: string): string {
let oldestTs;
let oldestUser;

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { ICryptoCallbacks, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import Modal from './Modal';
import * as sdk from './index';
@ -28,6 +28,7 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
@ -244,7 +245,7 @@ async function onSecretRequested(
deviceId: string,
requestId: string,
name: string,
deviceTrust: IDeviceTrustLevel,
deviceTrust: DeviceTrustLevel,
): Promise<string> {
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.get();
@ -271,7 +272,7 @@ async function onSecretRequested(
}
return key && encodeBase64(key);
} else if (name === "m.megolm_backup.v1") {
const key = await client._crypto.getSessionBackupPrivateKey();
const key = await client.crypto.getSessionBackupPrivateKey();
if (!key) {
console.log(
`session backup key requested by ${deviceId}, but not found in cache`,

View file

@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
import {MatrixClientPeg} from './MatrixClientPeg';
@ -150,6 +150,10 @@ function success(promise?: Promise<any>) {
return {promise};
}
function successSync(value: any) {
return success(Promise.resolve(value));
}
/* Disable the "unexpected this" error for these commands - all of the run
* functions are called with `this` bound to the Command instance.
*/
@ -160,7 +164,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends the given message as a spoiler'),
runFn: function(roomId, message) {
return success(ContentHelpers.makeHtmlMessage(
return successSync(ContentHelpers.makeHtmlMessage(
message,
`<span data-mx-spoiler>${message}</span>`,
));
@ -176,7 +180,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(ContentHelpers.makeTextMessage(message));
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -189,7 +193,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(ContentHelpers.makeTextMessage(message));
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -202,7 +206,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(ContentHelpers.makeTextMessage(message));
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -215,7 +219,7 @@ export const Commands = [
if (args) {
message = message + ' ' + args;
}
return success(ContentHelpers.makeTextMessage(message));
return successSync(ContentHelpers.makeTextMessage(message));
},
category: CommandCategories.messages,
}),
@ -224,7 +228,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as plain text, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(ContentHelpers.makeTextMessage(messages));
return successSync(ContentHelpers.makeTextMessage(messages));
},
category: CommandCategories.messages,
}),
@ -233,7 +237,7 @@ export const Commands = [
args: '<message>',
description: _td('Sends a message as html, without interpreting it as markdown'),
runFn: function(roomId, messages) {
return success(ContentHelpers.makeHtmlMessage(messages, messages));
return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
},
category: CommandCategories.messages,
}),
@ -978,7 +982,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -988,7 +992,7 @@ export const Commands = [
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
},
category: CommandCategories.messages,
}),
@ -1015,9 +1019,8 @@ export const Commands = [
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the
// receiver wants.
member: member || {userId},
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || { userId } as User,
});
return success();
},

View file

@ -36,14 +36,18 @@ export class Service {
}
}
interface Policy {
export interface LocalisedPolicy {
name: string;
url: string;
}
export interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys
version: string;
[lang: string]: {
url: string;
};
[lang: string]: LocalisedPolicy;
}
type Policies = {
export type Policies = {
[policy: string]: Policy,
};
@ -99,7 +103,7 @@ export async function startTermsFlow(
// fetch the set of agreed policy URLs from account data
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
let agreedUrlSet;
let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set();
} else {

View file

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

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,9 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClientPeg} from "./MatrixClientPeg";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import {haveTileForEvent} from "./components/views/rooms/EventTile";
import { haveTileForEvent } from "./components/views/rooms/EventTile";
/**
* Returns true iff this event arriving in a room should affect the room's
@ -25,26 +29,27 @@ import {haveTileForEvent} from "./components/views/rooms/EventTile";
* @param {Object} ev The event
* @returns {boolean} True if the given event should affect the unread message count
*/
export function eventTriggersUnreadCount(ev) {
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
return false;
} else if (ev.getType() == 'm.room.member') {
return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
return false;
} else if (ev.getType() == 'm.room.server_acl') {
return false;
}
switch (ev.getType()) {
case EventType.RoomMember:
case EventType.RoomThirdPartyInvite:
case EventType.CallAnswer:
case EventType.CallHangup:
case EventType.RoomAliases:
case EventType.RoomCanonicalAlias:
case EventType.RoomServerAcl:
return false;
}
if (ev.isRedacted()) return false;
return haveTileForEvent(ev);
}
export function doesRoomHaveUnreadMessages(room) {
export function doesRoomHaveUnreadMessages(room: Room): boolean {
const myUserId = MatrixClientPeg.get().getUserId();
// get the most recent read receipt sent by our account.

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");
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.
*/
const emailRegex = /^\S+@\S+\.\S+$/;
import PropTypes from "prop-types";
const emailRegex = /^\S+@\S+\.\S+$/;
const mxUserIdRegex = /^@\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
// an address that can be invited to a room (which
@ -40,18 +44,13 @@ export const UserAddressType = PropTypes.shape({
isKnown: PropTypes.bool,
});
export function getAddressType(inputText) {
const isEmailAddress = emailRegex.test(inputText);
const isUserId = mxUserIdRegex.test(inputText);
const isRoomId = mxRoomIdRegex.test(inputText);
// sanity check the input for user IDs
if (isEmailAddress) {
return 'email';
} else if (isUserId) {
return 'mx-user-id';
} else if (isRoomId) {
return 'mx-room-id';
export function getAddressType(inputText: string): AddressType | null {
if (emailRegex.test(inputText)) {
return AddressType.Email;
} else if (mxUserIdRegex.test(inputText)) {
return AddressType.MatrixUserId;
} else if (mxRoomIdRegex.test(inputText)) {
return AddressType.MatrixRoomId;
} else {
return null;
}

View file

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

View file

@ -57,6 +57,8 @@ export enum Modifiers {
// Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
export const DIGITS = "digits";
interface IKeybind {
modifiers?: Modifiers[];
@ -319,6 +321,7 @@ const alternateKeyName: Record<string, string> = {
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
[DIGITS]: _td("[number]"),
};
const keyIcon: Record<string, string> = {
[Key.ARROW_UP]: "↑",

View file

@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT") {
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:

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

View file

@ -93,7 +93,12 @@ export default class AutocompleteProvider {
};
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
return [];
}

View file

@ -15,8 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {ReactElement} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import { ReactElement } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
@ -24,8 +25,10 @@ import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import {timeout} from "../utils/promise";
import { timeout } from "../utils/promise";
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
import SettingsStore from "../settings/SettingsStore";
import SpaceProvider from "./SpaceProvider";
export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -52,10 +55,16 @@ const PROVIDERS = [
EmojiProvider,
NotifProvider,
CommandProvider,
CommunityProvider,
DuckDuckGoProvider,
];
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
if (SettingsStore.getValue("feature_spaces")) {
PROVIDERS.push(SpaceProvider);
} else {
PROVIDERS.push(CommunityProvider);
}
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
@ -82,15 +91,24 @@ export default class Autocompleter {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<IProviderCompletions[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<IProviderCompletions[]> {
/* Note: This intentionally waits for all providers to return,
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
*/
// list of results from each provider, each being a list of completions or null if it times out
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => {
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => {
return await timeout(
provider.getCompletions(query, selection, force, limit),
null,
PROVIDER_COMPLETION_TIMEOUT,
);
}));
// map then filter to maintain the index for the map-operation, for this.providers to line up

View file

@ -38,7 +38,12 @@ export default class CommandProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force?: boolean,
limit = -1,
): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!command) return [];
@ -55,10 +60,11 @@ export default class CommandProvider extends AutocompleteProvider {
} else {
if (query === '/') {
// If they have just entered `/` show everything
// We exclude the limit on purpose to have a comprehensive list
matches = Commands;
} else {
// otherwise fuzzy match against all of the fields
matches = this.matcher.match(command[1]);
matches = this.matcher.match(command[1], limit);
}
}

View file

@ -50,7 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues
@ -81,7 +86,7 @@ export default class CommunityProvider extends AutocompleteProvider {
this.matcher.setObjects(groups);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = this.matcher.match(matchedString, limit);
completions = sortBy(completions, [
(c) => score(matchedString, c.groupId),
(c) => c.groupId.length,

View file

@ -36,7 +36,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];
@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
method: 'GET',
});
const json = await response.json();
const results = json.Results.map((result) => {
const maxLength = limit > -1 ? limit : json.Results.length;
const results = json.Results.slice(0, maxLength).map((result) => {
return {
completion: result.Text,
component: (

View file

@ -84,7 +84,12 @@ export default class EmojiProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force?: boolean,
limit = -1,
): Promise<ICompletion[]> {
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}
@ -93,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = this.matcher.match(matchedString, limit);
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));

View file

@ -15,7 +15,8 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg';
@ -33,7 +34,12 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room;
}
async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();

View file

@ -21,7 +21,7 @@ import {removeHiddenChars} from "matrix-js-sdk/src/utils";
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
funcs?: Array<(T) => string | string[]>;
shouldMatchWordsOnly?: boolean;
// whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true
fuzzy?: boolean;
@ -69,7 +69,12 @@ export default class QueryMatcher<T extends Object> {
if (this._options.funcs) {
for (const f of this._options.funcs) {
keyValues.push(f(object));
const v = f(object);
if (Array.isArray(v)) {
keyValues.push(...v);
} else {
keyValues.push(v);
}
}
}
@ -87,7 +92,7 @@ export default class QueryMatcher<T extends Object> {
}
}
match(query: string): T[] {
match(query: string, limit = -1): T[] {
query = this.processQuery(query);
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
@ -129,7 +134,10 @@ export default class QueryMatcher<T extends Object> {
});
// Now map the keys to the result objects. Also remove any duplicates.
return uniq(matches.map((match) => match.object));
const dedupped = uniq(matches.map((match) => match.object));
const maxLength = limit === -1 ? dedupped.length : limit;
return dedupped.slice(0, maxLength);
}
private processQuery(query: string): string {

View file

@ -1,8 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2017, 2018, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,27 +16,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import React from "react";
import { uniqBy, sortBy } from "lodash";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { MatrixClientPeg } from '../MatrixClientPeg';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import {uniqBy, sortBy} from "lodash";
import { PillCompletion } from './Components';
import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter";
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import SettingsStore from "../settings/SettingsStore";
const ROOM_REGEX = /\B#\S*/g;
function score(query: string, space: string) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
// Prefer canonical aliases over non-canonical ones
function canonicalScore(displayedAlias: string, room: Room): number {
return displayedAlias === room.getCanonicalAlias() ? 0 : 1;
}
function matcherObject(room: Room, displayedAlias: string, matchName = "") {
@ -49,7 +46,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") {
}
export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
protected matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
@ -58,15 +55,28 @@ export default class RoomProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
protected getRooms() {
const cli = MatrixClientPeg.get();
let rooms = cli.getVisibleRooms();
const client = MatrixClientPeg.get();
if (SettingsStore.getValue("feature_spaces")) {
rooms = rooms.filter(r => !r.isSpaceRoom());
}
return rooms;
}
async getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => {
let matcherObjects = this.getRooms().reduce((aliases, room) => {
if (room.getCanonicalAlias()) {
aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name));
}
@ -90,9 +100,9 @@ export default class RoomProvider extends AutocompleteProvider {
this.matcher.setObjects(matcherObjects);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = this.matcher.match(matchedString, limit);
completions = sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => canonicalScore(c.displayedAlias, c.room),
(c) => c.displayedAlias.length,
]);
completions = uniqBy(completions, (match) => match.room);
@ -110,7 +120,7 @@ export default class RoomProvider extends AutocompleteProvider {
),
range,
};
}).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4);
}).filter((completion) => !!completion.completion && completion.completion.length > 0);
}
return completions;
}

View file

@ -0,0 +1,43 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg';
import RoomProvider from "./RoomProvider";
export default class SpaceProvider extends RoomProvider {
protected getRooms() {
return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom());
}
getName() {
return _t("Spaces");
}
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
aria-label={_t("Space Autocomplete")}
>
{ completions }
</div>
);
}
}

View file

@ -20,19 +20,19 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components';
import { PillCompletion } from './Components';
import * as sdk from '../index';
import QueryMatcher from './QueryMatcher';
import {sortBy} from 'lodash';
import {MatrixClientPeg} from '../MatrixClientPeg';
import { sortBy } from 'lodash';
import { MatrixClientPeg } from '../MatrixClientPeg';
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 EventTimeline from "matrix-js-sdk/src/models/event-timeline";
import {makeUserPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter";
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 { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import { makeUserPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter";
const USER_REGEX = /\B@\S*/g;
@ -102,7 +102,12 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null;
};
async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
async getCompletions(
rawQuery: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// lazy-load user list into matcher
@ -118,7 +123,7 @@ export default class UserProvider extends AutocompleteProvider {
if (fullMatch && fullMatch !== '@') {
// Don't include the '@' in our search query - it's only used as a way to trigger completion
const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch;
completions = this.matcher.match(query).map((user) => {
completions = this.matcher.match(query, limit).map((user) => {
const displayName = (user.name || user.userId || '');
return {
// Length of completion should equal length of text in decorator. draft-js

View file

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

View file

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

View file

@ -23,6 +23,7 @@ import classNames from "classnames";
import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -222,10 +223,12 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// don't let keyboard handling escape the context menu
ev.stopPropagation();
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
ev.stopPropagation();
ev.preventDefault();
}
return;
@ -258,7 +261,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
@ -409,12 +411,12 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
}
return menuOptions;
@ -429,12 +431,12 @@ export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFac
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
}
return menuOptions;
@ -450,7 +452,7 @@ export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFa
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
return menuOptions;
};

View file

@ -50,6 +50,9 @@ class FilePanel extends React.Component {
if (room?.roomId !== this.props?.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
} else {

View file

@ -24,7 +24,6 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
@ -83,7 +82,7 @@ class GroupFilterPanel extends React.Component {
}
};
onMouseDown = e => {
onClick = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'});
@ -123,12 +122,19 @@ class GroupFilterPanel extends React.Component {
mx_GroupFilterPanel_items_selected: itemsSelected,
});
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = (
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus" />
className="mx_TagTile mx_TagTile_plus">
{ betaDot }
</ActionButton>
);
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
@ -144,28 +150,15 @@ class GroupFilterPanel extends React.Component {
return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar
className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
onClick={this.onClick}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
</div>
) }
</Droppable>
<div className="mx_GroupFilterPanel_tagTileContainer">
{ this.renderGlobalIcon() }
{ tags }
<div>
{ createButton }
</div>
</div>
</AutoHideScrollbar>
</div>;
}

View file

@ -36,7 +36,7 @@ import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise";
import {sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
@ -99,7 +99,7 @@ class CategoryRoomList extends React.Component {
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
allSettled(addrs.map((addr) => {
Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroupSummary(this.props.groupId, addr.address)
.catch(() => { errorList.push(addr.address); });
@ -274,7 +274,7 @@ class RoleUserList extends React.Component {
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
allSettled(addrs.map((addr) => {
Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addUserToGroupSummary(addr.address)
.catch(() => { errorList.push(addr.address); });

View file

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

View file

@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow();
}
}
@ -183,21 +185,24 @@ export default class IndicatorScrollbar extends React.Component {
};
render() {
// eslint-disable-next-line no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow
const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow
const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{...this.props}
{...otherProps}
>
{ leftOverflowIndicator }
{ this.props.children }
{ children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}

View file

@ -24,6 +24,7 @@ import CustomRoomTagPanel from "./CustomRoomTagPanel";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
@ -43,6 +44,7 @@ import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
interface IProps {
isMinimized: boolean;
@ -66,6 +68,7 @@ const cssClasses = [
@replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string;
@ -90,10 +93,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
}
// We watch the middle panel because we don't actually get resized, the middle panel does.
// We listen to the noisy channel to avoid choppy reaction times.
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
public componentDidMount() {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
}
public componentWillUnmount() {
@ -103,17 +110,34 @@ export default class LeftPanel extends React.Component<IProps, IState> {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevState.activeSpace !== this.state.activeSpace) {
this.refreshStickyHeaders();
}
}
private updateActiveSpace = (activeSpace: Room) => {
this.setState({ activeSpace });
};
private onDialPad = () => {
dis.fire(Action.OpenDialPad);
}
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
private refreshStickyHeaders = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) {
@ -156,9 +180,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
@ -228,7 +249,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const offset = UIStore.instance.windowHeight -
(list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
@ -247,14 +269,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_sticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
if (listDimensions) {
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = listDimensions.width - headerRightMargin;
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
@ -276,16 +304,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
private onScroll = (ev: Event) => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onResize = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target;
};
@ -379,7 +402,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private renderSearchExplore(): React.ReactNode {
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton = null;
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}
onClick={this.onDialPad}
title={_t("Open dial pad")}
/>;
}
return (
<div
className="mx_LeftPanel_filterContainer"
@ -392,6 +428,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown}
onSelectRoom={this.selectRoom}
/>
{dialPadButton}
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
@ -420,8 +459,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
/>;
const containerClasses = classNames({
@ -435,17 +475,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
);
return (
<div className={containerClasses}>
<div className={containerClasses} ref={this.ref}>
{leftLeftPanel}
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
{this.renderSearchDialExplore()}
{this.renderBreadcrumbs()}
<RoomListNumResults />
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
@ -454,7 +493,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList}
</div>
</div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
{ !this.props.isMinimized && <LeftPanelWidget /> }
</aside>
</div>
);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import React, {useContext, useMemo} from "react";
import {Resizable} from "re-resizable";
import classNames from "classnames";
@ -27,16 +27,13 @@ import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings";
interface IProps {
onResize(): void;
}
import UIStore from "../../stores/UIStore";
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const LeftPanelWidget: React.FC = () => {
const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
@ -56,7 +53,6 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;
@ -68,8 +64,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
content = <Resizable
size={{height} as any}
minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
onResize={onResize}
maxHeight={Math.min(UIStore.instance.windowHeight / 2, MAX_HEIGHT)}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
}}

View file

@ -19,19 +19,16 @@ limitations under the License.
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
@ -170,7 +167,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
CallMediaHandler.loadDevices();
MediaDeviceHandler.loadDevices();
fixupColorFonts();
@ -219,16 +216,6 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
//
// This is required because `LoggedInView` maintains its own state and if this state
// updates after the client peg has been made null (during logout), then it will
// attempt to re-render and the children will throw errors.
shouldComponentUpdate() {
return Boolean(MatrixClientPeg.get());
}
canResetTimelineInRoom = (roomId) => {
if (!this._roomView.current) {
return true;
@ -368,7 +355,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) events.push(event);
}
@ -579,50 +566,6 @@ class LoggedInView extends React.Component<IProps, IState> {
}
};
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
const dest = result.destination.droppableId;
if (dest === 'tag-panel-droppable') {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from GroupFilterOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
draggableId,
result.destination.index,
), true);
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
};
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
if (prevTag === 'undefined') prevTag = undefined;
const roomId = result.draggableId.split('_')[1];
const oldIndex = result.source.index;
const newIndex = result.destination.index;
dis.dispatch(RoomListActions.tagRoom(
this._matrixClient,
this._matrixClient.getRoom(roomId),
prevTag, newTag,
oldIndex, newIndex,
), true);
};
render() {
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
@ -689,17 +632,15 @@ class LoggedInView extends React.Component<IProps, IState> {
aria-hidden={this.props.hideToSRUsers}
>
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
<div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>
</div>
<CallContainer />
<NonUrgentToastContainer />

View file

@ -48,7 +48,7 @@ import createRoom, {IOpts} from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { startAnyRegistrationFlow } from "../../Registration";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
@ -86,6 +86,9 @@ import {RoomUpdateCause} from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
/** constants for MatrixChat.state.view */
export enum Views {
// a special initial state which is only used at startup, while we are
@ -223,13 +226,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
firstSyncPromise: IDeferred<void>;
private screenAfterLogin?: IScreen;
private windowWidth: number;
private pageChanging: boolean;
private tokenLogin?: boolean;
private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean;
private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any;
@ -275,9 +278,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
}
this.windowWidth = 10000;
this.handleResize();
window.addEventListener('resize', this.handleResize);
this.prevWindowWidth = UIStore.instance.windowWidth || 1000;
UIStore.instance.on(UI_EVENTS.Resize, this.handleResize);
this.pageChanging = false;
@ -376,7 +378,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
const promisesList: Promise<any>[] = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
@ -434,7 +436,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.unregister(this.dispatcherRef);
this.themeWatcher.stop();
this.fontWatcher.stop();
window.removeEventListener('resize', this.handleResize);
UIStore.destroy();
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
@ -484,42 +486,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
startPageChangeTimer() {
// Tor doesn't support performance
if (!performance || !performance.mark) return null;
// This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate
// are used.
if (this.pageChanging) {
console.warn('MatrixChat.startPageChangeTimer: timer already started');
return;
}
this.pageChanging = true;
performance.mark('element_MatrixChat_page_change_start');
PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE);
}
stopPageChangeTimer() {
// Tor doesn't support performance
if (!performance || !performance.mark) return null;
const perfMonitor = PerformanceMonitor.instance;
if (!this.pageChanging) {
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
return;
}
this.pageChanging = false;
performance.mark('element_MatrixChat_page_change_stop');
performance.measure(
'element_MatrixChat_page_change_delta',
'element_MatrixChat_page_change_start',
'element_MatrixChat_page_change_stop',
);
performance.clearMarks('element_MatrixChat_page_change_start');
performance.clearMarks('element_MatrixChat_page_change_stop');
const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop();
perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE);
// In practice, sometimes the entries list is empty, so we get no measurement
if (!measurement) return null;
const entries = perfMonitor.getEntries({
name: PerformanceEntryNames.PAGE_CHANGE,
});
const measurement = entries.pop();
return measurement.duration;
return measurement
? measurement.duration
: null;
}
shouldTrackPageChange(prevState: IState, state: IState) {
@ -683,7 +665,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case 'view_create_room':
this.createRoom(payload.public);
this.createRoom(payload.public, payload.defaultName);
break;
case 'view_create_group': {
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
@ -740,6 +722,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreenAfterLogin();
break;
case 'toggle_my_groups':
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpacesBeta", "1");
// We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) {
@ -906,6 +890,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
@ -1022,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private async createRoom(defaultPublic = false) {
private async createRoom(defaultPublic = false, defaultName?: string) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
if (communityId) {
// double check the user will have permission to associate this room with the community
@ -1036,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
defaultPublic,
defaultName,
});
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
@ -1469,7 +1461,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
@ -1625,11 +1617,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'start_registration',
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER);
} else if (screen === 'login') {
dis.dispatch({
action: 'start_login',
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
} else if (screen === 'forgot_password') {
dis.dispatch({
action: 'start_password_recovery',
@ -1684,6 +1678,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') {
if (SettingsStore.getValue("feature_spaces")) {
dis.dispatch({ action: "view_home_page" });
return;
}
dis.dispatch({
action: 'view_my_groups',
});
@ -1767,6 +1765,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action,
});
} else if (screen.indexOf('group/') === 0) {
if (SettingsStore.getValue("feature_spaces")) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6);
// TODO: Check valid group ID
@ -1817,18 +1820,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
handleResize = () => {
const hideLhsThreshold = 1000;
const showLhsThreshold = 1000;
const LHS_THRESHOLD = 1000;
const width = UIStore.instance.windowWidth;
if (this.windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (this.windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) {
dis.dispatch({ action: 'show_left_panel' });
}
if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
this.prevWindowWidth = width;
this.state.resizeNotifier.notifyWindowResized();
this.windowWidth = window.innerWidth;
};
private dispatchTimelineResize() {
@ -1949,6 +1953,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
await this.postLoginSetup();
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};
// complete security / e2e setup has finished
@ -2085,6 +2092,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
{...this.getServerProperties()}
/>
);

View file

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

View file

@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component {
@ -81,8 +82,7 @@ export default class MyGroups extends React.Component {
</p>
<p>
{ _t(
"To set up a filter, drag a community avatar over to the filter panel on " +
"the far left hand side of the screen. You can click on an avatar in the " +
"You can click on an avatar in the " +
"filter panel at any time to see only the rooms and people associated " +
"with that community.",
) }
@ -139,6 +139,7 @@ export default class MyGroups extends React.Component {
</div>
</div>*/}
</div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content">
{ contentHeader }
{ content }

View file

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

View file

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

View file

@ -1,7 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,39 +15,90 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import React from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { mediaFromMxc } from "../../customisations/Media";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseAvatar from "../views/avatars/BaseAvatar";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
function track(action) {
function track(action: string) {
Analytics.trackEvent('RoomDirectory', action);
}
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string;
instanceId: string | symbol;
roomServer: string;
filterString: string;
selectedCommunityId?: string;
communityName?: string;
}
/* eslint-disable camelcase */
interface IRoom {
room_id: string;
name?: string;
avatar_url?: string;
topic?: string;
canonical_alias?: string;
aliases?: string[];
world_readable: boolean;
guest_can_join: boolean;
num_joined_members: number;
}
interface IPublicRoomsRequest {
limit?: number;
since?: string;
server?: string;
filter?: object;
include_all_networks?: boolean;
third_party_instance_id?: string;
}
/* eslint-enable camelcase */
@replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
onFinished: PropTypes.func.isRequired,
};
export default class RoomDirectory extends React.Component<IProps, IState> {
private readonly startTime: number;
private unmounted = false
private nextBatch: string = null;
private filterTimeout: NodeJS.Timeout;
private protocols: Protocols;
constructor(props) {
super(props);
@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component {
CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
communityName: null,
};
const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
? GroupFilterOrderStore.getSelectedTags()[0]
: null;
this._unmounted = false;
this.nextBatch = null;
this.filterTimeout = null;
this.scrollPanel = null;
this.protocols = null;
this.state.protocolsLoading = true;
let protocolsLoading = true;
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
this.state.protocolsLoading = false;
return;
}
if (!this.state.selectedCommunityId) {
protocolsLoading = false;
} else if (!selectedCommunityId) {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
this.setState({ protocolsLoading: false });
}, (err) => {
console.warn(`error loading third party protocols: ${err}`);
this.setState({protocolsLoading: false});
this.setState({ protocolsLoading: false });
if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so
// ignore this as otherwise this error is literally the
@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component {
error: _t(
'%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
{brand},
{ brand },
),
});
});
} else {
// We don't use the protocols in the communities v2 prototype experience
this.state.protocolsLoading = false;
protocolsLoading = false;
// Grab the profile info async
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
this.setState({communityName: profile.name});
this.setState({ communityName: profile.name });
});
}
this.state = {
publicRooms: [],
loading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId,
communityName: null,
protocolsLoading,
};
}
componentDidMount() {
@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
this._unmounted = true;
this.unmounted = true;
}
refreshRoomList = () => {
private refreshRoomList = () => {
if (this.state.selectedCommunityId) {
this.setState({
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component {
this.getMoreRooms();
};
getMoreRooms() {
private getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve();
@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component {
loading: true,
});
const my_filter_string = this.state.filterString;
const my_server = this.state.roomServer;
const filterString = this.state.filterString;
const roomServer = this.state.roomServer;
// remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it.
const my_next_batch = this.nextBatch;
const opts = {limit: 20};
if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server;
const nextBatch = this.nextBatch;
const opts: IPublicRoomsRequest = { limit: 20 };
if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = roomServer;
}
if (this.state.instanceId === ALL_ROOMS) {
opts.include_all_networks = true;
} else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
opts.third_party_instance_id = this.state.instanceId as string;
}
if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
if (filterString) opts.filter = { generic_search_term: filterString };
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
if (
my_filter_string != this.state.filterString ||
my_server != this.state.roomServer ||
my_next_batch != this.nextBatch) {
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag
// since we must still have a request in flight)
return;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
}
@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component {
}
this.nextBatch = data.next_batch;
this.setState((s) => {
s.publicRooms.push(...(data.chunk || []));
s.loading = false;
return s;
});
this.setState((s) => ({
...s,
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
loading: false,
}));
return Boolean(data.next_batch);
}, (err) => {
if (
my_filter_string != this.state.filterString ||
my_server != this.state.roomServer ||
my_next_batch != this.nextBatch) {
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// as above: we don't care about errors for old
// requests either
return;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
}
@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component {
* HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417.
*/
removeFromDirectory(room) {
const alias = get_display_alias_for_room(room);
private removeFromDirectory(room: IRoom) {
const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room');
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let desc;
if (alias) {
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component {
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
title: _t('Remove from Directory'),
description: desc,
onFinished: (should_delete) => {
if (!should_delete) return;
onFinished: (shouldDelete: boolean) => {
if (!shouldDelete) return;
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader);
const modal = Modal.createDialog(Spinner);
let step = _t('remove %(name)s from the directory.', {name: name});
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
@ -289,23 +327,24 @@ export default class RoomDirectory extends React.Component {
console.error("Failed to " + step + ": " + err);
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
title: _t('Error'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
description: (err && err.message)
? err.message
: _t('The server may be unavailable or overloaded'),
});
});
},
});
}
onRoomClicked = (room, ev) => {
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
this.removeFromDirectory(room);
} else {
this.showRoom(room);
}
};
onOptionChange = (server, instanceId) => {
private onOptionChange = (server: string, instanceId?: string | symbol) => {
// clear next batch so we don't try to load more rooms
this.nextBatch = null;
this.setState({
@ -325,13 +364,13 @@ export default class RoomDirectory extends React.Component {
// Easiest to just blow away the state & re-fetch.
};
onFillRequest = (backwards) => {
private onFillRequest = (backwards: boolean) => {
if (backwards || !this.nextBatch) return Promise.resolve(false);
return this.getMoreRooms();
};
onFilterChange = (alias) => {
private onFilterChange = (alias: string) => {
this.setState({
filterString: alias || null,
});
@ -349,7 +388,7 @@ export default class RoomDirectory extends React.Component {
}, 700);
};
onFilterClear = () => {
private onFilterClear = () => {
// update immediately
this.setState({
filterString: null,
@ -360,7 +399,7 @@ export default class RoomDirectory extends React.Component {
}
};
onJoinFromSearchClick = (alias) => {
private onJoinFromSearchClick = (alias: string) => {
// If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected
@ -373,9 +412,10 @@ export default class RoomDirectory extends React.Component {
// This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
const fields = protocolName
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
: null;
if (!fields) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
title: _t('Unable to join network'),
@ -387,14 +427,12 @@ export default class RoomDirectory extends React.Component {
if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias, true);
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
title: _t('Room not found'),
description: _t('Couldn\'t find a matching Matrix room'),
});
}
}, (e) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
title: _t('Fetching third party location failed'),
description: _t('Unable to look up room ID from server'),
@ -403,36 +441,37 @@ export default class RoomDirectory extends React.Component {
}
};
onPreviewClick = (ev, room) => {
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, false, true);
ev.stopPropagation();
};
onViewClick = (ev, room) => {
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room);
ev.stopPropagation();
};
onJoinClick = (ev, room) => {
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, true);
ev.stopPropagation();
};
onCreateRoomClick = room => {
private onCreateRoomClick = () => {
this.onFinished();
dis.dispatch({
action: 'view_create_room',
public: true,
defaultName: this.state.filterString.trim(),
});
};
showRoomAlias(alias, autoJoin=false) {
private showRoomAlias(alias: string, autoJoin = false) {
this.showRoom(null, alias, autoJoin);
}
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished();
const payload = {
const payload: ActionPayload = {
action: 'view_room',
auto_join: autoJoin,
should_peek: shouldPeek,
@ -449,15 +488,15 @@ export default class RoomDirectory extends React.Component {
}
}
if (!room_alias) {
room_alias = get_display_alias_for_room(room);
if (!roomAlias) {
roomAlias = getDisplayAliasForRoom(room);
}
payload.oob_data = {
avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which
// would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'),
name: room.name || roomAlias || _t('Unnamed room'),
};
if (this.state.roomServer) {
@ -471,21 +510,19 @@ export default class RoomDirectory extends React.Component {
// which servers to start querying. However, there's no other way to join rooms in
// this list without aliases at present, so if roomAlias isn't set here we have no
// choice but to supply the ID.
if (room_alias) {
payload.room_alias = room_alias;
if (roomAlias) {
payload.room_alias = roomAlias;
} else {
payload.room_id = room.room_id;
}
dis.dispatch(payload);
}
createRoomCells(room) {
private createRoomCells(room: IRoom) {
const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
const isGuest = client.isGuest();
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let previewButton;
let joinOrViewButton;
@ -495,20 +532,26 @@ export default class RoomDirectory extends React.Component {
// it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
{ _t("Preview") }
</AccessibleButton>
);
}
if (hasJoinedRoom) {
joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
{ _t("View") }
</AccessibleButton>
);
} else if (!isGuest) {
joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
{ _t("Join") }
</AccessibleButton>
);
}
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
@ -524,72 +567,80 @@ export default class RoomDirectory extends React.Component {
let avatarUrl = null;
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_avatar` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomAvatar"
>
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl }
<BaseAvatar
width={32}
height={32}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl}
/>
</div>,
<div key={ `${room.room_id}_description` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_description` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomDescription"
>
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
<div
className="mx_RoomDirectory_name"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ name }
</div>&nbsp;
<div
className="mx_RoomDirectory_topic"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
<div
className="mx_RoomDirectory_alias"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ getDisplayAliasForRoom(room) }
</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_memberCount` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
<div key={ `${room.room_id}_preview` }
onClick={(ev) => this.onRoomClicked(room, ev)}
<div
key={ `${room.room_id}_preview` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview"
>
{previewButton}
{ previewButton }
</div>,
<div key={ `${room.room_id}_join` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_join` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_join"
>
{joinOrViewButton}
{ joinOrViewButton }
</div>,
];
}
collectScrollPanel = (element) => {
this.scrollPanel = element;
};
_stringLooksLikeId(s, field_type) {
private stringLooksLikeId(s: string, fieldType: IFieldType) {
let pat = /^#[^\s]+:[^\s]/;
if (field_type && field_type.regexp) {
pat = new RegExp(field_type.regexp);
if (fieldType && fieldType.regexp) {
pat = new RegExp(fieldType.regexp);
}
return pat.test(s);
}
_getFieldsForThirdPartyLocation(userInput, protocol, instance) {
private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
// make an object with the fields specified by that protocol. We
// require that the values of all but the last field come from the
// instance. The last is the user input.
@ -605,71 +656,73 @@ export default class RoomDirectory extends React.Component {
return fields;
}
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
handleScrollKey = ev => {
if (this.scrollPanel) {
this.scrollPanel.handleScrollKey(ev);
}
};
onFinished = () => {
private onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished();
this.props.onFinished(false);
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let content;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading) {
content = <Loader />;
content = <Spinner />;
} else {
const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
let spinner;
if (this.state.loading) {
spinner = <Loader />;
spinner = <Spinner />;
}
let scrollpanel_content;
const createNewButton = <>
<hr />
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
{ _t("Create new room") }
</AccessibleButton>
</>;
let scrollPanelContent;
let footer;
if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
footer = <>
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
<p>
{ _t("Try different words or check for typos. " +
"Some results may not be visible as they're private and you need an invite to join them.") }
</p>
{ createNewButton }
</>;
} else {
scrollpanel_content = <div className="mx_RoomDirectory_table">
scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells }
</div>;
if (!this.state.loading && !this.nextBatch) {
footer = createNewButton;
}
}
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = <ScrollPanel ref={this.collectScrollPanel}
content = <ScrollPanel
className="mx_RoomDirectory_tableWrapper"
onFillRequest={ this.onFillRequest }
onFillRequest={this.onFillRequest}
stickyBottom={false}
startAtBottom={false}
>
{ scrollpanel_content }
{ scrollPanelContent }
{ spinner }
{ footer && <div className="mx_RoomDirectory_footer">
{ footer }
</div> }
</ScrollPanel>;
}
let listHeader;
if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
let instanceExpectedFieldType;
if (
protocolName &&
this.protocols &&
@ -677,21 +730,27 @@ export default class RoomDirectory extends React.Component {
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
}
let placeholder = _t('Find a room…');
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
exampleRoom: "#example:" + this.state.roomServer,
});
} else if (instanceExpectedFieldType) {
placeholder = instanceExpectedFieldType.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
if (this.getFieldsForThirdPartyLocation(
this.state.filterString,
this.protocols[protocolName],
instance,
) === null) {
showJoinButton = false;
}
}
@ -723,12 +782,11 @@ export default class RoomDirectory extends React.Component {
}
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => {
return (<AccessibleButton
kind="secondary"
onClick={this.onCreateRoomClick}
>{sub}</AccessibleButton>);
}},
{a: sub => (
<AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
{ sub }
</AccessibleButton>
)},
);
const title = this.state.selectedCommunityId
@ -756,6 +814,6 @@ export default class RoomDirectory extends React.Component {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function get_display_alias_for_room(room) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
function getDisplayAliasForRoom(room: IRoom) {
return room.canonical_alias || room.aliases?.[0] || "";
}

View file

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

View file

@ -23,8 +23,9 @@ limitations under the License.
import React, { createRef } from 'react';
import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room";
import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import { EventSubscription } from "fbemitter";
import shouldHideEvent from '../../shouldHideEvent';
@ -46,7 +47,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout";
import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
@ -54,16 +55,13 @@ import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import ForwardMessage from "../views/rooms/ForwardMessage";
import SearchBar from "../views/rooms/SearchBar";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common";
@ -82,7 +80,9 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -136,16 +136,15 @@ export interface IState {
// Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
searching: boolean;
searchTerm?: string;
searchScope?: "All" | "Room";
searchScope?: SearchScope;
searchResults?: XOR<{}, {
count: number;
highlights: string[];
results: MatrixEvent[];
results: SearchResult[];
next_batch: string; // eslint-disable-line camelcase
}>;
searchHighlights?: string[];
@ -155,8 +154,6 @@ export interface IState {
canPeek: boolean;
showApps: boolean;
isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
showRightPanel: boolean;
// error object, as from the matrix client/server API
// If we failed to load information about the room,
@ -175,14 +172,17 @@ export interface IState {
statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: {
version: string;
needsUpgrade: boolean;
urgent: boolean;
};
upgradeRecommendation?: IRecommendedVersion;
canReact: boolean;
canReply: boolean;
layout: Layout;
lowBandwidth: boolean;
showReadReceipts: boolean;
showRedactions: boolean;
showJoinLeaves: boolean;
showAvatarChanges: boolean;
showDisplaynameChanges: boolean;
matrixClientIsReady: boolean;
showUrlPreview?: boolean;
e2eStatus?: E2EStatus;
@ -193,6 +193,7 @@ export interface IState {
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean;
editState?: EditorStateTransfer;
}
@replaceableComponent("structures.RoomView")
@ -200,8 +201,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription;
private readonly showReadReceiptsWatchRef: string;
private readonly layoutWatcherRef: string;
private settingWatchers: string[];
private unmounted = false;
private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
@ -232,8 +232,6 @@ export default class RoomView extends React.Component<IProps, IState> {
canPeek: false,
showApps: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
joining: false,
atEndOfLiveTimeline: true,
@ -243,6 +241,12 @@ export default class RoomView extends React.Component<IProps, IState> {
canReact: false,
canReply: false,
layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0,
};
@ -269,9 +273,14 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
this.settingWatchers = [
SettingsStore.watchSetting("layout", null, () =>
this.setState({ layout: SettingsStore.getValue("layout") }),
),
SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
),
];
}
private onWidgetStoreUpdate = () => {
@ -324,14 +333,45 @@ export default class RoomView extends React.Component<IProps, IState> {
initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
showRedactions: SettingsStore.getValue("showRedactions", roomId),
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(),
};
// Add watchers for each of the settings we just looked up
this.settingWatchers = this.settingWatchers.concat([
SettingsStore.watchSetting("showReadReceipts", null, () =>
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}),
),
SettingsStore.watchSetting("showRedactions", null, () =>
this.setState({
showRedactions: SettingsStore.getValue("showRedactions", roomId),
}),
),
SettingsStore.watchSetting("showJoinLeaves", null, () =>
this.setState({
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
}),
),
SettingsStore.watchSetting("showAvatarChanges", null, () =>
this.setState({
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
}),
),
SettingsStore.watchSetting("showDisplaynameChanges", null, () =>
this.setState({
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
}),
),
]);
if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
// Stop peeking because we have joined this room now
this.context.stopPeeking();
@ -528,7 +568,16 @@ export default class RoomView extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
const hasPropsDiff = objectHasDiff(this.props, nextProps);
const { upgradeRecommendation, ...state } = this.state;
const { upgradeRecommendation: newUpgradeRecommendation, ...newState } = nextState;
const hasStateDiff =
newUpgradeRecommendation?.needsUpgrade !== upgradeRecommendation?.needsUpgrade ||
objectHasDiff(state, newState);
return hasPropsDiff || hasStateDiff;
}
componentDidUpdate() {
@ -627,10 +676,6 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
}
// cancel any pending calls to the rate_limited_funcs
this.updateRoomMembers.cancelPendingCall();
@ -638,14 +683,22 @@ export default class RoomView extends React.Component<IProps, IState> {
// console.log("Tinter.tint from RoomView.unmount");
// Tinter.tint(); // reset colourscheme
SettingsStore.unwatchSetting(this.layoutWatcherRef);
for (const watcher of this.settingWatchers) {
SettingsStore.unwatchSetting(watcher);
}
}
private onLayoutChange = () => {
this.setState({
layout: SettingsStore.getValue("layout"),
});
};
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
replyingToEvent: this.state.replyToEvent,
});
}
}
private onRightPanelStoreUpdate = () => {
this.setState({
@ -764,6 +817,36 @@ export default class RoomView extends React.Component<IProps, IState> {
case 'focus_search':
this.onSearchClick();
break;
case "edit_event": {
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => {
if (payload.event) {
this.messagePanel?.scrollToEventIfNeeded(payload.event.getId());
}
});
break;
}
case Action.ComposerInsert: {
// re-dispatch to the correct composer
if (this.state.editState) {
dis.dispatch({
...payload,
action: "edit_composer_insert",
});
} else {
dis.dispatch({
...payload,
action: "send_composer_insert",
});
}
break;
}
case "scroll_to_bottom":
this.messagePanel?.jumpToLiveTimeline();
break;
}
};
@ -797,7 +880,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change
} else if (!shouldHideEvent(ev)) {
} else if (!shouldHideEvent(ev, this.state)) {
this.setState((state, props) => {
return {numUnreadMessages: state.numUnreadMessages + 1};
});
@ -1114,7 +1197,8 @@ export default class RoomView extends React.Component<IProps, IState> {
Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({
action: 'join_room',
action: Action.JoinRoom,
roomId: this.getRoomId(),
opts: { inviteSignUrl: signUrl },
_type: "unknown", // TODO: instrumentation
});
@ -1211,7 +1295,7 @@ export default class RoomView extends React.Component<IProps, IState> {
});
}
private onSearch = (term: string, scope) => {
private onSearch = (term: string, scope: SearchScope) => {
this.setState({
searchTerm: term,
searchScope: scope,
@ -1232,7 +1316,7 @@ export default class RoomView extends React.Component<IProps, IState> {
this.searchId = new Date().getTime();
let roomId;
if (scope === "Room") roomId = this.state.room.roomId;
if (scope === SearchScope.Room) roomId = this.state.room.roomId;
debuglog("sending search request");
const searchPromise = eventSearch(term, roomId);
@ -1375,13 +1459,6 @@ export default class RoomView extends React.Component<IProps, IState> {
return ret;
}
private onPinnedClick = () => {
const nowShowingPinned = !this.state.showingPinned;
const roomId = this.state.room.roomId;
this.setState({showingPinned: nowShowingPinned, searching: false});
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
};
private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({
action: 'place_call',
@ -1394,18 +1471,6 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({ action: "open_room_settings" });
};
private onCancelClick = () => {
console.log("updateTint from onCancelClick");
this.updateTint();
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
event: null,
});
}
dis.fire(Action.FocusComposer);
};
private onAppsClick = () => {
dis.dispatch({
action: "appsDrawer",
@ -1498,7 +1563,6 @@ export default class RoomView extends React.Component<IProps, IState> {
private onSearchClick = () => {
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
};
@ -1511,8 +1575,19 @@ export default class RoomView extends React.Component<IProps, IState> {
// jump down to the bottom of this room, where new events are arriving
private jumpToLiveTimeline = () => {
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
// If we were viewing a highlighted event, firing view_room without
// an event will take care of both clearing the URL fragment and
// jumping to the bottom
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
});
} else {
// Otherwise we have to jump manually
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
}
};
// jump up to wherever our read marker is
@ -1585,59 +1660,30 @@ export default class RoomView extends React.Component<IProps, IState> {
// a maxHeight on the underlying remote video tag.
// header + footer + status + give us at least 120px of scrollback at all times.
let auxPanelMaxHeight = window.innerHeight -
let auxPanelMaxHeight = UIStore.instance.windowHeight -
(54 + // height of RoomHeader
36 + // height of the status area
51 + // minimum height of the message compmoser
51 + // minimum height of the message composer
120); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
};
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
}, true);
};
private onMuteAudioClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) {
this.setState({ auxPanelMaxHeight });
}
const newState = !call.isMicrophoneMuted();
call.setMicrophoneMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onMuteVideoClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
}
const newState = !call.isLocalVideoMuted();
call.setLocalVideoMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onStatusBarVisible = () => {
if (this.unmounted) return;
this.setState({
statusBarVisible: true,
});
if (this.unmounted || this.state.statusBarVisible) return;
this.setState({ statusBarVisible: true });
};
private onStatusBarHidden = () => {
// This is currently not desired as it is annoying if it keeps expanding and collapsing
if (this.unmounted) return;
this.setState({
statusBarVisible: false,
});
if (this.unmounted || !this.state.statusBarVisible) return;
this.setState({ statusBarVisible: false });
};
/**
@ -1856,11 +1902,7 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null;
let previewBar;
let hideCancel = false;
if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
if (this.state.searching) {
aux = <SearchBar
searchInProgress={this.state.searchInProgress}
onCancelClick={this.onCancelSearchClick}
@ -1869,10 +1911,6 @@ export default class RoomView extends React.Component<IProps, IState> {
/>;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it.
@ -1881,7 +1919,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inviterName = this.props.oobData.inviterName;
}
const invitedEmail = this.props.threepidInvite?.toEmail;
hideCancel = true;
previewBar = (
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
@ -1917,7 +1954,7 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) {
if (this.state.room?.isSpaceRoom()) {
return <SpaceRoomView
space={this.state.room}
justCreatedOpts={this.props.justCreatedOpts}
@ -1999,11 +2036,8 @@ export default class RoomView extends React.Component<IProps, IState> {
hideMessagePanel = true;
}
const shouldHighlight = this.state.isInitialEventHighlighted;
let highlightedEventId = null;
if (this.state.forwardingEvent) {
highlightedEventId = this.state.forwardingEvent.getId();
} else if (shouldHighlight) {
if (this.state.isInitialEventHighlighted) {
highlightedEventId = this.state.initialEventId;
}
@ -2028,6 +2062,7 @@ export default class RoomView extends React.Component<IProps, IState> {
eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll}
onUserScroll={this.onUserScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview}
className={messagePanelClassNames}
@ -2036,6 +2071,7 @@ export default class RoomView extends React.Component<IProps, IState> {
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
layout={this.state.layout}
editState={this.state.editState}
/>);
let topUnreadMessagesBar = null;
@ -2051,9 +2087,10 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>);
}
@ -2090,8 +2127,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}

View file

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

View file

@ -14,33 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import classNames from "classnames";
import {sortBy} from "lodash";
import { sortBy } from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import { EnhancedMap } from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {mediaFromMxc} from "../../customisations/Media";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
interface IHierarchyProps {
space: Room;
@ -75,7 +76,7 @@ export interface ISpaceSummaryEvent {
order?: string;
suggested?: boolean;
auto_join?: boolean;
via?: string;
via?: string[];
};
}
/* eslint-enable camelcase */
@ -100,15 +101,13 @@ const Tile: React.FC<ITileProps> = ({
numChildRooms,
children,
}) => {
const name = room.name || room.canonical_alias || room.aliases?.[0]
const cli = MatrixClientPeg.get();
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -121,7 +120,7 @@ const Tile: React.FC<ITileProps> = ({
}
let button;
if (myMembership === "join") {
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
@ -145,17 +144,27 @@ const Tile: React.FC<ITileProps> = ({
}
}
let url: string;
if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms) {
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
if (room.topic) {
description += " · " + room.topic;
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
@ -166,13 +175,22 @@ const Tile: React.FC<ITileProps> = ({
}
const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
{ avatar }
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_info">
<div
className="mx_SpaceRoomDirectory_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
@ -268,7 +286,7 @@ export const HierarchyLevel = ({
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
return getChildOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
@ -301,7 +319,7 @@ export const HierarchyLevel = ({
key={roomId}
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length}
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
@ -346,9 +364,9 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
}
if (Array.isArray(ev.content["via"])) {
if (Array.isArray(ev.content.via)) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
ev.content.via.forEach(via => set.add(via));
}
});
@ -419,7 +437,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
@ -461,8 +479,12 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
@ -498,6 +520,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
@ -574,7 +597,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Search names and description") }
placeholder={ _t("Search names and descriptions") }
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}

View file

@ -14,52 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {EventSubscription} from "fbemitter";
import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomAvatar from "../views/avatars/RoomAvatar";
import {_t} from "../../languageHandler";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom";
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
import { useRoomMembers } from "../../hooks/useRoomMembers";
import createRoom, { IOpts } from "../../createRoom";
import Field from "../views/elements/Field";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import withValidation from "../views/elements/Validation";
import * as Email from "../../email";
import defaultDispatcher from "../../dispatcher/dispatcher";
import {Action} from "../../dispatcher/actions";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import ResizeNotifier from "../../utils/ResizeNotifier"
import MainSplit from './MainSplit';
import ErrorBoundary from "../views/elements/ErrorBoundary";
import {ActionPayload} from "../../dispatcher/payloads";
import { ActionPayload } from "../../dispatcher/payloads";
import RightPanel from "./RightPanel";
import RightPanelStore from "../../stores/RightPanelStore";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import {useStateArray} from "../../hooks/useStateArray";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory";
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
import { useStateToggle } from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {sleep} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
interface IProps {
space: Room;
@ -71,6 +79,7 @@ interface IProps {
interface IState {
phase: Phase;
createdRooms?: boolean; // internal state for the creation wizard
showRightPanel: boolean;
myMembership: string;
}
@ -85,6 +94,26 @@ enum Phase {
PrivateExistingRooms,
}
// XXX: Temporary for the Spaces Beta only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<hr />
<div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton kind="link" onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces",
});
}}>
{ _t("Feedback") }
</AccessibleButton>
</div>
</div>;
};
const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room);
const count = members.length;
@ -136,15 +165,42 @@ const SpaceInfo = ({ space }) => {
</div>
};
const onBetaClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const [busy, setBusy] = useState(false);
const spacesEnabled = SettingsStore.getValue("feature_spaces");
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& space.getJoinRule() !== JoinRule.Public;
let inviterSection;
let joinButtons;
if (myMembership === "invite") {
if (myMembership === "join") {
// XXX remove this when spaces leaves Beta
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
dis.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave") }
</AccessibleButton>
);
} else if (myMembership === "invite") {
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender);
@ -180,6 +236,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled}
>
{ _t("Accept") }
</AccessibleButton>
@ -192,17 +249,43 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled || cannotJoin}
>
{ _t("Join") }
</AccessibleButton>
)
);
}
if (busy) {
joinButtons = <InlineSpinner />;
}
let footer;
if (!spacesEnabled) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
}
</div>;
} else if (cannotJoin) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ _t("To view %(spaceName)s, you need an invite", {
spaceName: space.name,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
@ -220,6 +303,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
{ footer }
</div>;
};
@ -350,9 +434,14 @@ const SpaceLanding = ({ space }) => {
{ inviteButton }
{ settingsButton }
</div>
<div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} />
</div>
<RoomTopic room={space}>
{(topic, ref) => (
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
</div>
)}
</RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy
@ -369,7 +458,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const [error, setError] = useState("");
const numFields = 3;
const placeholders = [_t("General"), _t("Random"), _t("Support")];
// TODO vary default prefills for "Just Me" spaces
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((_, i) => {
const name = "roomName" + i;
@ -382,14 +470,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
/>;
});
const onNextClick = async () => {
const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError("");
setBusy(true);
try {
await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => {
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
@ -402,7 +494,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
parentSpace: space,
});
}));
onFinished();
onFinished(filteredRoomNames.length > 0);
} catch (e) {
console.error("Failed to create initial space rooms", e);
setError(_t("Failed to create initial space rooms"));
@ -410,7 +502,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let onClick = (ev) => {
ev.preventDefault();
onFinished(false);
};
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
onClick = onNextClick;
@ -422,54 +517,26 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
<div className="mx_SpaceRoomView_description">{ description }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
{ fields }
</form>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
element="input"
type="submit"
form="mx_SpaceSetupFirstRooms"
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceAddExistingRooms = ({ space, onFinished }) => {
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) {
onClick = async () => {
setBusy(true);
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
break;
}
}
setBusy(false);
};
buttonLabel = busy ? _t("Adding...") : _t("Add");
}
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
@ -477,36 +544,28 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
"no one will be informed. You can add more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
emptySelectionButton={
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Skip for now") }
</AccessibleButton>
}
onFinished={onFinished}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => {
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
<h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") }
</div>
@ -515,17 +574,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Go to my first room") }
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
{ _t("Make sure the right people have access to %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }
</div>
<AccessibleButton
@ -542,6 +604,11 @@ const SpaceSetupPrivateScope = ({ space, onFinished }) => {
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -572,10 +639,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
disabled={busy}
/>;
});
const onNextClick = async () => {
const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError("");
for (let i = 0; i < fieldRefs.length; i++) {
const fieldRef = fieldRefs[i];
@ -609,7 +679,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let onClick = (ev) => {
ev.preventDefault();
onFinished();
};
let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) {
onClick = onNextClick;
@ -622,8 +695,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
{ _t("Make sure the right people have access. You can invite more later.") }
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} />
{ _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>,
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
app.element.io
</a>,
}) }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
{ fields }
</form>
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton
@ -635,10 +721,17 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
{ buttonLabel }
</AccessibleButton>
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupPrivateInvite"
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -737,7 +830,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms;
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
}
if (suggestedRooms.length) {
@ -745,9 +838,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
room_alias: room.canonical_alias || room.aliases?.[0],
via_servers: room.viaServers,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
},
});
return;
@ -759,7 +854,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
private renderBody() {
switch (this.state.phase) {
case Phase.Landing:
if (this.state.myMembership === "join") {
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
@ -772,20 +867,26 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.space.name,
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
})}
description={
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={() => this.setState({ phase: Phase.PublicShare })}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
createdRooms={this.state.createdRooms}
/>;
case Phase.PrivateScope:
return <SpaceSetupPrivateScope
space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}}
@ -801,7 +902,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={() => this.setState({ phase: Phase.Landing })}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms

View file

@ -18,14 +18,15 @@ limitations under the License.
*/
import SettingsStore from "../../settings/SettingsStore";
import {LayoutPropType} from "../../settings/Layout";
import React, {createRef} from 'react';
import { LayoutPropType } from "../../settings/Layout";
import React, { createRef } from 'react';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline";
import {TimelineWindow} from "matrix-js-sdk/src/timeline-window";
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
@ -33,11 +34,10 @@ import * as sdk from "../../index";
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { haveTileForEvent } from "../views/rooms/EventTile";
import { UIFeature } from "../../settings/UIFeature";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays";
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
@ -70,6 +70,8 @@ class TimelinePanel extends React.Component {
manageReadReceipts: PropTypes.bool,
sendReadReceiptOnLoad: PropTypes.bool,
manageReadMarkers: PropTypes.bool,
// with this enabled it'll listen and react to Action.ComposerInsert and `edit_event`
manageComposerDispatches: PropTypes.bool,
// true to give the component a 'display: none' style.
hidden: PropTypes.bool,
@ -93,6 +95,9 @@ class TimelinePanel extends React.Component {
// callback which is called when the panel is scrolled.
onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func,
@ -117,8 +122,13 @@ class TimelinePanel extends React.Component {
// which layout to use
layout: LayoutPropType,
// whether to always show timestamps for an event
alwaysShowTimestamps: PropTypes.bool,
}
static contextType = RoomContext;
// a map from room id to read marker event timestamp
static roomReadMarkerTsMap = {};
@ -257,37 +267,15 @@ class TimelinePanel extends React.Component {
console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
}
if (newProps.eventId != this.props.eventId) {
const differentEventId = newProps.eventId != this.props.eventId;
const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId;
if (differentEventId || differentHighlightedEventId) {
console.log("TimelinePanel switching to eventId " + newProps.eventId +
" (was " + this.props.eventId + ")");
return this._initTimeline(newProps);
}
}
shouldComponentUpdate(nextProps, nextState) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
}
componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
@ -452,21 +440,10 @@ class TimelinePanel extends React.Component {
};
onAction = payload => {
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
if (payload.action === "edit_event") {
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({editState}, () => {
if (payload.event && this._messagePanel.current) {
this._messagePanel.current.scrollToEventIfNeeded(
payload.event.getId(),
);
}
});
}
if (payload.action === "scroll_to_bottom") {
this.jumpToLiveTimeline();
switch (payload.action) {
case "ignore_state_changed":
this.forceUpdate();
break;
}
};
@ -857,6 +834,12 @@ class TimelinePanel extends React.Component {
}
};
scrollToEventIfNeeded = (eventId) => {
if (this._messagePanel.current) {
this._messagePanel.current.scrollToEventIfNeeded(eventId);
}
}
/* scroll to show the read-up-to marker. We put it 1/3 of the way down
* the container.
*/
@ -1141,6 +1124,17 @@ class TimelinePanel extends React.Component {
// get the list of events from the timeline window and the pending event list
_getEvents() {
const events = this._timelineWindow.getEvents();
// `arrayFastClone` performs a shallow copy of the array
// we want the last event to be decrypted first but displayed last
// `reverse` is destructive and unfortunately mutates the "events" array
arrayFastClone(events)
.reverse()
.forEach(event => {
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(event);
});
const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
// Hold onto the live events separately. The read receipt and read marker
@ -1293,7 +1287,7 @@ class TimelinePanel extends React.Component {
const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev);
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) {
// don't start counting if the event should be ignored,
@ -1444,15 +1438,16 @@ class TimelinePanel extends React.Component {
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
editState={this.state.editState}
editState={this.props.editState}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}

View file

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

View file

@ -26,7 +26,7 @@ import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import { USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB } from "../views/dialogs/UserSettingsDialog";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal";
@ -57,7 +57,8 @@ import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName";
import {replaceableComponent} from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
interface IProps {
isMinimized: boolean;
}
@ -68,6 +69,7 @@ interface IState {
contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean;
selectedSpace?: Room;
pendingRoomJoin: Set<string>;
}
@replaceableComponent("structures.UserMenu")
@ -84,6 +86,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.state = {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
pendingRoomJoin: new Set<string>(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -103,6 +106,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
}
public componentWillUnmount() {
@ -114,6 +118,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
}
private onTagStoreUpdate = () => {
@ -147,15 +156,39 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
switch (ev.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
case Action.JoinRoom:
this.addPendingJoinRoom(ev.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
this.removePendingJoinRoom(ev.roomId);
break;
}
};
private addPendingJoinRoom(roomId: string): void {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
.add(roomId),
});
}
private removePendingJoinRoom(roomId: string): void {
if (this.state.pendingRoomJoin.delete(roomId)) {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
})
}
}
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -333,9 +366,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.domains || validDomains.length > 0) {
topSection = <div onClick={this.onCloseMenu}>
<HostSignupAction />
</div>;
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
}
}
}
@ -377,12 +408,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notification settings")}
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
@ -617,6 +648,14 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</span>
{name}
{this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
)}
{dnd}
{buttons}
</div>

View file

@ -55,7 +55,7 @@ export default class ViewSource extends React.Component {
viewSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted();
const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event;
if (isEncrypted) {

View file

@ -18,14 +18,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
} from '../../../stores/SetupEncryptionStore';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -61,18 +54,18 @@ export default class CompleteSecurity extends React.Component {
let icon;
let title;
if (phase === PHASE_LOADING) {
if (phase === Phase.Loading) {
return null;
} else if (phase === PHASE_INTRO) {
} else if (phase === Phase.Intro) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else if (phase === PHASE_DONE) {
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified");
} else if (phase === PHASE_CONFIRM_SKIP) {
} else if (phase === Phase.ConfirmSkip) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Are you sure?");
} else if (phase === PHASE_BUSY) {
} else if (phase === Phase.Busy) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else {

View file

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

View file

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

View file

@ -21,15 +21,7 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function keyHasPassphrase(keyInfo) {
@ -63,7 +55,7 @@ export default class SetupEncryptionBody extends React.Component {
_onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === PHASE_FINISHED) {
if (store.phase === Phase.Finished) {
this.props.onFinished();
return;
}
@ -136,7 +128,7 @@ export default class SetupEncryptionBody extends React.Component {
onClose={this.props.onFinished}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
/>;
} else if (phase === PHASE_INTRO) {
} else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
@ -174,7 +166,7 @@ export default class SetupEncryptionBody extends React.Component {
</div>
</div>
);
} else if (phase === PHASE_DONE) {
} else if (phase === Phase.Done) {
let message;
if (this.state.backupInfo) {
message = <p>{_t(
@ -200,7 +192,7 @@ export default class SetupEncryptionBody extends React.Component {
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
} else if (phase === Phase.ConfirmSkip) {
return (
<div>
<p>{_t(
@ -224,7 +216,7 @@ export default class SetupEncryptionBody extends React.Component {
</div>
</div>
);
} else if (phase === PHASE_BUSY || phase === PHASE_LOADING) {
} else if (phase === Phase.Busy || phase === Phase.Loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <Spinner />;
} else {

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import classNames from 'classnames';
import { MatrixClient } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -27,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { LocalisedPolicy, Policies } from '../../../Terms';
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -74,36 +73,72 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* focus: set the input focus appropriately in the form.
*/
enum AuthType {
Password = "m.login.password",
Recaptcha = "m.login.recaptcha",
Terms = "m.login.terms",
Email = "m.login.email.identity",
Msisdn = "m.login.msisdn",
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
}
/* eslint-disable camelcase */
interface IAuthDict {
type?: AuthType;
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user?: string;
identifier?: any;
password?: string;
response?: string;
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds?: any;
threepidCreds?: any;
}
/* eslint-enable camelcase */
export const DEFAULT_PHASE = 0;
@replaceableComponent("views.auth.PasswordAuthEntry")
export class PasswordAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.password";
interface IAuthEntryProps {
matrixClient: MatrixClient;
loginType: string;
authSessionId: string;
errorText?: string;
// Is the auth logic currently waiting for something to happen?
busy?: boolean;
onPhaseChange: (phase: number) => void;
submitAuthDict: (auth: IAuthDict) => void;
}
static propTypes = {
matrixClient: PropTypes.object.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
// is the auth logic currently waiting for something to
// happen?
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
};
interface IPasswordAuthEntryState {
password: string;
}
@replaceableComponent("views.auth.PasswordAuthEntry")
export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswordAuthEntryState> {
static LOGIN_TYPE = AuthType.Password;
constructor(props) {
super(props);
this.state = {
password: "",
};
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
state = {
password: "",
};
_onSubmit = e => {
private onSubmit = (e: FormEvent) => {
e.preventDefault();
if (this.props.busy) return;
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
type: AuthType.Password,
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user: this.props.matrixClient.credentials.userId,
@ -115,7 +150,7 @@ export class PasswordAuthEntry extends React.Component {
});
};
_onPasswordFieldChange = ev => {
private onPasswordFieldChange = (ev: ChangeEvent<HTMLInputElement>) => {
// enable the submit button iff the password is non-empty
this.setState({
password: ev.target.value,
@ -123,7 +158,7 @@ export class PasswordAuthEntry extends React.Component {
};
render() {
const passwordBoxClass = classnames({
const passwordBoxClass = classNames({
"error": this.props.errorText,
});
@ -155,7 +190,7 @@ export class PasswordAuthEntry extends React.Component {
return (
<div>
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<form onSubmit={this.onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
className={passwordBoxClass}
type="password"
@ -163,7 +198,7 @@ export class PasswordAuthEntry extends React.Component {
label={_t('Password')}
autoFocus={true}
value={this.state.password}
onChange={this._onPasswordFieldChange}
onChange={this.onPasswordFieldChange}
/>
<div className="mx_button_row">
{ submitButtonOrSpinner }
@ -175,26 +210,26 @@ export class PasswordAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.recaptcha";
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
/* eslint-disable camelcase */
interface IRecaptchaAuthEntryProps extends IAuthEntryProps {
stageParams?: {
public_key?: string;
};
}
/* eslint-enable camelcase */
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps> {
static LOGIN_TYPE = AuthType.Recaptcha;
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
_onCaptchaResponse = response => {
private onCaptchaResponse = (response: string) => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE,
type: AuthType.Recaptcha,
response: response,
});
};
@ -230,7 +265,7 @@ export class RecaptchaAuthEntry extends React.Component {
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
onCaptchaResponse={this.onCaptchaResponse}
/>
{ errorSection }
</div>
@ -238,18 +273,28 @@ export class RecaptchaAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.terms";
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
showContinue: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
interface ITermsAuthEntryProps extends IAuthEntryProps {
stageParams?: {
policies?: Policies;
};
showContinue: boolean;
}
interface LocalisedPolicyWithId extends LocalisedPolicy {
id: string;
}
interface ITermsAuthEntryState {
policies: LocalisedPolicyWithId[];
toggledPolicies: {
[policy: string]: boolean;
};
errorText?: string;
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITermsAuthEntryState> {
static LOGIN_TYPE = AuthType.Terms;
constructor(props) {
super(props);
@ -294,8 +339,11 @@ export class TermsAuthEntry extends React.Component {
initToggles[policyId] = false;
langPolicy.id = policyId;
pickedPolicies.push(langPolicy);
pickedPolicies.push({
id: policyId,
name: langPolicy.name,
url: langPolicy.url,
});
}
this.state = {
@ -311,11 +359,11 @@ export class TermsAuthEntry extends React.Component {
this.props.onPhaseChange(DEFAULT_PHASE);
}
tryContinue = () => {
this._trySubmit();
public tryContinue = () => {
this.trySubmit();
};
_togglePolicy(policyId) {
private togglePolicy(policyId: string) {
const newToggles = {};
for (const policy of this.state.policies) {
let checked = this.state.toggledPolicies[policy.id];
@ -326,7 +374,7 @@ export class TermsAuthEntry extends React.Component {
this.setState({"toggledPolicies": newToggles});
}
_trySubmit = () => {
private trySubmit = () => {
let allChecked = true;
for (const policy of this.state.policies) {
const checked = this.state.toggledPolicies[policy.id];
@ -334,7 +382,7 @@ export class TermsAuthEntry extends React.Component {
}
if (allChecked) {
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
this.props.submitAuthDict({type: AuthType.Terms});
CountlyAnalytics.instance.track("onboarding_terms_complete");
} else {
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
@ -356,7 +404,7 @@ export class TermsAuthEntry extends React.Component {
checkboxes.push(
// XXX: replace with StyledCheckbox
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
<input type="checkbox" onChange={() => this.togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
</label>,
);
@ -375,7 +423,7 @@ export class TermsAuthEntry extends React.Component {
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
}
return (
@ -389,21 +437,18 @@ export class TermsAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.email.identity";
static propTypes = {
matrixClient: PropTypes.object.isRequired,
submitAuthDict: PropTypes.func.isRequired,
authSessionId: PropTypes.string.isRequired,
clientSecret: PropTypes.string.isRequired,
inputs: PropTypes.object.isRequired,
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
onPhaseChange: PropTypes.func.isRequired,
interface IEmailIdentityAuthEntryProps extends IAuthEntryProps {
inputs?: {
emailAddress?: string;
};
stageState?: {
emailSid: string;
};
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEntryProps> {
static LOGIN_TYPE = AuthType.Email;
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
@ -427,7 +472,7 @@ export class EmailIdentityAuthEntry extends React.Component {
return (
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
{ emailAddress: <b>{ this.props.inputs.emailAddress }</b> },
) }
</p>
<p>{ _t("Open the link in the email to continue registration.") }</p>
@ -437,37 +482,44 @@ export class EmailIdentityAuthEntry extends React.Component {
}
}
interface IMsisdnAuthEntryProps extends IAuthEntryProps {
inputs: {
phoneCountry: string;
phoneNumber: string;
};
clientSecret: string;
fail: (error: Error) => void;
}
interface IMsisdnAuthEntryState {
token: string;
requestingToken: boolean;
errorText: string;
}
@replaceableComponent("views.auth.MsisdnAuthEntry")
export class MsisdnAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.msisdn";
export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsisdnAuthEntryState> {
static LOGIN_TYPE = AuthType.Msisdn;
static propTypes = {
inputs: PropTypes.shape({
phoneCountry: PropTypes.string,
phoneNumber: PropTypes.string,
}),
fail: PropTypes.func,
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
onPhaseChange: PropTypes.func.isRequired,
};
private submitUrl: string;
private sid: string;
private msisdn: string;
state = {
token: '',
requestingToken: false,
};
constructor(props) {
super(props);
this.state = {
token: '',
requestingToken: false,
errorText: '',
};
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
this.setState({requestingToken: true});
this._requestMsisdnToken().catch((e) => {
this.requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
@ -477,26 +529,26 @@ export class MsisdnAuthEntry extends React.Component {
/*
* Requests a verification token by SMS.
*/
_requestMsisdnToken() {
private requestMsisdnToken(): Promise<void> {
return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._submitUrl = result.submit_url;
this._sid = result.sid;
this._msisdn = result.msisdn;
this.submitUrl = result.submit_url;
this.sid = result.sid;
this.msisdn = result.msisdn;
});
}
_onTokenChange = e => {
private onTokenChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
token: e.target.value,
});
};
_onFormSubmit = async e => {
private onFormSubmit = async (e: FormEvent) => {
e.preventDefault();
if (this.state.token == '') return;
@ -506,20 +558,20 @@ export class MsisdnAuthEntry extends React.Component {
try {
let result;
if (this._submitUrl) {
if (this.submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
this.submitUrl, this.sid, this.props.clientSecret, this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
}
if (result.success) {
const creds = {
sid: this._sid,
sid: this.sid,
client_secret: this.props.clientSecret,
};
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
type: AuthType.Msisdn,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
@ -543,7 +595,7 @@ export class MsisdnAuthEntry extends React.Component {
return <Loader />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
const submitClasses = classNames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_GeneralButton: true,
});
@ -558,16 +610,16 @@ export class MsisdnAuthEntry extends React.Component {
return (
<div>
<p>{ _t("A text message has been sent to %(msisdn)s",
{ msisdn: <i>{ this._msisdn }</i> },
{ msisdn: <i>{ this.msisdn }</i> },
) }
</p>
<p>{ _t("Please enter the code it contains:") }</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}>
<form onSubmit={this.onFormSubmit}>
<input type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
onChange={this.onTokenChange}
aria-label={ _t("Code")}
/>
<br />
@ -584,40 +636,40 @@ export class MsisdnAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
continueText: PropTypes.string,
continueKind: PropTypes.string,
onCancel: PropTypes.func,
};
interface ISSOAuthEntryProps extends IAuthEntryProps {
continueText?: string;
continueKind?: string;
onCancel?: () => void;
}
static LOGIN_TYPE = "m.login.sso";
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso";
interface ISSOAuthEntryState {
phase: number;
attemptFailed: boolean;
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEntryState> {
static LOGIN_TYPE = AuthType.Sso;
static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable;
static PHASE_PREAUTH = 1; // button to start SSO
static PHASE_POSTAUTH = 2; // button to confirm SSO completed
_ssoUrl: string;
private ssoUrl: string;
private popupWindow: Window;
constructor(props) {
super(props);
// We actually send the user through fallback auth so we don't have to
// deal with a redirect back to us, losing application context.
this._ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this.popupWindow = null;
window.addEventListener("message", this.onReceiveMessage);
this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH,
@ -625,44 +677,44 @@ export class SSOAuthEntry extends React.Component {
};
}
componentDidMount(): void {
componentDidMount() {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
window.removeEventListener("message", this.onReceiveMessage);
if (this.popupWindow) {
this.popupWindow.close();
this.popupWindow = null;
}
}
attemptFailed = () => {
public attemptFailed = () => {
this.setState({
attemptFailed: true,
});
};
_onReceiveMessage = event => {
private onReceiveMessage = (event: MessageEvent) => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
if (this.popupWindow) {
this.popupWindow.close();
this.popupWindow = null;
}
}
};
onStartAuthClick = () => {
private onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid losing application
// context.
this._popupWindow = window.open(this._ssoUrl, "_blank");
this.popupWindow = window.open(this.ssoUrl, "_blank");
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
onConfirmClick = () => {
private onConfirmClick = () => {
this.props.submitAuthDict({});
};
@ -716,46 +768,37 @@ export class SSOAuthEntry extends React.Component {
}
@replaceableComponent("views.auth.FallbackAuthEntry")
export class FallbackAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
};
export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
private popupWindow: Window;
private fallbackButton = createRef<HTMLAnchorElement>();
constructor(props) {
super(props);
// we have to make the user click a button, as browsers will block
// the popup if we open it immediately.
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this._fallbackButton = createRef();
this.popupWindow = null;
window.addEventListener("message", this.onReceiveMessage);
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
window.removeEventListener("message", this.onReceiveMessage);
if (this.popupWindow) {
this.popupWindow.close();
}
}
focus = () => {
if (this._fallbackButton.current) {
this._fallbackButton.current.focus();
public focus = () => {
if (this.fallbackButton.current) {
this.fallbackButton.current.focus();
}
};
_onShowFallbackClick = e => {
private onShowFallbackClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -763,10 +806,10 @@ export class FallbackAuthEntry extends React.Component {
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = window.open(url, "_blank");
this.popupWindow = window.open(url, "_blank");
};
_onReceiveMessage = event => {
private onReceiveMessage = (event: MessageEvent) => {
if (
event.data === "authDone" &&
event.origin === this.props.matrixClient.getHomeserverUrl()
@ -786,27 +829,31 @@ export class FallbackAuthEntry extends React.Component {
}
return (
<div>
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
<a href="" ref={this.fallbackButton} onClick={this.onShowFallbackClick}>{
_t("Start authentication")
}</a>
{errorSection}
</div>
);
}
}
const AuthEntryComponents = [
PasswordAuthEntry,
RecaptchaAuthEntry,
EmailIdentityAuthEntry,
MsisdnAuthEntry,
TermsAuthEntry,
SSOAuthEntry,
];
export default function getEntryComponentForLoginType(loginType) {
for (const c of AuthEntryComponents) {
if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) {
return c;
}
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;
case AuthType.Recaptcha:
return RecaptchaAuthEntry;
case AuthType.Email:
return EmailIdentityAuthEntry;
case AuthType.Msisdn:
return MsisdnAuthEntry;
case AuthType.Terms:
return TermsAuthEntry;
case AuthType.Sso:
case AuthType.SsoUnstable:
return SSOAuthEntry;
default:
return FallbackAuthEntry;
}
return FallbackAuthEntry;
}

View file

@ -17,15 +17,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useState} from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from '../../../languageHandler';
interface IProps {
@ -44,12 +46,12 @@ interface IProps {
className?: string;
}
const calculateUrls = (url, urls) => {
const calculateUrls = (url, urls, lowBandwidth) => {
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ]
let _urls = [];
if (!SettingsStore.getValue("lowBandwidth")) {
if (!lowBandwidth) {
_urls = urls || [];
if (url) {
@ -63,7 +65,13 @@ const calculateUrls = (url, urls) => {
};
const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls));
// Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext ?
roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0);
const onError = useCallback(() => {
@ -71,7 +79,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => {
}, []);
useEffect(() => {
setUrls(calculateUrls(url, urls));
setUrls(calculateUrls(url, urls, lowBandwidth));
setIndex(0);
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
@ -179,7 +187,7 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title} alt={_t("Avatar")}
inputRef={inputRef}
{...otherProps} />
);

View file

@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar";
import NotificationBadge from '../rooms/NotificationBadge';
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
room: Room;
avatarSize: number;
tag: TagID;
displayBadge?: boolean;
forceCount?: boolean;
oobData?: object;
@ -121,7 +119,10 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
if (this.props.room.roomId !== room.roomId) return;
if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') {
this.setState({icon: this.calculateIcon()});
const newIcon = this.calculateIcon();
if (newIcon !== this.state.icon) {
this.setState({icon: newIcon});
}
}
};

View file

@ -15,10 +15,11 @@ limitations under the License.
*/
import React from 'react';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
export interface IProps {
groupId?: string;

View file

@ -16,14 +16,14 @@ limitations under the License.
*/
import React from 'react';
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { Action } from "../../../dispatcher/actions";
import BaseAvatar from "./BaseAvatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember;

View file

@ -13,17 +13,17 @@ 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, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,

View file

@ -0,0 +1,116 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import TextWithTooltip from "../elements/TextWithTooltip";
import Modal from "../../../Modal";
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
interface IProps {
title?: string;
featureId: string;
}
export const BetaPill = ({ onClick }: { onClick?: () => void }) => {
if (onClick) {
return <TextWithTooltip
class={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
tooltip={<div>
<div className="mx_Tooltip_title">
{ _t("Spaces is a beta feature") }
</div>
<div className="mx_Tooltip_sub">
{ _t("Tap for more info") }
</div>
</div>}
onClick={onClick}
tooltipProps={{ yOffset: -10 }}
>
{ _t("Beta") }
</TextWithTooltip>;
}
return <span
className={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
onClick={onClick}
>
{ _t("Beta") }
</span>;
};
const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null; // Beta is invalid/disabled
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info;
const value = SettingsStore.getValue(featureId);
let feedbackButton;
if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) {
feedbackButton = <AccessibleButton
onClick={() => {
Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId });
}}
kind="primary"
>
{ _t("Feedback") }
</AccessibleButton>;
}
return <div className="mx_BetaCard">
<div className="mx_BetaCard_columns">
<div>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div className="mx_BetaCard_buttons">
{ feedbackButton }
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind={feedbackButton ? "primary_outline" : "primary"}
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
</div>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
</div>
<img src={image} alt="" />
</div>
{ extraSettings && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) }
</div> }
</div>;
};
export default BetaCard;

View file

@ -18,6 +18,7 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import Dialpad from '../voip/DialPad';
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});
}
onChange = (ev) => {
this.setState({value: ev.target.value});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</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 className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -28,9 +28,12 @@ import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
import { Action } from "../../../dispatcher/actions";
export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -82,7 +85,7 @@ export default class MessageContextMenu extends React.Component {
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
@ -92,7 +95,7 @@ export default class MessageContextMenu extends React.Component {
_isPinned() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
@ -156,34 +159,32 @@ export default class MessageContextMenu extends React.Component {
};
onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({
action: 'forward_event',
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
matrixClient: MatrixClientPeg.get(),
event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
};
onPinClick = () => {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
.catch((e) => {
// Intercept the Event Not Found error and fall through the promise chain with no event.
if (e.errcode === "M_NOT_FOUND") return null;
throw e;
})
.then((event) => {
const eventIds = (event ? event.pinned : []) || [];
if (!eventIds.includes(this.props.mxEvent.getId())) {
// Not pinned - add
eventIds.push(this.props.mxEvent.getId());
} else {
// Pinned - remove
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
}
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
const cli = MatrixClientPeg.get();
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []),
eventId,
],
});
}
cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
this.closeMenu();
};
@ -200,7 +201,7 @@ export default class MessageContextMenu extends React.Component {
onQuoteClick = () => {
dis.dispatch({
action: 'quote',
action: Action.ComposerInsert,
event: this.props.mxEvent,
});
this.closeMenu();
@ -256,55 +257,68 @@ export default class MessageContextMenu extends React.Component {
let externalURLButton;
let quoteButton;
let collapseReplyThread;
let redactItemList;
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
{ _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconResend"
label={ _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) }
onClick={this.onResendReactionsClick}
/>
);
}
}
if (isSent && this.state.canRedact) {
redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconRedact"
label={_t("Remove")}
onClick={this.onRedactClick}
/>
);
}
if (isContentActionable(mxEvent)) {
forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconForward"
label={_t("Forward")}
onClick={this.onForwardClick}
/>
);
if (this.state.canPin) {
pinButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={ this._isPinned() ? _t('Unpin') : _t('Pin') }
onClick={this.onPinClick}
/>
);
}
}
const viewSourceButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
{ _t('View Source') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconSource"
label={_t("View source")}
onClick={this.onViewSourceClick}
/>
);
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
{ _t('Unhide Preview') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconUnhidePreview"
label={_t("Show preview")}
onClick={this.onUnhidePreviewClick}
/>
);
}
}
@ -315,77 +329,97 @@ export default class MessageContextMenu extends React.Component {
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<MenuItem
element="a"
className="mx_MessageContextMenu_field"
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPermalink"
onClick={this.onPermalinkClick}
label= {_t('Share')}
element="a"
href={permalink}
target="_blank"
rel="noreferrer noopener"
>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
</MenuItem>
/>
);
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
{ _t('Quote') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconQuote"
label={_t("Quote")}
onClick={this.onQuoteClick}
/>
);
}
// Bridges can provide a 'external_url' to link back to the source.
if (
typeof(mxEvent.event.content.external_url) === "string" &&
if (typeof (mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(mxEvent.event.content.external_url)
) {
externalURLButton = (
<MenuItem
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconLink"
onClick={this.closeMenu}
label={ _t('Source URL') }
element="a"
className="mx_MessageContextMenu_field"
target="_blank"
rel="noreferrer noopener"
onClick={this.closeMenu}
href={mxEvent.event.content.external_url}
>
{ _t('Source URL') }
</MenuItem>
/>
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
{ _t('Collapse Reply Thread') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCollapse"
label={_t("Collapse reply thread")}
onClick={this.onCollapseReplyThreadClick}
/>
);
}
let reportEventButton;
if (mxEvent.getSender() !== me) {
reportEventButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onReportEventClick}>
{ _t('Report Content') }
</MenuItem>
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconReport"
label={_t("Report")}
onClick={this.onReportEventClick}
/>
);
}
const commonItemsList = (
<IconizedContextMenuOptionList>
{ quoteButton }
{ forwardButton }
{ pinButton }
{ permalinkButton }
{ reportEventButton }
{ externalURLButton }
{ unhidePreviewButton }
{ viewSourceButton }
{ resendReactionsButton }
{ collapseReplyThread }
</IconizedContextMenuOptionList>
);
if (redactButton) {
redactItemList = (
<IconizedContextMenuOptionList red>
{ redactButton }
</IconizedContextMenuOptionList>
);
}
return (
<div className="mx_MessageContextMenu">
{ resendReactionsButton }
{ redactButton }
{ forwardButton }
{ pinButton }
{ viewSourceButton }
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ externalURLButton }
{ collapseReplyThread }
{ reportEventButton }
</div>
<IconizedContextMenu
{...this.props}
className="mx_MessageContextMenu"
compact={true}
>
{ commonItemsList }
{ redactItemList }
</IconizedContextMenu>
);
}
}

View file

@ -23,45 +23,70 @@ import TagOrderActions from '../../../actions/TagOrderActions';
import {MenuItem} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func.isRequired,
};
static contextType = MatrixClientContext;
constructor() {
super();
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
this._onRemoveClick = this._onRemoveClick.bind(this);
}
_onViewCommunityClick() {
_onViewCommunityClick = () => {
dis.dispatch({
action: 'view_group',
group_id: this.props.tag,
});
this.props.onFinished();
}
};
_onRemoveClick() {
_onRemoveClick = () => {
dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag));
this.props.onFinished();
}
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
};
_onMoveDown = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1));
this.props.onFinished();
};
render() {
let moveUp;
let moveDown;
if (this.props.index > 0) {
moveUp = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveUp" onClick={this._onMoveUp}>
{ _t("Move up") }
</MenuItem>
);
}
if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) {
moveDown = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveDown" onClick={this._onMoveDown}>
{ _t("Move down") }
</MenuItem>
);
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
</MenuItem>
{ (moveUp || moveDown) ? <hr className="mx_TagTileContextMenu_separator" role="separator" /> : null }
{ moveUp }
{ moveDown }
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t('Hide') }
{ _t("Unpin") }
</MenuItem>
</div>;
}

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useMemo, useState} from "react";
import React, {ReactNode, useContext, useMemo, useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -36,6 +36,12 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -45,7 +51,10 @@ interface IProps extends IDialogProps {
const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} />
{ room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} />
: <DecoratedRoomAvatar room={room} avatarSize={32} />
}
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
@ -57,150 +66,59 @@ const Entry = ({ room, checked, onChange }) => {
interface IAddExistingToSpaceProps {
space: Room;
selected: Set<Room>;
onChange(checked: boolean, room: Room): void;
footerPrompt?: ReactNode;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selected.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState<Error>(null);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
let spaceOptionSection;
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]);
const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]);
const [spaces, rooms, dms] = useMemo(() => {
let rooms = visibleRooms;
if (lcQuery) {
const matcher = new QueryMatcher<Room>(visibleRooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
spaceOptionSection = (
<Dropdown
id="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
label={_t("Space selection")}
>
{ options }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}
rooms = matcher.match(lcQuery);
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
</div>
</React.Fragment>;
const joinRule = space.getJoinRule();
return sortRooms(rooms).reduce((arr, room) => {
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
}, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]);
const addRooms = async () => {
setError(null);
@ -264,20 +182,161 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div>
</span>;
} else {
let button = emptySelectionButton;
if (!button || selectedToAdd.size > 0) {
button = <AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>;
}
footer = <>
<span>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
{ footerPrompt }
</span>
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>
{ button }
</>;
}
const onChange = !busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : 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">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpace_footer">
{ footer }
</div>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
let spaceOptionSection;
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
spaceOptionSection = (
<Dropdown
id="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
label={_t("Space selection")}
>
{ options }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
</div>
</React.Fragment>;
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
@ -288,21 +347,17 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={!busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : null}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</>}
/>
</MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer">
{ footer }
</div>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>;
};

View file

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

View file

@ -15,39 +15,41 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { SettingLevel } from "../../../settings/SettingLevel";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
unknownProfileUsers: Array<{
userId: string;
errorText: string;
}>;
onInviteAnyways: () => void;
onGiveUp: () => void;
onFinished: (success: boolean) => void;
}
@replaceableComponent("views.dialogs.AskInviteAnywayDialog")
export default class AskInviteAnywayDialog extends React.Component {
static propTypes = {
unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ]
onInviteAnyways: PropTypes.func.isRequired,
onGiveUp: PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
};
_onInviteClicked = () => {
export default class AskInviteAnywayDialog extends React.Component<IProps> {
private onInviteClicked = (): void => {
this.props.onInviteAnyways();
this.props.onFinished(true);
};
_onInviteNeverWarnClicked = () => {
private onInviteNeverWarnClicked = (): void => {
SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false);
this.props.onInviteAnyways();
this.props.onFinished(true);
};
_onGiveUpClicked = () => {
private onGiveUpClicked = (): void => {
this.props.onGiveUp();
this.props.onFinished(false);
};
render() {
public render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const errorList = this.props.unknownProfileUsers
@ -55,11 +57,12 @@ export default class AskInviteAnywayDialog extends React.Component {
return (
<BaseDialog className='mx_RetryInvitesDialog'
onFinished={this._onGiveUpClicked}
onFinished={this.onGiveUpClicked}
title={_t('The following users may not exist')}
contentId='mx_Dialog_content'
>
<div id='mx_Dialog_content'>
{/* eslint-disable-next-line */}
<p>{_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?")}</p>
<ul>
{ errorList }
@ -67,13 +70,13 @@ export default class AskInviteAnywayDialog extends React.Component {
</div>
<div className="mx_Dialog_buttons">
<button onClick={this._onGiveUpClicked}>
<button onClick={this.onGiveUpClicked}>
{ _t('Close') }
</button>
<button onClick={this._onInviteNeverWarnClicked}>
<button onClick={this.onInviteNeverWarnClicked}>
{ _t('Invite anyway and never warn me again') }
</button>
<button onClick={this._onInviteClicked} autoFocus={true}>
<button onClick={this.onInviteClicked} autoFocus={true}>
{ _t('Invite anyway') }
</button>
</div>

View file

@ -0,0 +1,111 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import {IDialogProps} from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore";
import {submitFeedback} from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
interface IProps extends IDialogProps {
featureId: string;
}
const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment>
<div className="mx_BetaFeedbackDialog_subheading">
{ _t(info.feedbackSubheading) }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.")}
<AccessibleButton kind="link" onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default BetaFeedbackDialog;

View file

@ -18,7 +18,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
@ -27,8 +26,27 @@ import sendBugReport, {downloadBugReport} from '../../../rageshake/submit-ragesh
import AccessibleButton from "../elements/AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
initialText?: string;
label?: string;
}
interface IState {
sendLogs: boolean;
busy: boolean;
err: string;
issueUrl: string;
text: string;
progress: string;
downloadBusy: boolean;
downloadProgress: string;
}
@replaceableComponent("views.dialogs.BugReportDialog")
export default class BugReportDialog extends React.Component {
export default class BugReportDialog extends React.Component<IProps, IState> {
private unmounted: boolean;
constructor(props) {
super(props);
this.state = {
@ -41,25 +59,18 @@ export default class BugReportDialog extends React.Component {
downloadBusy: false,
downloadProgress: null,
};
this._unmounted = false;
this._onSubmit = this._onSubmit.bind(this);
this._onCancel = this._onCancel.bind(this);
this._onTextChange = this._onTextChange.bind(this);
this._onIssueUrlChange = this._onIssueUrlChange.bind(this);
this._onSendLogsChange = this._onSendLogsChange.bind(this);
this._sendProgressCallback = this._sendProgressCallback.bind(this);
this._downloadProgressCallback = this._downloadProgressCallback.bind(this);
this.unmounted = false;
}
componentWillUnmount() {
this._unmounted = true;
public componentWillUnmount() {
this.unmounted = true;
}
_onCancel(ev) {
private onCancel = (): void => {
this.props.onFinished(false);
}
_onSubmit(ev) {
private onSubmit = (): void => {
if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) {
this.setState({
err: _t("Please tell us what went wrong or, better, create a GitHub issue that describes the problem."),
@ -72,15 +83,15 @@ export default class BugReportDialog extends React.Component {
(this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given');
this.setState({ busy: true, progress: null, err: null });
this._sendProgressCallback(_t("Preparing to send logs"));
this.sendProgressCallback(_t("Preparing to send logs"));
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
progressCallback: this.sendProgressCallback,
label: this.props.label,
}).then(() => {
if (!this._unmounted) {
if (!this.unmounted) {
this.props.onFinished(false);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
@ -91,7 +102,7 @@ export default class BugReportDialog extends React.Component {
});
}
}, (err) => {
if (!this._unmounted) {
if (!this.unmounted) {
this.setState({
busy: false,
progress: null,
@ -101,14 +112,14 @@ export default class BugReportDialog extends React.Component {
});
}
_onDownload = async (ev) => {
private onDownload = async (): Promise<void> => {
this.setState({ downloadBusy: true });
this._downloadProgressCallback(_t("Preparing to download logs"));
this.downloadProgressCallback(_t("Preparing to download logs"));
try {
await downloadBugReport({
sendLogs: true,
progressCallback: this._downloadProgressCallback,
progressCallback: this.downloadProgressCallback,
label: this.props.label,
});
@ -117,7 +128,7 @@ export default class BugReportDialog extends React.Component {
downloadProgress: null,
});
} catch (err) {
if (!this._unmounted) {
if (!this.unmounted) {
this.setState({
downloadBusy: false,
downloadProgress: _t("Failed to send logs: ") + `${err.message}`,
@ -126,33 +137,29 @@ export default class BugReportDialog extends React.Component {
}
};
_onTextChange(ev) {
this.setState({ text: ev.target.value });
private onTextChange = (ev: React.FormEvent<HTMLTextAreaElement>): void => {
this.setState({ text: ev.currentTarget.value });
}
_onIssueUrlChange(ev) {
this.setState({ issueUrl: ev.target.value });
private onIssueUrlChange = (ev: React.FormEvent<HTMLInputElement>): void => {
this.setState({ issueUrl: ev.currentTarget.value });
}
_onSendLogsChange(ev) {
this.setState({ sendLogs: ev.target.checked });
}
_sendProgressCallback(progress) {
if (this._unmounted) {
private sendProgressCallback = (progress: string): void => {
if (this.unmounted) {
return;
}
this.setState({progress: progress});
this.setState({ progress });
}
_downloadProgressCallback(downloadProgress) {
if (this._unmounted) {
private downloadProgressCallback = (downloadProgress: string): void => {
if (this.unmounted) {
return;
}
this.setState({ downloadProgress });
}
render() {
public render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -183,7 +190,7 @@ export default class BugReportDialog extends React.Component {
}
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
<BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>
@ -213,7 +220,7 @@ export default class BugReportDialog extends React.Component {
</b></p>
<div className="mx_BugReportDialog_download">
<AccessibleButton onClick={this._onDownload} kind="link" disabled={this.state.downloadBusy}>
<AccessibleButton onClick={this.onDownload} kind="link" disabled={this.state.downloadBusy}>
{ _t("Download logs") }
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
@ -223,7 +230,7 @@ export default class BugReportDialog extends React.Component {
type="text"
className="mx_BugReportDialog_field_input"
label={_t("GitHub issue")}
onChange={this._onIssueUrlChange}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
/>
@ -232,7 +239,7 @@ export default class BugReportDialog extends React.Component {
element="textarea"
label={_t("Notes")}
rows={5}
onChange={this._onTextChange}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t(
"If there is additional context that would help in " +
@ -245,17 +252,12 @@ export default class BugReportDialog extends React.Component {
{error}
</div>
<DialogButtons primaryButton={_t("Send logs")}
onPrimaryButtonClick={this._onSubmit}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this._onCancel}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}
BugReportDialog.propTypes = {
onFinished: PropTypes.func.isRequired,
initialText: PropTypes.string,
};

View file

@ -16,21 +16,26 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import request from 'browser-request';
import { _t } from '../../../languageHandler';
interface IProps {
newVersion: string;
version: string;
onFinished: (success: boolean) => void;
}
const REPOS = ['vector-im/element-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
export default class ChangelogDialog extends React.Component {
export default class ChangelogDialog extends React.Component<IProps> {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
public componentDidMount() {
const version = this.props.newVersion.split('-');
const version2 = this.props.version.split('-');
if (version == null || version2 == null) return;
@ -49,7 +54,7 @@ export default class ChangelogDialog extends React.Component {
}
}
_elementsForCommit(commit) {
private elementsForCommit(commit): JSX.Element {
return (
<li key={commit.sha} className="mx_ChangelogDialog_li">
<a href={commit.html_url} target="_blank" rel="noreferrer noopener">
@ -59,7 +64,7 @@ export default class ChangelogDialog extends React.Component {
);
}
render() {
public render() {
const Spinner = sdk.getComponent('views.elements.Spinner');
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
@ -72,7 +77,7 @@ export default class ChangelogDialog extends React.Component {
msg: this.state[repo],
});
} else {
content = this.state[repo].map(this._elementsForCommit);
content = this.state[repo].map(this.elementsForCommit);
}
return (
<div key={repo}>
@ -99,9 +104,3 @@ export default class ChangelogDialog extends React.Component {
);
}
}
ChangelogDialog.propTypes = {
version: PropTypes.string.isRequired,
newVersion: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};

View file

@ -17,7 +17,17 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
redact: () => Promise<void>;
onFinished: (success: boolean) => void;
}
interface IState {
isRedacting: boolean;
redactionErrorCode: string | number;
}
/*
* A dialog for confirming a redaction.
@ -32,7 +42,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* To avoid this, we keep the dialog open as long as /redact is in progress.
*/
@replaceableComponent("views.dialogs.ConfirmAndWaitRedactDialog")
export default class ConfirmAndWaitRedactDialog extends React.PureComponent {
export default class ConfirmAndWaitRedactDialog extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.state = {
@ -41,7 +51,7 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent {
};
}
onParentFinished = async (proceed) => {
public onParentFinished = async (proceed: boolean): Promise<void> => {
if (proceed) {
this.setState({isRedacting: true});
try {
@ -60,7 +70,7 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent {
}
};
render() {
public render() {
if (this.state.isRedacting) {
if (this.state.redactionErrorCode) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");

View file

@ -19,11 +19,15 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
}
/*
* A dialog for confirming a redaction.
*/
@replaceableComponent("views.dialogs.ConfirmRedactDialog")
export default class ConfirmRedactDialog extends React.Component {
export default class ConfirmRedactDialog extends React.Component<IProps> {
render() {
const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog');
return (

View file

@ -15,13 +15,31 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember'
member: RoomMember;
// group member object. Supply either this or 'member'
groupMember: GroupMemberType;
// needed if a group member is specified
matrixClient?: MatrixClient,
action: string; // eg. 'Ban'
title: string; // eg. 'Ban this user?'
// Whether to display a text field for a reason
// If true, the second argument to onFinished will
// be the string entered.
askReason?: boolean;
danger?: boolean;
onFinished: (success: boolean, reason?: string) => void;
}
/*
* A dialog for confirming an operation on another user.
@ -32,53 +50,23 @@ import {mediaFromMxc} from "../../../customisations/Media";
* Also tweaks the style for 'dangerous' actions (albeit only with colour)
*/
@replaceableComponent("views.dialogs.ConfirmUserActionDialog")
export default class ConfirmUserActionDialog extends React.Component {
static propTypes = {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember'
member: PropTypes.object,
// group member object. Supply either this or 'member'
groupMember: GroupMemberType,
// needed if a group member is specified
matrixClient: PropTypes.instanceOf(MatrixClient),
action: PropTypes.string.isRequired, // eg. 'Ban'
title: PropTypes.string.isRequired, // eg. 'Ban this user?'
// Whether to display a text field for a reason
// If true, the second argument to onFinished will
// be the string entered.
askReason: PropTypes.bool,
danger: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
};
export default class ConfirmUserActionDialog extends React.Component<IProps> {
private reasonField: React.RefObject<HTMLInputElement> = React.createRef();
static defaultProps = {
danger: false,
askReason: false,
};
constructor(props) {
super(props);
this._reasonField = null;
}
onOk = () => {
let reason;
if (this._reasonField) {
reason = this._reasonField.value;
}
this.props.onFinished(true, reason);
public onOk = (): void => {
this.props.onFinished(true, this.reasonField.current?.value);
};
onCancel = () => {
public onCancel = (): void => {
this.props.onFinished(false);
};
_collectReasonField = e => {
this._reasonField = e;
};
render() {
public render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
@ -92,7 +80,7 @@ export default class ConfirmUserActionDialog extends React.Component {
<div>
<form onSubmit={this.onOk}>
<input className="mx_ConfirmUserActionDialog_reasonField"
ref={this._collectReasonField}
ref={this.reasonField}
placeholder={_t("Reason")}
autoFocus={true}
/>

View file

@ -15,22 +15,21 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../languageHandler";
import * as sdk from "../../../index";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog")
export default class ConfirmWipeDeviceDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
interface IProps {
onFinished: (success: boolean) => void;
}
_onConfirm = () => {
@replaceableComponent("views.dialogs.ConfirmWipeDeviceDialog")
export default class ConfirmWipeDeviceDialog extends React.Component<IProps> {
private onConfirm = (): void => {
this.props.onFinished(true);
};
_onDecline = () => {
private onDecline = (): void => {
this.props.onFinished(false);
};
@ -55,10 +54,10 @@ export default class ConfirmWipeDeviceDialog extends React.Component {
</div>
<DialogButtons
primaryButton={_t("Clear all data")}
onPrimaryButtonClick={this._onConfirm}
onPrimaryButtonClick={this.onConfirm}
primaryButtonClass="danger"
cancelButton={_t("Cancel")}
onCancel={this._onDecline}
onCancel={this.onDecline}
/>
</BaseDialog>
);

View file

@ -15,44 +15,51 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.dialogs.CreateGroupDialog")
export default class CreateGroupDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
interface IProps {
onFinished: (success: boolean) => void;
}
state = {
interface IState {
groupName: string;
groupId: string;
groupIdError: string;
creating: boolean;
createError: Error;
}
@replaceableComponent("views.dialogs.CreateGroupDialog")
export default class CreateGroupDialog extends React.Component<IProps, IState> {
public state = {
groupName: '',
groupId: '',
groupError: null,
groupIdError: '',
creating: false,
createError: null,
};
_onGroupNameChange = e => {
private onGroupNameChange = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({
groupName: e.target.value,
groupName: e.currentTarget.value,
});
};
_onGroupIdChange = e => {
private onGroupIdChange = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({
groupId: e.target.value,
groupId: e.currentTarget.value,
});
};
_onGroupIdBlur = e => {
this._checkGroupId();
private onGroupIdBlur = (): void => {
this.checkGroupId();
};
_checkGroupId(e) {
private checkGroupId() {
let error = null;
if (!this.state.groupId) {
error = _t("Community IDs cannot be empty.");
@ -67,12 +74,12 @@ export default class CreateGroupDialog extends React.Component {
return error;
}
_onFormSubmit = e => {
private onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (this._checkGroupId()) return;
if (this.checkGroupId()) return;
const profile = {};
const profile: any = {};
if (this.state.groupName !== '') {
profile.name = this.state.groupName;
}
@ -121,7 +128,7 @@ export default class CreateGroupDialog extends React.Component {
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
title={_t('Create Community')}
>
<form onSubmit={this._onFormSubmit}>
<form onSubmit={this.onFormSubmit}>
<div className="mx_Dialog_content">
<div className="mx_CreateGroupDialog_inputRow">
<div className="mx_CreateGroupDialog_label">
@ -129,9 +136,9 @@ export default class CreateGroupDialog extends React.Component {
</div>
<div>
<input id="groupname" className="mx_CreateGroupDialog_input"
autoFocus={true} size="64"
autoFocus={true} size={64}
placeholder={_t('Example')}
onChange={this._onGroupNameChange}
onChange={this.onGroupNameChange}
value={this.state.groupName}
/>
</div>
@ -144,10 +151,10 @@ export default class CreateGroupDialog extends React.Component {
<span className="mx_CreateGroupDialog_prefix">+</span>
<input id="groupid"
className="mx_CreateGroupDialog_input mx_CreateGroupDialog_input_hasPrefixAndSuffix"
size="32"
size={32}
placeholder={_t('example')}
onChange={this._onGroupIdChange}
onBlur={this._onGroupIdBlur}
onChange={this.onGroupIdChange}
onBlur={this.onGroupIdBlur}
value={this.state.groupId}
/>
<span className="mx_CreateGroupDialog_suffix">

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,27 +15,47 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {Room} from "matrix-js-sdk/src/models/room";
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation';
import withValidation, { IFieldState } from '../elements/Validation';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard";
import {privateShouldBeEncrypted} from "../../../createRoom";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { Key } from "../../../Keyboard";
import { IOpts, privateShouldBeEncrypted } from "../../../createRoom";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
alias: string;
detailsOpen: boolean;
noFederate: boolean;
nameIsValid: boolean;
canChangeEncryption: boolean;
}
@replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
defaultPublic: PropTypes.bool,
parentSpace: PropTypes.instanceOf(Room),
};
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
constructor(props) {
super(props);
@ -44,7 +64,7 @@ export default class CreateRoomDialog extends React.Component {
this.state = {
isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(),
name: "",
name: this.props.defaultName || "",
topic: "",
alias: "",
detailsOpen: false,
@ -53,27 +73,26 @@ export default class CreateRoomDialog extends React.Component {
canChangeEncryption: true,
};
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private")
.then(isForced => this.setState({canChangeEncryption: !isForced}));
MatrixClientPeg.get().doesServerForceEncryptionForPreset(Preset.PrivateChat)
.then(isForced => this.setState({ canChangeEncryption: !isForced }));
}
_roomCreateOptions() {
const opts = {};
const createOpts = opts.createOpts = {};
private roomCreateOptions() {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
createOpts.visibility = "public";
createOpts.preset = "public_chat";
createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat;
opts.guestAccess = false;
const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
const { alias } = this.state;
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false};
createOpts.creation_content = { 'm.federate': false };
}
if (!this.state.isPublic) {
@ -98,16 +117,14 @@ export default class CreateRoomDialog extends React.Component {
}
componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog
this._nameFieldRef.focus();
this.nameField.current.focus();
}
componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
}
_onKeyDown = event => {
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === Key.ENTER) {
this.onOk();
event.preventDefault();
@ -115,26 +132,26 @@ export default class CreateRoomDialog extends React.Component {
}
};
onOk = async () => {
const activeElement = document.activeElement;
private onOk = async () => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
await this._nameFieldRef.validate({allowEmpty: false});
if (this._aliasFieldRef) {
await this._aliasFieldRef.validate({allowEmpty: false});
await this.nameField.current.validate({allowEmpty: false});
if (this.aliasField.current) {
await this.aliasField.current.validate({allowEmpty: false});
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) {
this.props.onFinished(true, this._roomCreateOptions());
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
this.props.onFinished(true, this.roomCreateOptions());
} else {
let field;
if (!this.state.nameIsValid) {
field = this._nameFieldRef;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) {
field = this._aliasFieldRef;
field = this.nameField.current;
} else if (this.aliasField.current && !this.aliasField.current.isValid) {
field = this.aliasField.current;
}
if (field) {
field.focus();
@ -143,49 +160,45 @@ export default class CreateRoomDialog extends React.Component {
}
};
onCancel = () => {
private onCancel = () => {
this.props.onFinished(false);
};
onNameChange = ev => {
this.setState({name: ev.target.value});
private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ name: ev.target.value });
};
onTopicChange = ev => {
this.setState({topic: ev.target.value});
private onTopicChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ topic: ev.target.value });
};
onPublicChange = isPublic => {
this.setState({isPublic});
private onPublicChange = (isPublic: boolean) => {
this.setState({ isPublic });
};
onEncryptedChange = isEncrypted => {
this.setState({isEncrypted});
private onEncryptedChange = (isEncrypted: boolean) => {
this.setState({ isEncrypted });
};
onAliasChange = alias => {
this.setState({alias});
private onAliasChange = (alias: string) => {
this.setState({ alias });
};
onDetailsToggled = ev => {
this.setState({detailsOpen: ev.target.open});
private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>) => {
this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
};
onNoFederateChange = noFederate => {
this.setState({noFederate});
private onNoFederateChange = (noFederate: boolean) => {
this.setState({ noFederate });
};
collectDetailsRef = ref => {
this._detailsRef = ref;
};
onNameValidate = async fieldState => {
const result = await CreateRoomDialog._validateRoomName(fieldState);
private onNameValidate = async (fieldState: IFieldState) => {
const result = await CreateRoomDialog.validateRoomName(fieldState);
this.setState({nameIsValid: result.valid});
return result;
};
static _validateRoomName = withValidation({
private static validateRoomName = withValidation({
rules: [
{
key: "required",
@ -196,18 +209,17 @@ export default class CreateRoomDialog extends React.Component {
});
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let aliasField;
if (this.state.isPublic) {
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
<RoomAliasField
ref={this.aliasField}
onChange={this.onAliasChange}
domain={domain}
value={this.state.alias}
/>
</div>
);
}
@ -270,16 +282,34 @@ export default class CreateRoomDialog extends React.Component {
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title}
>
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
<Field ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" />
<Field label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} className="mx_CreateRoomDialog_topic" />
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} />
<Field
ref={this.nameField}
label={_t('Name')}
onChange={this.onNameChange}
onValidate={this.onNameValidate}
value={this.state.name}
className="mx_CreateRoomDialog_name"
/>
<Field
label={_t('Topic (optional)')}
onChange={this.onTopicChange}
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
/>
{ publicPrivateLabel }
{ e2eeSection }
{ aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary>
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">
{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }
</summary>
<LabelledToggleSwitch
label={_t(
"Block anyone not part of %(serverName)s from ever joining this room.",

View file

@ -22,7 +22,11 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
export default (props) => {
interface IProps {
onFinished: (success: boolean) => void;
}
export default (props: IProps) => {
const brand = SdkConfig.get().brand;
const _onLogoutClicked = () => {
@ -40,7 +44,7 @@ export default (props) => {
onFinished: (doLogout) => {
if (doLogout) {
dis.dispatch({action: 'logout'});
props.onFinished();
props.onFinished(true);
}
},
});

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import Analytics from '../../../Analytics';
@ -28,8 +27,25 @@ import {DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry} from "../auth/Interactiv
import StyledCheckbox from "../elements/StyledCheckbox";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IState {
shouldErase: boolean;
errStr: string;
authData: any; // for UIA
authEnabled: boolean; // see usages for information
// A few strings that are passed to InteractiveAuth for design or are displayed
// next to the InteractiveAuth component.
bodyText: string;
continueText: string;
continueKind: string;
}
@replaceableComponent("views.dialogs.DeactivateAccountDialog")
export default class DeactivateAccountDialog extends React.Component {
export default class DeactivateAccountDialog extends React.Component<IProps, IState> {
constructor(props) {
super(props);
@ -46,10 +62,10 @@ export default class DeactivateAccountDialog extends React.Component {
continueKind: null,
};
this._initAuth(/* shouldErase= */false);
this.initAuth(/* shouldErase= */false);
}
_onStagePhaseChange = (stage, phase) => {
private onStagePhaseChange = (stage: string, phase: string): void => {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."),
@ -87,19 +103,19 @@ export default class DeactivateAccountDialog extends React.Component {
this.setState({bodyText, continueText, continueKind});
};
_onUIAuthFinished = (success, result, extra) => {
private onUIAuthFinished = (success: boolean, result: Error) => {
if (success) return; // great! makeRequest() will be called too.
if (result === ERROR_USER_CANCELLED) {
this._onCancel();
this.onCancel();
return;
}
console.error("Error during UI Auth:", {result, extra});
console.error("Error during UI Auth:", { result });
this.setState({errStr: _t("There was a problem communicating with the server. Please try again.")});
};
_onUIAuthComplete = (auth) => {
private onUIAuthComplete = (auth: any): void => {
MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => {
// Deactivation worked - logout & close this dialog
Analytics.trackEvent('Account', 'Deactivate Account');
@ -111,9 +127,9 @@ export default class DeactivateAccountDialog extends React.Component {
});
};
_onEraseFieldChange = (ev) => {
private onEraseFieldChange = (ev: React.FormEvent<HTMLInputElement>): void => {
this.setState({
shouldErase: ev.target.checked,
shouldErase: ev.currentTarget.checked,
// Disable the auth form because we're going to have to reinitialize the auth
// information. We do this because we can't modify the parameters in the UIA
@ -123,14 +139,14 @@ export default class DeactivateAccountDialog extends React.Component {
});
// As mentioned above, set up for auth again to get updated UIA session info
this._initAuth(/* shouldErase= */ev.target.checked);
this.initAuth(/* shouldErase= */ev.currentTarget.checked);
};
_onCancel() {
private onCancel(): void {
this.props.onFinished(false);
}
_initAuth(shouldErase) {
private initAuth(shouldErase: boolean): void {
MatrixClientPeg.get().deactivateAccount(null, shouldErase).then(r => {
// If we got here, oops. The server didn't require any auth.
// Our application lifecycle will catch the error and do the logout bits.
@ -148,7 +164,7 @@ export default class DeactivateAccountDialog extends React.Component {
});
}
render() {
public render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let error = null;
@ -166,9 +182,9 @@ export default class DeactivateAccountDialog extends React.Component {
<InteractiveAuth
matrixClient={MatrixClientPeg.get()}
authData={this.state.authData}
makeRequest={this._onUIAuthComplete}
onAuthFinished={this._onUIAuthFinished}
onStagePhaseChange={this._onStagePhaseChange}
makeRequest={this.onUIAuthComplete}
onAuthFinished={this.onUIAuthFinished}
onStagePhaseChange={this.onStagePhaseChange}
continueText={this.state.continueText}
continueKind={this.state.continueKind}
/>
@ -214,7 +230,7 @@ export default class DeactivateAccountDialog extends React.Component {
<p>
<StyledCheckbox
checked={this.state.shouldErase}
onChange={this._onEraseFieldChange}
onChange={this.onEraseFieldChange}
>
{_t(
"Please forget all messages I have sent when my account is deactivated " +
@ -235,7 +251,3 @@ export default class DeactivateAccountDialog extends React.Component {
);
}
}
DeactivateAccountDialog.propTypes = {
onFinished: PropTypes.func.isRequired,
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,14 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react';
import * as sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import {
PHASE_UNSENT,
@ -30,27 +30,33 @@ import {
PHASE_DONE,
PHASE_STARTED,
PHASE_CANCELLED,
VerificationRequest,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import {SETTINGS} from "../../../settings/Settings";
import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore";
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { SETTINGS } from "../../../settings/Settings";
import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SettingLevel } from '../../../settings/SettingLevel';
class GenericEditor extends React.PureComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};
interface IGenericEditorProps {
onBack: () => void;
}
constructor(props) {
super(props);
this._onChange = this._onChange.bind(this);
this.onBack = this.onBack.bind(this);
}
interface IGenericEditorState {
message?: string;
[inputId: string]: boolean | string;
}
onBack() {
abstract class GenericEditor<
P extends IGenericEditorProps = IGenericEditorProps,
S extends IGenericEditorState = IGenericEditorState,
> extends React.PureComponent<P, S> {
protected onBack = () => {
if (this.state.message) {
this.setState({ message: null });
} else {
@ -58,47 +64,60 @@ class GenericEditor extends React.PureComponent {
}
}
_onChange(e) {
protected onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// @ts-ignore: Unsure how to convince TS this is okay when the state
// type can be extended.
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
_buttons() {
protected abstract send();
protected buttons(): React.ReactNode {
return <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
</div>;
}
textInput(id, label) {
protected textInput(id: string, label: string): React.ReactNode {
return <Field
id={id}
label={label}
size="42"
size={42}
autoFocus={true}
type="text"
autoComplete="on"
value={this.state[id]}
onChange={this._onChange}
value={this.state[id] as string}
onChange={this.onChange}
/>;
}
}
export class SendCustomEvent extends GenericEditor {
static getLabel() { return _t('Send Custom Event'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
forceStateEvent: PropTypes.bool,
forceGeneralEvent: PropTypes.bool,
inputs: PropTypes.object,
interface ISendCustomEventProps extends IGenericEditorProps {
room: Room;
forceStateEvent?: boolean;
forceGeneralEvent?: boolean;
inputs?: {
eventType?: string;
stateKey?: string;
evContent?: string;
};
}
interface ISendCustomEventState extends IGenericEditorState {
isStateEvent: boolean;
eventType: string;
stateKey: string;
evContent: string;
}
export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendCustomEventState> {
static getLabel() { return _t('Send Custom Event'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this._send = this._send.bind(this);
const {eventType, stateKey, evContent} = Object.assign({
eventType: '',
@ -115,7 +134,7 @@ export class SendCustomEvent extends GenericEditor {
};
}
send(content) {
private doSend(content: object): Promise<void> {
const cli = this.context;
if (this.state.isStateEvent) {
return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey);
@ -124,7 +143,7 @@ export class SendCustomEvent extends GenericEditor {
}
}
async _send() {
protected send = async () => {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
@ -133,7 +152,7 @@ export class SendCustomEvent extends GenericEditor {
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
await this.doSend(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -147,7 +166,7 @@ export class SendCustomEvent extends GenericEditor {
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
{ this.buttons() }
</div>;
}
@ -163,35 +182,51 @@ export class SendCustomEvent extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{float: "right"}}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" />
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Event"
data-tg-on="State Event"
htmlFor="isStateEvent"
/>
</div> }
</div>
</div>;
}
}
class SendAccountData extends GenericEditor {
static getLabel() { return _t('Send Account Data'); }
static propTypes = {
room: PropTypes.instanceOf(Room).isRequired,
isRoomAccountData: PropTypes.bool,
forceMode: PropTypes.bool,
inputs: PropTypes.object,
interface ISendAccountDataProps extends IGenericEditorProps {
room: Room;
isRoomAccountData: boolean;
forceMode: boolean;
inputs?: {
eventType?: string;
evContent?: string;
};
}
interface ISendAccountDataState extends IGenericEditorState {
isRoomAccountData: boolean;
eventType: string;
evContent: string;
}
class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountDataState> {
static getLabel() { return _t('Send Account Data'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this._send = this._send.bind(this);
const {eventType, evContent} = Object.assign({
eventType: '',
@ -206,7 +241,7 @@ class SendAccountData extends GenericEditor {
};
}
send(content) {
private doSend(content: object): Promise<void> {
const cli = this.context;
if (this.state.isRoomAccountData) {
return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content);
@ -214,7 +249,7 @@ class SendAccountData extends GenericEditor {
return cli.setAccountData(this.state.eventType, content);
}
async _send() {
protected send = async () => {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
@ -223,7 +258,7 @@ class SendAccountData extends GenericEditor {
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
await this.doSend(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -237,7 +272,7 @@ class SendAccountData extends GenericEditor {
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
{ this.buttons() }
</div>;
}
@ -247,14 +282,23 @@ class SendAccountData extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div> }
</div>
</div>;
@ -264,17 +308,22 @@ class SendAccountData extends GenericEditor {
const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50;
class FilteredList extends React.PureComponent {
static propTypes = {
children: PropTypes.any,
query: PropTypes.string,
onChange: PropTypes.func,
};
interface IFilteredListProps {
children: React.ReactElement[];
query: string;
onChange: (value: string) => void;
}
static filterChildren(children, query) {
interface IFilteredListState {
filteredChildren: React.ReactElement[];
truncateAt: number;
}
class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredListState> {
static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] {
if (!query) return children;
const lcQuery = query.toLowerCase();
return children.filter((child) => child.key.toLowerCase().includes(lcQuery));
return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery));
}
constructor(props) {
@ -295,27 +344,27 @@ class FilteredList extends React.PureComponent {
});
}
showAll = () => {
private showAll = () => {
this.setState({
truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE,
});
};
createOverflowElement = (overflowCount: number, totalCount: number) => {
private createOverflowElement = (overflowCount: number, totalCount: number) => {
return <button className="mx_DevTools_RoomStateExplorer_button" onClick={this.showAll}>
{ _t("and %(count)s others...", { count: overflowCount }) }
</button>;
};
onQuery = (ev) => {
private onQuery = (ev: ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) this.props.onChange(ev.target.value);
};
getChildren = (start: number, end: number) => {
private getChildren = (start: number, end: number): React.ReactElement[] => {
return this.state.filteredChildren.slice(start, end);
};
getChildCount = (): number => {
private getChildCount = (): number => {
return this.state.filteredChildren.length;
};
@ -336,28 +385,31 @@ class FilteredList extends React.PureComponent {
}
}
class RoomStateExplorer extends React.PureComponent {
static getLabel() { return _t('Explore Room State'); }
interface IExplorerProps {
room: Room;
onBack: () => void;
}
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
interface IRoomStateExplorerState {
eventType?: string;
event?: MatrixEvent;
editing: boolean;
queryEventType: string;
queryStateKey: string;
}
class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateExplorerState> {
static getLabel() { return _t('Explore Room State'); }
static contextType = MatrixClientContext;
roomStateEvents: Map<string, Map<string, MatrixEvent>>;
private roomStateEvents: Map<string, Map<string, MatrixEvent>>;
constructor(props) {
super(props);
this.roomStateEvents = this.props.room.currentState.events;
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.onQueryStateKey = this.onQueryStateKey.bind(this);
this.state = {
eventType: null,
event: null,
@ -368,19 +420,19 @@ class RoomStateExplorer extends React.PureComponent {
};
}
browseEventType(eventType) {
private browseEventType(eventType: string) {
return () => {
this.setState({ eventType });
};
}
onViewSourceClick(event) {
private onViewSourceClick(event: MatrixEvent) {
return () => {
this.setState({ event });
};
}
onBack() {
private onBack = () => {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
@ -392,15 +444,15 @@ class RoomStateExplorer extends React.PureComponent {
}
}
editEv() {
private editEv = () => {
this.setState({ editing: true });
}
onQueryEventType(filterEventType) {
private onQueryEventType = (filterEventType: string) => {
this.setState({ queryEventType: filterEventType });
}
onQueryStateKey(filterStateKey) {
private onQueryStateKey = (filterStateKey: string) => {
this.setState({ queryStateKey: filterStateKey });
}
@ -472,24 +524,22 @@ class RoomStateExplorer extends React.PureComponent {
}
}
class AccountDataExplorer extends React.PureComponent {
static getLabel() { return _t('Explore Account Data'); }
interface IAccountDataExplorerState {
[inputId: string]: boolean | string | any;
isRoomAccountData: boolean;
event?: MatrixEvent;
editing: boolean;
queryEventType: string;
}
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDataExplorerState> {
static getLabel() { return _t('Explore Account Data'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this._onChange = this._onChange.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.state = {
isRoomAccountData: false,
event: null,
@ -499,20 +549,20 @@ class AccountDataExplorer extends React.PureComponent {
};
}
getData() {
private getData(): Record<string, MatrixEvent> {
if (this.state.isRoomAccountData) {
return this.props.room.accountData;
}
return this.context.store.accountData;
}
onViewSourceClick(event) {
private onViewSourceClick(event: MatrixEvent) {
return () => {
this.setState({ event });
};
}
onBack() {
private onBack = () => {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
@ -522,15 +572,15 @@ class AccountDataExplorer extends React.PureComponent {
}
}
_onChange(e) {
private onChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
editEv() {
private editEv = () => {
this.setState({ editing: true });
}
onQueryEventType(queryEventType) {
private onQueryEventType = (queryEventType: string) => {
this.setState({ queryEventType });
}
@ -580,30 +630,39 @@ class AccountDataExplorer extends React.PureComponent {
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
</div> }
<div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div>
</div>
</div>;
}
}
class ServersInRoomList extends React.PureComponent {
interface IServersInRoomListState {
query: string;
}
class ServersInRoomList extends React.PureComponent<IExplorerProps, IServersInRoomListState> {
static getLabel() { return _t('View Servers in Room'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
static contextType = MatrixClientContext;
private servers: React.ReactElement[];
constructor(props) {
super(props);
const room = this.props.room;
const servers = new Set();
const servers = new Set<string>();
room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
this.servers = Array.from(servers).map(s =>
<button key={s} className="mx_DevTools_ServersInRoomList_button">
@ -615,7 +674,7 @@ class ServersInRoomList extends React.PureComponent {
};
}
onQuery = (query) => {
private onQuery = (query: string) => {
this.setState({ query });
}
@ -642,7 +701,10 @@ const PHASE_MAP = {
[PHASE_CANCELLED]: "cancelled",
};
function VerificationRequest({txnId, request}) {
const VerificationRequestExplorer: React.FC<{
txnId: string;
request: VerificationRequest;
}> = ({txnId, request}) => {
const [, updateState] = useState();
const [timeout, setRequestTimeout] = useState(request.timeout);
@ -679,7 +741,7 @@ function VerificationRequest({txnId, request}) {
</div>);
}
class VerificationExplorer extends React.Component {
class VerificationExplorer extends React.PureComponent<IExplorerProps> {
static getLabel() {
return _t("Verification Requests");
}
@ -687,7 +749,7 @@ class VerificationExplorer extends React.Component {
/* Ensure this.context is the cli */
static contextType = MatrixClientContext;
onNewRequest = () => {
private onNewRequest = () => {
this.forceUpdate();
}
@ -704,13 +766,13 @@ class VerificationExplorer extends React.Component {
render() {
const cli = this.context;
const room = this.props.room;
const inRoomChannel = cli._crypto._inRoomVerificationRequests;
const inRoomChannel = cli.crypto.inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div>
<div className="mx_Dialog_content">
{Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) =>
<VerificationRequest txnId={txnId} request={request} key={txnId} />,
<VerificationRequestExplorer txnId={txnId} request={request} key={txnId} />,
)}
</div>
<div className="mx_Dialog_buttons">
@ -720,7 +782,12 @@ class VerificationExplorer extends React.Component {
}
}
class WidgetExplorer extends React.Component {
interface IWidgetExplorerState {
query: string;
editWidget?: IApp;
}
class WidgetExplorer extends React.Component<IExplorerProps, IWidgetExplorerState> {
static getLabel() {
return _t("Active Widgets");
}
@ -734,19 +801,19 @@ class WidgetExplorer extends React.Component {
};
}
onWidgetStoreUpdate = () => {
private onWidgetStoreUpdate = () => {
this.forceUpdate();
};
onQueryChange = (query) => {
private onQueryChange = (query: string) => {
this.setState({query});
};
onEditWidget = (widget) => {
private onEditWidget = (widget: IApp) => {
this.setState({editWidget: widget});
};
onBack = () => {
private onBack = () => {
const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
this.setState({editWidget: null});
@ -769,8 +836,11 @@ class WidgetExplorer extends React.Component {
const editWidget = this.state.editWidget;
const widgets = WidgetStore.instance.getApps(room.roomId);
if (editWidget && widgets.includes(editWidget)) {
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values()))
.reduce((p, c) => {p.push(...c); return p;}, []);
const allState = Array.from(
Array.from(room.currentState.events.values()).map((e: Map<string, MatrixEvent>) => {
return e.values();
}),
).reduce((p, c) => { p.push(...c); return p; }, []);
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
if (!stateEv) { // "should never happen"
return <div>
@ -811,7 +881,15 @@ class WidgetExplorer extends React.Component {
}
}
class SettingsExplorer extends React.Component {
interface ISettingsExplorerState {
query: string;
editSetting?: string;
viewSetting?: string;
explicitValues?: string;
explicitRoomValues?: string;
}
class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExplorerState> {
static getLabel() {
return _t("Settings Explorer");
}
@ -829,19 +907,19 @@ class SettingsExplorer extends React.Component {
};
}
onQueryChange = (ev) => {
private onQueryChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({query: ev.target.value});
};
onExplValuesEdit = (ev) => {
private onExplValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitValues: ev.target.value});
};
onExplRoomValuesEdit = (ev) => {
private onExplRoomValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitRoomValues: ev.target.value});
};
onBack = () => {
private onBack = () => {
if (this.state.editSetting) {
this.setState({editSetting: null});
} else if (this.state.viewSetting) {
@ -851,12 +929,12 @@ class SettingsExplorer extends React.Component {
}
};
onViewClick = (ev, settingId) => {
private onViewClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault();
this.setState({viewSetting: settingId});
};
onEditClick = (ev, settingId) => {
private onEditClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault();
this.setState({
editSetting: settingId,
@ -865,7 +943,7 @@ class SettingsExplorer extends React.Component {
});
};
onSaveClick = async () => {
private onSaveClick = async () => {
try {
const settingId = this.state.editSetting;
const parsedExplicit = JSON.parse(this.state.explicitValues);
@ -874,7 +952,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`);
try {
const val = parsedExplicit[level];
await SettingsStore.setValue(settingId, null, level, val);
await SettingsStore.setValue(settingId, null, level as SettingLevel, val);
} catch (e) {
console.warn(e);
}
@ -884,7 +962,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`);
try {
const val = parsedExplicitRoom[level];
await SettingsStore.setValue(settingId, roomId, level, val);
await SettingsStore.setValue(settingId, roomId, level as SettingLevel, val);
} catch (e) {
console.warn(e);
}
@ -901,7 +979,7 @@ class SettingsExplorer extends React.Component {
}
};
renderSettingValue(val) {
private renderSettingValue(val: any): string {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ['boolean', 'number'];
if (toStringTypes.includes(typeof(val))) {
@ -911,7 +989,7 @@ class SettingsExplorer extends React.Component {
}
}
renderExplicitSettingValues(setting, roomId) {
private renderExplicitSettingValues(setting: string, roomId: string): string {
const vals = {};
for (const level of LEVEL_ORDER) {
try {
@ -926,7 +1004,7 @@ class SettingsExplorer extends React.Component {
return JSON.stringify(vals, null, 4);
}
renderCanEditLevel(roomId, level) {
private renderCanEditLevel(roomId: string, level: SettingLevel): React.ReactNode {
const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level);
const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable';
return <td className={className}><code>{canEdit.toString()}</code></td>;
@ -1062,27 +1140,37 @@ class SettingsExplorer extends React.Component {
<div>
{_t("Value:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code>
<code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting),
)}</code>
</div>
<div>
{_t("Value in this room:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))}</code>
<code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting, room.roomId),
)}</code>
</div>
<div>
{_t("Values at explicit levels:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, null)}</code></pre>
<pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, null,
)}</code></pre>
</div>
<div>
{_t("Values at explicit levels in this room:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}</code></pre>
<pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, room.roomId,
)}</code></pre>
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{_t("Edit Values")}</button>
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{
_t("Edit Values")
}</button>
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>
@ -1091,7 +1179,11 @@ class SettingsExplorer extends React.Component {
}
}
const Entries = [
type DevtoolsDialogEntry = React.JSXElementConstructor<any> & {
getLabel: () => string;
};
const Entries: DevtoolsDialogEntry[] = [
SendCustomEvent,
RoomStateExplorer,
SendAccountData,
@ -1102,43 +1194,36 @@ const Entries = [
SettingsExplorer,
];
@replaceableComponent("views.dialogs.DevtoolsDialog")
export default class DevtoolsDialog extends React.PureComponent {
static propTypes = {
roomId: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};
interface IProps {
roomId: string;
onFinished: (finished: boolean) => void;
}
interface IState {
mode?: DevtoolsDialogEntry;
}
@replaceableComponent("views.dialogs.DevtoolsDialog")
export default class DevtoolsDialog extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.onBack = this.onBack.bind(this);
this.onCancel = this.onCancel.bind(this);
this.state = {
mode: null,
};
}
componentWillUnmount() {
this._unmounted = true;
}
_setMode(mode) {
private setMode(mode: DevtoolsDialogEntry) {
return () => {
this.setState({ mode });
};
}
onBack() {
if (this.prevMode) {
this.setState({ mode: this.prevMode });
this.prevMode = null;
} else {
this.setState({ mode: null });
}
private onBack = () => {
this.setState({ mode: null });
}
onCancel() {
private onCancel = () => {
this.props.onFinished(false);
}
@ -1165,7 +1250,7 @@ export default class DevtoolsDialog extends React.PureComponent {
<div className="mx_Dialog_content">
{ Entries.map((Entry) => {
const label = Entry.getLabel();
const onClick = this._setMode(Entry);
const onClick = this.setMode(Entry);
return <button className={classes} key={label} onClick={onClick}>{ label }</button>;
}) }
</div>

View file

@ -26,37 +26,37 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.dialogs.ErrorDialog")
export default class ErrorDialog extends React.Component {
static propTypes = {
title: PropTypes.string,
description: PropTypes.oneOfType([
PropTypes.element,
PropTypes.string,
]),
button: PropTypes.string,
focus: PropTypes.bool,
onFinished: PropTypes.func.isRequired,
headerImage: PropTypes.string,
};
interface IProps {
onFinished: (success: boolean) => void;
title?: string;
description?: React.ReactNode;
button?: string;
focus?: boolean;
headerImage?: string;
}
static defaultProps = {
interface IState {
onFinished: (success: boolean) => void;
}
@replaceableComponent("views.dialogs.ErrorDialog")
export default class ErrorDialog extends React.Component<IProps, IState> {
public static defaultProps = {
focus: true,
title: null,
description: null,
button: null,
};
onClick = () => {
private onClick = () => {
this.props.onFinished(true);
};
render() {
public render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog

View file

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

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