Merge branch 'develop' into gsouquet/message-bubbles-4635
This commit is contained in:
commit
06c9a060b4
171 changed files with 3649 additions and 2694 deletions
23
src/@types/worker-loader.d.ts
vendored
Normal file
23
src/@types/worker-loader.d.ts
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
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 "*.worker.ts" {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
||||
export default WebpackWorker;
|
||||
}
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
|
||||
type Listener = (isActive: boolean) => void;
|
||||
|
||||
|
@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
|
|||
export class ActiveRoomObserver {
|
||||
private listeners: {[key: string]: Listener[]} = {};
|
||||
private _activeRoomId = RoomViewStore.getRoomId();
|
||||
private readonly roomStoreToken: string;
|
||||
private readonly roomStoreToken: EventSubscription;
|
||||
|
||||
constructor() {
|
||||
// TODO: We could self-destruct when the last listener goes away, or at least stop listening.
|
||||
|
|
|
@ -248,7 +248,7 @@ export default class AddThreepid {
|
|||
|
||||
/**
|
||||
* Takes a phone number verification code as entered by the user and validates
|
||||
* it with the ID server, then if successful, adds the phone number.
|
||||
* it with the identity server, then if successful, adds the phone number.
|
||||
* @param {string} msisdnToken phone number verification code as entered by the user
|
||||
* @return {Promise} Resolves if the phone number was added. Rejects with an object
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
|
|
|
@ -18,10 +18,11 @@ 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 { split } from "lodash";
|
||||
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SpaceStore from "./stores/SpaceStore";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(
|
||||
|
@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
const initial = name[0];
|
||||
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
|
||||
idx++;
|
||||
name = name.substring(1);
|
||||
}
|
||||
|
||||
// string.codePointAt(0) would do this, but that isn't supported by
|
||||
// some browsers (notably PhantomJS).
|
||||
let chars = 1;
|
||||
const first = name.charCodeAt(idx);
|
||||
|
||||
// check if it’s the start of a surrogate pair
|
||||
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
|
||||
const second = name.charCodeAt(idx+1);
|
||||
if (second >= 0xDC00 && second <= 0xDFFF) {
|
||||
chars++;
|
||||
}
|
||||
}
|
||||
|
||||
const firstChar = name.substring(idx, idx+chars);
|
||||
return firstChar.toUpperCase();
|
||||
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
|
||||
return split(name, "", 1)[0].toUpperCase();
|
||||
}
|
||||
|
||||
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
|
@ -153,7 +140,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
|
|||
}
|
||||
|
||||
// space rooms cannot be DMs so skip the rest
|
||||
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
|
||||
if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
|
||||
|
||||
let otherMember = null;
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
|
|
60
src/BlurhashEncoder.ts
Normal file
60
src/BlurhashEncoder.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
|
||||
|
||||
// @ts-ignore - `.ts` is needed here to make TS happy
|
||||
import BlurhashWorker from "./workers/blurhash.worker.ts";
|
||||
|
||||
interface IBlurhashWorkerResponse {
|
||||
seq: number;
|
||||
blurhash: string;
|
||||
}
|
||||
|
||||
export class BlurhashEncoder {
|
||||
private static internalInstance = new BlurhashEncoder();
|
||||
|
||||
public static get instance(): BlurhashEncoder {
|
||||
return BlurhashEncoder.internalInstance;
|
||||
}
|
||||
|
||||
private readonly worker: Worker;
|
||||
private seq = 0;
|
||||
private pendingDeferredMap = new Map<number, IDeferred<string>>();
|
||||
|
||||
constructor() {
|
||||
this.worker = new BlurhashWorker();
|
||||
this.worker.onmessage = this.onMessage;
|
||||
}
|
||||
|
||||
private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => {
|
||||
const { seq, blurhash } = ev.data;
|
||||
const deferred = this.pendingDeferredMap.get(seq);
|
||||
if (deferred) {
|
||||
this.pendingDeferredMap.delete(seq);
|
||||
deferred.resolve(blurhash);
|
||||
}
|
||||
};
|
||||
|
||||
public getBlurhash(imageData: ImageData): Promise<string> {
|
||||
const seq = this.seq++;
|
||||
const deferred = defer<string>();
|
||||
this.pendingDeferredMap.set(seq, deferred);
|
||||
this.worker.postMessage({ seq, imageData });
|
||||
return deferred.promise;
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ export default class CallHandler extends EventEmitter {
|
|||
private supportsPstnProtocol = null;
|
||||
private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
|
||||
private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
|
||||
private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
|
||||
private pstnSupportCheckTimer: number;
|
||||
// For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
|
||||
private invitedRoomsAreVirtual = new Map<string, boolean>();
|
||||
private invitedRoomCheckInProgress = false;
|
||||
|
@ -394,7 +394,7 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
private setCallListeners(call: MatrixCall) {
|
||||
let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
|
||||
let mappedRoomId = this.roomIdForCall(call);
|
||||
|
||||
call.on(CallEvent.Error, (err: CallError) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
@ -871,6 +871,12 @@ export default class CallHandler extends EventEmitter {
|
|||
case Action.DialNumber:
|
||||
this.dialNumber(payload.number);
|
||||
break;
|
||||
case Action.TransferCallToMatrixID:
|
||||
this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
|
||||
break;
|
||||
case Action.TransferCallToPhoneNumber:
|
||||
this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -905,6 +911,48 @@ export default class CallHandler extends EventEmitter {
|
|||
});
|
||||
}
|
||||
|
||||
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
||||
const results = await this.pstnLookup(destination);
|
||||
if (!results || results.length === 0 || !results[0].userid) {
|
||||
Modal.createTrackedDialog('', '', ErrorDialog, {
|
||||
title: _t("Unable to transfer call"),
|
||||
description: _t("There was an error looking up the phone number"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startTransferToMatrixID(call, results[0].userid, consultFirst);
|
||||
}
|
||||
|
||||
private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) {
|
||||
if (consultFirst) {
|
||||
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: call.type,
|
||||
room_id: dmRoomId,
|
||||
transferee: call,
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: dmRoomId,
|
||||
should_peek: false,
|
||||
joining: false,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await call.transfer(destination);
|
||||
} catch (e) {
|
||||
console.log("Failed to transfer call", e);
|
||||
Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, {
|
||||
title: _t('Transfer Failed'),
|
||||
description: _t('Failed to transfer call'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveCallRoomId(activeCallRoomId: string) {
|
||||
logger.info("Setting call in room " + activeCallRoomId + " active");
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { encode } from "blurhash";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import dis from './dispatcher/dispatcher';
|
||||
|
@ -28,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore';
|
|||
import encrypt from "browser-encrypt-attachment";
|
||||
import extractPngChunks from "png-chunks-extract";
|
||||
import Spinner from "./components/views/elements/Spinner";
|
||||
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import {
|
||||
|
@ -39,7 +37,8 @@ import {
|
|||
UploadStartedPayload,
|
||||
} from "./dispatcher/payloads/UploadPayload";
|
||||
import { IUpload } from "./models/IUpload";
|
||||
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { BlurhashEncoder } from "./BlurhashEncoder";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -85,10 +84,6 @@ interface IThumbnail {
|
|||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
interface IAbortablePromise<T> extends Promise<T> {
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail for a image DOM element.
|
||||
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
|
||||
|
@ -107,55 +102,62 @@ interface IAbortablePromise<T> extends Promise<T> {
|
|||
* @return {Promise} A promise that resolves with an object with an info key
|
||||
* and a thumbnail key.
|
||||
*/
|
||||
function createThumbnail(
|
||||
async function createThumbnail(
|
||||
element: ThumbnailableElement,
|
||||
inputWidth: number,
|
||||
inputHeight: number,
|
||||
mimeType: string,
|
||||
): Promise<IThumbnail> {
|
||||
return new Promise((resolve) => {
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
let targetWidth = inputWidth;
|
||||
let targetHeight = inputHeight;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
let canvas: HTMLCanvasElement | OffscreenCanvas;
|
||||
if (window.OffscreenCanvas) {
|
||||
canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
|
||||
} else {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const blurhash = encode(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
// use 4 components on the longer dimension, if square then both
|
||||
imageData.width >= imageData.height ? 4 : 3,
|
||||
imageData.height >= imageData.width ? 4 : 3,
|
||||
);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
});
|
||||
}, mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
let thumbnailPromise: Promise<Blob>;
|
||||
|
||||
if (window.OffscreenCanvas) {
|
||||
thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
|
||||
} else {
|
||||
thumbnailPromise = new Promise<Blob>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
|
||||
}
|
||||
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
// thumbnailPromise and blurhash promise are being awaited concurrently
|
||||
const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
|
||||
const thumbnail = await thumbnailPromise;
|
||||
|
||||
return {
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -333,7 +335,7 @@ export function uploadFile(
|
|||
roomId: string,
|
||||
file: File | Blob,
|
||||
progressHandler?: any, // TODO: Types
|
||||
): Promise<{url?: string, file?: any}> { // TODO: Types
|
||||
): IAbortablePromise<{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.
|
||||
|
@ -365,8 +367,8 @@ export function uploadFile(
|
|||
encryptInfo.mimetype = file.type;
|
||||
}
|
||||
return { "file": encryptInfo };
|
||||
});
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
}) as IAbortablePromise<{ file: any }>;
|
||||
prom.abort = () => {
|
||||
canceled = true;
|
||||
if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
|
||||
};
|
||||
|
@ -379,8 +381,8 @@ export function uploadFile(
|
|||
if (canceled) throw new UploadCanceledError();
|
||||
// If the attachment isn't encrypted then include the URL directly.
|
||||
return { url };
|
||||
});
|
||||
(promise1 as any).abort = () => {
|
||||
}) as IAbortablePromise<{ url: string }>;
|
||||
promise1.abort = () => {
|
||||
canceled = true;
|
||||
matrixClient.cancelUpload(basePromise);
|
||||
};
|
||||
|
@ -551,10 +553,10 @@ export default class ContentMessages {
|
|||
content.msgtype = 'm.file';
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}) as IAbortablePromise<void>;
|
||||
|
||||
// create temporary abort handler for before the actual upload gets passed off to js-sdk
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
prom.abort = () => {
|
||||
upload.canceled = true;
|
||||
};
|
||||
|
||||
|
@ -583,9 +585,7 @@ export default class ContentMessages {
|
|||
// XXX: upload.promise must be the promise that
|
||||
// is returned by uploadFile as it has an abort()
|
||||
// method hacked onto it.
|
||||
upload.promise = uploadFile(
|
||||
matrixClient, roomId, file, onProgress,
|
||||
);
|
||||
upload.promise = uploadFile(matrixClient, roomId, file, onProgress);
|
||||
return upload.promise.then(function(result) {
|
||||
content.file = result.file;
|
||||
content.url = result.url;
|
||||
|
|
|
@ -364,8 +364,8 @@ export default class CountlyAnalytics {
|
|||
|
||||
private initTime = CountlyAnalytics.getTimestamp();
|
||||
private firstPage = true;
|
||||
private heartbeatIntervalId: NodeJS.Timeout;
|
||||
private activityIntervalId: NodeJS.Timeout;
|
||||
private heartbeatIntervalId: number;
|
||||
private activityIntervalId: number;
|
||||
private trackTime = true;
|
||||
private lastBeat: number;
|
||||
private storedDuration = 0;
|
||||
|
|
|
@ -46,8 +46,8 @@ export class DecryptionFailureTracker {
|
|||
};
|
||||
|
||||
// Set to an interval ID when `start` is called
|
||||
public checkInterval: NodeJS.Timeout = null;
|
||||
public trackInterval: NodeJS.Timeout = null;
|
||||
public checkInterval: number = null;
|
||||
public trackInterval: number = null;
|
||||
|
||||
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
|
||||
static TRACK_INTERVAL_MS = 60000;
|
||||
|
|
|
@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element';
|
|||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import url from 'url';
|
||||
import katex from 'katex';
|
||||
import { AllHtmlEntities } from 'html-entities';
|
||||
import { IContent } from 'matrix-js-sdk/src/models/event';
|
||||
|
@ -153,10 +152,8 @@ export function getHtmlText(insaneHtml: string): string {
|
|||
*/
|
||||
export function isUrlPermitted(inputUrl: string): boolean {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
// URL parser protocol includes the trailing colon
|
||||
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
|
||||
return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -127,7 +127,7 @@ export default class IdentityAuthClient {
|
|||
await this._matrixClient.getIdentityAccount(token);
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_TERMS_NOT_SIGNED") {
|
||||
console.log("Identity Server requires new terms to be agreed to");
|
||||
console.log("Identity server requires new terms to be agreed to");
|
||||
await startTermsFlow([new Service(
|
||||
SERVICE_TYPES.IS,
|
||||
identityServerUrl,
|
||||
|
|
|
@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix';
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
|
||||
import { QueryDict } from 'matrix-js-sdk/src/utils';
|
||||
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
@ -65,7 +66,7 @@ interface ILoadSessionOpts {
|
|||
guestIsUrl?: string;
|
||||
ignoreGuest?: boolean;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentQueryParams?: Record<string, string>;
|
||||
fragmentQueryParams?: QueryDict;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
|
|||
) {
|
||||
console.log("Using guest access credentials");
|
||||
return doSetLoggedIn({
|
||||
userId: fragmentQueryParams.guest_user_id,
|
||||
accessToken: fragmentQueryParams.guest_access_token,
|
||||
userId: fragmentQueryParams.guest_user_id as string,
|
||||
accessToken: fragmentQueryParams.guest_access_token as string,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: guestIsUrl,
|
||||
guest: true,
|
||||
|
@ -173,7 +174,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
|
|||
* login, else false
|
||||
*/
|
||||
export function attemptTokenLogin(
|
||||
queryParams: Record<string, string>,
|
||||
queryParams: QueryDict,
|
||||
defaultDeviceDisplayName?: string,
|
||||
fragmentAfterLogin?: string,
|
||||
): Promise<boolean> {
|
||||
|
@ -198,7 +199,7 @@ export function attemptTokenLogin(
|
|||
homeserver,
|
||||
identityServer,
|
||||
"m.login.token", {
|
||||
token: queryParams.loginToken,
|
||||
token: queryParams.loginToken as string,
|
||||
initial_device_display_name: defaultDeviceDisplayName,
|
||||
},
|
||||
).then(function(creds) {
|
||||
|
|
|
@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
|
||||
import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
|
||||
import * as utils from 'matrix-js-sdk/src/utils';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
|
@ -47,25 +47,8 @@ export interface IMatrixClientCreds {
|
|||
freshLogin?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Move this to the js-sdk
|
||||
export interface IOpts {
|
||||
initialSyncLimit?: number;
|
||||
pendingEventOrdering?: "detached" | "chronological";
|
||||
lazyLoadMembers?: boolean;
|
||||
clientWellKnownPollPeriod?: number;
|
||||
}
|
||||
|
||||
export interface IMatrixClientPeg {
|
||||
opts: IOpts;
|
||||
|
||||
/**
|
||||
* Sets the script href passed to the IndexedDB web worker
|
||||
* If set, a separate web worker will be started to run the IndexedDB
|
||||
* queries on.
|
||||
*
|
||||
* @param {string} script href to the script to be passed to the web worker
|
||||
*/
|
||||
setIndexedDbWorkerScript(script: string): void;
|
||||
opts: IStartClientOpts;
|
||||
|
||||
/**
|
||||
* Return the server name of the user's homeserver
|
||||
|
@ -127,7 +110,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
// client is started in 'start'. These can be altered
|
||||
// at any time up to after the 'will_start_client'
|
||||
// event is finished processing.
|
||||
public opts: IOpts = {
|
||||
public opts: IStartClientOpts = {
|
||||
initialSyncLimit: 20,
|
||||
};
|
||||
|
||||
|
@ -141,10 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
constructor() {
|
||||
}
|
||||
|
||||
public setIndexedDbWorkerScript(script: string): void {
|
||||
createMatrixClient.indexedDbWorkerScript = script;
|
||||
}
|
||||
|
||||
public get(): MatrixClient {
|
||||
return this.matrixClient;
|
||||
}
|
||||
|
@ -231,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
|
||||
const opts = utils.deepCopy(this.opts);
|
||||
// the react sdk doesn't work without this, so don't allow
|
||||
opts.pendingEventOrdering = "detached";
|
||||
opts.pendingEventOrdering = PendingEventOrdering.Detached;
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
|
||||
|
|
|
@ -328,7 +328,7 @@ export const Notifier = {
|
|||
|
||||
onEvent: function(ev: MatrixEvent) {
|
||||
if (!this.isSyncing) return; // don't alert for any messages initially
|
||||
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
|
||||
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
|
||||
|
||||
MatrixClientPeg.get().decryptEventIfNeeded(ev);
|
||||
|
||||
|
|
|
@ -84,10 +84,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void>
|
|||
this room as a DM room
|
||||
* @returns {object} A promise
|
||||
*/
|
||||
export function setDMRoom(roomId: string, userId: string): Promise<void> {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
|
||||
let dmRoomMap = {};
|
||||
|
@ -116,7 +114,7 @@ export function setDMRoom(roomId: string, userId: string): Promise<void> {
|
|||
dmRoomMap[userId] = roomList;
|
||||
}
|
||||
|
||||
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.
|
||||
|
@ -14,26 +14,42 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
IResultRoomEvents,
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
ISearchResults,
|
||||
SearchOrderBy,
|
||||
} from "matrix-js-sdk/src/@types/search";
|
||||
import { IRoomEventFilter } from "matrix-js-sdk/src/filter";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { ISearchArgs } from "./indexing/BaseEventIndexManager";
|
||||
import EventIndexPeg from "./indexing/EventIndexPeg";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
|
||||
async function serverSideSearch(term, roomId = undefined) {
|
||||
async function serverSideSearch(
|
||||
term: string,
|
||||
roomId: string = undefined,
|
||||
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const filter = {
|
||||
const filter: IRoomEventFilter = {
|
||||
limit: SEARCH_LIMIT,
|
||||
};
|
||||
|
||||
if (roomId !== undefined) filter.rooms = [roomId];
|
||||
|
||||
const body = {
|
||||
const body: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
search_term: term,
|
||||
filter: filter,
|
||||
order_by: "recent",
|
||||
order_by: SearchOrderBy.Recent,
|
||||
event_context: {
|
||||
before_limit: 1,
|
||||
after_limit: 1,
|
||||
|
@ -45,31 +61,26 @@ async function serverSideSearch(term, roomId = undefined) {
|
|||
|
||||
const response = await client.search({ body: body });
|
||||
|
||||
const result = {
|
||||
response: response,
|
||||
query: body,
|
||||
};
|
||||
|
||||
return result;
|
||||
return { response, query: body };
|
||||
}
|
||||
|
||||
async function serverSideSearchProcess(term, roomId = undefined) {
|
||||
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
const result = await serverSideSearch(term, roomId);
|
||||
|
||||
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally
|
||||
// so we're reusing the concept here since we wan't to delegate the
|
||||
// so we're reusing the concept here since we want to delegate the
|
||||
// pagination back to backPaginateRoomEventsSearch() in some cases.
|
||||
const searchResult = {
|
||||
const searchResults: ISearchResults = {
|
||||
_query: result.query,
|
||||
results: [],
|
||||
highlights: [],
|
||||
};
|
||||
|
||||
return client.processRoomEventsSearch(searchResult, result.response);
|
||||
return client.processRoomEventsSearch(searchResults, result.response);
|
||||
}
|
||||
|
||||
function compareEvents(a, b) {
|
||||
function compareEvents(a: ISearchResult, b: ISearchResult): number {
|
||||
const aEvent = a.result;
|
||||
const bEvent = b.result;
|
||||
|
||||
|
@ -79,7 +90,7 @@ function compareEvents(a, b) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
async function combinedSearch(searchTerm) {
|
||||
async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
// Create two promises, one for the local search, one for the
|
||||
|
@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) {
|
|||
// returns since that one can be either a server-side one, a local one or a
|
||||
// fake one to fetch the remaining cached events. See the docs for
|
||||
// combineEvents() for an explanation why we need to cache events.
|
||||
const emptyResult = {
|
||||
const emptyResult: ISeshatSearchResults = {
|
||||
seshatQuery: localQuery,
|
||||
_query: serverQuery,
|
||||
serverSideNextBatch: serverResponse.next_batch,
|
||||
serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
|
||||
cachedEvents: [],
|
||||
oldestEventFrom: "server",
|
||||
results: [],
|
||||
|
@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) {
|
|||
const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
|
||||
|
||||
// Let the client process the combined result.
|
||||
const response = {
|
||||
const response: ISearchResponse = {
|
||||
search_categories: {
|
||||
room_events: combinedResult,
|
||||
},
|
||||
|
@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) {
|
|||
return result;
|
||||
}
|
||||
|
||||
async function localSearch(searchTerm, roomId = undefined, processResult = true) {
|
||||
async function localSearch(
|
||||
searchTerm: string,
|
||||
roomId: string = undefined,
|
||||
processResult = true,
|
||||
): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const searchArgs = {
|
||||
const searchArgs: ISearchArgs = {
|
||||
search_term: searchTerm,
|
||||
before_limit: 1,
|
||||
after_limit: 1,
|
||||
|
@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true)
|
|||
return result;
|
||||
}
|
||||
|
||||
async function localSearchProcess(searchTerm, roomId = undefined) {
|
||||
export interface ISeshatSearchResults extends ISearchResults {
|
||||
seshatQuery?: ISearchArgs;
|
||||
cachedEvents?: ISearchResult[];
|
||||
oldestEventFrom?: "local" | "server";
|
||||
serverSideNextBatch?: string;
|
||||
}
|
||||
|
||||
async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise<ISeshatSearchResults> {
|
||||
const emptyResult = {
|
||||
results: [],
|
||||
highlights: [],
|
||||
};
|
||||
} as ISeshatSearchResults;
|
||||
|
||||
if (searchTerm === "") return emptyResult;
|
||||
|
||||
|
@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
|
|||
|
||||
emptyResult.seshatQuery = result.query;
|
||||
|
||||
const response = {
|
||||
const response: ISearchResponse = {
|
||||
search_categories: {
|
||||
room_events: result.response,
|
||||
},
|
||||
|
@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
|
|||
return processedResult;
|
||||
}
|
||||
|
||||
async function localPagination(searchResult) {
|
||||
async function localPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
|
@ -221,10 +243,10 @@ async function localPagination(searchResult) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function compareOldestEvents(firstResults, secondResults) {
|
||||
function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
|
||||
try {
|
||||
const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result;
|
||||
const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result;
|
||||
const oldestFirstEvent = firstResults[firstResults.length - 1].result;
|
||||
const oldestSecondEvent = secondResults[secondResults.length - 1].result;
|
||||
|
||||
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
|
||||
return -1;
|
||||
|
@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) {
|
|||
}
|
||||
}
|
||||
|
||||
function combineEventSources(previousSearchResult, response, a, b) {
|
||||
function combineEventSources(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
response: IResultRoomEvents,
|
||||
a: ISearchResult[],
|
||||
b: ISearchResult[],
|
||||
): void {
|
||||
// Merge event sources and sort the events.
|
||||
const combinedEvents = a.concat(b).sort(compareEvents);
|
||||
// Put half of the events in the response, and cache the other half.
|
||||
|
@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) {
|
|||
* different event sources.
|
||||
*
|
||||
*/
|
||||
function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
const response = {};
|
||||
function combineEvents(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
localEvents: IResultRoomEvents = undefined,
|
||||
serverEvents: IResultRoomEvents = undefined,
|
||||
): IResultRoomEvents {
|
||||
const response = {} as IResultRoomEvents;
|
||||
|
||||
const cachedEvents = previousSearchResult.cachedEvents;
|
||||
let oldestEventFrom = previousSearchResult.oldestEventFrom;
|
||||
|
@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// This is a first search call, combine the events from the server and
|
||||
// the local index. Note where our oldest event came from, we shall
|
||||
// fetch the next batch of events from the other source.
|
||||
if (compareOldestEvents(localEvents, serverEvents) < 0) {
|
||||
if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
|
||||
|
@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// meaning that our oldest event was on the server.
|
||||
// Change the source of the oldest event if our local event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(localEvents, cachedEvents) < 0) {
|
||||
if (compareOldestEvents(localEvents.results, cachedEvents) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
|
||||
|
@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// meaning that our oldest event was in the local index.
|
||||
// Change the source of the oldest event if our server event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(serverEvents, cachedEvents) < 0) {
|
||||
if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
|
||||
oldestEventFrom = "server";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
|
||||
|
@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
* @return {object} A response object that combines the events from the
|
||||
* different event sources.
|
||||
*/
|
||||
function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
function combineResponses(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
localEvents: IResultRoomEvents = undefined,
|
||||
serverEvents: IResultRoomEvents = undefined,
|
||||
): IResultRoomEvents {
|
||||
// Combine our events first.
|
||||
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
|
||||
|
||||
|
@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
|
|||
return response;
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice = []) {
|
||||
interface IEncryptedSeshatEvent {
|
||||
curve25519Key: string;
|
||||
ed25519Key: string;
|
||||
algorithm: string;
|
||||
forwardingCurve25519KeyChain: string[];
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
|
||||
for (let i = 0; i < searchResultSlice.length; i++) {
|
||||
const timeline = searchResultSlice[i].context.getTimeline();
|
||||
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
const ev = timeline[j];
|
||||
const mxEv = timeline[j];
|
||||
const ev = mxEv.event as IEncryptedSeshatEvent;
|
||||
|
||||
if (ev.event.curve25519Key) {
|
||||
ev.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
{ algorithm: ev.event.algorithm },
|
||||
ev.event.curve25519Key,
|
||||
ev.event.ed25519Key,
|
||||
if (ev.curve25519Key) {
|
||||
mxEv.makeEncrypted(
|
||||
EventType.RoomMessageEncrypted,
|
||||
{ algorithm: ev.algorithm },
|
||||
ev.curve25519Key,
|
||||
ev.ed25519Key,
|
||||
);
|
||||
ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
|
||||
// @ts-ignore
|
||||
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
|
||||
|
||||
delete ev.event.curve25519Key;
|
||||
delete ev.event.ed25519Key;
|
||||
delete ev.event.algorithm;
|
||||
delete ev.event.forwardingCurve25519KeyChain;
|
||||
delete ev.curve25519Key;
|
||||
delete ev.ed25519Key;
|
||||
delete ev.algorithm;
|
||||
delete ev.forwardingCurve25519KeyChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function combinedPagination(searchResult) {
|
||||
async function combinedPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
const oldestEventFrom = searchResult.oldestEventFrom;
|
||||
|
||||
let localResult;
|
||||
let serverSideResult;
|
||||
let localResult: IResultRoomEvents;
|
||||
let serverSideResult: ISearchResponse;
|
||||
|
||||
// Fetch events from the local index if we have a token for itand if it's
|
||||
// Fetch events from the local index if we have a token for it and if it's
|
||||
// the local indexes turn or the server has exhausted its results.
|
||||
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
|
||||
localResult = await eventIndex.search(searchArgs);
|
||||
|
@ -502,7 +546,7 @@ async function combinedPagination(searchResult) {
|
|||
serverSideResult = await client.search(body);
|
||||
}
|
||||
|
||||
let serverEvents;
|
||||
let serverEvents: IResultRoomEvents;
|
||||
|
||||
if (serverSideResult) {
|
||||
serverEvents = serverSideResult.search_categories.room_events;
|
||||
|
@ -532,8 +576,8 @@ async function combinedPagination(searchResult) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function eventIndexSearch(term, roomId = undefined) {
|
||||
let searchPromise;
|
||||
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
let searchPromise: Promise<ISearchResults>;
|
||||
|
||||
if (roomId !== undefined) {
|
||||
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
|
||||
|
@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) {
|
|||
return searchPromise;
|
||||
}
|
||||
|
||||
function eventIndexSearchPagination(searchResult) {
|
||||
function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const seshatQuery = searchResult.seshatQuery;
|
||||
|
@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) {
|
|||
}
|
||||
}
|
||||
|
||||
export function searchPagination(searchResult) {
|
||||
export function searchPagination(searchResult: ISearchResults): Promise<ISearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
|
@ -590,7 +634,7 @@ export function searchPagination(searchResult) {
|
|||
else return eventIndexSearchPagination(searchResult);
|
||||
}
|
||||
|
||||
export default function eventSearch(term, roomId = undefined) {
|
||||
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
|
|
@ -13,7 +13,6 @@ 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';
|
||||
|
@ -32,7 +31,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
// 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 {
|
||||
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => 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();
|
||||
|
@ -84,7 +83,7 @@ function textForMemberEvent(ev): () => string | null {
|
|||
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 });
|
||||
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
} else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
// 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 {
|
||||
|
@ -127,7 +126,7 @@ function textForMemberEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForTopicEvent(ev): () => string | null {
|
||||
function textForTopicEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
|
||||
senderDisplayName,
|
||||
|
@ -135,7 +134,7 @@ function textForTopicEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForRoomNameEvent(ev): () => string | null {
|
||||
function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
|
||||
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
|
||||
|
@ -154,12 +153,12 @@ function textForRoomNameEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForTombstoneEvent(ev): () => string | null {
|
||||
function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
|
||||
}
|
||||
|
||||
function textForJoinRulesEvent(ev): () => string | null {
|
||||
function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().join_rule) {
|
||||
case "public":
|
||||
|
@ -179,7 +178,7 @@ function textForJoinRulesEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForGuestAccessEvent(ev): () => string | null {
|
||||
function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().guest_access) {
|
||||
case "can_join":
|
||||
|
@ -195,7 +194,7 @@ function textForGuestAccessEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForRelatedGroupsEvent(ev): () => string | null {
|
||||
function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const groups = ev.getContent().groups || [];
|
||||
const prevGroups = ev.getPrevContent().groups || [];
|
||||
|
@ -225,7 +224,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForServerACLEvent(ev): () => string | null {
|
||||
function textForServerACLEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const current = ev.getContent();
|
||||
|
@ -255,7 +254,7 @@ function textForServerACLEvent(ev): () => string | null {
|
|||
return getText;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev): () => string | null {
|
||||
function textForMessageEvent(ev: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
|
@ -268,7 +267,7 @@ function textForMessageEvent(ev): () => string | null {
|
|||
};
|
||||
}
|
||||
|
||||
function textForCanonicalAliasEvent(ev): () => string | null {
|
||||
function textForCanonicalAliasEvent(ev: MatrixEvent): () => 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 || [];
|
||||
|
@ -319,7 +318,7 @@ function textForCanonicalAliasEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForCallAnswerEvent(event): () => string | null {
|
||||
function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
|
||||
|
@ -327,7 +326,7 @@ function textForCallAnswerEvent(event): () => string | null {
|
|||
};
|
||||
}
|
||||
|
||||
function textForCallHangupEvent(event): () => string | null {
|
||||
function textForCallHangupEvent(event: MatrixEvent): () => string | null {
|
||||
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||
const eventContent = event.getContent();
|
||||
let getReason = () => "";
|
||||
|
@ -364,14 +363,14 @@ function textForCallHangupEvent(event): () => string | null {
|
|||
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
|
||||
}
|
||||
|
||||
function textForCallRejectEvent(event): () => string | null {
|
||||
function textForCallRejectEvent(event: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
return _t('%(senderName)s declined the call.', { senderName });
|
||||
};
|
||||
}
|
||||
|
||||
function textForCallInviteEvent(event): () => string | null {
|
||||
function textForCallInviteEvent(event: MatrixEvent): () => 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;
|
||||
|
@ -403,7 +402,7 @@ function textForCallInviteEvent(event): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForThreePidInviteEvent(event): () => string | null {
|
||||
function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
|
||||
if (!isValid3pidInvite(event)) {
|
||||
|
@ -419,7 +418,7 @@ function textForThreePidInviteEvent(event): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForHistoryVisibilityEvent(event): () => string | null {
|
||||
function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
switch (event.getContent().history_visibility) {
|
||||
case 'invited':
|
||||
|
@ -441,7 +440,7 @@ function textForHistoryVisibilityEvent(event): () => string | null {
|
|||
}
|
||||
|
||||
// Currently will only display a change if a user's power level is changed
|
||||
function textForPowerEvent(event): () => string | null {
|
||||
function textForPowerEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
if (!event.getPrevContent() || !event.getPrevContent().users ||
|
||||
!event.getContent() || !event.getContent().users) {
|
||||
|
@ -523,7 +522,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
|
|||
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
|
||||
}
|
||||
|
||||
function textForWidgetEvent(event): () => string | null {
|
||||
function textForWidgetEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
|
||||
const { name, type, url } = event.getContent() || {};
|
||||
|
@ -553,12 +552,12 @@ function textForWidgetEvent(event): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForWidgetLayoutEvent(event): () => string | null {
|
||||
function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender?.name || event.getSender();
|
||||
return () => _t("%(senderName)s has updated the widget layout", { senderName });
|
||||
}
|
||||
|
||||
function textForMjolnirEvent(event): () => string | null {
|
||||
function textForMjolnirEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { entity: prevEntity } = event.getPrevContent();
|
||||
const { entity, recommendation, reason } = event.getContent();
|
||||
|
@ -646,7 +645,9 @@ function textForMjolnirEvent(event): () => string | null {
|
|||
}
|
||||
|
||||
interface IHandlers {
|
||||
[type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null);
|
||||
[type: string]:
|
||||
(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
|
||||
(() => string | JSX.Element | null);
|
||||
}
|
||||
|
||||
const handlers: IHandlers = {
|
||||
|
@ -682,14 +683,27 @@ for (const evType of ALL_RULE_TYPES) {
|
|||
stateHandlers[evType] = textForMjolnirEvent;
|
||||
}
|
||||
|
||||
export function hasText(ev): boolean {
|
||||
/**
|
||||
* Determines whether the given event has text to display.
|
||||
* @param ev The event
|
||||
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
|
||||
* to avoid hitting the settings store
|
||||
*/
|
||||
export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
return Boolean(handler?.(ev));
|
||||
return Boolean(handler?.(ev, false, showHiddenEvents));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the textual content of the given event.
|
||||
* @param ev The event
|
||||
* @param allowJSX Whether to output rich JSX content
|
||||
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
|
||||
* to avoid hitting the settings store
|
||||
*/
|
||||
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 {
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
return handler?.(ev, allowJSX)?.() || '';
|
||||
return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile";
|
|||
* @returns {boolean} True if the given event should affect the unread message count
|
||||
*/
|
||||
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
|
||||
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
|
||||
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
|
|||
// https://github.com/vector-im/element-web/issues/2427
|
||||
// ...and possibly some of the others at
|
||||
// https://github.com/vector-im/element-web/issues/3363
|
||||
if (room.timeline.length &&
|
||||
room.timeline[room.timeline.length - 1].sender &&
|
||||
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
|
||||
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,8 +27,8 @@ import EmojiProvider from './EmojiProvider';
|
|||
import NotifProvider from './NotifProvider';
|
||||
import { timeout } from "../utils/promise";
|
||||
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import SpaceProvider from "./SpaceProvider";
|
||||
import SpaceStore from "../stores/SpaceStore";
|
||||
|
||||
export interface ISelectionRange {
|
||||
beginning?: boolean; // whether the selection is in the first block of the editor or not
|
||||
|
@ -58,8 +58,7 @@ const PROVIDERS = [
|
|||
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")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
PROVIDERS.push(SpaceProvider);
|
||||
} else {
|
||||
PROVIDERS.push(CommunityProvider);
|
||||
|
|
|
@ -28,7 +28,7 @@ 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";
|
||||
import SpaceStore from "../stores/SpaceStore";
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
||||
|
@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
const cli = MatrixClientPeg.get();
|
||||
let rooms = cli.getVisibleRooms();
|
||||
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
// if spaces are enabled then filter them out here as they get their own autocomplete provider
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
rooms = rooms.filter(r => !r.isSpaceRoom());
|
||||
}
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component {
|
|||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
// sessions with the identity server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
|
|
|
@ -63,6 +63,7 @@ import ToastContainer from './ToastContainer';
|
|||
import MyGroups from "./MyGroups";
|
||||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -631,7 +632,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
>
|
||||
<ToastContainer />
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
|
||||
{ SpaceStore.spacesEnabled ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
|
|
|
@ -19,7 +19,7 @@ import { createClient } from "matrix-js-sdk/src/matrix";
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils";
|
||||
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
|
||||
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
|
@ -105,6 +105,8 @@ import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
|
|||
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
import SoftLogout from './auth/SoftLogout';
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -153,7 +155,7 @@ const ONBOARDING_FLOW_STARTERS = [
|
|||
|
||||
interface IScreen {
|
||||
screen: string;
|
||||
params?: object;
|
||||
params?: QueryDict;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -183,9 +185,9 @@ interface IProps { // TODO type things better
|
|||
onNewScreen: (screen: string, replaceLast: boolean) => void;
|
||||
enableGuest?: boolean;
|
||||
// the queryParams extracted from the [real] query-string of the URI
|
||||
realQueryParams?: Record<string, string>;
|
||||
realQueryParams?: QueryDict;
|
||||
// the initial queryParams extracted from the hash-fragment of the URI
|
||||
startingFragmentQueryParams?: Record<string, string>;
|
||||
startingFragmentQueryParams?: QueryDict;
|
||||
// called when we have completed a token login
|
||||
onTokenLoginCompleted?: () => void;
|
||||
// Represents the screen to display as a result of parsing the initial window.location
|
||||
|
@ -193,7 +195,7 @@ interface IProps { // TODO type things better
|
|||
// displayname, if any, to set on the device when logging in/registering.
|
||||
defaultDeviceDisplayName?: string;
|
||||
// A function that makes a registration URL
|
||||
makeRegistrationUrl: (object) => string;
|
||||
makeRegistrationUrl: (params: QueryDict) => string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -251,7 +253,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
private pageChanging: boolean;
|
||||
private tokenLogin?: boolean;
|
||||
private accountPassword?: string;
|
||||
private accountPasswordTimer?: NodeJS.Timeout;
|
||||
private accountPasswordTimer?: number;
|
||||
private focusComposer: boolean;
|
||||
private subTitleStatus: string;
|
||||
private prevWindowWidth: number;
|
||||
|
@ -296,7 +298,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
|
||||
// probably a threepid invite - try to store it
|
||||
const roomId = this.screenAfterLogin.screen.substring("room/".length);
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat);
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -561,7 +563,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
switch (payload.action) {
|
||||
case 'MatrixActions.accountData':
|
||||
// XXX: This is a collection of several hacks to solve a minor problem. We want to
|
||||
// update our local state when the ID server changes, but don't want to put that in
|
||||
// update our local state when the identity server changes, but don't want to put that in
|
||||
// the js-sdk as we'd be then dictating how all consumers need to behave. However,
|
||||
// this component is already bloated and we probably don't want this tiny logic in
|
||||
// here, but there's no better place in the react-sdk for it. Additionally, we're
|
||||
|
@ -627,6 +629,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'forget_room':
|
||||
this.forgetRoom(payload.room_id);
|
||||
break;
|
||||
case 'copy_room':
|
||||
this.copyRoom(payload.room_id);
|
||||
break;
|
||||
case 'reject_invite':
|
||||
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
|
||||
title: _t('Reject invitation'),
|
||||
|
@ -1099,7 +1104,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private leaveRoomWarnings(roomId: string) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
// Show a warning if there are additional complications.
|
||||
const warnings = [];
|
||||
|
||||
|
@ -1137,7 +1142,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
|
||||
title: isSpace ? _t("Leave space") : _t("Leave room"),
|
||||
description: (
|
||||
|
@ -1193,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private async copyRoom(roomId: string) {
|
||||
const roomLink = makeRoomPermalink(roomId);
|
||||
const success = await copyPlaintext(roomLink);
|
||||
if (!success) {
|
||||
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
|
||||
title: _t("Unable to copy room link"),
|
||||
description: _t("Unable to copy a link to the room to the clipboard."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a chat with the welcome user, if the user doesn't already have one
|
||||
* @returns {string} The room ID of the new room, or null if no room was created
|
||||
|
@ -1687,7 +1703,7 @@ 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")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
@ -1774,7 +1790,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
@ -1936,7 +1952,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.setState({ serverConfig });
|
||||
};
|
||||
|
||||
private makeRegistrationUrl = (params: {[key: string]: string}) => {
|
||||
private makeRegistrationUrl = (params: QueryDict) => {
|
||||
if (this.props.startingFragmentQueryParams.referrer) {
|
||||
params.referrer = this.props.startingFragmentQueryParams.referrer;
|
||||
}
|
||||
|
@ -2091,7 +2107,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}
|
||||
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
|
||||
{...this.getServerProperties()}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -54,7 +54,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E
|
|||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean {
|
||||
function shouldFormContinuation(
|
||||
prevEvent: MatrixEvent,
|
||||
mxEvent: MatrixEvent,
|
||||
showHiddenEvents: boolean,
|
||||
): boolean {
|
||||
// sanity check inputs
|
||||
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
|
||||
// check if within the max continuation period
|
||||
|
@ -74,7 +78,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b
|
|||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
|
||||
|
||||
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
|
||||
if (!haveTileForEvent(prevEvent)) return false;
|
||||
if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -239,7 +243,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
// Cache hidden events setting on mount since Settings is expensive to
|
||||
// query, and we check this in a hot code path.
|
||||
// query, and we check this in a hot code path. This is also cached in
|
||||
// our RoomContext, however we still need a fallback for roomless MessagePanels.
|
||||
this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
|
||||
|
||||
this.showTypingNotificationsWatcherRef =
|
||||
|
@ -399,17 +404,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
return !this.isMounted;
|
||||
};
|
||||
|
||||
private get showHiddenEvents(): boolean {
|
||||
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
|
||||
}
|
||||
|
||||
// TODO: Implement granular (per-room) hide options
|
||||
public shouldShowEvent(mxEv: MatrixEvent): boolean {
|
||||
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
|
||||
if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
|
||||
return false; // ignored = no show (only happens if the ignore happens after an event was received)
|
||||
}
|
||||
|
||||
if (this.showHiddenEventsInTimeline) {
|
||||
if (this.showHiddenEvents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
|
||||
return false; // no tile = no show
|
||||
}
|
||||
|
||||
|
@ -569,7 +578,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
|
||||
if (grouper) {
|
||||
if (grouper.shouldGroup(mxEv)) {
|
||||
grouper.add(mxEv);
|
||||
grouper.add(mxEv, this.showHiddenEvents);
|
||||
continue;
|
||||
} else {
|
||||
// not part of group, so get the group tiles, close the
|
||||
|
@ -651,7 +660,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
|
||||
const continuation = !wantsDateSeparator &&
|
||||
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
|
||||
|
||||
const eventId = mxEv.getId();
|
||||
const highlight = (eventId === this.props.highlightedEventId);
|
||||
|
@ -949,7 +959,7 @@ abstract class BaseGrouper {
|
|||
}
|
||||
|
||||
public abstract shouldGroup(ev: MatrixEvent): boolean;
|
||||
public abstract add(ev: MatrixEvent): void;
|
||||
public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void;
|
||||
public abstract getTiles(): ReactNode[];
|
||||
public abstract getNewPrevEvent(): MatrixEvent;
|
||||
}
|
||||
|
@ -1203,10 +1213,10 @@ class MemberGrouper extends BaseGrouper {
|
|||
return membershipTypes.includes(ev.getType() as EventType);
|
||||
}
|
||||
|
||||
public add(ev: MatrixEvent): void {
|
||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||
if (ev.getType() === EventType.RoomMember) {
|
||||
// We can ignore any events that don't actually have a message to display
|
||||
if (!hasText(ev)) return;
|
||||
if (!hasText(ev, showHiddenEvents)) return;
|
||||
}
|
||||
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
|
||||
ev.getId(),
|
||||
|
|
|
@ -48,6 +48,7 @@ import NotificationPanel from "./NotificationPanel";
|
|||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
room?: Room; // if showing panels for a given room, this is set
|
||||
|
@ -107,7 +108,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
|||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
|
||||
} else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
|
||||
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
|
||||
) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
|
|
|
@ -16,6 +16,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
|
||||
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
@ -25,7 +28,7 @@ import { _t } from '../../languageHandler';
|
|||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown";
|
||||
import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
|
||||
import GroupStore from "../../stores/GroupStore";
|
||||
|
@ -40,7 +43,6 @@ 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";
|
||||
|
@ -61,7 +63,7 @@ interface IProps extends IDialogProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
publicRooms: IRoom[];
|
||||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
|
@ -72,35 +74,12 @@ interface IState {
|
|||
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<IProps, IState> {
|
||||
private readonly startTime: number;
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: NodeJS.Timeout;
|
||||
private filterTimeout: number;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -253,7 +232,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
const opts: IPublicRoomsRequest = { limit: 20 };
|
||||
const opts: IRoomDirectoryOptions = { limit: 20 };
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
|
@ -326,7 +305,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
private removeFromDirectory(room: IRoom) {
|
||||
private removeFromDirectory(room: IPublicRoomsChunkRoom) {
|
||||
const alias = getDisplayAliasForRoom(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
|
@ -346,7 +325,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
const modal = Modal.createDialog(Spinner);
|
||||
let step = _t('remove %(name)s from the directory.', { name: name });
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the address.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
|
@ -368,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
|
||||
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => {
|
||||
// If room was shift-clicked, remove it from the room directory
|
||||
if (ev.shiftKey && !this.state.selectedCommunityId) {
|
||||
ev.preventDefault();
|
||||
|
@ -481,17 +460,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
@ -509,7 +488,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
this.showRoom(null, alias, autoJoin);
|
||||
}
|
||||
|
||||
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
this.onFinished();
|
||||
const payload: ActionPayload = {
|
||||
action: 'view_room',
|
||||
|
@ -558,7 +537,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
private createRoomCells(room: IRoom) {
|
||||
private createRoomCells(room: IPublicRoomsChunkRoom) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
|
@ -854,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IRoom) {
|
||||
function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ import React, { createRef } from 'react';
|
|||
import classNames from 'classnames';
|
||||
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 { ISearchResults } from 'matrix-js-sdk/src/@types/search';
|
||||
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
@ -89,6 +89,7 @@ import RoomStatusBar from "./RoomStatusBar";
|
|||
import MessageComposer from '../views/rooms/MessageComposer';
|
||||
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
|
||||
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function(msg: string) {};
|
||||
|
@ -133,12 +134,7 @@ export interface IState {
|
|||
searching: boolean;
|
||||
searchTerm?: string;
|
||||
searchScope?: SearchScope;
|
||||
searchResults?: XOR<{}, {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: SearchResult[];
|
||||
next_batch: string; // eslint-disable-line camelcase
|
||||
}>;
|
||||
searchResults?: XOR<{}, ISearchResults>;
|
||||
searchHighlights?: string[];
|
||||
searchInProgress?: boolean;
|
||||
callState?: CallState;
|
||||
|
@ -170,6 +166,7 @@ export interface IState {
|
|||
canReply: boolean;
|
||||
layout: Layout;
|
||||
lowBandwidth: boolean;
|
||||
showHiddenEventsInTimeline: boolean;
|
||||
showReadReceipts: boolean;
|
||||
showRedactions: boolean;
|
||||
showJoinLeaves: boolean;
|
||||
|
@ -234,6 +231,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
canReply: false,
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
|
||||
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
|
@ -257,7 +255,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.on("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.on("event", this.onEvent);
|
||||
// Start listening for RoomViewStore updates
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
|
@ -272,6 +269,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
SettingsStore.watchSetting("lowBandwidth", null, () =>
|
||||
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
|
||||
),
|
||||
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () =>
|
||||
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -641,7 +641,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.removeListener("event", this.onEvent);
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
|
@ -841,8 +840,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (!room) return;
|
||||
if (!this.state.room || room.roomId != this.state.room.roomId) return;
|
||||
if (!room || room.roomId !== this.state.room?.roomId) return;
|
||||
|
||||
// ignore events from filtered timelines
|
||||
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
|
||||
|
@ -863,6 +861,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
// we'll only be showing a spinner.
|
||||
if (this.state.joining) return;
|
||||
|
||||
if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) {
|
||||
this.handleEffects(ev);
|
||||
}
|
||||
|
||||
if (ev.getSender() !== this.context.credentials.userId) {
|
||||
// update unread count when scrolled up
|
||||
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
|
||||
|
@ -875,20 +877,14 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev) => {
|
||||
private onEventDecrypted = (ev: MatrixEvent) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private onEvent = (ev) => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private handleEffects = (ev) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
|
||||
private handleEffects = (ev: MatrixEvent) => {
|
||||
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
|
||||
if (!notifState.isUnread) return;
|
||||
|
||||
|
@ -921,6 +917,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
// called when state.room is first initialised (either at initial load,
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room) => {
|
||||
if (this.unmounted) return;
|
||||
// Attach a widget store listener only when we get a room
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
this.onWidgetLayoutChange(); // provoke an update
|
||||
|
@ -935,9 +932,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private async calculateRecommendedVersion(room: Room) {
|
||||
this.setState({
|
||||
upgradeRecommendation: await room.getRecommendedVersion(),
|
||||
});
|
||||
const upgradeRecommendation = await room.getRecommendedVersion();
|
||||
if (this.unmounted) return;
|
||||
this.setState({ upgradeRecommendation });
|
||||
}
|
||||
|
||||
private async loadMembersIfJoined(room: Room) {
|
||||
|
@ -1027,23 +1024,19 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private async updateE2EStatus(room: Room) {
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) {
|
||||
return;
|
||||
}
|
||||
if (!this.context.isCryptoEnabled()) {
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
this.setState({
|
||||
e2eStatus: E2EStatus.Warning,
|
||||
});
|
||||
return;
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) return;
|
||||
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
let e2eStatus = E2EStatus.Warning;
|
||||
if (this.context.isCryptoEnabled()) {
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
e2eStatus = await shieldStatusForRoom(this.context, room);
|
||||
}
|
||||
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
this.setState({
|
||||
e2eStatus: await shieldStatusForRoom(this.context, room),
|
||||
});
|
||||
if (this.unmounted) return;
|
||||
this.setState({ e2eStatus });
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
|
@ -1137,7 +1130,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
|
||||
if (this.state.searchResults.next_batch) {
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(this.state.searchResults);
|
||||
const searchPromise = searchPagination(this.state.searchResults as ISearchResults);
|
||||
return this.handleSearchResult(searchPromise);
|
||||
} else {
|
||||
debuglog("no more search results");
|
||||
|
@ -1400,7 +1393,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
|
@ -1753,10 +1746,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const myMembership = this.state.room.getMyMembership();
|
||||
if (myMembership === "invite"
|
||||
// SpaceRoomView handles invites itself
|
||||
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
|
||||
) {
|
||||
// SpaceRoomView handles invites itself
|
||||
if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
@ -1887,7 +1878,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
|||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
|
||||
if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
|
|
|
@ -187,7 +187,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
|||
private fillRequestWhileRunning: boolean;
|
||||
private scrollState: IScrollState;
|
||||
private preventShrinkingState: IPreventShrinkingState;
|
||||
private unfillDebouncer: NodeJS.Timeout;
|
||||
private unfillDebouncer: number;
|
||||
private bottomGrowth: number;
|
||||
private pages: number;
|
||||
private heightUpdateInProgress: boolean;
|
||||
|
|
|
@ -18,6 +18,7 @@ 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 { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
|
@ -52,36 +53,6 @@ interface IHierarchyProps {
|
|||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface ISpaceSummaryRoom {
|
||||
canonical_alias?: string;
|
||||
aliases: string[];
|
||||
avatar_url?: string;
|
||||
guest_can_join: boolean;
|
||||
name?: string;
|
||||
num_joined_members: number;
|
||||
room_id: string;
|
||||
topic?: string;
|
||||
world_readable: boolean;
|
||||
num_refs: number;
|
||||
room_type: string;
|
||||
}
|
||||
|
||||
export interface ISpaceSummaryEvent {
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
origin_server_ts: number;
|
||||
type: string;
|
||||
state_key: string;
|
||||
content: {
|
||||
order?: string;
|
||||
suggested?: boolean;
|
||||
auto_join?: boolean;
|
||||
via?: string[];
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface ITileProps {
|
||||
room: ISpaceSummaryRoom;
|
||||
suggested?: boolean;
|
||||
|
|
|
@ -62,7 +62,6 @@ import 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";
|
||||
|
@ -178,7 +177,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
const spacesEnabled = SpaceStore.spacesEnabled;
|
||||
|
||||
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
|
||||
&& space.getJoinRule() !== JoinRule.Public;
|
||||
|
@ -854,7 +853,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
|||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
|
||||
if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
|
|
|
@ -20,6 +20,7 @@ import * as React from "react";
|
|||
import { _t } from '../../languageHandler';
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import classNames from "classnames";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
|
||||
/**
|
||||
|
@ -37,9 +38,16 @@ export class Tab {
|
|||
}
|
||||
}
|
||||
|
||||
export enum TabLocation {
|
||||
LEFT = 'left',
|
||||
TOP = 'top',
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
tabLocation: TabLocation;
|
||||
onChange?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -62,6 +70,10 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
tabLocation: TabLocation.LEFT,
|
||||
};
|
||||
|
||||
private _getActiveTabIndex() {
|
||||
if (!this.state || !this.state.activeTabIndex) return 0;
|
||||
return this.state.activeTabIndex;
|
||||
|
@ -75,6 +87,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
private _setActiveTab(tab: Tab) {
|
||||
const idx = this.props.tabs.indexOf(tab);
|
||||
if (idx !== -1) {
|
||||
if (this.props.onChange) this.props.onChange(tab.id);
|
||||
this.setState({ activeTabIndex: idx });
|
||||
} else {
|
||||
console.error("Could not find tab " + tab.label + " in tabs");
|
||||
|
@ -119,8 +132,14 @@ export default class TabbedView extends React.Component<IProps, IState> {
|
|||
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
|
||||
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
|
||||
|
||||
const tabbedViewClasses = classNames({
|
||||
'mx_TabbedView': true,
|
||||
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
|
||||
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_TabbedView">
|
||||
<div className={tabbedViewClasses}>
|
||||
<div className="mx_TabbedView_tabLabels">
|
||||
{labels}
|
||||
</div>
|
||||
|
|
|
@ -555,9 +555,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
// more than the timeout on userActiveRecently.
|
||||
//
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const sender = ev.sender ? ev.sender.userId : null;
|
||||
callRMUpdated = false;
|
||||
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
|
@ -863,7 +862,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
for (i++; i < events.length; i++) {
|
||||
const ev = events[i];
|
||||
if (!ev.sender || ev.sender.userId != myUserId) {
|
||||
if (ev.getSender() !== myUserId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -1051,6 +1050,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
{ windowLimit: this.props.timelineCap });
|
||||
|
||||
const onLoaded = () => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// clear the timeline min-height when
|
||||
// (re)loading the timeline
|
||||
if (this.messagePanel.current) {
|
||||
|
@ -1092,6 +1093,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
const onError = (error) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState({ timelineLoading: false });
|
||||
console.error(
|
||||
`Error loading timeline panel at ${eventId}: ${error}`,
|
||||
|
@ -1333,8 +1336,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const shouldIgnore = !!ev.status || // local echo
|
||||
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
|
||||
(ignoreOwn && ev.getSender() === myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) ||
|
||||
shouldHideEvent(ev, this.context);
|
||||
|
||||
if (isWithoutTile || !node) {
|
||||
// don't start counting if the event should be ignored,
|
||||
|
|
|
@ -90,7 +90,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
|
@ -115,7 +115,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
this.tagStoreRef.remove();
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
MatrixClientPeg.get().removeListener("Room", this.onRoom);
|
||||
|
|
|
@ -41,7 +41,7 @@ import CaptchaForm from "./CaptchaForm";
|
|||
* one HS whilst beign a guest on another).
|
||||
* loginType: the login type of the auth stage being attempted
|
||||
* authSessionId: session id from the server
|
||||
* clientSecret: The client secret in use for ID server auth sessions
|
||||
* clientSecret: The client secret in use for identity server auth sessions
|
||||
* stageParams: params from the server for the stage being attempted
|
||||
* errorText: error message from a previous attempt to authenticate
|
||||
* submitAuthDict: a function which will be called with the new auth dict
|
||||
|
@ -54,8 +54,8 @@ import CaptchaForm from "./CaptchaForm";
|
|||
* Defined keys for stages are:
|
||||
* m.login.email.identity:
|
||||
* * emailSid: string representing the sid of the active
|
||||
* verification session from the ID server, or
|
||||
* null if no session is active.
|
||||
* verification session from the identity server,
|
||||
* or null if no session is active.
|
||||
* fail: a function which should be called with an error object if an
|
||||
* error occurred during the auth stage. This will cause the auth
|
||||
* session to be failed and the process to go back to the start.
|
||||
|
|
|
@ -22,6 +22,7 @@ import ImageView from '../elements/ImageView';
|
|||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import Modal from '../../../Modal';
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { IOOBData } from '../../../stores/ThreepidInviteStore';
|
||||
|
@ -131,11 +132,14 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
|||
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
// If the room is a DM, we use the other user's ID for the color hash
|
||||
// in order to match the room avatar with their avatar
|
||||
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : null;
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps}
|
||||
name={roomName}
|
||||
idName={room ? room.roomId : null}
|
||||
idName={idName}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
|
|
|
@ -105,7 +105,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
|
|||
</div>
|
||||
<img src={image} alt="" />
|
||||
</div>
|
||||
{ extraSettings && <div className="mx_BetaCard_relatedSettings">
|
||||
{ extraSettings && value && <div className="mx_BetaCard_relatedSettings">
|
||||
{ extraSettings.map(key => (
|
||||
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
|
||||
)) }
|
||||
|
|
|
@ -53,7 +53,7 @@ export default class CallContextMenu extends React.Component<IProps> {
|
|||
onTransferClick = () => {
|
||||
Modal.createTrackedDialog(
|
||||
'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
/*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
|
|
@ -15,11 +15,11 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
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 DialPad from '../voip/DialPad';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
|
@ -45,24 +45,29 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
this.setState({ value: this.state.value + digit });
|
||||
};
|
||||
|
||||
onCancelClick = () => {
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
onChange = (ev) => {
|
||||
this.setState({ value: ev.target.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
return <ContextMenu {...this.props}>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<div className="mx_DialPadContextMenuWrapper">
|
||||
<div>
|
||||
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
|
||||
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<Field className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value} autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_dialPad">
|
||||
<DialPad onDigitPress={this.onDigitPress} hasDial={false} />
|
||||
</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">
|
||||
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
|
||||
</div>
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
|||
import TruncatedList from "../elements/TruncatedList";
|
||||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
|
||||
const AVATAR_SIZE = 30;
|
||||
|
||||
|
@ -180,7 +181,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
|||
const [query, setQuery] = useState("");
|
||||
const lcQuery = query.toLowerCase();
|
||||
|
||||
const spacesEnabled = useFeatureEnabled("feature_spaces");
|
||||
const spacesEnabled = SpaceStore.spacesEnabled;
|
||||
const flairEnabled = useFeatureEnabled(UIFeature.Flair);
|
||||
const previewLayout = useSettingValue<Layout>("layout");
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ export default class IntegrationsImpossibleDialog extends React.Component {
|
|||
<div className='mx_IntegrationsImpossibleDialog_content'>
|
||||
<p>
|
||||
{_t(
|
||||
"Your %(brand)s doesn't allow you to use an Integration Manager to do this. " +
|
||||
"Your %(brand)s doesn't allow you to use an integration manager to do this. " +
|
||||
"Please contact an admin.",
|
||||
{ brand },
|
||||
)}
|
||||
|
|
|
@ -32,7 +32,6 @@ import Modal from "../../../Modal";
|
|||
import { humanizeTime } from "../../../utils/humanize";
|
||||
import createRoom, {
|
||||
canEncryptToAllUsers,
|
||||
ensureDMExists,
|
||||
findDMForUser,
|
||||
privateShouldBeEncrypted,
|
||||
} from "../../../createRoom";
|
||||
|
@ -64,9 +63,15 @@ import { copyPlaintext, selectText } from "../../../utils/strings";
|
|||
import * as ContextMenu from "../../structures/ContextMenu";
|
||||
import { toRightOf } from "../../structures/ContextMenu";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||
import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload';
|
||||
import Field from '../elements/Field';
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
import Dialpad from '../voip/DialPad';
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
|
||||
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -79,11 +84,19 @@ interface IRecentUser {
|
|||
|
||||
export const KIND_DM = "dm";
|
||||
export const KIND_INVITE = "invite";
|
||||
// NB. This dialog needs the 'mx_InviteDialog_transferWrapper' wrapper class to have the correct
|
||||
// padding on the bottom (because all modals have 24px padding on all sides), so this needs to
|
||||
// be passed when creating the modal
|
||||
export const KIND_CALL_TRANSFER = "call_transfer";
|
||||
|
||||
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
|
||||
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
|
||||
|
||||
enum TabId {
|
||||
UserDirectory = 'users',
|
||||
DialPad = 'dialpad',
|
||||
}
|
||||
|
||||
// This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
|
||||
// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
|
||||
// for 3PIDs/email addresses.
|
||||
|
@ -109,11 +122,11 @@ export abstract class Member {
|
|||
|
||||
class DirectoryMember extends Member {
|
||||
private readonly _userId: string;
|
||||
private readonly displayName: string;
|
||||
private readonly avatarUrl: string;
|
||||
private readonly displayName?: string;
|
||||
private readonly avatarUrl?: string;
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) {
|
||||
constructor(userDirResult: { user_id: string, display_name?: string, avatar_url?: string }) {
|
||||
super();
|
||||
this._userId = userDirResult.user_id;
|
||||
this.displayName = userDirResult.display_name;
|
||||
|
@ -356,6 +369,8 @@ interface IInviteDialogState {
|
|||
canUseIdentityServer: boolean;
|
||||
tryingIdentityServer: boolean;
|
||||
consultFirst: boolean;
|
||||
dialPadValue: string;
|
||||
currentTabId: TabId;
|
||||
|
||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||
busy: boolean;
|
||||
|
@ -370,7 +385,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
};
|
||||
|
||||
private closeCopiedTooltip: () => void;
|
||||
private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
|
||||
private debounceTimer: number = null; // actually number because we're in the browser
|
||||
private editorRef = createRef<HTMLInputElement>();
|
||||
private unmounted = false;
|
||||
|
||||
|
@ -407,6 +422,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
||||
tryingIdentityServer: false,
|
||||
consultFirst: false,
|
||||
dialPadValue: '',
|
||||
currentTabId: TabId.UserDirectory,
|
||||
|
||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||
busy: false,
|
||||
|
@ -768,44 +785,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
};
|
||||
|
||||
private transferCall = async () => {
|
||||
this.convertFilter();
|
||||
const targets = this.convertFilter();
|
||||
const targetIds = targets.map(t => t.userId);
|
||||
if (targetIds.length > 1) {
|
||||
this.setState({
|
||||
errorText: _t("A call can only be transferred to a single user."),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.consultFirst) {
|
||||
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'place_call',
|
||||
type: this.props.call.type,
|
||||
room_id: dmRoomId,
|
||||
transferee: this.props.call,
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: dmRoomId,
|
||||
should_peek: false,
|
||||
joining: false,
|
||||
});
|
||||
this.props.onFinished();
|
||||
} else {
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.props.call.transfer(targetIds[0]);
|
||||
this.setState({ busy: false });
|
||||
this.props.onFinished();
|
||||
} catch (e) {
|
||||
if (this.state.currentTabId == TabId.UserDirectory) {
|
||||
this.convertFilter();
|
||||
const targets = this.convertFilter();
|
||||
const targetIds = targets.map(t => t.userId);
|
||||
if (targetIds.length > 1) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: _t("Failed to transfer call"),
|
||||
errorText: _t("A call can only be transferred to a single user."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: Action.TransferCallToMatrixID,
|
||||
call: this.props.call,
|
||||
destination: targetIds[0],
|
||||
consultFirst: this.state.consultFirst,
|
||||
} as TransferCallPayload);
|
||||
} else {
|
||||
dis.dispatch({
|
||||
action: Action.TransferCallToPhoneNumber,
|
||||
call: this.props.call,
|
||||
destination: this.state.dialPadValue,
|
||||
consultFirst: this.state.consultFirst,
|
||||
} as TransferCallPayload);
|
||||
}
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
private onKeyDown = (e) => {
|
||||
|
@ -827,6 +832,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
};
|
||||
|
||||
private onCancel = () => {
|
||||
this.props.onFinished([]);
|
||||
};
|
||||
|
||||
private updateSuggestions = async (term) => {
|
||||
MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => {
|
||||
if (term !== this.state.filterText) {
|
||||
|
@ -962,11 +971,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
private toggleMember = (member: Member) => {
|
||||
if (!this.state.busy) {
|
||||
let filterText = this.state.filterText;
|
||||
const targets = this.state.targets.map(t => t); // cheap clone for mutation
|
||||
let targets = this.state.targets.map(t => t); // cheap clone for mutation
|
||||
const idx = targets.indexOf(member);
|
||||
if (idx >= 0) {
|
||||
targets.splice(idx, 1);
|
||||
} else {
|
||||
if (this.props.kind === KIND_CALL_TRANSFER && targets.length > 0) {
|
||||
targets = [];
|
||||
}
|
||||
targets.push(member);
|
||||
filterText = ""; // clear the filter when the user accepts a suggestion
|
||||
}
|
||||
|
@ -1189,6 +1201,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
|
||||
private renderEditor() {
|
||||
const hasPlaceholder = (
|
||||
this.props.kind == KIND_CALL_TRANSFER &&
|
||||
this.state.targets.length === 0 &&
|
||||
this.state.filterText.length === 0
|
||||
);
|
||||
const targets = this.state.targets.map(t => (
|
||||
<DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} />
|
||||
));
|
||||
|
@ -1201,8 +1218,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
ref={this.editorRef}
|
||||
onPaste={this.onPaste}
|
||||
autoFocus={true}
|
||||
disabled={this.state.busy}
|
||||
disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)}
|
||||
autoComplete="off"
|
||||
placeholder={hasPlaceholder ? _t("Search") : null}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
@ -1249,6 +1267,28 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
}
|
||||
|
||||
private onDialFormSubmit = ev => {
|
||||
ev.preventDefault();
|
||||
this.transferCall();
|
||||
};
|
||||
|
||||
private onDialChange = ev => {
|
||||
this.setState({ dialPadValue: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private onDigitPress = digit => {
|
||||
this.setState({ dialPadValue: this.state.dialPadValue + digit });
|
||||
};
|
||||
|
||||
private onDeletePress = () => {
|
||||
if (this.state.dialPadValue.length === 0) return;
|
||||
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
|
||||
};
|
||||
|
||||
private onTabChange = (tabId: TabId) => {
|
||||
this.setState({ currentTabId: tabId });
|
||||
};
|
||||
|
||||
private async onLinkClick(e) {
|
||||
e.preventDefault();
|
||||
selectText(e.target);
|
||||
|
@ -1278,12 +1318,16 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
let helpText;
|
||||
let buttonText;
|
||||
let goButtonFn;
|
||||
let consultConnectSection;
|
||||
let extraSection;
|
||||
let footer;
|
||||
let keySharingWarning = <span />;
|
||||
|
||||
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
|
||||
|
||||
const hasSelection = this.state.targets.length > 0
|
||||
|| (this.state.filterText && this.state.filterText.includes('@'));
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
if (this.props.kind === KIND_DM) {
|
||||
|
@ -1364,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
</div>;
|
||||
} else if (this.props.kind === KIND_INVITE) {
|
||||
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && room?.isSpaceRoom();
|
||||
title = isSpace
|
||||
? _t("Invite to %(spaceName)s", {
|
||||
spaceName: room.name || _t("Unnamed Space"),
|
||||
|
@ -1421,23 +1465,116 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
} else if (this.props.kind === KIND_CALL_TRANSFER) {
|
||||
title = _t("Transfer");
|
||||
buttonText = _t("Transfer");
|
||||
goButtonFn = this.transferCall;
|
||||
footer = <div>
|
||||
|
||||
consultConnectSection = <div className="mx_InviteDialog_transferConsultConnect">
|
||||
<label>
|
||||
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
|
||||
{_t("Consult first")}
|
||||
</label>
|
||||
<AccessibleButton
|
||||
kind="secondary"
|
||||
onClick={this.onCancel}
|
||||
className='mx_InviteDialog_transferConsultConnect_pushRight'
|
||||
>
|
||||
{_t("Cancel")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={this.transferCall}
|
||||
className='mx_InviteDialog_transferButton'
|
||||
disabled={!hasSelection && this.state.dialPadValue === ''}
|
||||
>
|
||||
{_t("Transfer")}
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
} else {
|
||||
console.error("Unknown kind of InviteDialog: " + this.props.kind);
|
||||
}
|
||||
|
||||
const hasSelection = this.state.targets.length > 0
|
||||
|| (this.state.filterText && this.state.filterText.includes('@'));
|
||||
const goButton = this.props.kind == KIND_CALL_TRANSFER ? null : <AccessibleButton
|
||||
kind="primary"
|
||||
onClick={goButtonFn}
|
||||
className='mx_InviteDialog_goButton'
|
||||
disabled={this.state.busy || !hasSelection}
|
||||
>
|
||||
{buttonText}
|
||||
</AccessibleButton>;
|
||||
|
||||
const usersSection = <React.Fragment>
|
||||
<p className='mx_InviteDialog_helpText'>{helpText}</p>
|
||||
<div className='mx_InviteDialog_addressBar'>
|
||||
{this.renderEditor()}
|
||||
<div className='mx_InviteDialog_buttonAndSpinner'>
|
||||
{goButton}
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
{keySharingWarning}
|
||||
{this.renderIdentityServerWarning()}
|
||||
<div className='error'>{this.state.errorText}</div>
|
||||
<div className='mx_InviteDialog_userSections'>
|
||||
{this.renderSection('recents')}
|
||||
{this.renderSection('suggestions')}
|
||||
{extraSection}
|
||||
</div>
|
||||
{footer}
|
||||
</React.Fragment>;
|
||||
|
||||
let dialogContent;
|
||||
if (this.props.kind === KIND_CALL_TRANSFER) {
|
||||
const tabs = [];
|
||||
tabs.push(new Tab(
|
||||
TabId.UserDirectory, _td("User Directory"), 'mx_InviteDialog_userDirectoryIcon', usersSection,
|
||||
));
|
||||
|
||||
const backspaceButton = (
|
||||
<DialPadBackspaceButton onBackspacePress={this.onDeletePress} />
|
||||
);
|
||||
|
||||
// Only show the backspace button if the field has content
|
||||
let dialPadField;
|
||||
if (this.state.dialPadValue.length !== 0) {
|
||||
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
autoFocus={true}
|
||||
onChange={this.onDialChange}
|
||||
postfixComponent={backspaceButton}
|
||||
/>;
|
||||
} else {
|
||||
dialPadField = <Field className="mx_InviteDialog_dialPadField" id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
autoFocus={true}
|
||||
onChange={this.onDialChange}
|
||||
/>;
|
||||
}
|
||||
|
||||
const dialPadSection = <div className="mx_InviteDialog_dialPad">
|
||||
<form onSubmit={this.onDialFormSubmit}>
|
||||
{dialPadField}
|
||||
</form>
|
||||
<Dialpad hasDial={false}
|
||||
onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress}
|
||||
/>
|
||||
</div>;
|
||||
tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection));
|
||||
dialogContent = <React.Fragment>
|
||||
<TabbedView tabs={tabs} initialTabId={this.state.currentTabId}
|
||||
tabLocation={TabLocation.TOP} onChange={this.onTabChange}
|
||||
/>
|
||||
{consultConnectSection}
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
dialogContent = <React.Fragment>
|
||||
{usersSection}
|
||||
{consultConnectSection}
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className={classNames("mx_InviteDialog", {
|
||||
className={classNames({
|
||||
mx_InviteDialog_transfer: this.props.kind === KIND_CALL_TRANSFER,
|
||||
mx_InviteDialog_other: this.props.kind !== KIND_CALL_TRANSFER,
|
||||
mx_InviteDialog_hasFooter: !!footer,
|
||||
})}
|
||||
hasCancel={true}
|
||||
|
@ -1445,30 +1582,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
title={title}
|
||||
>
|
||||
<div className='mx_InviteDialog_content'>
|
||||
<p className='mx_InviteDialog_helpText'>{helpText}</p>
|
||||
<div className='mx_InviteDialog_addressBar'>
|
||||
{this.renderEditor()}
|
||||
<div className='mx_InviteDialog_buttonAndSpinner'>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
onClick={goButtonFn}
|
||||
className='mx_InviteDialog_goButton'
|
||||
disabled={this.state.busy || !hasSelection}
|
||||
>
|
||||
{buttonText}
|
||||
</AccessibleButton>
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
{keySharingWarning}
|
||||
{this.renderIdentityServerWarning()}
|
||||
<div className='error'>{this.state.errorText}</div>
|
||||
<div className='mx_InviteDialog_userSections'>
|
||||
{this.renderSection('recents')}
|
||||
{this.renderSection('suggestions')}
|
||||
{extraSection}
|
||||
</div>
|
||||
{footer}
|
||||
{dialogContent}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -205,13 +205,14 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
|
|||
className="mx_ServerPickerDialog_otherHomeserverRadio"
|
||||
checked={!this.state.defaultChosen}
|
||||
onChange={this.onOtherChosen}
|
||||
childrenInLabel={false}
|
||||
>
|
||||
<Field
|
||||
type="text"
|
||||
className="mx_ServerPickerDialog_otherHomeserver"
|
||||
label={_t("Other homeserver")}
|
||||
onChange={this.onHomeserverChange}
|
||||
onClick={this.onOtherChosen}
|
||||
onFocus={this.onOtherChosen}
|
||||
ref={this.fieldRef}
|
||||
onValidate={this.onHomeserverValidate}
|
||||
value={this.state.otherHomeserver}
|
||||
|
|
|
@ -35,7 +35,7 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu.js";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||
|
||||
const socials = [
|
||||
{
|
||||
|
|
|
@ -90,9 +90,9 @@ export default class TermsDialog extends React.PureComponent<ITermsDialogProps,
|
|||
private nameForServiceType(serviceType: SERVICE_TYPES, host: string): JSX.Element {
|
||||
switch (serviceType) {
|
||||
case SERVICE_TYPES.IS:
|
||||
return <div>{_t("Identity Server")}<br />({host})</div>;
|
||||
return <div>{_t("Identity server")}<br />({host})</div>;
|
||||
case SERVICE_TYPES.IM:
|
||||
return <div>{_t("Integration Manager")}<br />({host})</div>;
|
||||
return <div>{_t("Integration manager")}<br />({host})</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import EncryptionPanel from "../right_panel/EncryptionPanel";
|
||||
import { User } from 'matrix-js-sdk';
|
||||
import { User } from 'matrix-js-sdk/src/models/user';
|
||||
|
||||
interface IProps {
|
||||
verificationRequest: VerificationRequest;
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { IProtocol } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
|
||||
|
@ -83,30 +84,6 @@ const validServer = withValidation<undefined, { error?: MatrixError }>({
|
|||
],
|
||||
});
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IFieldType {
|
||||
regexp: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export interface IInstance {
|
||||
desc: string;
|
||||
icon?: string;
|
||||
fields: object;
|
||||
network_id: string;
|
||||
// XXX: this is undocumented but we rely on it.
|
||||
instance_id: string;
|
||||
}
|
||||
|
||||
export interface IProtocol {
|
||||
user_fields: string[];
|
||||
location_fields: string[];
|
||||
icon: string;
|
||||
field_types: Record<string, IFieldType>;
|
||||
instances: IInstance[];
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export type Protocols = Record<string, IProtocol>;
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -114,7 +114,7 @@ export default class AppPermission extends React.Component {
|
|||
|
||||
// Due to i18n limitations, we can't dedupe the code for variables in these two messages.
|
||||
const warning = this.state.isWrapped
|
||||
? _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s & your Integration Manager.",
|
||||
? _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.",
|
||||
{ widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip })
|
||||
: _t("Using this widget may share data <helpIcon /> with %(widgetDomain)s.",
|
||||
{ widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip });
|
||||
|
|
31
src/components/views/elements/DialPadBackspaceButton.tsx
Normal file
31
src/components/views/elements/DialPadBackspaceButton.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
// Callback for when the button is pressed
|
||||
onBackspacePress: () => void;
|
||||
}
|
||||
|
||||
export default class DialPadBackspaceButton extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
return <div className="mx_DialPadBackspaceButtonWrapper">
|
||||
<AccessibleButton className="mx_DialPadBackspaceButton" onClick={this.props.onBackspacePress} />
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { normalizeWheelEvent } from "../../../utils/Mouse";
|
||||
import { IDialogProps } from '../dialogs/IDialogProps';
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
|
@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
|
|||
// If we have moved only this much we can zoom
|
||||
const ZOOM_DISTANCE = 10;
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends IDialogProps {
|
||||
src: string; // the source of the image being displayed
|
||||
name?: string; // the main title ('name') for the image
|
||||
link?: string; // the link (if any) applied to the name of the image
|
||||
width?: number; // width of the image src in pixels
|
||||
height?: number; // height of the image src in pixels
|
||||
fileSize?: number; // size of the image src in bytes
|
||||
onFinished(): void; // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
|
@ -488,8 +488,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
|||
>
|
||||
<img
|
||||
src={this.props.src}
|
||||
title={this.props.name}
|
||||
style={style}
|
||||
alt={this.props.name}
|
||||
ref={this.image}
|
||||
className="mx_ImageView_image"
|
||||
draggable={true}
|
||||
|
|
|
@ -32,7 +32,7 @@ interface IProps {
|
|||
hasAvatar: boolean;
|
||||
noAvatarLabel?: string;
|
||||
hasAvatarLabel?: string;
|
||||
setAvatarUrl(url: string): Promise<void>;
|
||||
setAvatarUrl(url: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => {
|
||||
|
|
|
@ -14,72 +14,72 @@ 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 * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import PropTypes from 'prop-types';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { wantsDateSeparator } from '../../../DateUtils';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { LayoutPropType } from "../../../settings/Layout";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import escapeHtml from "escape-html";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import Spinner from './Spinner';
|
||||
import ReplyTile from "../rooms/ReplyTile";
|
||||
import Pill from './Pill';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
|
||||
interface IProps {
|
||||
// the latest event in this chain of replies
|
||||
parentEv?: MatrixEvent;
|
||||
// called when the ReplyThread contents has changed, including EventTiles thereof
|
||||
onHeightChanged: () => void;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
// Specifies which layout to use.
|
||||
layout?: Layout;
|
||||
// Whether to always show a timestamp
|
||||
alwaysShowTimestamps?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// The loaded events to be rendered as linear-replies
|
||||
events: MatrixEvent[];
|
||||
// The latest loaded event which has not yet been shown
|
||||
loadedEv: MatrixEvent;
|
||||
// Whether the component is still loading more events
|
||||
loading: boolean;
|
||||
// Whether as error was encountered fetching a replied to event.
|
||||
err: boolean;
|
||||
}
|
||||
|
||||
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
||||
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
||||
// be low as each event being loaded (after the first) is triggered by an explicit user action.
|
||||
@replaceableComponent("views.elements.ReplyThread")
|
||||
export default class ReplyThread extends React.Component {
|
||||
static propTypes = {
|
||||
// the latest event in this chain of replies
|
||||
parentEv: PropTypes.instanceOf(MatrixEvent),
|
||||
// called when the ReplyThread contents has changed, including EventTiles thereof
|
||||
onHeightChanged: PropTypes.func.isRequired,
|
||||
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
|
||||
// Specifies which layout to use.
|
||||
layout: LayoutPropType,
|
||||
// Whether to always show a timestamp
|
||||
alwaysShowTimestamps: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default class ReplyThread extends React.Component<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
private unmounted = false;
|
||||
private room: Room;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
// The loaded events to be rendered as linear-replies
|
||||
events: [],
|
||||
|
||||
// The latest loaded event which has not yet been shown
|
||||
loadedEv: null,
|
||||
// Whether the component is still loading more events
|
||||
loading: true,
|
||||
|
||||
// Whether as error was encountered fetching a replied to event.
|
||||
err: false,
|
||||
};
|
||||
|
||||
this.unmounted = false;
|
||||
this.context.on("Event.replaced", this.onEventReplaced);
|
||||
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
|
||||
this.room.on("Room.redaction", this.onRoomRedaction);
|
||||
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
|
||||
|
||||
this.onQuoteClick = this.onQuoteClick.bind(this);
|
||||
this.canCollapse = this.canCollapse.bind(this);
|
||||
this.collapse = this.collapse.bind(this);
|
||||
}
|
||||
|
||||
static getParentEventId(ev) {
|
||||
public static getParentEventId(ev: MatrixEvent): string {
|
||||
if (!ev || ev.isRedacted()) return;
|
||||
|
||||
// XXX: For newer relations (annotations, replacements, etc.), we now
|
||||
|
@ -95,7 +95,7 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static stripPlainReply(body) {
|
||||
public static stripPlainReply(body: string): string {
|
||||
// Removes lines beginning with `> ` until you reach one that doesn't.
|
||||
const lines = body.split('\n');
|
||||
while (lines.length && lines[0].startsWith('> ')) lines.shift();
|
||||
|
@ -105,7 +105,7 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static stripHTMLReply(html) {
|
||||
public static stripHTMLReply(html: string): string {
|
||||
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
|
||||
// any HTML, since the original sender could use special tags that we
|
||||
// don't recognize, but want to pass along to any recipients who do
|
||||
|
@ -127,7 +127,10 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static getNestedReplyText(ev, permalinkCreator) {
|
||||
public static getNestedReplyText(
|
||||
ev: MatrixEvent,
|
||||
permalinkCreator: RoomPermalinkCreator,
|
||||
): { body: string, html: string } {
|
||||
if (!ev) return null;
|
||||
|
||||
let { body, formatted_body: html } = ev.getContent();
|
||||
|
@ -203,7 +206,7 @@ export default class ReplyThread extends React.Component {
|
|||
return { body, html };
|
||||
}
|
||||
|
||||
static makeReplyMixIn(ev) {
|
||||
public static makeReplyMixIn(ev: MatrixEvent) {
|
||||
if (!ev) return {};
|
||||
return {
|
||||
'm.relates_to': {
|
||||
|
@ -214,10 +217,15 @@ export default class ReplyThread extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) {
|
||||
if (!ReplyThread.getParentEventId(parentEv)) {
|
||||
return null;
|
||||
}
|
||||
public static makeThread(
|
||||
parentEv: MatrixEvent,
|
||||
onHeightChanged: () => void,
|
||||
permalinkCreator: RoomPermalinkCreator,
|
||||
ref: React.RefObject<ReplyThread>,
|
||||
layout: Layout,
|
||||
alwaysShowTimestamps: boolean,
|
||||
): JSX.Element {
|
||||
if (!ReplyThread.getParentEventId(parentEv)) return null;
|
||||
return <ReplyThread
|
||||
parentEv={parentEv}
|
||||
onHeightChanged={onHeightChanged}
|
||||
|
@ -238,37 +246,9 @@ export default class ReplyThread extends React.Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Event.replaced", this.onEventReplaced);
|
||||
if (this.room) {
|
||||
this.room.removeListener("Room.redaction", this.onRoomRedaction);
|
||||
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
|
||||
}
|
||||
}
|
||||
|
||||
updateForEventId = (eventId) => {
|
||||
if (this.state.events.some(event => event.getId() === eventId)) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
onEventReplaced = (ev) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// If one of the events we are rendering gets replaced, force a re-render
|
||||
this.updateForEventId(ev.getId());
|
||||
};
|
||||
|
||||
onRoomRedaction = (ev) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
const eventId = ev.getAssociatedId();
|
||||
if (!eventId) return;
|
||||
|
||||
// If one of the events we are rendering gets redacted, force a re-render
|
||||
this.updateForEventId(eventId);
|
||||
};
|
||||
|
||||
async initialize() {
|
||||
private async initialize(): Promise<void> {
|
||||
const { parentEv } = this.props;
|
||||
// at time of making this component we checked that props.parentEv has a parentEventId
|
||||
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
|
||||
|
@ -287,7 +267,7 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async getNextEvent(ev) {
|
||||
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
|
||||
try {
|
||||
const inReplyToEventId = ReplyThread.getParentEventId(ev);
|
||||
return await this.getEvent(inReplyToEventId);
|
||||
|
@ -296,7 +276,7 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async getEvent(eventId) {
|
||||
private async getEvent(eventId: string): Promise<MatrixEvent> {
|
||||
if (!eventId) return null;
|
||||
const event = this.room.findEventById(eventId);
|
||||
if (event) return event;
|
||||
|
@ -313,15 +293,15 @@ export default class ReplyThread extends React.Component {
|
|||
return this.room.findEventById(eventId);
|
||||
}
|
||||
|
||||
canCollapse() {
|
||||
public canCollapse = (): boolean => {
|
||||
return this.state.events.length > 1;
|
||||
}
|
||||
};
|
||||
|
||||
collapse() {
|
||||
public collapse = (): void => {
|
||||
this.initialize();
|
||||
}
|
||||
};
|
||||
|
||||
async onQuoteClick() {
|
||||
private onQuoteClick = async (): Promise<void> => {
|
||||
const events = [this.state.loadedEv, ...this.state.events];
|
||||
|
||||
let loadedEv = null;
|
||||
|
@ -335,6 +315,10 @@ export default class ReplyThread extends React.Component {
|
|||
});
|
||||
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
private getReplyThreadColorClass(ev: MatrixEvent): string {
|
||||
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -349,9 +333,8 @@ export default class ReplyThread extends React.Component {
|
|||
</blockquote>;
|
||||
} else if (this.state.loadedEv) {
|
||||
const ev = this.state.loadedEv;
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
const room = this.context.getRoom(ev.getRoomId());
|
||||
header = <blockquote className="mx_ReplyThread">
|
||||
header = <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`}>
|
||||
{
|
||||
_t('<a>In reply to</a> <pill>', {}, {
|
||||
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
|
||||
|
@ -367,33 +350,15 @@ export default class ReplyThread extends React.Component {
|
|||
}
|
||||
</blockquote>;
|
||||
} else if (this.state.loading) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
header = <Spinner w={16} h={16} />;
|
||||
}
|
||||
|
||||
const EventTile = sdk.getComponent('views.rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const evTiles = this.state.events.map((ev) => {
|
||||
let dateSep = null;
|
||||
|
||||
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
|
||||
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
|
||||
}
|
||||
|
||||
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
|
||||
{ dateSep }
|
||||
<EventTile
|
||||
return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
|
||||
<ReplyTile
|
||||
mxEvent={ev}
|
||||
tileShape={TileShape.Reply}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
isRedacted={ev.isRedacted()}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
layout={this.props.layout}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
replacingEventId={ev.replacingEventId()}
|
||||
as="div"
|
||||
/>
|
||||
</blockquote>;
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
const Spinner = ({ w = 32, h = 32, message }) => (
|
||||
<div className="mx_Spinner">
|
||||
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div> </React.Fragment> }
|
||||
<div
|
||||
className="mx_Spinner_icon"
|
||||
style={{ width: w, height: h }}
|
||||
aria-label={_t("Loading...")}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Spinner.propTypes = {
|
||||
w: PropTypes.number,
|
||||
h: PropTypes.number,
|
||||
message: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Spinner;
|
45
src/components/views/elements/Spinner.tsx
Normal file
45
src/components/views/elements/Spinner.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
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.
|
||||
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";
|
||||
|
||||
interface IProps {
|
||||
w?: number;
|
||||
h?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default class Spinner extends React.PureComponent<IProps> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
w: 32,
|
||||
h: 32,
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { w, h, message } = this.props;
|
||||
return (
|
||||
<div className="mx_Spinner">
|
||||
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div> </React.Fragment> }
|
||||
<div
|
||||
className="mx_Spinner_icon"
|
||||
style={{ width: w, height: h }}
|
||||
aria-label={_t("Loading...")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -20,6 +20,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
outlined?: boolean;
|
||||
// If true (default), the children will be contained within a <label> element
|
||||
// If false, they'll be in a div. Putting interactive components that have labels
|
||||
// themselves in labels can cause strange bugs like https://github.com/vector-im/element-web/issues/18031
|
||||
childrenInLabel?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -29,10 +33,11 @@ interface IState {
|
|||
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
|
||||
public static readonly defaultProps = {
|
||||
className: '',
|
||||
childrenInLabel: true,
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { children, className, disabled, outlined, ...otherProps } = this.props;
|
||||
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
|
||||
const _className = classnames(
|
||||
'mx_RadioButton',
|
||||
className,
|
||||
|
@ -42,12 +47,27 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
"mx_RadioButton_checked": this.props.checked,
|
||||
"mx_RadioButton_outlined": outlined,
|
||||
});
|
||||
return <label className={_className}>
|
||||
|
||||
const radioButton = <React.Fragment>
|
||||
<input type='radio' disabled={disabled} {...otherProps} />
|
||||
{/* Used to render the radio button circle */}
|
||||
<div><div /></div>
|
||||
<div className="mx_RadioButton_content">{children}</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</label>;
|
||||
</React.Fragment>;
|
||||
|
||||
if (childrenInLabel) {
|
||||
return <label className={_className}>
|
||||
{radioButton}
|
||||
<div className="mx_RadioButton_content">{children}</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</label>;
|
||||
} else {
|
||||
return <div className={_className}>
|
||||
<label className="mx_RadioButton_innerLabel">
|
||||
{radioButton}
|
||||
</label>
|
||||
<div className="mx_RadioButton_content">{children}</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
91
src/components/views/elements/TagComposer.tsx
Normal file
91
src/components/views/elements/TagComposer.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
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, { ChangeEvent, FormEvent } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Field from "./Field";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
tags: string[];
|
||||
onAdd: (tag: string) => void;
|
||||
onRemove: (tag: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
newTag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple, controlled, composer for entering string tags. Contains a simple
|
||||
* input, add button, and per-tag remove button.
|
||||
*/
|
||||
@replaceableComponent("views.elements.TagComposer")
|
||||
export default class TagComposer extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
newTag: "",
|
||||
};
|
||||
}
|
||||
|
||||
private onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ newTag: ev.target.value });
|
||||
};
|
||||
|
||||
private onAdd = (ev: FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!this.state.newTag) return;
|
||||
|
||||
this.props.onAdd(this.state.newTag);
|
||||
this.setState({ newTag: "" });
|
||||
};
|
||||
|
||||
private onRemove(tag: string) {
|
||||
// We probably don't need to proxy this, but for
|
||||
// sanity of `this` we'll do so anyways.
|
||||
this.props.onRemove(tag);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div className='mx_TagComposer'>
|
||||
<form className='mx_TagComposer_input' onSubmit={this.onAdd}>
|
||||
<Field
|
||||
value={this.state.newTag}
|
||||
onChange={this.onInputChange}
|
||||
label={this.props.label || _t("Keyword")}
|
||||
placeholder={this.props.placeholder || _t("New keyword")}
|
||||
disabled={this.props.disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<AccessibleButton onClick={this.onAdd} kind='primary' disabled={this.props.disabled}>
|
||||
{ _t("Add") }
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
<div className='mx_TagComposer_tags'>
|
||||
{ this.props.tags.map((t, i) => (<div className='mx_TagComposer_tag' key={i}>
|
||||
<span>{ t }</span>
|
||||
<AccessibleButton onClick={this.onRemove.bind(this, t)} disabled={this.props.disabled} />
|
||||
</div>)) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -90,6 +90,35 @@ function computedStyle(element) {
|
|||
return cssText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human readable label for the file attachment to use as
|
||||
* link text.
|
||||
*
|
||||
* @param {Object} content The "content" key of the matrix event.
|
||||
* @param {boolean} withSize Whether to include size information. Default true.
|
||||
* @return {string} the human readable link text for the attachment.
|
||||
*/
|
||||
export function presentableTextForFile(content, withSize = true) {
|
||||
let linkText = _t("Attachment");
|
||||
if (content.body && content.body.length > 0) {
|
||||
// The content body should be the name of the file including a
|
||||
// file extension.
|
||||
linkText = content.body;
|
||||
}
|
||||
|
||||
if (content.info && content.info.size && withSize) {
|
||||
// If we know the size of the file then add it as human readable
|
||||
// string to the end of the link text so that the user knows how
|
||||
// big a file they are downloading.
|
||||
// The content.info also contains a MIME-type but we don't display
|
||||
// it since it is "ugly", users generally aren't aware what it
|
||||
// means and the type of the attachment can usually be inferrered
|
||||
// from the file extension.
|
||||
linkText += ' (' + filesize(content.info.size) + ')';
|
||||
}
|
||||
return linkText;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MFileBody")
|
||||
export default class MFileBody extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -120,35 +149,6 @@ export default class MFileBody extends React.Component {
|
|||
this._dummyLink = createRef();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a human readable label for the file attachment to use as
|
||||
* link text.
|
||||
*
|
||||
* @param {Object} content The "content" key of the matrix event.
|
||||
* @param {boolean} withSize Whether to include size information. Default true.
|
||||
* @return {string} the human readable link text for the attachment.
|
||||
*/
|
||||
presentableTextForFile(content, withSize = true) {
|
||||
let linkText = _t("Attachment");
|
||||
if (content.body && content.body.length > 0) {
|
||||
// The content body should be the name of the file including a
|
||||
// file extension.
|
||||
linkText = content.body;
|
||||
}
|
||||
|
||||
if (content.info && content.info.size && withSize) {
|
||||
// If we know the size of the file then add it as human readable
|
||||
// string to the end of the link text so that the user knows how
|
||||
// big a file they are downloading.
|
||||
// The content.info also contains a MIME-type but we don't display
|
||||
// it since it is "ugly", users generally aren't aware what it
|
||||
// means and the type of the attachment can usually be inferrered
|
||||
// from the file extension.
|
||||
linkText += ' (' + filesize(content.info.size) + ')';
|
||||
}
|
||||
return linkText;
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
|
@ -162,7 +162,7 @@ export default class MFileBody extends React.Component {
|
|||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const text = this.presentableTextForFile(content);
|
||||
const text = presentableTextForFile(content);
|
||||
const isEncrypted = content.file !== undefined;
|
||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
||||
const contentUrl = this._getContentUrl();
|
||||
|
@ -174,7 +174,9 @@ export default class MFileBody extends React.Component {
|
|||
placeholder = (
|
||||
<div className="mx_MFileBody_info">
|
||||
<span className="mx_MFileBody_info_icon" />
|
||||
<span className="mx_MFileBody_info_filename">{this.presentableTextForFile(content, false)}</span>
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(content, false) }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,13 +16,11 @@ 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 React, { ComponentProps, createRef } from 'react';
|
||||
import { Blurhash } from "react-blurhash";
|
||||
|
||||
import MFileBody from './MFileBody';
|
||||
import Modal from '../../../Modal';
|
||||
import * as sdk from '../../../index';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -31,36 +29,49 @@ import InlineSpinner from '../elements/InlineSpinner';
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
|
||||
export interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: MatrixEvent;
|
||||
/* called when the image has loaded */
|
||||
onHeightChanged(): void;
|
||||
|
||||
/* the maximum image height to use */
|
||||
maxImageHeight?: number;
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
decryptedThumbnailUrl?: string;
|
||||
decryptedBlob?: Blob;
|
||||
error;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
hover: boolean;
|
||||
showImage: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MImageBody")
|
||||
export default class MImageBody extends React.Component {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
|
||||
/* called when the image has loaded */
|
||||
onHeightChanged: PropTypes.func.isRequired,
|
||||
|
||||
/* the maximum image height to use */
|
||||
maxImageHeight: PropTypes.number,
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator: PropTypes.object,
|
||||
};
|
||||
|
||||
export default class MImageBody extends React.Component<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
private unmounted = true;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.onImageError = this.onImageError.bind(this);
|
||||
this.onImageLoad = this.onImageLoad.bind(this);
|
||||
this.onImageEnter = this.onImageEnter.bind(this);
|
||||
this.onImageLeave = this.onImageLeave.bind(this);
|
||||
this.onClientSync = this.onClientSync.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this._isGif = this._isGif.bind(this);
|
||||
|
||||
this.state = {
|
||||
decryptedUrl: null,
|
||||
decryptedThumbnailUrl: null,
|
||||
|
@ -72,12 +83,10 @@ export default class MImageBody extends React.Component {
|
|||
hover: false,
|
||||
showImage: SettingsStore.getValue("showImages"),
|
||||
};
|
||||
|
||||
this._image = createRef();
|
||||
}
|
||||
|
||||
// FIXME: factor this out and apply it to MVideoBody and MAudioBody too!
|
||||
onClientSync(syncState, prevState) {
|
||||
private onClientSync = (syncState: SyncState, prevState: SyncState): void => {
|
||||
if (this.unmounted) return;
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
|
@ -88,15 +97,15 @@ export default class MImageBody extends React.Component {
|
|||
imgError: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showImage() {
|
||||
protected showImage(): void {
|
||||
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
|
||||
this.setState({ showImage: true });
|
||||
this._downloadImage();
|
||||
this.downloadImage();
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
if (ev.button === 0 && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
if (!this.state.showImage) {
|
||||
|
@ -104,12 +113,11 @@ export default class MImageBody extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const httpUrl = this._getContentUrl();
|
||||
const ImageView = sdk.getComponent("elements.ImageView");
|
||||
const params = {
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const httpUrl = this.getContentUrl();
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t('Attachment'),
|
||||
name: content.body?.length > 0 ? content.body : _t('Attachment'),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
@ -122,58 +130,54 @@ export default class MImageBody extends React.Component {
|
|||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_isGif() {
|
||||
private isGif = (): boolean => {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
return (
|
||||
content &&
|
||||
content.info &&
|
||||
content.info.mimetype === "image/gif"
|
||||
);
|
||||
}
|
||||
return content.info?.mimetype === "image/gif";
|
||||
};
|
||||
|
||||
onImageEnter(e) {
|
||||
private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
|
||||
this.setState({ hover: true });
|
||||
|
||||
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
return;
|
||||
}
|
||||
const imgElement = e.target;
|
||||
imgElement.src = this._getContentUrl();
|
||||
}
|
||||
const imgElement = e.currentTarget;
|
||||
imgElement.src = this.getContentUrl();
|
||||
};
|
||||
|
||||
onImageLeave(e) {
|
||||
private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
|
||||
this.setState({ hover: false });
|
||||
|
||||
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
return;
|
||||
}
|
||||
const imgElement = e.target;
|
||||
imgElement.src = this._getThumbUrl();
|
||||
}
|
||||
const imgElement = e.currentTarget;
|
||||
imgElement.src = this.getThumbUrl();
|
||||
};
|
||||
|
||||
onImageError() {
|
||||
private onImageError = (): void => {
|
||||
this.setState({
|
||||
imgError: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onImageLoad() {
|
||||
private onImageLoad = (): void => {
|
||||
this.props.onHeightChanged();
|
||||
|
||||
let loadedImageDimensions;
|
||||
|
||||
if (this._image.current) {
|
||||
const { naturalWidth, naturalHeight } = this._image.current;
|
||||
if (this.image.current) {
|
||||
const { naturalWidth, naturalHeight } = this.image.current;
|
||||
// this is only used as a fallback in case content.info.w/h is missing
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
}
|
||||
};
|
||||
|
||||
_getContentUrl() {
|
||||
protected getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
if (media.isEncrypted) {
|
||||
return this.state.decryptedUrl;
|
||||
|
@ -182,7 +186,7 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_getThumbUrl() {
|
||||
protected getThumbUrl(): string {
|
||||
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
|
@ -190,7 +194,7 @@ export default class MImageBody extends React.Component {
|
|||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const media = mediaFromContent(content);
|
||||
|
||||
if (media.isEncrypted) {
|
||||
|
@ -218,7 +222,7 @@ export default class MImageBody extends React.Component {
|
|||
// - If there's no sizing info in the event, default to thumbnail
|
||||
const info = content.info;
|
||||
if (
|
||||
this._isGif() ||
|
||||
this.isGif() ||
|
||||
window.devicePixelRatio === 1.0 ||
|
||||
(!info || !info.w || !info.h || !info.size)
|
||||
) {
|
||||
|
@ -253,7 +257,7 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_downloadImage() {
|
||||
private downloadImage(): void {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
|
@ -297,7 +301,7 @@ export default class MImageBody extends React.Component {
|
|||
|
||||
if (showImage) {
|
||||
// Don't download anything becaue we don't want to display anything.
|
||||
this._downloadImage();
|
||||
this.downloadImage();
|
||||
this.setState({ showImage: true });
|
||||
}
|
||||
|
||||
|
@ -312,7 +316,6 @@ export default class MImageBody extends React.Component {
|
|||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener('sync', this.onClientSync);
|
||||
this._afterComponentWillUnmount();
|
||||
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
|
@ -322,12 +325,12 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
// To be overridden by subclasses (e.g. MStickerBody) for further
|
||||
// cleanup after componentWillUnmount
|
||||
_afterComponentWillUnmount() {
|
||||
}
|
||||
|
||||
_messageContent(contentUrl, thumbUrl, content) {
|
||||
protected messageContent(
|
||||
contentUrl: string,
|
||||
thumbUrl: string,
|
||||
content: IMediaEventContent,
|
||||
forcedHeight?: number,
|
||||
): JSX.Element {
|
||||
let infoWidth;
|
||||
let infoHeight;
|
||||
|
||||
|
@ -348,7 +351,7 @@ export default class MImageBody extends React.Component {
|
|||
imageElement = <HiddenImagePlaceholder />;
|
||||
} else {
|
||||
imageElement = (
|
||||
<img style={{ display: 'none' }} src={thumbUrl} ref={this._image}
|
||||
<img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
|
@ -362,7 +365,7 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
|
||||
// The maximum height of the thumbnail as it is rendered as an <img>
|
||||
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight);
|
||||
const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
|
||||
// The maximum width of the thumbnail, as dictated by its natural
|
||||
// maximum height.
|
||||
const maxWidth = infoWidth * maxHeight / infoHeight;
|
||||
|
@ -382,7 +385,7 @@ export default class MImageBody extends React.Component {
|
|||
// which has the same width as the timeline
|
||||
// mx_MImageBody_thumbnail resizes img to exactly container size
|
||||
img = (
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image}
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
|
||||
style={{ maxWidth: maxWidth + "px" }}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
|
@ -393,18 +396,18 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
|
||||
if (!this.state.showImage) {
|
||||
img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />;
|
||||
img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
|
||||
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
|
||||
}
|
||||
|
||||
if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
|
||||
if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
|
||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
const thumbnail = (
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} >
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
|
||||
{ /* Calculate aspect ratio, using %padding will size _container correctly */ }
|
||||
<div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} />
|
||||
<div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
|
||||
{ showPlaceholder &&
|
||||
<div className="mx_MImageBody_thumbnail" style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
|
@ -427,14 +430,14 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
wrapImage(contentUrl, children) {
|
||||
protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
|
||||
return <a href={contentUrl} onClick={this.onClick}>
|
||||
{children}
|
||||
</a>;
|
||||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
getPlaceholder(width, height) {
|
||||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
|
||||
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
|
||||
return <div className="mx_MImageBody_thumbnail_spinner">
|
||||
|
@ -443,17 +446,17 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
getTooltip() {
|
||||
protected getTooltip(): JSX.Element {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
getFileBody() {
|
||||
protected getFileBody(): JSX.Element {
|
||||
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
|
@ -464,15 +467,15 @@ export default class MImageBody extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
const contentUrl = this._getContentUrl();
|
||||
const contentUrl = this.getContentUrl();
|
||||
let thumbUrl;
|
||||
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this._getThumbUrl();
|
||||
thumbUrl = this.getThumbUrl();
|
||||
}
|
||||
|
||||
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);
|
||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return <span className="mx_MImageBody">
|
||||
|
@ -482,16 +485,18 @@ export default class MImageBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export class HiddenImagePlaceholder extends React.PureComponent {
|
||||
static propTypes = {
|
||||
hover: PropTypes.bool,
|
||||
};
|
||||
interface PlaceholderIProps {
|
||||
hover?: boolean;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProps> {
|
||||
render() {
|
||||
const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null;
|
||||
let className = 'mx_HiddenImagePlaceholder';
|
||||
if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={className} style={{ maxWidth: maxWidth }}>
|
||||
<div className='mx_HiddenImagePlaceholder_button'>
|
||||
<span className='mx_HiddenImagePlaceholder_eye' />
|
||||
<span>{_t("Show image")}</span>
|
62
src/components/views/messages/MImageReplyBody.tsx
Normal file
62
src/components/views/messages/MImageReplyBody.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
|
||||
|
||||
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 MImageBody from "./MImageBody";
|
||||
import { presentableTextForFile } from "./MFileBody";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import SenderProfile from "./SenderProfile";
|
||||
|
||||
const FORCED_IMAGE_HEIGHT = 44;
|
||||
|
||||
export default class MImageReplyBody extends MImageBody {
|
||||
public onClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Don't show "Download this_file.png ..."
|
||||
public getFileBody(): JSX.Element {
|
||||
return presentableTextForFile(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error !== null) {
|
||||
return super.render();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
|
||||
const contentUrl = this.getContentUrl();
|
||||
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
|
||||
const fileBody = this.getFileBody();
|
||||
const sender = <SenderProfile
|
||||
mxEvent={this.props.mxEvent}
|
||||
enableFlair={false}
|
||||
/>;
|
||||
|
||||
return <div className="mx_MImageReplyBody">
|
||||
{ thumbnail }
|
||||
<div className="mx_MImageReplyBody_info">
|
||||
<div className="mx_MImageReplyBody_sender">{ sender }</div>
|
||||
<div className="mx_MImageReplyBody_filename">{ fileBody }</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -47,6 +47,10 @@ export default class MessageEvent extends React.Component {
|
|||
/* the maximum image height to use, if the event is an image */
|
||||
maxImageHeight: PropTypes.number,
|
||||
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes: PropTypes.object,
|
||||
overrideEventTypes: PropTypes.object,
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator: PropTypes.object,
|
||||
};
|
||||
|
@ -74,9 +78,12 @@ export default class MessageEvent extends React.Component {
|
|||
'm.file': sdk.getComponent('messages.MFileBody'),
|
||||
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
|
||||
'm.video': sdk.getComponent('messages.MVideoBody'),
|
||||
|
||||
...(this.props.overrideBodyTypes || {}),
|
||||
};
|
||||
const evTypes = {
|
||||
'm.sticker': sdk.getComponent('messages.MStickerBody'),
|
||||
...(this.props.overrideEventTypes || {}),
|
||||
};
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
|
@ -113,7 +120,7 @@ export default class MessageEvent extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
return <BodyType
|
||||
return BodyType ? <BodyType
|
||||
ref={this._body}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
|
@ -126,6 +133,6 @@ export default class MessageEvent extends React.Component {
|
|||
onHeightChanged={this.props.onHeightChanged}
|
||||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>;
|
||||
/> : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,14 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import Flair from '../elements/Flair';
|
||||
import FlairStore from '../../../stores/FlairStore';
|
||||
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -36,7 +38,7 @@ interface IState {
|
|||
@replaceableComponent("views.messages.SenderProfile")
|
||||
export default class SenderProfile extends React.Component<IProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
private unmounted: boolean;
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -49,8 +51,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this._updateRelatedGroups();
|
||||
this.updateRelatedGroups();
|
||||
|
||||
if (this.state.userGroups.length === 0) {
|
||||
this.getPublicisedGroups();
|
||||
|
@ -64,35 +65,29 @@ export default class SenderProfile extends React.Component<IProps, IState> {
|
|||
this.context.removeListener('RoomState.events', this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
async getPublicisedGroups() {
|
||||
if (!this.unmounted) {
|
||||
const userGroups = await FlairStore.getPublicisedGroupsCached(
|
||||
this.context, this.props.mxEvent.getSender(),
|
||||
);
|
||||
this.setState({ userGroups });
|
||||
}
|
||||
private async getPublicisedGroups() {
|
||||
const userGroups = await FlairStore.getPublicisedGroupsCached(this.context, this.props.mxEvent.getSender());
|
||||
if (this.unmounted) return;
|
||||
this.setState({ userGroups });
|
||||
}
|
||||
|
||||
onRoomStateEvents = event => {
|
||||
if (event.getType() === 'm.room.related_groups' &&
|
||||
event.getRoomId() === this.props.mxEvent.getRoomId()
|
||||
) {
|
||||
this._updateRelatedGroups();
|
||||
private onRoomStateEvents = (event: MatrixEvent) => {
|
||||
if (event.getType() === 'm.room.related_groups' && event.getRoomId() === this.props.mxEvent.getRoomId()) {
|
||||
this.updateRelatedGroups();
|
||||
}
|
||||
};
|
||||
|
||||
_updateRelatedGroups() {
|
||||
if (this.unmounted) return;
|
||||
private updateRelatedGroups() {
|
||||
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
|
||||
this.setState({
|
||||
relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [],
|
||||
relatedGroups: relatedGroupsEvent?.getContent().groups || [],
|
||||
});
|
||||
}
|
||||
|
||||
_getDisplayedGroups(userGroups, relatedGroups) {
|
||||
private getDisplayedGroups(userGroups?: string[], relatedGroups?: string[]) {
|
||||
let displayedGroups = userGroups || [];
|
||||
if (relatedGroups && relatedGroups.length > 0) {
|
||||
displayedGroups = relatedGroups.filter((groupId) => {
|
||||
|
@ -113,7 +108,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
|
|||
const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || "";
|
||||
const mxid = mxEvent.sender?.userId || mxEvent.getSender() || "";
|
||||
|
||||
if (msgtype === 'm.emote') {
|
||||
if (msgtype === MsgType.Emote) {
|
||||
return null; // emote message must include the name so don't duplicate it
|
||||
}
|
||||
|
||||
|
@ -128,7 +123,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
|
|||
|
||||
let flair;
|
||||
if (this.props.enableFlair) {
|
||||
const displayedGroups = this._getDisplayedGroups(
|
||||
const displayedGroups = this.getDisplayedGroups(
|
||||
this.state.userGroups, this.state.relatedGroups,
|
||||
);
|
||||
|
||||
|
|
|
@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import * as TextForEvent from "../../../TextForEvent";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
|
@ -26,11 +27,11 @@ interface IProps {
|
|||
|
||||
@replaceableComponent("views.messages.TextualEvent")
|
||||
export default class TextualEvent extends React.Component<IProps> {
|
||||
render() {
|
||||
const text = TextForEvent.textForEvent(this.props.mxEvent, true);
|
||||
if (!text || (text as string).length === 0) return null;
|
||||
return (
|
||||
<div className="mx_TextualEvent">{ text }</div>
|
||||
);
|
||||
static contextType = RoomContext;
|
||||
|
||||
public render() {
|
||||
const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEventsInTimeline);
|
||||
if (!text) return null;
|
||||
return <div className="mx_TextualEvent">{ text }</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,6 +69,7 @@ import RoomName from "../elements/RoomName";
|
|||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
|
||||
export interface IDevice {
|
||||
deviceId: string;
|
||||
|
@ -728,7 +729,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
|
|||
// if muting self, warn as it may be irreversible
|
||||
if (target === cli.getUserId()) {
|
||||
try {
|
||||
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
|
||||
if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
return;
|
||||
|
@ -817,7 +818,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
|||
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
|
||||
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
|
||||
}
|
||||
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
|
||||
if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
|
||||
redactButton = (
|
||||
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
|
||||
);
|
||||
|
@ -1096,7 +1097,7 @@ const PowerLevelEditor: React.FC<{
|
|||
} else if (myUserId === target) {
|
||||
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
|
||||
try {
|
||||
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
|
||||
if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
|
||||
} catch (e) {
|
||||
console.error("Failed to warn about self demotion: ", e);
|
||||
}
|
||||
|
@ -1326,10 +1327,10 @@ const BasicUserInfo: React.FC<{
|
|||
if (!isRoomEncrypted) {
|
||||
if (!cryptoEnabled) {
|
||||
text = _t("This client does not support end-to-end encryption.");
|
||||
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
|
||||
} else if (room && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
|
||||
text = _t("Messages in this room are not end-to-end encrypted.");
|
||||
}
|
||||
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) {
|
||||
} else if (!SpaceStore.spacesEnabled || !room.isSpaceRoom()) {
|
||||
text = _t("Messages in this room are end-to-end encrypted.");
|
||||
}
|
||||
|
||||
|
@ -1405,7 +1406,7 @@ const BasicUserInfo: React.FC<{
|
|||
canInvite={roomPermissions.canInvite}
|
||||
isIgnored={isIgnored}
|
||||
member={member as RoomMember}
|
||||
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
|
||||
isSpace={SpaceStore.spacesEnabled && room?.isSpaceRoom()}
|
||||
/>
|
||||
|
||||
{ adminToolsContainer }
|
||||
|
@ -1568,7 +1569,7 @@ const UserInfo: React.FC<IProps> = ({
|
|||
previousPhase = RightPanelPhases.RoomMemberInfo;
|
||||
refireParams = { member: member };
|
||||
} else if (room) {
|
||||
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
|
||||
previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom()
|
||||
? RightPanelPhases.SpaceMemberList
|
||||
: RightPanelPhases.RoomMemberList;
|
||||
}
|
||||
|
@ -1617,7 +1618,7 @@ const UserInfo: React.FC<IProps> = ({
|
|||
}
|
||||
|
||||
let scopeHeader;
|
||||
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
|
||||
if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
|
||||
scopeHeader = <div className="mx_RightPanel_scopeHeader">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
<RoomName room={room} />
|
||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Visibility } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
@ -50,7 +51,7 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
|
|||
|
||||
client.setRoomDirectoryVisibility(
|
||||
this.props.roomId,
|
||||
newValue ? 'public' : 'private',
|
||||
newValue ? Visibility.Public : Visibility.Private,
|
||||
).catch(() => {
|
||||
// Roll back the local echo on the change
|
||||
this.setState({ isRoomPublished: valueBefore });
|
||||
|
|
|
@ -55,7 +55,7 @@ interface IState {
|
|||
export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||
autocompleter: Autocompleter;
|
||||
queryRequested: string;
|
||||
debounceCompletionsRequest: NodeJS.Timeout;
|
||||
debounceCompletionsRequest: number;
|
||||
private containerRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
|
|
|
@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler';
|
|||
import { hasText } from "../../../TextForEvent";
|
||||
import * as sdk from "../../../index";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { formatTime } from "../../../DateUtils";
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton';
|
|||
import ReadReceiptMarker from "./ReadReceiptMarker";
|
||||
import MessageActionBar from "../messages/MessageActionBar";
|
||||
import ReactionsRow from '../messages/ReactionsRow';
|
||||
import { getEventDisplayInfo } from '../../../utils/EventUtils';
|
||||
|
||||
const eventTileTypes = {
|
||||
[EventType.RoomMessage]: 'messages.MessageEvent',
|
||||
|
@ -190,8 +190,6 @@ export interface IReadReceiptProps {
|
|||
export enum TileShape {
|
||||
Notif = "notif",
|
||||
FileGrid = "file_grid",
|
||||
Reply = "reply",
|
||||
ReplyPreview = "reply_preview",
|
||||
Pinned = "pinned",
|
||||
}
|
||||
|
||||
|
@ -323,7 +321,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
private suppressReadReceiptAnimation: boolean;
|
||||
private isListeningForReceipts: boolean;
|
||||
private tile = React.createRef();
|
||||
private replyThread = React.createRef();
|
||||
private replyThread = React.createRef<ReplyThread>();
|
||||
|
||||
public readonly ref = createRef<HTMLElement>();
|
||||
|
||||
|
@ -853,35 +851,9 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
||||
const msgtype = this.props.mxEvent.getContent().msgtype;
|
||||
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
|
||||
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const msgtype = content.msgtype;
|
||||
const eventType = this.props.mxEvent.getType();
|
||||
|
||||
let tileHandler = getHandlerTile(this.props.mxEvent);
|
||||
|
||||
// Info messages are basically information about commands processed on a room
|
||||
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
|
||||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
|
||||
(eventType === EventType.RoomCreate) ||
|
||||
(eventType === EventType.RoomEncryption) ||
|
||||
(tileHandler === "messages.MJitsiWidgetEvent");
|
||||
let isInfoMessage = (
|
||||
!isBubbleMessage && eventType !== EventType.RoomMessage &&
|
||||
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
|
||||
);
|
||||
|
||||
// If we're showing hidden events in the timeline, we should use the
|
||||
// source tile when there's no regular tile for an event and also for
|
||||
// replace relations (which otherwise would display as a confusing
|
||||
// duplicate of the thing they are replacing).
|
||||
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
|
||||
tileHandler = "messages.ViewSourceEvent";
|
||||
isBubbleMessage = false;
|
||||
// Reuse info message avatar and sender profile styling
|
||||
isInfoMessage = true;
|
||||
}
|
||||
// This shouldn't happen: the caller should check we support this type
|
||||
// before trying to instantiate us
|
||||
if (!tileHandler) {
|
||||
|
@ -985,11 +957,7 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (needsSenderProfile && this.props.hideSender !== true) {
|
||||
if (
|
||||
!this.props.tileShape
|
||||
|| this.props.tileShape === TileShape.Reply
|
||||
|| this.props.tileShape === TileShape.ReplyPreview
|
||||
) {
|
||||
if (!this.props.tileShape) {
|
||||
sender = <SenderProfile onClick={this.onSenderProfileClick}
|
||||
mxEvent={this.props.mxEvent}
|
||||
enableFlair={this.props.enableFlair}
|
||||
|
@ -1143,44 +1111,6 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
]);
|
||||
}
|
||||
|
||||
case TileShape.Reply:
|
||||
case TileShape.ReplyPreview: {
|
||||
let thread;
|
||||
if (this.props.tileShape === TileShape.ReplyPreview) {
|
||||
thread = ReplyThread.makeThread(
|
||||
this.props.mxEvent,
|
||||
this.props.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this.replyThread,
|
||||
null,
|
||||
this.props.alwaysShowTimestamps || this.state.hover,
|
||||
);
|
||||
}
|
||||
return React.createElement(this.props.as || "li", {
|
||||
"className": classes,
|
||||
"aria-live": ariaLive,
|
||||
"aria-atomic": true,
|
||||
"data-scroll-tokens": scrollToken,
|
||||
}, [
|
||||
ircTimestamp,
|
||||
avatar,
|
||||
sender,
|
||||
ircPadlock,
|
||||
<div className="mx_EventTile_reply" key="mx_EventTile_reply">
|
||||
{ groupTimestamp }
|
||||
{ groupPadlock }
|
||||
{ thread }
|
||||
<EventTileType ref={this.tile}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
replacingEventId={this.props.replacingEventId}
|
||||
showUrlPreview={false}
|
||||
/>
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
default: {
|
||||
const thread = ReplyThread.makeThread(
|
||||
this.props.mxEvent,
|
||||
|
@ -1207,10 +1137,10 @@ export default class EventTile extends React.Component<IProps, IState> {
|
|||
"data-has-reply": !!thread,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
}, [
|
||||
ircTimestamp,
|
||||
sender,
|
||||
ircPadlock,
|
||||
}, <>
|
||||
{ ircTimestamp }
|
||||
{ sender }
|
||||
{ ircPadlock }
|
||||
<div className="mx_EventTile_line" key="mx_EventTile_line">
|
||||
{ groupTimestamp }
|
||||
{ groupPadlock }
|
||||
|
@ -1245,7 +1175,7 @@ function isMessageEvent(ev) {
|
|||
return (messageTypes.includes(ev.getType()));
|
||||
}
|
||||
|
||||
export function haveTileForEvent(e) {
|
||||
export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
|
||||
// Only messages have a tile (black-rectangle) if redacted
|
||||
if (e.isRedacted() && !isMessageEvent(e)) return false;
|
||||
|
||||
|
@ -1255,7 +1185,7 @@ export function haveTileForEvent(e) {
|
|||
const handler = getHandlerTile(e);
|
||||
if (handler === undefined) return false;
|
||||
if (handler === 'messages.TextualEvent') {
|
||||
return hasText(e);
|
||||
return hasText(e, showHiddenEvents);
|
||||
} else if (handler === 'messages.RoomCreate') {
|
||||
return Boolean(e.getContent()['predecessor']);
|
||||
} else {
|
||||
|
|
|
@ -40,10 +40,12 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
|
|||
|
||||
const ts = mxEvent.getTs();
|
||||
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
|
||||
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => {
|
||||
return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => {
|
||||
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
|
||||
try {
|
||||
return [link, await cli.getUrlPreview(link, ts)];
|
||||
} catch (error) {
|
||||
console.error("Failed to get URL preview: " + error);
|
||||
});
|
||||
}
|
||||
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
|
||||
}, [links, ts], []);
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ import EntityTile from "./EntityTile";
|
|||
import MemberTile from "./MemberTile";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import { throttle } from 'lodash';
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
|
||||
const INITIAL_LOAD_NUM_MEMBERS = 30;
|
||||
const INITIAL_LOAD_NUM_INVITED = 5;
|
||||
|
@ -509,7 +510,7 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
|
||||
if (chat && chat.roomId === this.props.roomId) {
|
||||
inviteButtonText = _t("Invite to this community");
|
||||
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
|
||||
} else if (SpaceStore.spacesEnabled && room.isSpaceRoom()) {
|
||||
inviteButtonText = _t("Invite to this space");
|
||||
}
|
||||
|
||||
|
@ -549,7 +550,7 @@ export default class MemberList extends React.Component<IProps, IState> {
|
|||
let previousPhase = RightPanelPhases.RoomSummary;
|
||||
// We have no previousPhase for when viewing a MemberList from a Space
|
||||
let scopeHeader;
|
||||
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
|
||||
if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
|
||||
previousPhase = undefined;
|
||||
scopeHeader = <div className="mx_RightPanel_scopeHeader">
|
||||
<RoomAvatar room={room} height={32} width={32} />
|
||||
|
|
|
@ -16,15 +16,13 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import PropTypes from "prop-types";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { TileShape } from "./EventTile";
|
||||
import ReplyTile from './ReplyTile';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
|
||||
function cancelQuoting() {
|
||||
dis.dispatch({
|
||||
|
@ -33,47 +31,50 @@ function cancelQuoting() {
|
|||
});
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
event: MatrixEvent;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.ReplyPreview")
|
||||
export default class ReplyPreview extends React.Component {
|
||||
static propTypes = {
|
||||
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
|
||||
};
|
||||
export default class ReplyPreview extends React.Component<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private readonly roomStoreToken: EventSubscription;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.unmounted = false;
|
||||
|
||||
this.state = {
|
||||
event: RoomViewStore.getQuotingEvent(),
|
||||
};
|
||||
|
||||
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
|
||||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
|
||||
// Remove RoomStore listener
|
||||
if (this._roomStoreToken) {
|
||||
this._roomStoreToken.remove();
|
||||
if (this.roomStoreToken) {
|
||||
this.roomStoreToken.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomViewStoreUpdate() {
|
||||
private onRoomViewStoreUpdate = (): void => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
const event = RoomViewStore.getQuotingEvent();
|
||||
if (this.state.event !== event) {
|
||||
this.setState({ event });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.event) return null;
|
||||
|
||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
|
||||
return <div className="mx_ReplyPreview">
|
||||
<div className="mx_ReplyPreview_section">
|
||||
<div className="mx_ReplyPreview_header mx_ReplyPreview_title">
|
||||
|
@ -89,15 +90,12 @@ export default class ReplyPreview extends React.Component {
|
|||
/>
|
||||
</div>
|
||||
<div className="mx_ReplyPreview_clear" />
|
||||
<EventTile
|
||||
alwaysShowTimestamps={true}
|
||||
tileShape={TileShape.ReplyPreview}
|
||||
mxEvent={this.state.event}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
as="div"
|
||||
/>
|
||||
<div className="mx_ReplyPreview_tile">
|
||||
<ReplyTile
|
||||
mxEvent={this.state.event}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
155
src/components/views/rooms/ReplyTile.tsx
Normal file
155
src/components/views/rooms/ReplyTile.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
|
||||
|
||||
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 dis from '../../../dispatcher/dispatcher';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import SenderProfile from "../messages/SenderProfile";
|
||||
import MImageReplyBody from "../messages/MImageReplyBody";
|
||||
import * as sdk from '../../../index';
|
||||
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { replaceableComponent } from '../../../utils/replaceableComponent';
|
||||
import { getEventDisplayInfo } from '../../../utils/EventUtils';
|
||||
import MFileBody from "../messages/MFileBody";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
highlights?: string[];
|
||||
highlightLink?: string;
|
||||
onHeightChanged?(): void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.ReplyTile")
|
||||
export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
static defaultProps = {
|
||||
onHeightChanged: () => {},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
|
||||
this.props.mxEvent.on("Event.beforeRedaction", this.onEventRequiresUpdate);
|
||||
this.props.mxEvent.on("Event.replaced", this.onEventRequiresUpdate);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
|
||||
this.props.mxEvent.removeListener("Event.beforeRedaction", this.onEventRequiresUpdate);
|
||||
this.props.mxEvent.removeListener("Event.replaced", this.onEventRequiresUpdate);
|
||||
}
|
||||
|
||||
private onDecrypted = (): void => {
|
||||
this.forceUpdate();
|
||||
if (this.props.onHeightChanged) {
|
||||
this.props.onHeightChanged();
|
||||
}
|
||||
};
|
||||
|
||||
private onEventRequiresUpdate = (): void => {
|
||||
// Force update when necessary - redactions and edits
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onClick = (e: React.MouseEvent): void => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const msgType = mxEvent.getContent().msgtype;
|
||||
const evType = mxEvent.getType() as EventType;
|
||||
|
||||
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
|
||||
// This shouldn't happen: the caller should check we support this type
|
||||
// before trying to instantiate us
|
||||
if (!tileHandler) {
|
||||
const { mxEvent } = this.props;
|
||||
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
|
||||
return <div className="mx_ReplyTile mx_ReplyTile_info mx_MNoticeBody">
|
||||
{ _t('This event could not be displayed') }
|
||||
</div>;
|
||||
}
|
||||
|
||||
const EventTileType = sdk.getComponent(tileHandler);
|
||||
|
||||
const classes = classNames("mx_ReplyTile", {
|
||||
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
|
||||
mx_ReplyTile_audio: msgType === MsgType.Audio,
|
||||
mx_ReplyTile_video: msgType === MsgType.Video,
|
||||
});
|
||||
|
||||
let permalink = "#";
|
||||
if (this.props.permalinkCreator) {
|
||||
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
let sender;
|
||||
const needsSenderProfile = (
|
||||
!isInfoMessage &&
|
||||
msgType !== MsgType.Image &&
|
||||
tileHandler !== EventType.RoomCreate &&
|
||||
evType !== EventType.Sticker
|
||||
);
|
||||
|
||||
if (needsSenderProfile) {
|
||||
sender = <SenderProfile
|
||||
mxEvent={this.props.mxEvent}
|
||||
enableFlair={false}
|
||||
/>;
|
||||
}
|
||||
|
||||
const msgtypeOverrides = {
|
||||
[MsgType.Image]: MImageReplyBody,
|
||||
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
|
||||
[MsgType.Audio]: MFileBody,
|
||||
[MsgType.Video]: MFileBody,
|
||||
};
|
||||
const evOverrides = {
|
||||
// Use MImageReplyBody so that the sticker isn't taking up a lot of space
|
||||
[EventType.Sticker]: MImageReplyBody,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<a href={permalink} onClick={this.onClick}>
|
||||
{ sender }
|
||||
<EventTileType
|
||||
ref="tile"
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
showUrlPreview={false}
|
||||
overrideBodyTypes={msgtypeOverrides}
|
||||
overrideEventTypes={evOverrides}
|
||||
replacingEventId={this.props.mxEvent.replacingEventId()}
|
||||
maxImageHeight={96} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -417,7 +417,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
|
||||
if (SettingsStore.getValue("feature_spaces")) return [];
|
||||
if (SpaceStore.spacesEnabled) return [];
|
||||
// TODO: Put community invites in a more sensible place (not in the room list)
|
||||
// See https://github.com/vector-im/element-web/issues/14456
|
||||
return MatrixClientPeg.get().getGroups().filter(g => {
|
||||
|
|
|
@ -408,10 +408,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
|
|||
this.setState({ addRoomContextMenuPosition: null });
|
||||
};
|
||||
|
||||
private onUnreadFirstChanged = async () => {
|
||||
private onUnreadFirstChanged = () => {
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
|
||||
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
|
||||
RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
|
||||
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
|
||||
};
|
||||
|
||||
|
|
|
@ -358,6 +358,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
this.setState({ generalMenuPosition: null }); // hide the menu
|
||||
};
|
||||
|
||||
private onCopyRoomClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
action: 'copy_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({ generalMenuPosition: null }); // hide the menu
|
||||
};
|
||||
|
||||
private onInviteClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -408,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
>
|
||||
<IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuRadio
|
||||
label={_t("Use default")}
|
||||
label={_t("Global")}
|
||||
active={state === ALL_MESSAGES}
|
||||
iconClassName="mx_RoomTile_iconBell"
|
||||
onClick={this.onClickAllNotifs}
|
||||
|
@ -517,6 +528,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
iconClassName="mx_RoomTile_iconInvite"
|
||||
/>
|
||||
) : null}
|
||||
<IconizedContextMenuOption
|
||||
onClick={this.onCopyRoomClick}
|
||||
label={_t("Copy Room Link")}
|
||||
iconClassName="mx_RoomTile_iconCopyLink"
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
onClick={this.onOpenRoomSettings}
|
||||
label={_t("Settings")}
|
||||
|
|
|
@ -15,14 +15,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
import EventTile, { haveTileForEvent } from "./EventTile";
|
||||
import DateSeparator from '../messages/DateSeparator';
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import DateSeparator from "../messages/DateSeparator";
|
||||
import EventTile, { haveTileForEvent } from "./EventTile";
|
||||
|
||||
interface IProps {
|
||||
// a matrix-js-sdk SearchResult containing the details of this result
|
||||
|
@ -37,6 +38,8 @@ interface IProps {
|
|||
|
||||
@replaceableComponent("views.rooms.SearchResultTile")
|
||||
export default class SearchResultTile extends React.Component<IProps> {
|
||||
static contextType = RoomContext;
|
||||
|
||||
public render() {
|
||||
const result = this.props.searchResult;
|
||||
const mxEv = result.context.getEvent();
|
||||
|
@ -44,7 +47,10 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
|
||||
const ts1 = mxEv.getTs();
|
||||
const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
|
||||
const layout = SettingsStore.getValue("layout");
|
||||
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
|
||||
const enableFlair = SettingsStore.getValue(UIFeature.Flair);
|
||||
|
||||
const timeline = result.context.getTimeline();
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
|
@ -54,26 +60,25 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
if (!contextual) {
|
||||
highlights = this.props.searchHighlights;
|
||||
}
|
||||
if (haveTileForEvent(ev)) {
|
||||
ret.push((
|
||||
if (haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline)) {
|
||||
ret.push(
|
||||
<EventTile
|
||||
key={`${eventId}+${j}`}
|
||||
mxEvent={ev}
|
||||
layout={layout}
|
||||
contextual={contextual}
|
||||
highlights={highlights}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
highlightLink={this.props.resultLink}
|
||||
onHeightChanged={this.props.onHeightChanged}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
isTwelveHour={isTwelveHour}
|
||||
alwaysShowTimestamps={alwaysShowTimestamps}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
/>
|
||||
));
|
||||
enableFlair={enableFlair}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<li data-scroll-tokens={eventId}>
|
||||
{ ret }
|
||||
</li>);
|
||||
|
||||
return <li data-scroll-tokens={eventId}>{ ret }</li>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -224,7 +224,7 @@ export default class Stickerpicker extends React.PureComponent {
|
|||
}
|
||||
|
||||
_getStickerpickerContent() {
|
||||
// Handle Integration Manager errors
|
||||
// Handle integration manager errors
|
||||
if (this.state._imError) {
|
||||
return this._errorStickerpickerContent();
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ import { isValid3pidInvite } from "../../../RoomInvite";
|
|||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import RoomName from "../elements/RoomName";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import ErrorDialog from '../dialogs/ErrorDialog';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
|
||||
interface IProps {
|
||||
event: MatrixEvent;
|
||||
|
@ -134,7 +134,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
|
|||
}
|
||||
|
||||
let scopeHeader;
|
||||
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
|
||||
if (SpaceStore.spacesEnabled && this.room.isSpaceRoom()) {
|
||||
scopeHeader = <div className="mx_RightPanel_scopeHeader">
|
||||
<RoomAvatar room={this.room} height={32} width={32} />
|
||||
<RoomName room={this.room} />
|
||||
|
|
|
@ -1,917 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket 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';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import Modal from '../../../Modal';
|
||||
import {
|
||||
NotificationUtils,
|
||||
VectorPushRulesDefinitions,
|
||||
PushRuleVectorState,
|
||||
ContentRules,
|
||||
} from '../../../notifications';
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
// TODO: this "view" component still has far too much application logic in it,
|
||||
// which should be factored out to other files.
|
||||
|
||||
// TODO: this component also does a lot of direct poking into this.state, which
|
||||
// is VERY NAUGHTY.
|
||||
|
||||
/**
|
||||
* Rules that Vector used to set in order to override the actions of default rules.
|
||||
* These are used to port peoples existing overrides to match the current API.
|
||||
* These can be removed and forgotten once everyone has moved to the new client.
|
||||
*/
|
||||
const LEGACY_RULES = {
|
||||
"im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
|
||||
"im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
|
||||
"im.vector.rule.room_message": ".m.rule.message",
|
||||
"im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
|
||||
"im.vector.rule.call": ".m.rule.call",
|
||||
"im.vector.rule.notices": ".m.rule.suppress_notices",
|
||||
};
|
||||
|
||||
function portLegacyActions(actions) {
|
||||
const decoded = NotificationUtils.decodeActions(actions);
|
||||
if (decoded !== null) {
|
||||
return NotificationUtils.encodeActions(decoded);
|
||||
} else {
|
||||
// We don't recognise one of the actions here, so we don't try to
|
||||
// canonicalise them.
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
@replaceableComponent("views.settings.Notifications")
|
||||
export default class Notifications extends React.Component {
|
||||
static phases = {
|
||||
LOADING: "LOADING", // The component is loading or sending data to the hs
|
||||
DISPLAY: "DISPLAY", // The component is ready and display data
|
||||
ERROR: "ERROR", // There was an error
|
||||
};
|
||||
|
||||
state = {
|
||||
phase: Notifications.phases.LOADING,
|
||||
masterPushRule: undefined, // The master rule ('.m.rule.master')
|
||||
vectorPushRules: [], // HS default push rules displayed in Vector UI
|
||||
vectorContentRules: { // Keyword push rules displayed in Vector UI
|
||||
vectorState: PushRuleVectorState.ON,
|
||||
rules: [],
|
||||
},
|
||||
externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
|
||||
externalContentRules: [], // Keyword push rules that have been defined outside Vector UI
|
||||
threepids: [], // used for email notifications
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._refreshFromServer();
|
||||
}
|
||||
|
||||
onEnableNotificationsChange = (checked) => {
|
||||
const self = this;
|
||||
this.setState({
|
||||
phase: Notifications.phases.LOADING,
|
||||
});
|
||||
|
||||
MatrixClientPeg.get().setPushRuleEnabled(
|
||||
'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
|
||||
).then(function() {
|
||||
self._refreshFromServer();
|
||||
});
|
||||
};
|
||||
|
||||
onEnableDesktopNotificationsChange = (checked) => {
|
||||
SettingsStore.setValue(
|
||||
"notificationsEnabled", null,
|
||||
SettingLevel.DEVICE,
|
||||
checked,
|
||||
).finally(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
onEnableDesktopNotificationBodyChange = (checked) => {
|
||||
SettingsStore.setValue(
|
||||
"notificationBodyEnabled", null,
|
||||
SettingLevel.DEVICE,
|
||||
checked,
|
||||
).finally(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
onEnableAudioNotificationsChange = (checked) => {
|
||||
SettingsStore.setValue(
|
||||
"audioNotificationsEnabled", null,
|
||||
SettingLevel.DEVICE,
|
||||
checked,
|
||||
).finally(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Returns the email pusher (pusher of type 'email') for a given
|
||||
* email address. Email pushers all have the same app ID, so since
|
||||
* pushers are unique over (app ID, pushkey), there will be at most
|
||||
* one such pusher.
|
||||
*/
|
||||
getEmailPusher(pushers, address) {
|
||||
if (pushers === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
|
||||
return pushers[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onEnableEmailNotificationsChange = (address, checked) => {
|
||||
let emailPusherPromise;
|
||||
if (checked) {
|
||||
const data = {};
|
||||
data['brand'] = SdkConfig.get().brand;
|
||||
emailPusherPromise = MatrixClientPeg.get().setPusher({
|
||||
kind: 'email',
|
||||
app_id: 'm.email',
|
||||
pushkey: address,
|
||||
app_display_name: 'Email Notifications',
|
||||
device_display_name: address,
|
||||
lang: navigator.language,
|
||||
data: data,
|
||||
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
|
||||
});
|
||||
} else {
|
||||
const emailPusher = this.getEmailPusher(this.state.pushers, address);
|
||||
emailPusher.kind = null;
|
||||
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
|
||||
}
|
||||
emailPusherPromise.then(() => {
|
||||
this._refreshFromServer();
|
||||
}, (error) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
|
||||
title: _t('Error saving email notification preferences'),
|
||||
description: _t('An error occurred whilst saving your email notification preferences.'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onNotifStateButtonClicked = (event) => {
|
||||
// FIXME: use .bind() rather than className metadata here surely
|
||||
const vectorRuleId = event.target.className.split("-")[0];
|
||||
const newPushRuleVectorState = event.target.className.split("-")[1];
|
||||
|
||||
if ("_keywords" === vectorRuleId) {
|
||||
this._setKeywordsPushRuleVectorState(newPushRuleVectorState);
|
||||
} else {
|
||||
const rule = this.getRule(vectorRuleId);
|
||||
if (rule) {
|
||||
this._setPushRuleVectorState(rule, newPushRuleVectorState);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onKeywordsClicked = (event) => {
|
||||
// Compute the keywords list to display
|
||||
let keywords = [];
|
||||
for (const i in this.state.vectorContentRules.rules) {
|
||||
const rule = this.state.vectorContentRules.rules[i];
|
||||
keywords.push(rule.pattern);
|
||||
}
|
||||
if (keywords.length) {
|
||||
// As keeping the order of per-word push rules hs side is a bit tricky to code,
|
||||
// display the keywords in alphabetical order to the user
|
||||
keywords.sort();
|
||||
|
||||
keywords = keywords.join(", ");
|
||||
} else {
|
||||
keywords = "";
|
||||
}
|
||||
|
||||
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
|
||||
Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
|
||||
title: _t('Keywords'),
|
||||
description: _t('Enter keywords separated by a comma:'),
|
||||
button: _t('OK'),
|
||||
value: keywords,
|
||||
onFinished: (shouldLeave, newValue) => {
|
||||
if (shouldLeave && newValue !== keywords) {
|
||||
let newKeywords = newValue.split(',');
|
||||
for (const i in newKeywords) {
|
||||
newKeywords[i] = newKeywords[i].trim();
|
||||
}
|
||||
|
||||
// Remove duplicates and empty
|
||||
newKeywords = newKeywords.reduce(function(array, keyword) {
|
||||
if (keyword !== "" && array.indexOf(keyword) < 0) {
|
||||
array.push(keyword);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
this._setKeywords(newKeywords);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
getRule(vectorRuleId) {
|
||||
for (const i in this.state.vectorPushRules) {
|
||||
const rule = this.state.vectorPushRules[i];
|
||||
if (rule.vectorRuleId === vectorRuleId) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_setPushRuleVectorState(rule, newPushRuleVectorState) {
|
||||
if (rule && rule.vectorState !== newPushRuleVectorState) {
|
||||
this.setState({
|
||||
phase: Notifications.phases.LOADING,
|
||||
});
|
||||
|
||||
const self = this;
|
||||
const cli = MatrixClientPeg.get();
|
||||
const deferreds = [];
|
||||
const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
|
||||
|
||||
if (rule.rule) {
|
||||
const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
|
||||
|
||||
if (!actions) {
|
||||
// The new state corresponds to disabling the rule.
|
||||
deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
|
||||
} else {
|
||||
// The new state corresponds to enabling the rule and setting specific actions
|
||||
deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(deferreds).then(function() {
|
||||
self._refreshFromServer();
|
||||
}, function(error) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to change settings: " + error);
|
||||
Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
|
||||
title: _t('Failed to change settings'),
|
||||
description: ((error && error.message) ? error.message : _t('Operation failed')),
|
||||
onFinished: self._refreshFromServer,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_setKeywordsPushRuleVectorState(newPushRuleVectorState) {
|
||||
// Is there really a change?
|
||||
if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
|
||||
|| this.state.vectorContentRules.rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
this.setState({
|
||||
phase: Notifications.phases.LOADING,
|
||||
});
|
||||
|
||||
// Update all rules in self.state.vectorContentRules
|
||||
const deferreds = [];
|
||||
for (const i in this.state.vectorContentRules.rules) {
|
||||
const rule = this.state.vectorContentRules.rules[i];
|
||||
|
||||
let enabled; let actions;
|
||||
switch (newPushRuleVectorState) {
|
||||
case PushRuleVectorState.ON:
|
||||
if (rule.actions.length !== 1) {
|
||||
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
|
||||
}
|
||||
|
||||
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
|
||||
enabled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case PushRuleVectorState.LOUD:
|
||||
if (rule.actions.length !== 3) {
|
||||
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
|
||||
}
|
||||
|
||||
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
|
||||
enabled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case PushRuleVectorState.OFF:
|
||||
enabled = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
// Note that the workaround in _updatePushRuleActions will automatically
|
||||
// enable the rule
|
||||
deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
|
||||
} else if (enabled != undefined) {
|
||||
deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(deferreds).then(function(resps) {
|
||||
self._refreshFromServer();
|
||||
}, function(error) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Can't update user notification settings: " + error);
|
||||
Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
|
||||
title: _t('Can\'t update user notification settings'),
|
||||
description: ((error && error.message) ? error.message : _t('Operation failed')),
|
||||
onFinished: self._refreshFromServer,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_setKeywords(newKeywords) {
|
||||
this.setState({
|
||||
phase: Notifications.phases.LOADING,
|
||||
});
|
||||
|
||||
const self = this;
|
||||
const cli = MatrixClientPeg.get();
|
||||
const removeDeferreds = [];
|
||||
|
||||
// Remove per-word push rules of keywords that are no more in the list
|
||||
const vectorContentRulesPatterns = [];
|
||||
for (const i in self.state.vectorContentRules.rules) {
|
||||
const rule = self.state.vectorContentRules.rules[i];
|
||||
|
||||
vectorContentRulesPatterns.push(rule.pattern);
|
||||
|
||||
if (newKeywords.indexOf(rule.pattern) < 0) {
|
||||
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
|
||||
}
|
||||
}
|
||||
|
||||
// If the keyword is part of `externalContentRules`, remove the rule
|
||||
// before recreating it in the right Vector path
|
||||
for (const i in self.state.externalContentRules) {
|
||||
const rule = self.state.externalContentRules[i];
|
||||
|
||||
if (newKeywords.indexOf(rule.pattern) >= 0) {
|
||||
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
|
||||
}
|
||||
}
|
||||
|
||||
const onError = function(error) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to update keywords: " + error);
|
||||
Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
|
||||
title: _t('Failed to update keywords'),
|
||||
description: ((error && error.message) ? error.message : _t('Operation failed')),
|
||||
onFinished: self._refreshFromServer,
|
||||
});
|
||||
};
|
||||
|
||||
// Then, add the new ones
|
||||
Promise.all(removeDeferreds).then(function(resps) {
|
||||
const deferreds = [];
|
||||
|
||||
let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
|
||||
if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
|
||||
// When the current global keywords rule is OFF, we need to look at
|
||||
// the flavor of rules in 'vectorContentRules' to apply the same actions
|
||||
// when creating the new rule.
|
||||
// Thus, this new rule will join the 'vectorContentRules' set.
|
||||
if (self.state.vectorContentRules.rules.length) {
|
||||
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(
|
||||
self.state.vectorContentRules.rules[0],
|
||||
);
|
||||
} else {
|
||||
// ON is default
|
||||
pushRuleVectorStateKind = PushRuleVectorState.ON;
|
||||
}
|
||||
}
|
||||
|
||||
for (const i in newKeywords) {
|
||||
const keyword = newKeywords[i];
|
||||
|
||||
if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
|
||||
if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
|
||||
deferreds.push(cli.addPushRule('global', 'content', keyword, {
|
||||
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
|
||||
pattern: keyword,
|
||||
}));
|
||||
} else {
|
||||
deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
|
||||
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
|
||||
pattern: keyword,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(deferreds).then(function(resps) {
|
||||
self._refreshFromServer();
|
||||
}, onError);
|
||||
}, onError);
|
||||
}
|
||||
|
||||
// Create a push rule but disabled
|
||||
_addDisabledPushRule(scope, kind, ruleId, body) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
return cli.addPushRule(scope, kind, ruleId, body).then(() =>
|
||||
cli.setPushRuleEnabled(scope, kind, ruleId, false),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any legacy im.vector rules need to be ported to the new API
|
||||
// for overriding the actions of default rules.
|
||||
_portRulesToNewAPI(rulesets) {
|
||||
const needsUpdate = [];
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
for (const kind in rulesets.global) {
|
||||
const ruleset = rulesets.global[kind];
|
||||
for (let i = 0; i < ruleset.length; ++i) {
|
||||
const rule = ruleset[i];
|
||||
if (rule.rule_id in LEGACY_RULES) {
|
||||
console.log("Porting legacy rule", rule);
|
||||
needsUpdate.push( function(kind, rule) {
|
||||
return cli.setPushRuleActions(
|
||||
'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions),
|
||||
).then(() =>
|
||||
cli.deletePushRule('global', kind, rule.rule_id),
|
||||
).catch( (e) => {
|
||||
console.warn(`Error when porting legacy rule: ${e}`);
|
||||
});
|
||||
}(kind, rule));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate.length > 0) {
|
||||
// If some of the rules need to be ported then wait for the porting
|
||||
// to happen and then fetch the rules again.
|
||||
return Promise.all(needsUpdate).then(() =>
|
||||
cli.getPushRules(),
|
||||
);
|
||||
} else {
|
||||
// Otherwise return the rules that we already have.
|
||||
return rulesets;
|
||||
}
|
||||
}
|
||||
|
||||
_refreshFromServer = () => {
|
||||
const self = this;
|
||||
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(
|
||||
self._portRulesToNewAPI,
|
||||
).then(function(rulesets) {
|
||||
/// XXX seriously? wtf is this?
|
||||
MatrixClientPeg.get().pushRules = rulesets;
|
||||
|
||||
// Get homeserver default rules and triage them by categories
|
||||
const ruleCategories = {
|
||||
// The master rule (all notifications disabling)
|
||||
'.m.rule.master': 'master',
|
||||
|
||||
// The default push rules displayed by Vector UI
|
||||
'.m.rule.contains_display_name': 'vector',
|
||||
'.m.rule.contains_user_name': 'vector',
|
||||
'.m.rule.roomnotif': 'vector',
|
||||
'.m.rule.room_one_to_one': 'vector',
|
||||
'.m.rule.encrypted_room_one_to_one': 'vector',
|
||||
'.m.rule.message': 'vector',
|
||||
'.m.rule.encrypted': 'vector',
|
||||
'.m.rule.invite_for_me': 'vector',
|
||||
//'.m.rule.member_event': 'vector',
|
||||
'.m.rule.call': 'vector',
|
||||
'.m.rule.suppress_notices': 'vector',
|
||||
'.m.rule.tombstone': 'vector',
|
||||
|
||||
// Others go to others
|
||||
};
|
||||
|
||||
// HS default rules
|
||||
const defaultRules = { master: [], vector: {}, others: [] };
|
||||
|
||||
for (const kind in rulesets.global) {
|
||||
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
|
||||
const r = rulesets.global[kind][i];
|
||||
const cat = ruleCategories[r.rule_id];
|
||||
r.kind = kind;
|
||||
|
||||
if (r.rule_id[0] === '.') {
|
||||
if (cat === 'vector') {
|
||||
defaultRules.vector[r.rule_id] = r;
|
||||
} else if (cat === 'master') {
|
||||
defaultRules.master.push(r);
|
||||
} else {
|
||||
defaultRules['others'].push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the master rule if any defined by the hs
|
||||
if (defaultRules.master.length > 0) {
|
||||
self.state.masterPushRule = defaultRules.master[0];
|
||||
}
|
||||
|
||||
// parse the keyword rules into our state
|
||||
const contentRules = ContentRules.parseContentRules(rulesets);
|
||||
self.state.vectorContentRules = {
|
||||
vectorState: contentRules.vectorState,
|
||||
rules: contentRules.rules,
|
||||
};
|
||||
self.state.externalContentRules = contentRules.externalRules;
|
||||
|
||||
// Build the rules displayed in the Vector UI matrix table
|
||||
self.state.vectorPushRules = [];
|
||||
self.state.externalPushRules = [];
|
||||
|
||||
const vectorRuleIds = [
|
||||
'.m.rule.contains_display_name',
|
||||
'.m.rule.contains_user_name',
|
||||
'.m.rule.roomnotif',
|
||||
'_keywords',
|
||||
'.m.rule.room_one_to_one',
|
||||
'.m.rule.encrypted_room_one_to_one',
|
||||
'.m.rule.message',
|
||||
'.m.rule.encrypted',
|
||||
'.m.rule.invite_for_me',
|
||||
//'im.vector.rule.member_event',
|
||||
'.m.rule.call',
|
||||
'.m.rule.suppress_notices',
|
||||
'.m.rule.tombstone',
|
||||
];
|
||||
for (const i in vectorRuleIds) {
|
||||
const vectorRuleId = vectorRuleIds[i];
|
||||
|
||||
if (vectorRuleId === '_keywords') {
|
||||
// keywords needs a special handling
|
||||
// For Vector UI, this is a single global push rule but translated in Matrix,
|
||||
// it corresponds to all content push rules (stored in self.state.vectorContentRule)
|
||||
self.state.vectorPushRules.push({
|
||||
"vectorRuleId": "_keywords",
|
||||
"description": (
|
||||
<span>
|
||||
{ _t('Messages containing <span>keywords</span>',
|
||||
{},
|
||||
{ 'span': (sub) =>
|
||||
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
"vectorState": self.state.vectorContentRules.vectorState,
|
||||
});
|
||||
} else {
|
||||
const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
|
||||
const rule = defaultRules.vector[vectorRuleId];
|
||||
|
||||
const vectorState = ruleDefinition.ruleToVectorState(rule);
|
||||
|
||||
//console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
|
||||
|
||||
self.state.vectorPushRules.push({
|
||||
"vectorRuleId": vectorRuleId,
|
||||
"description": _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
|
||||
"rule": rule,
|
||||
"vectorState": vectorState,
|
||||
});
|
||||
|
||||
// if there was a rule which we couldn't parse, add it to the external list
|
||||
if (rule && !vectorState) {
|
||||
rule.description = ruleDefinition.description;
|
||||
self.state.externalPushRules.push(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the rules not managed by Vector UI
|
||||
const otherRulesDescriptions = {
|
||||
'.m.rule.message': _t('Notify for all other messages/rooms'),
|
||||
'.m.rule.fallback': _t('Notify me for anything else'),
|
||||
};
|
||||
|
||||
for (const i in defaultRules.others) {
|
||||
const rule = defaultRules.others[i];
|
||||
const ruleDescription = otherRulesDescriptions[rule.rule_id];
|
||||
|
||||
// Show enabled default rules that was modified by the user
|
||||
if (ruleDescription && rule.enabled && !rule.default) {
|
||||
rule.description = ruleDescription;
|
||||
self.state.externalPushRules.push(rule);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
|
||||
self.setState({ pushers: resp.pushers });
|
||||
});
|
||||
|
||||
Promise.all([pushRulesPromise, pushersPromise]).then(function() {
|
||||
self.setState({
|
||||
phase: Notifications.phases.DISPLAY,
|
||||
});
|
||||
}, function(error) {
|
||||
console.error(error);
|
||||
self.setState({
|
||||
phase: Notifications.phases.ERROR,
|
||||
});
|
||||
}).finally(() => {
|
||||
// actually explicitly update our state having been deep-manipulating it
|
||||
self.setState({
|
||||
masterPushRule: self.state.masterPushRule,
|
||||
vectorContentRules: self.state.vectorContentRules,
|
||||
vectorPushRules: self.state.vectorPushRules,
|
||||
externalContentRules: self.state.externalContentRules,
|
||||
externalPushRules: self.state.externalPushRules,
|
||||
});
|
||||
});
|
||||
|
||||
MatrixClientPeg.get().getThreePids().then((r) => this.setState({ threepids: r.threepids }));
|
||||
};
|
||||
|
||||
_onClearNotifications = () => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
cli.getRooms().forEach(r => {
|
||||
if (r.getUnreadNotificationCount() > 0) {
|
||||
const events = r.getLiveTimeline().getEvents();
|
||||
if (events.length) cli.sendReadReceipt(events.pop());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_updatePushRuleActions(rule, actions, enabled) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
return cli.setPushRuleActions(
|
||||
'global', rule.kind, rule.rule_id, actions,
|
||||
).then( function() {
|
||||
// Then, if requested, enabled or disabled the rule
|
||||
if (undefined != enabled) {
|
||||
return cli.setPushRuleEnabled(
|
||||
'global', rule.kind, rule.rule_id, enabled,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderNotifRulesTableRow(title, className, pushRuleVectorState) {
|
||||
return (
|
||||
<tr key={ className }>
|
||||
<th>
|
||||
{ title }
|
||||
</th>
|
||||
|
||||
<th>
|
||||
<input className= {className + "-" + PushRuleVectorState.OFF}
|
||||
type="radio"
|
||||
checked={ pushRuleVectorState === PushRuleVectorState.OFF }
|
||||
onChange={ this.onNotifStateButtonClicked } />
|
||||
</th>
|
||||
|
||||
<th>
|
||||
<input className= {className + "-" + PushRuleVectorState.ON}
|
||||
type="radio"
|
||||
checked={ pushRuleVectorState === PushRuleVectorState.ON }
|
||||
onChange={ this.onNotifStateButtonClicked } />
|
||||
</th>
|
||||
|
||||
<th>
|
||||
<input className= {className + "-" + PushRuleVectorState.LOUD}
|
||||
type="radio"
|
||||
checked={ pushRuleVectorState === PushRuleVectorState.LOUD }
|
||||
onChange={ this.onNotifStateButtonClicked } />
|
||||
</th>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
renderNotifRulesTableRows() {
|
||||
const rows = [];
|
||||
for (const i in this.state.vectorPushRules) {
|
||||
const rule = this.state.vectorPushRules[i];
|
||||
if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
|
||||
console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
|
||||
continue;
|
||||
}
|
||||
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
|
||||
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
hasEmailPusher(pushers, address) {
|
||||
if (pushers === undefined) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
emailNotificationsRow(address, label) {
|
||||
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
|
||||
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
|
||||
label={label} key={`emailNotif_${label}`} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
let spinner;
|
||||
if (this.state.phase === Notifications.phases.LOADING) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
spinner = <Loader />;
|
||||
}
|
||||
|
||||
let masterPushRuleDiv;
|
||||
if (this.state.masterPushRule) {
|
||||
masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
|
||||
onChange={this.onEnableNotificationsChange}
|
||||
label={_t('Enable notifications for this account')} />;
|
||||
}
|
||||
|
||||
let clearNotificationsButton;
|
||||
if (MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)) {
|
||||
clearNotificationsButton = <AccessibleButton onClick={this._onClearNotifications} kind='danger'>
|
||||
{_t("Clear notifications")}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
// When enabled, the master rule inhibits all existing rules
|
||||
// So do not show all notification settings
|
||||
if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
|
||||
return (
|
||||
<div>
|
||||
{masterPushRuleDiv}
|
||||
|
||||
<div className="mx_UserNotifSettings_notifTable">
|
||||
{ _t('All notifications are currently disabled for all targets.') }
|
||||
</div>
|
||||
|
||||
{clearNotificationsButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
|
||||
let emailNotificationsRows;
|
||||
if (emailThreepids.length > 0) {
|
||||
emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
|
||||
threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
|
||||
));
|
||||
} else if (SettingsStore.getValue(UIFeature.ThirdPartyID)) {
|
||||
emailNotificationsRows = <div>
|
||||
{ _t('Add an email address to configure email notifications') }
|
||||
</div>;
|
||||
}
|
||||
|
||||
// Build external push rules
|
||||
const externalRules = [];
|
||||
for (const i in this.state.externalPushRules) {
|
||||
const rule = this.state.externalPushRules[i];
|
||||
externalRules.push(<li>{ _t(rule.description) }</li>);
|
||||
}
|
||||
|
||||
// Show keywords not displayed by the vector UI as a single external push rule
|
||||
let externalKeywords = [];
|
||||
for (const i in this.state.externalContentRules) {
|
||||
const rule = this.state.externalContentRules[i];
|
||||
externalKeywords.push(rule.pattern);
|
||||
}
|
||||
if (externalKeywords.length) {
|
||||
externalKeywords = externalKeywords.join(", ");
|
||||
externalRules.push(<li>
|
||||
{_t('Notifications on the following keywords follow rules which can’t be displayed here:') }
|
||||
{ externalKeywords }
|
||||
</li>);
|
||||
}
|
||||
|
||||
let devicesSection;
|
||||
if (this.state.pushers === undefined) {
|
||||
devicesSection = <div className="error">{ _t('Unable to fetch notification target list') }</div>;
|
||||
} else if (this.state.pushers.length === 0) {
|
||||
devicesSection = null;
|
||||
} else {
|
||||
// TODO: It would be great to be able to delete pushers from here too,
|
||||
// and this wouldn't be hard to add.
|
||||
const rows = [];
|
||||
for (let i = 0; i < this.state.pushers.length; ++i) {
|
||||
rows.push(<tr key={ i }>
|
||||
<td>{this.state.pushers[i].app_display_name}</td>
|
||||
<td>{this.state.pushers[i].device_display_name}</td>
|
||||
</tr>);
|
||||
}
|
||||
devicesSection = (<table className="mx_UserNotifSettings_devicesTable">
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>);
|
||||
}
|
||||
if (devicesSection) {
|
||||
devicesSection = (<div>
|
||||
<h3>{ _t('Notification targets') }</h3>
|
||||
{ devicesSection }
|
||||
</div>);
|
||||
}
|
||||
|
||||
let advancedSettings;
|
||||
if (externalRules.length) {
|
||||
const brand = SdkConfig.get().brand;
|
||||
advancedSettings = (
|
||||
<div>
|
||||
<h3>{ _t('Advanced notification settings') }</h3>
|
||||
{ _t('There are advanced notifications which are not shown here.') }<br />
|
||||
{_t(
|
||||
'You might have configured them in a client other than %(brand)s. ' +
|
||||
'You cannot tune them in %(brand)s but they still apply.',
|
||||
{ brand },
|
||||
)}
|
||||
<ul>
|
||||
{ externalRules }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
{masterPushRuleDiv}
|
||||
|
||||
<div className="mx_UserNotifSettings_notifTable">
|
||||
|
||||
{ spinner }
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
|
||||
onChange={this.onEnableDesktopNotificationsChange}
|
||||
label={_t('Enable desktop notifications for this session')} />
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
|
||||
onChange={this.onEnableDesktopNotificationBodyChange}
|
||||
label={_t('Show message in desktop notification')} />
|
||||
|
||||
<LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
|
||||
onChange={this.onEnableAudioNotificationsChange}
|
||||
label={_t('Enable audible notifications for this session')} />
|
||||
|
||||
{ emailNotificationsRows }
|
||||
|
||||
<div className="mx_UserNotifSettings_pushRulesTableWrapper">
|
||||
<table className="mx_UserNotifSettings_pushRulesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="55%"></th>
|
||||
<th width="15%">{ _t('Off') }</th>
|
||||
<th width="15%">{ _t('On') }</th>
|
||||
<th width="15%">{ _t('Noisy') }</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{ this.renderNotifRulesTableRows() }
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{ advancedSettings }
|
||||
|
||||
{ devicesSection }
|
||||
|
||||
{ clearNotificationsButton }
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
647
src/components/views/settings/Notifications.tsx
Normal file
647
src/components/views/settings/Notifications.tsx
Normal file
|
@ -0,0 +1,647 @@
|
|||
/*
|
||||
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.
|
||||
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 Spinner from "../elements/Spinner";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import {
|
||||
ContentRules,
|
||||
IContentRules,
|
||||
PushRuleVectorState,
|
||||
VectorPushRulesDefinitions,
|
||||
VectorState,
|
||||
} from "../../../notifications";
|
||||
import { _t, TranslatedString } from "../../../languageHandler";
|
||||
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import TagComposer from "../elements/TagComposer";
|
||||
import { objectClone } from "../../../utils/objects";
|
||||
import { arrayDiff } from "../../../utils/arrays";
|
||||
|
||||
// TODO: this "view" component still has far too much application logic in it,
|
||||
// which should be factored out to other files.
|
||||
|
||||
enum Phase {
|
||||
Loading = "loading",
|
||||
Ready = "ready",
|
||||
Persisting = "persisting", // technically a meta-state for Ready, but whatever
|
||||
Error = "error",
|
||||
}
|
||||
|
||||
enum RuleClass {
|
||||
Master = "master",
|
||||
|
||||
// The vector sections map approximately to UI sections
|
||||
VectorGlobal = "vector_global",
|
||||
VectorMentions = "vector_mentions",
|
||||
VectorOther = "vector_other",
|
||||
Other = "other", // unknown rules, essentially
|
||||
}
|
||||
|
||||
const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component
|
||||
const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions;
|
||||
|
||||
// This array doesn't care about categories: it's just used for a simple sort
|
||||
const RULE_DISPLAY_ORDER: string[] = [
|
||||
// Global
|
||||
RuleId.DM,
|
||||
RuleId.EncryptedDM,
|
||||
RuleId.Message,
|
||||
RuleId.EncryptedMessage,
|
||||
|
||||
// Mentions
|
||||
RuleId.ContainsDisplayName,
|
||||
RuleId.ContainsUserName,
|
||||
RuleId.AtRoomNotification,
|
||||
|
||||
// Other
|
||||
RuleId.InviteToSelf,
|
||||
RuleId.IncomingCall,
|
||||
RuleId.SuppressNotices,
|
||||
RuleId.Tombstone,
|
||||
];
|
||||
|
||||
interface IVectorPushRule {
|
||||
ruleId: RuleId | typeof KEYWORD_RULE_ID | string;
|
||||
rule?: IAnnotatedPushRule;
|
||||
description: TranslatedString | string;
|
||||
vectorState: VectorState;
|
||||
}
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
|
||||
// Optional stuff is required when `phase === Ready`
|
||||
masterPushRule?: IAnnotatedPushRule;
|
||||
vectorKeywordRuleInfo?: IContentRules;
|
||||
vectorPushRules?: {
|
||||
[category in RuleClass]?: IVectorPushRule[];
|
||||
};
|
||||
pushers?: IPusher[];
|
||||
threepids?: IThreepid[];
|
||||
}
|
||||
|
||||
export default class Notifications extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
phase: Phase.Loading,
|
||||
};
|
||||
}
|
||||
|
||||
private get isInhibited(): boolean {
|
||||
// Caution: The master rule's enabled state is inverted from expectation. When
|
||||
// the master rule is *enabled* it means all other rules are *disabled* (or
|
||||
// inhibited). Conversely, when the master rule is *disabled* then all other rules
|
||||
// are *enabled* (or operate fine).
|
||||
return this.state.masterPushRule?.enabled;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.refreshFromServer();
|
||||
}
|
||||
|
||||
private async refreshFromServer() {
|
||||
try {
|
||||
const newState = (await Promise.all([
|
||||
this.refreshRules(),
|
||||
this.refreshPushers(),
|
||||
this.refreshThreepids(),
|
||||
])).reduce((p, c) => Object.assign(c, p), {});
|
||||
|
||||
this.setState({
|
||||
...newState,
|
||||
phase: Phase.Ready,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error setting up notifications for settings: ", e);
|
||||
this.setState({ phase: Phase.Error });
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshRules(): Promise<Partial<IState>> {
|
||||
const ruleSets = await MatrixClientPeg.get().getPushRules();
|
||||
|
||||
const categories = {
|
||||
[RuleId.Master]: RuleClass.Master,
|
||||
|
||||
[RuleId.DM]: RuleClass.VectorGlobal,
|
||||
[RuleId.EncryptedDM]: RuleClass.VectorGlobal,
|
||||
[RuleId.Message]: RuleClass.VectorGlobal,
|
||||
[RuleId.EncryptedMessage]: RuleClass.VectorGlobal,
|
||||
|
||||
[RuleId.ContainsDisplayName]: RuleClass.VectorMentions,
|
||||
[RuleId.ContainsUserName]: RuleClass.VectorMentions,
|
||||
[RuleId.AtRoomNotification]: RuleClass.VectorMentions,
|
||||
|
||||
[RuleId.InviteToSelf]: RuleClass.VectorOther,
|
||||
[RuleId.IncomingCall]: RuleClass.VectorOther,
|
||||
[RuleId.SuppressNotices]: RuleClass.VectorOther,
|
||||
[RuleId.Tombstone]: RuleClass.VectorOther,
|
||||
|
||||
// Everything maps to a generic "other" (unknown rule)
|
||||
};
|
||||
|
||||
const defaultRules: {
|
||||
[k in RuleClass]: IAnnotatedPushRule[];
|
||||
} = {
|
||||
[RuleClass.Master]: [],
|
||||
[RuleClass.VectorGlobal]: [],
|
||||
[RuleClass.VectorMentions]: [],
|
||||
[RuleClass.VectorOther]: [],
|
||||
[RuleClass.Other]: [],
|
||||
};
|
||||
|
||||
for (const k in ruleSets.global) {
|
||||
// noinspection JSUnfilteredForInLoop
|
||||
const kind = k as PushRuleKind;
|
||||
for (const r of ruleSets.global[kind]) {
|
||||
const rule: IAnnotatedPushRule = Object.assign(r, { kind });
|
||||
const category = categories[rule.rule_id] ?? RuleClass.Other;
|
||||
|
||||
if (rule.rule_id[0] === '.') {
|
||||
defaultRules[category].push(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preparedNewState: Partial<IState> = {};
|
||||
if (defaultRules.master.length > 0) {
|
||||
preparedNewState.masterPushRule = defaultRules.master[0];
|
||||
} else {
|
||||
// XXX: Can this even happen? How do we safely recover?
|
||||
throw new Error("Failed to locate a master push rule");
|
||||
}
|
||||
|
||||
// Parse keyword rules
|
||||
preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets);
|
||||
|
||||
// Prepare rendering for all of our known rules
|
||||
preparedNewState.vectorPushRules = {};
|
||||
const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther];
|
||||
for (const category of vectorCategories) {
|
||||
preparedNewState.vectorPushRules[category] = [];
|
||||
for (const rule of defaultRules[category]) {
|
||||
const definition = VectorPushRulesDefinitions[rule.rule_id];
|
||||
const vectorState = definition.ruleToVectorState(rule);
|
||||
preparedNewState.vectorPushRules[category].push({
|
||||
ruleId: rule.rule_id,
|
||||
rule, vectorState,
|
||||
description: _t(definition.description),
|
||||
});
|
||||
}
|
||||
|
||||
// Quickly sort the rules for display purposes
|
||||
preparedNewState.vectorPushRules[category].sort((a, b) => {
|
||||
let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId);
|
||||
let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId);
|
||||
|
||||
// Assume unknown things go at the end
|
||||
if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length;
|
||||
if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length;
|
||||
|
||||
return idxA - idxB;
|
||||
});
|
||||
|
||||
if (category === KEYWORD_RULE_CATEGORY) {
|
||||
preparedNewState.vectorPushRules[category].push({
|
||||
ruleId: KEYWORD_RULE_ID,
|
||||
description: _t("Messages containing keywords"),
|
||||
vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return preparedNewState;
|
||||
}
|
||||
|
||||
private refreshPushers(): Promise<Partial<IState>> {
|
||||
return MatrixClientPeg.get().getPushers();
|
||||
}
|
||||
|
||||
private refreshThreepids(): Promise<Partial<IState>> {
|
||||
return MatrixClientPeg.get().getThreePids();
|
||||
}
|
||||
|
||||
private showSaveError() {
|
||||
Modal.createTrackedDialog('Error saving notification preferences', '', ErrorDialog, {
|
||||
title: _t('Error saving notification preferences'),
|
||||
description: _t('An error occurred whilst saving your notification preferences.'),
|
||||
});
|
||||
}
|
||||
|
||||
private onMasterRuleChanged = async (checked: boolean) => {
|
||||
this.setState({ phase: Phase.Persisting });
|
||||
|
||||
try {
|
||||
const masterRule = this.state.masterPushRule;
|
||||
await MatrixClientPeg.get().setPushRuleEnabled('global', masterRule.kind, masterRule.rule_id, !checked);
|
||||
await this.refreshFromServer();
|
||||
} catch (e) {
|
||||
this.setState({ phase: Phase.Error });
|
||||
console.error("Error updating master push rule:", e);
|
||||
this.showSaveError();
|
||||
}
|
||||
};
|
||||
|
||||
private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
|
||||
this.setState({ phase: Phase.Persisting });
|
||||
|
||||
try {
|
||||
if (checked) {
|
||||
await MatrixClientPeg.get().setPusher({
|
||||
kind: "email",
|
||||
app_id: "m.email",
|
||||
pushkey: email,
|
||||
app_display_name: "Email Notifications",
|
||||
device_display_name: email,
|
||||
lang: navigator.language,
|
||||
data: {
|
||||
brand: SdkConfig.get().brand,
|
||||
},
|
||||
|
||||
// We always append for email pushers since we don't want to stop other
|
||||
// accounts notifying to the same email address
|
||||
append: true,
|
||||
});
|
||||
} else {
|
||||
const pusher = this.state.pushers.find(p => p.kind === "email" && p.pushkey === email);
|
||||
pusher.kind = null; // flag for delete
|
||||
await MatrixClientPeg.get().setPusher(pusher);
|
||||
}
|
||||
|
||||
await this.refreshFromServer();
|
||||
} catch (e) {
|
||||
this.setState({ phase: Phase.Error });
|
||||
console.error("Error updating email pusher:", e);
|
||||
this.showSaveError();
|
||||
}
|
||||
};
|
||||
|
||||
private onDesktopNotificationsChanged = async (checked: boolean) => {
|
||||
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
|
||||
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
|
||||
};
|
||||
|
||||
private onDesktopShowBodyChanged = async (checked: boolean) => {
|
||||
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
|
||||
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
|
||||
};
|
||||
|
||||
private onAudioNotificationsChanged = async (checked: boolean) => {
|
||||
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
|
||||
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
|
||||
};
|
||||
|
||||
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => {
|
||||
this.setState({ phase: Phase.Persisting });
|
||||
|
||||
try {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (rule.ruleId === KEYWORD_RULE_ID) {
|
||||
// Update all the keywords
|
||||
for (const rule of this.state.vectorKeywordRuleInfo.rules) {
|
||||
let enabled: boolean;
|
||||
let actions: PushRuleAction[];
|
||||
if (checkedState === VectorState.On) {
|
||||
if (rule.actions.length !== 1) { // XXX: Magic number
|
||||
actions = PushRuleVectorState.actionsFor(checkedState);
|
||||
}
|
||||
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
|
||||
enabled = true;
|
||||
}
|
||||
} else if (checkedState === VectorState.Loud) {
|
||||
if (rule.actions.length !== 3) { // XXX: Magic number
|
||||
actions = PushRuleVectorState.actionsFor(checkedState);
|
||||
}
|
||||
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
|
||||
enabled = true;
|
||||
}
|
||||
} else {
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
await cli.setPushRuleActions('global', rule.kind, rule.rule_id, actions);
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
await cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const definition = VectorPushRulesDefinitions[rule.ruleId];
|
||||
const actions = definition.vectorStateToActions[checkedState];
|
||||
if (!actions) {
|
||||
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false);
|
||||
} else {
|
||||
await cli.setPushRuleActions('global', rule.rule.kind, rule.rule.rule_id, actions);
|
||||
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, true);
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshFromServer();
|
||||
} catch (e) {
|
||||
this.setState({ phase: Phase.Error });
|
||||
console.error("Error updating push rule:", e);
|
||||
this.showSaveError();
|
||||
}
|
||||
};
|
||||
|
||||
private onClearNotificationsClicked = () => {
|
||||
MatrixClientPeg.get().getRooms().forEach(r => {
|
||||
if (r.getUnreadNotificationCount() > 0) {
|
||||
const events = r.getLiveTimeline().getEvents();
|
||||
if (events.length) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]) {
|
||||
try {
|
||||
// De-duplicate and remove empties
|
||||
keywords = Array.from(new Set(keywords)).filter(k => !!k);
|
||||
const oldKeywords = Array.from(new Set(originalRules.map(r => r.pattern))).filter(k => !!k);
|
||||
|
||||
// Note: Technically because of the UI interaction (at the time of writing), the diff
|
||||
// will only ever be +/-1 so we don't really have to worry about efficiently handling
|
||||
// tons of keyword changes.
|
||||
|
||||
const diff = arrayDiff(oldKeywords, keywords);
|
||||
|
||||
for (const word of diff.removed) {
|
||||
for (const rule of originalRules.filter(r => r.pattern === word)) {
|
||||
await MatrixClientPeg.get().deletePushRule('global', rule.kind, rule.rule_id);
|
||||
}
|
||||
}
|
||||
|
||||
let ruleVectorState = this.state.vectorKeywordRuleInfo.vectorState;
|
||||
if (ruleVectorState === VectorState.Off) {
|
||||
// When the current global keywords rule is OFF, we need to look at
|
||||
// the flavor of existing rules to apply the same actions
|
||||
// when creating the new rule.
|
||||
if (originalRules.length) {
|
||||
ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]);
|
||||
} else {
|
||||
ruleVectorState = VectorState.On; // default
|
||||
}
|
||||
}
|
||||
const kind = PushRuleKind.ContentSpecific;
|
||||
for (const word of diff.added) {
|
||||
await MatrixClientPeg.get().addPushRule('global', kind, word, {
|
||||
actions: PushRuleVectorState.actionsFor(ruleVectorState),
|
||||
pattern: word,
|
||||
});
|
||||
if (ruleVectorState === VectorState.Off) {
|
||||
await MatrixClientPeg.get().setPushRuleEnabled('global', kind, word, false);
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshFromServer();
|
||||
} catch (e) {
|
||||
this.setState({ phase: Phase.Error });
|
||||
console.error("Error updating keyword push rules:", e);
|
||||
this.showSaveError();
|
||||
}
|
||||
}
|
||||
|
||||
private onKeywordAdd = (keyword: string) => {
|
||||
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
|
||||
|
||||
// We add the keyword immediately as a sort of local echo effect
|
||||
this.setState({
|
||||
phase: Phase.Persisting,
|
||||
vectorKeywordRuleInfo: {
|
||||
...this.state.vectorKeywordRuleInfo,
|
||||
rules: [
|
||||
...this.state.vectorKeywordRuleInfo.rules,
|
||||
|
||||
// XXX: Horrible assumption that we don't need the remaining fields
|
||||
{ pattern: keyword } as IAnnotatedPushRule,
|
||||
],
|
||||
},
|
||||
}, async () => {
|
||||
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
|
||||
});
|
||||
};
|
||||
|
||||
private onKeywordRemove = (keyword: string) => {
|
||||
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
|
||||
|
||||
// We remove the keyword immediately as a sort of local echo effect
|
||||
this.setState({
|
||||
phase: Phase.Persisting,
|
||||
vectorKeywordRuleInfo: {
|
||||
...this.state.vectorKeywordRuleInfo,
|
||||
rules: this.state.vectorKeywordRuleInfo.rules.filter(r => r.pattern !== keyword),
|
||||
},
|
||||
}, async () => {
|
||||
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
|
||||
});
|
||||
};
|
||||
|
||||
private renderTopSection() {
|
||||
const masterSwitch = <LabelledToggleSwitch
|
||||
value={!this.isInhibited}
|
||||
label={_t("Enable for this account")}
|
||||
onChange={this.onMasterRuleChanged}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>;
|
||||
|
||||
// If all the rules are inhibited, don't show anything.
|
||||
if (this.isInhibited) {
|
||||
return masterSwitch;
|
||||
}
|
||||
|
||||
const emailSwitches = this.state.threepids.filter(t => t.medium === ThreepidMedium.Email)
|
||||
.map(e => <LabelledToggleSwitch
|
||||
key={e.address}
|
||||
value={this.state.pushers.some(p => p.kind === "email" && p.pushkey === e.address)}
|
||||
label={_t("Enable email notifications for %(email)s", { email: e.address })}
|
||||
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>);
|
||||
|
||||
return <>
|
||||
{ masterSwitch }
|
||||
|
||||
<LabelledToggleSwitch
|
||||
value={SettingsStore.getValue("notificationsEnabled")}
|
||||
onChange={this.onDesktopNotificationsChanged}
|
||||
label={_t('Enable desktop notifications for this session')}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>
|
||||
|
||||
<LabelledToggleSwitch
|
||||
value={SettingsStore.getValue("notificationBodyEnabled")}
|
||||
onChange={this.onDesktopShowBodyChanged}
|
||||
label={_t('Show message in desktop notification')}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>
|
||||
|
||||
<LabelledToggleSwitch
|
||||
value={SettingsStore.getValue("audioNotificationsEnabled")}
|
||||
onChange={this.onAudioNotificationsChanged}
|
||||
label={_t('Enable audible notifications for this session')}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>
|
||||
|
||||
{ emailSwitches }
|
||||
</>;
|
||||
}
|
||||
|
||||
private renderCategory(category: RuleClass) {
|
||||
if (category !== RuleClass.VectorOther && this.isInhibited) {
|
||||
return null; // nothing to show for the section
|
||||
}
|
||||
|
||||
let clearNotifsButton: JSX.Element;
|
||||
if (
|
||||
category === RuleClass.VectorOther
|
||||
&& MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)
|
||||
) {
|
||||
clearNotifsButton = <AccessibleButton
|
||||
onClick={this.onClearNotificationsClicked}
|
||||
kind='danger'
|
||||
className='mx_UserNotifSettings_clearNotifsButton'
|
||||
>{ _t("Clear notifications") }</AccessibleButton>;
|
||||
}
|
||||
|
||||
if (category === RuleClass.VectorOther && this.isInhibited) {
|
||||
// only render the utility buttons (if needed)
|
||||
if (clearNotifsButton) {
|
||||
return <div className='mx_UserNotifSettings_floatingSection'>
|
||||
<div>{ _t("Other") }</div>
|
||||
{ clearNotifsButton }
|
||||
</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let keywordComposer: JSX.Element;
|
||||
if (category === RuleClass.VectorMentions) {
|
||||
keywordComposer = <TagComposer
|
||||
tags={this.state.vectorKeywordRuleInfo?.rules.map(r => r.pattern)}
|
||||
onAdd={this.onKeywordAdd}
|
||||
onRemove={this.onKeywordRemove}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
label={_t("Keyword")}
|
||||
placeholder={_t("New keyword")}
|
||||
/>;
|
||||
}
|
||||
|
||||
const makeRadio = (r: IVectorPushRule, s: VectorState) => (
|
||||
<StyledRadioButton
|
||||
key={r.ruleId}
|
||||
name={r.ruleId}
|
||||
checked={r.vectorState === s}
|
||||
onChange={this.onRadioChecked.bind(this, r, s)}
|
||||
disabled={this.state.phase === Phase.Persisting}
|
||||
/>
|
||||
);
|
||||
|
||||
const rows = this.state.vectorPushRules[category].map(r => <tr key={category + r.ruleId}>
|
||||
<td>{ r.description }</td>
|
||||
<td>{ makeRadio(r, VectorState.Off) }</td>
|
||||
<td>{ makeRadio(r, VectorState.On) }</td>
|
||||
<td>{ makeRadio(r, VectorState.Loud) }</td>
|
||||
</tr>);
|
||||
|
||||
let sectionName: TranslatedString;
|
||||
switch (category) {
|
||||
case RuleClass.VectorGlobal:
|
||||
sectionName = _t("Global");
|
||||
break;
|
||||
case RuleClass.VectorMentions:
|
||||
sectionName = _t("Mentions & keywords");
|
||||
break;
|
||||
case RuleClass.VectorOther:
|
||||
sectionName = _t("Other");
|
||||
break;
|
||||
default:
|
||||
throw new Error("Developer error: Unnamed notifications section: " + category);
|
||||
}
|
||||
|
||||
return <>
|
||||
<table className='mx_UserNotifSettings_pushRulesTable'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{ sectionName }</th>
|
||||
<th>{ _t("Off") }</th>
|
||||
<th>{ _t("On") }</th>
|
||||
<th>{ _t("Noisy") }</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ rows }
|
||||
</tbody>
|
||||
</table>
|
||||
{ clearNotifsButton }
|
||||
{ keywordComposer }
|
||||
</>;
|
||||
}
|
||||
|
||||
private renderTargets() {
|
||||
if (this.isInhibited) return null; // no targets if there's no notifications
|
||||
|
||||
const rows = this.state.pushers.map(p => <tr key={p.kind+p.pushkey}>
|
||||
<td>{ p.app_display_name }</td>
|
||||
<td>{ p.device_display_name }</td>
|
||||
</tr>);
|
||||
|
||||
if (!rows.length) return null; // no targets to show
|
||||
|
||||
return <div className='mx_UserNotifSettings_floatingSection'>
|
||||
<div>{ _t("Notification targets") }</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{ rows }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.phase === Phase.Loading) {
|
||||
// Ends up default centered
|
||||
return <Spinner />;
|
||||
} else if (this.state.phase === Phase.Error) {
|
||||
return <p>{ _t("There was an error loading your notification settings.") }</p>;
|
||||
}
|
||||
|
||||
return <div className='mx_UserNotifSettings'>
|
||||
{ this.renderTopSection() }
|
||||
{ this.renderCategory(RuleClass.VectorGlobal) }
|
||||
{ this.renderCategory(RuleClass.VectorMentions) }
|
||||
{ this.renderCategory(RuleClass.VectorOther) }
|
||||
{ this.renderTargets() }
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -44,7 +44,7 @@ const REACHABILITY_TIMEOUT = 10000; // ms
|
|||
async function checkIdentityServerUrl(u) {
|
||||
const parsedUrl = url.parse(u);
|
||||
|
||||
if (parsedUrl.protocol !== 'https:') return _t("Identity Server URL must be HTTPS");
|
||||
if (parsedUrl.protocol !== 'https:') return _t("Identity server URL must be HTTPS");
|
||||
|
||||
// XXX: duplicated logic from js-sdk but it's quite tied up in the validation logic in the
|
||||
// js-sdk so probably as easy to duplicate it than to separate it out so we can reuse it
|
||||
|
@ -53,17 +53,17 @@ async function checkIdentityServerUrl(u) {
|
|||
if (response.ok) {
|
||||
return null;
|
||||
} else if (response.status < 200 || response.status >= 300) {
|
||||
return _t("Not a valid Identity Server (status code %(code)s)", { code: response.status });
|
||||
return _t("Not a valid identity server (status code %(code)s)", { code: response.status });
|
||||
} else {
|
||||
return _t("Could not connect to Identity Server");
|
||||
return _t("Could not connect to identity server");
|
||||
}
|
||||
} catch (e) {
|
||||
return _t("Could not connect to Identity Server");
|
||||
return _t("Could not connect to identity server");
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// Whether or not the ID server is missing terms. This affects the text
|
||||
// Whether or not the identity server is missing terms. This affects the text
|
||||
// shown to the user.
|
||||
missingTerms: boolean;
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
|
||||
let defaultIdServer = '';
|
||||
if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
|
||||
// If no ID server is configured but there's one in the config, prepopulate
|
||||
// If no identity server is configured but there's one in the config, prepopulate
|
||||
// the field to help the user.
|
||||
defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl());
|
||||
}
|
||||
|
@ -112,7 +112,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
// We react to changes in the ID server in the event the user is staring at this form
|
||||
// We react to changes in the identity server in the event the user is staring at this form
|
||||
// when changing their identity server on another device.
|
||||
if (payload.action !== "id_server_changed") return;
|
||||
|
||||
|
@ -356,7 +356,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
let sectionTitle;
|
||||
let bodyText;
|
||||
if (idServerUrl) {
|
||||
sectionTitle = _t("Identity Server (%(server)s)", { server: abbreviateUrl(idServerUrl) });
|
||||
sectionTitle = _t("Identity server (%(server)s)", { server: abbreviateUrl(idServerUrl) });
|
||||
bodyText = _t(
|
||||
"You are currently using <server></server> to discover and be discoverable by " +
|
||||
"existing contacts you know. You can change your identity server below.",
|
||||
|
@ -371,7 +371,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
sectionTitle = _t("Identity Server");
|
||||
sectionTitle = _t("Identity server");
|
||||
bodyText = _t(
|
||||
"You are not currently using an identity server. " +
|
||||
"To discover and be discoverable by existing contacts you know, " +
|
||||
|
|
|
@ -65,13 +65,13 @@ export default class SetIntegrationManager extends React.Component<IProps, IStat
|
|||
if (currentManager) {
|
||||
managerName = `(${currentManager.name})`;
|
||||
bodyText = _t(
|
||||
"Use an Integration Manager <b>(%(serverName)s)</b> to manage bots, widgets, " +
|
||||
"Use an integration manager <b>(%(serverName)s)</b> to manage bots, widgets, " +
|
||||
"and sticker packs.",
|
||||
{ serverName: currentManager.name },
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
);
|
||||
} else {
|
||||
bodyText = _t("Use an Integration Manager to manage bots, widgets, and sticker packs.");
|
||||
bodyText = _t("Use an integration manager to manage bots, widgets, and sticker packs.");
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -86,7 +86,7 @@ export default class SetIntegrationManager extends React.Component<IProps, IStat
|
|||
<br />
|
||||
<br />
|
||||
{_t(
|
||||
"Integration Managers receive configuration data, and can modify widgets, " +
|
||||
"Integration managers receive configuration data, and can modify widgets, " +
|
||||
"send room invites, and set power levels on your behalf.",
|
||||
)}
|
||||
</span>
|
||||
|
|
|
@ -77,7 +77,8 @@ interface IState extends IThemeState {
|
|||
export default class AppearanceUserSettingsTab extends React.Component<IProps, IState> {
|
||||
private readonly MESSAGE_PREVIEW_TEXT = _t("Hey you. You're the best!");
|
||||
|
||||
private themeTimer: NodeJS.Timeout;
|
||||
private themeTimer: number;
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -103,6 +104,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
const client = MatrixClientPeg.get();
|
||||
const userId = client.getUserId();
|
||||
const profileInfo = await client.getProfileInfo(userId);
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState({
|
||||
userId,
|
||||
|
@ -111,6 +113,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
private calculateThemeState(): IThemeState {
|
||||
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
|
||||
// show the right values for things.
|
||||
|
|
|
@ -364,7 +364,7 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
onFinished={this.state.requiredPolicyInfo.resolve}
|
||||
introElement={intro}
|
||||
/>
|
||||
{ /* has its own heading as it includes the current ID server */ }
|
||||
{ /* has its own heading as it includes the current identity server */ }
|
||||
<SetIdServer missingTerms={true} />
|
||||
</div>
|
||||
);
|
||||
|
@ -387,7 +387,7 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
return (
|
||||
<div className="mx_SettingsTab_section">
|
||||
{threepidSection}
|
||||
{ /* has its own heading as it includes the current ID server */ }
|
||||
{ /* has its own heading as it includes the current identity server */ }
|
||||
<SetIdServer />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -290,7 +290,7 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
|
||||
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
|
||||
{_t("Identity server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
|
||||
<br />
|
||||
<details>
|
||||
<summary>{_t("Access Token")}</summary><br />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,17 +16,12 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import * as sdk from "../../../../../index";
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import Notifications from "../../Notifications";
|
||||
|
||||
@replaceableComponent("views.settings.tabs.user.NotificationUserSettingsTab")
|
||||
export default class NotificationUserSettingsTab extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
render() {
|
||||
const Notifications = sdk.getComponent("views.settings.Notifications");
|
||||
return (
|
||||
<div className="mx_SettingsTab mx_NotificationUserSettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{_t("Notifications")}</div>
|
|
@ -42,7 +42,6 @@ import {
|
|||
import { Key } from "../../../Keyboard";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationState } from "../../../stores/notifications/NotificationState";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
interface IButtonProps {
|
||||
space?: Room;
|
||||
|
@ -134,7 +133,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
|
|||
const [invites, spaces, activeSpace] = useSpaces();
|
||||
const activeSpaces = activeSpace ? [activeSpace] : [];
|
||||
|
||||
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms")
|
||||
const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
|
||||
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
|
||||
|
||||
return <div className="mx_SpaceTreeLevel">
|
||||
|
@ -142,7 +141,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
|
|||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
selected={!activeSpace}
|
||||
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")}
|
||||
tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
|
||||
notificationState={homeNotificationState}
|
||||
isNarrow={isPanelCollapsed}
|
||||
/>
|
||||
|
|
|
@ -39,7 +39,7 @@ enum SpaceVisibility {
|
|||
|
||||
const useLocalEcho = <T extends any>(
|
||||
currentFactory: () => T,
|
||||
setterFn: (value: T) => Promise<void>,
|
||||
setterFn: (value: T) => Promise<unknown>,
|
||||
errorFn: (error: Error) => void,
|
||||
): [value: T, handler: (value: T) => void] => {
|
||||
const [value, setValue] = useState(currentFactory);
|
||||
|
|
|
@ -44,7 +44,7 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.toasts.VerificationRequestToast")
|
||||
export default class VerificationRequestToast extends React.PureComponent<IProps, IState> {
|
||||
private intervalHandle: NodeJS.Timeout;
|
||||
private intervalHandle: number;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
|
@ -30,6 +30,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import UIStore from '../../../stores/UIStore';
|
||||
import { lerp } from '../../../utils/AnimationUtils';
|
||||
import { MarkedExecution } from '../../../utils/MarkedExecution';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
|
||||
const PIP_VIEW_WIDTH = 336;
|
||||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
@ -108,7 +109,7 @@ function getPrimarySecondaryCalls(calls: MatrixCall[]): [MatrixCall, MatrixCall[
|
|||
*/
|
||||
@replaceableComponent("views.voip.CallPreview")
|
||||
export default class CallPreview extends React.Component<IProps, IState> {
|
||||
private roomStoreToken: any;
|
||||
private roomStoreToken: EventSubscription;
|
||||
private dispatcherRef: string;
|
||||
private settingsWatcherRef: string;
|
||||
private callViewWrapper = createRef<HTMLDivElement>();
|
||||
|
@ -240,7 +241,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private onRoomViewStoreUpdate = (payload) => {
|
||||
private onRoomViewStoreUpdate = () => {
|
||||
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
||||
|
||||
const roomId = RoomViewStore.getRoomId();
|
||||
|
|
|
@ -19,16 +19,17 @@ import AccessibleButton from "../elements/AccessibleButton";
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
const BUTTON_LETTERS = ['', 'ABC', 'DEF', 'GHI', 'JKL', 'MNO', 'PQRS', 'TUV', 'WXYZ', '', '+', ''];
|
||||
|
||||
enum DialPadButtonKind {
|
||||
Digit,
|
||||
Delete,
|
||||
Dial,
|
||||
}
|
||||
|
||||
interface IButtonProps {
|
||||
kind: DialPadButtonKind;
|
||||
digit?: string;
|
||||
digitSubtext?: string;
|
||||
onButtonPress: (string) => void;
|
||||
}
|
||||
|
||||
|
@ -42,11 +43,10 @@ class DialPadButton extends React.PureComponent<IButtonProps> {
|
|||
case DialPadButtonKind.Digit:
|
||||
return <AccessibleButton className="mx_DialPad_button" onClick={this.onClick}>
|
||||
{this.props.digit}
|
||||
<div className="mx_DialPad_buttonSubText">
|
||||
{this.props.digitSubtext}
|
||||
</div>
|
||||
</AccessibleButton>;
|
||||
case DialPadButtonKind.Delete:
|
||||
return <AccessibleButton className="mx_DialPad_button mx_DialPad_deleteButton"
|
||||
onClick={this.onClick}
|
||||
/>;
|
||||
case DialPadButtonKind.Dial:
|
||||
return <AccessibleButton className="mx_DialPad_button mx_DialPad_dialButton" onClick={this.onClick} />;
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ class DialPadButton extends React.PureComponent<IButtonProps> {
|
|||
|
||||
interface IProps {
|
||||
onDigitPress: (string) => void;
|
||||
hasDialAndDelete: boolean;
|
||||
hasDial: boolean;
|
||||
onDeletePress?: (string) => void;
|
||||
onDialPress?: (string) => void;
|
||||
}
|
||||
|
@ -65,16 +65,15 @@ export default class Dialpad extends React.PureComponent<IProps> {
|
|||
render() {
|
||||
const buttonNodes = [];
|
||||
|
||||
for (const button of BUTTONS) {
|
||||
for (let i = 0; i < BUTTONS.length; i++) {
|
||||
const button = BUTTONS[i];
|
||||
const digitSubtext = BUTTON_LETTERS[i];
|
||||
buttonNodes.push(<DialPadButton key={button} kind={DialPadButtonKind.Digit}
|
||||
digit={button} onButtonPress={this.props.onDigitPress}
|
||||
digit={button} digitSubtext={digitSubtext} onButtonPress={this.props.onDigitPress}
|
||||
/>);
|
||||
}
|
||||
|
||||
if (this.props.hasDialAndDelete) {
|
||||
buttonNodes.push(<DialPadButton key="del" kind={DialPadButtonKind.Delete}
|
||||
onButtonPress={this.props.onDeletePress}
|
||||
/>);
|
||||
if (this.props.hasDial) {
|
||||
buttonNodes.push(<DialPadButton key="dial" kind={DialPadButtonKind.Dial}
|
||||
onButtonPress={this.props.onDialPress}
|
||||
/>);
|
||||
|
|
|
@ -15,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import DialPad from './DialPad';
|
||||
|
@ -23,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { DialNumberPayload } from "../../../dispatcher/payloads/DialNumberPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (boolean) => void;
|
||||
|
@ -74,22 +74,38 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const backspaceButton = (
|
||||
<DialPadBackspaceButton onBackspacePress={this.onDeletePress} />
|
||||
);
|
||||
|
||||
// Only show the backspace button if the field has content
|
||||
let dialPadField;
|
||||
if (this.state.value.length !== 0) {
|
||||
dialPadField = <Field className="mx_DialPadModal_field" id="dialpad_number"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
postfixComponent={backspaceButton}
|
||||
/>;
|
||||
} else {
|
||||
dialPadField = <Field className="mx_DialPadModal_field" id="dialpad_number"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className="mx_DialPadModal">
|
||||
<div>
|
||||
<AccessibleButton className="mx_DialPadModal_cancel" onClick={this.onCancelClick} />
|
||||
</div>
|
||||
<div className="mx_DialPadModal_header">
|
||||
<div>
|
||||
<span className="mx_DialPadModal_title">{_t("Dial pad")}</span>
|
||||
<AccessibleButton className="mx_DialPadModal_cancel" onClick={this.onCancelClick} />
|
||||
</div>
|
||||
<form onSubmit={this.onFormSubmit}>
|
||||
<Field className="mx_DialPadModal_field" id="dialpad_number"
|
||||
value={this.state.value} autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
{dialPadField}
|
||||
</form>
|
||||
</div>
|
||||
<div className="mx_DialPadModal_horizSep" />
|
||||
<div className="mx_DialPadModal_dialPad">
|
||||
<DialPad hasDialAndDelete={true}
|
||||
<DialPad hasDial={true}
|
||||
onDigitPress={this.onDigitPress}
|
||||
onDeletePress={this.onDeletePress}
|
||||
onDialPress={this.onDialPress}
|
||||
|
|
|
@ -41,6 +41,7 @@ const RoomContext = createContext<IState>({
|
|||
canReply: false,
|
||||
layout: Layout.Group,
|
||||
lowBandwidth: false,
|
||||
showHiddenEventsInTimeline: false,
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
|
|
|
@ -32,11 +32,16 @@ export interface IEncryptedFile {
|
|||
}
|
||||
|
||||
export interface IMediaEventContent {
|
||||
body?: string;
|
||||
url?: string; // required on unencrypted media
|
||||
file?: IEncryptedFile; // required for *encrypted* media
|
||||
info?: {
|
||||
thumbnail_url?: string; // eslint-disable-line camelcase
|
||||
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
|
||||
mimetype: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
size?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -118,6 +118,18 @@ export enum Action {
|
|||
*/
|
||||
DialNumber = "dial_number",
|
||||
|
||||
/**
|
||||
* Start a call transfer to a Matrix ID
|
||||
* payload: TransferCallPayload
|
||||
*/
|
||||
TransferCallToMatrixID = "transfer_call_to_matrix_id",
|
||||
|
||||
/**
|
||||
* Start a call transfer to a phone number
|
||||
* payload: TransferCallPayload
|
||||
*/
|
||||
TransferCallToPhoneNumber = "transfer_call_to_phone_number",
|
||||
|
||||
/**
|
||||
* Fired when CallHandler has checked for PSTN protocol support
|
||||
* payload: none
|
||||
|
|
33
src/dispatcher/payloads/TransferCallPayload.ts
Normal file
33
src/dispatcher/payloads/TransferCallPayload.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
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 { ActionPayload } from "../payloads";
|
||||
import { Action } from "../actions";
|
||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
export interface TransferCallPayload extends ActionPayload {
|
||||
action: Action.TransferCallToMatrixID | Action.TransferCallToPhoneNumber;
|
||||
// The call to transfer
|
||||
call: MatrixCall;
|
||||
// Where to transfer the call. A Matrix ID if action == TransferCallToMatrixID
|
||||
// and a phone number if action == TransferCallToPhoneNumber
|
||||
destination: string;
|
||||
// If true, puts the current call on hold and dials the transfer target, giving
|
||||
// the user a button to complete the transfer when ready.
|
||||
// If false, ends the call immediately and sends the user to the transfer
|
||||
// destination
|
||||
consultFirst: boolean;
|
||||
}
|
|
@ -65,6 +65,9 @@
|
|||
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
|
||||
"Unable to look up phone number": "Unable to look up phone number",
|
||||
"There was an error looking up the phone number": "There was an error looking up the phone number",
|
||||
"Unable to transfer call": "Unable to transfer call",
|
||||
"Transfer Failed": "Transfer Failed",
|
||||
"Failed to transfer call": "Failed to transfer call",
|
||||
"Call in Progress": "Call in Progress",
|
||||
"A call is currently being placed!": "A call is currently being placed!",
|
||||
"Permission Required": "Permission Required",
|
||||
|
@ -911,7 +914,6 @@
|
|||
"Fill Screen": "Fill Screen",
|
||||
"Return to call": "Return to call",
|
||||
"%(name)s on hold": "%(name)s on hold",
|
||||
"Dial pad": "Dial pad",
|
||||
"Unknown caller": "Unknown caller",
|
||||
"Incoming voice call": "Incoming voice call",
|
||||
"Incoming video call": "Incoming video call",
|
||||
|
@ -1132,33 +1134,24 @@
|
|||
"Connecting to integration manager...": "Connecting to integration manager...",
|
||||
"Cannot connect to integration manager": "Cannot connect to integration manager",
|
||||
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
|
||||
"Error saving email notification preferences": "Error saving email notification preferences",
|
||||
"An error occurred whilst saving your email notification preferences.": "An error occurred whilst saving your email notification preferences.",
|
||||
"Keywords": "Keywords",
|
||||
"Enter keywords separated by a comma:": "Enter keywords separated by a comma:",
|
||||
"Failed to change settings": "Failed to change settings",
|
||||
"Can't update user notification settings": "Can't update user notification settings",
|
||||
"Failed to update keywords": "Failed to update keywords",
|
||||
"Messages containing <span>keywords</span>": "Messages containing <span>keywords</span>",
|
||||
"Notify for all other messages/rooms": "Notify for all other messages/rooms",
|
||||
"Notify me for anything else": "Notify me for anything else",
|
||||
"Enable notifications for this account": "Enable notifications for this account",
|
||||
"Clear notifications": "Clear notifications",
|
||||
"All notifications are currently disabled for all targets.": "All notifications are currently disabled for all targets.",
|
||||
"Enable email notifications": "Enable email notifications",
|
||||
"Add an email address to configure email notifications": "Add an email address to configure email notifications",
|
||||
"Notifications on the following keywords follow rules which can’t be displayed here:": "Notifications on the following keywords follow rules which can’t be displayed here:",
|
||||
"Unable to fetch notification target list": "Unable to fetch notification target list",
|
||||
"Notification targets": "Notification targets",
|
||||
"Advanced notification settings": "Advanced notification settings",
|
||||
"There are advanced notifications which are not shown here.": "There are advanced notifications which are not shown here.",
|
||||
"You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.",
|
||||
"Messages containing keywords": "Messages containing keywords",
|
||||
"Error saving notification preferences": "Error saving notification preferences",
|
||||
"An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
|
||||
"Enable for this account": "Enable for this account",
|
||||
"Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
|
||||
"Enable desktop notifications for this session": "Enable desktop notifications for this session",
|
||||
"Show message in desktop notification": "Show message in desktop notification",
|
||||
"Enable audible notifications for this session": "Enable audible notifications for this session",
|
||||
"Clear notifications": "Clear notifications",
|
||||
"Keyword": "Keyword",
|
||||
"New keyword": "New keyword",
|
||||
"Global": "Global",
|
||||
"Mentions & keywords": "Mentions & keywords",
|
||||
"Off": "Off",
|
||||
"On": "On",
|
||||
"Noisy": "Noisy",
|
||||
"Notification targets": "Notification targets",
|
||||
"There was an error loading your notification settings.": "There was an error loading your notification settings.",
|
||||
"Failed to save your profile": "Failed to save your profile",
|
||||
"The operation could not be completed": "The operation could not be completed",
|
||||
"<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
|
||||
|
@ -1203,9 +1196,9 @@
|
|||
"Secret storage:": "Secret storage:",
|
||||
"ready": "ready",
|
||||
"not ready": "not ready",
|
||||
"Identity Server URL must be HTTPS": "Identity Server URL must be HTTPS",
|
||||
"Not a valid Identity Server (status code %(code)s)": "Not a valid Identity Server (status code %(code)s)",
|
||||
"Could not connect to Identity Server": "Could not connect to Identity Server",
|
||||
"Identity server URL must be HTTPS": "Identity server URL must be HTTPS",
|
||||
"Not a valid identity server (status code %(code)s)": "Not a valid identity server (status code %(code)s)",
|
||||
"Could not connect to identity server": "Could not connect to identity server",
|
||||
"Checking server": "Checking server",
|
||||
"Change identity server": "Change identity server",
|
||||
"Disconnect from the identity server <current /> and connect to <new /> instead?": "Disconnect from the identity server <current /> and connect to <new /> instead?",
|
||||
|
@ -1222,20 +1215,20 @@
|
|||
"Disconnect anyway": "Disconnect anyway",
|
||||
"You are still <b>sharing your personal data</b> on the identity server <idserver />.": "You are still <b>sharing your personal data</b> on the identity server <idserver />.",
|
||||
"We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.",
|
||||
"Identity Server (%(server)s)": "Identity Server (%(server)s)",
|
||||
"Identity server (%(server)s)": "Identity server (%(server)s)",
|
||||
"You are currently using <server></server> to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using <server></server> to discover and be discoverable by existing contacts you know. You can change your identity server below.",
|
||||
"If you don't want to use <server /> to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use <server /> to discover and be discoverable by existing contacts you know, enter another identity server below.",
|
||||
"Identity Server": "Identity Server",
|
||||
"Identity server": "Identity server",
|
||||
"You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.",
|
||||
"Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.",
|
||||
"Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.",
|
||||
"Do not use an identity server": "Do not use an identity server",
|
||||
"Enter a new identity server": "Enter a new identity server",
|
||||
"Change": "Change",
|
||||
"Use an Integration Manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.": "Use an Integration Manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.",
|
||||
"Use an Integration Manager to manage bots, widgets, and sticker packs.": "Use an Integration Manager to manage bots, widgets, and sticker packs.",
|
||||
"Use an integration manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.": "Use an integration manager <b>(%(serverName)s)</b> to manage bots, widgets, and sticker packs.",
|
||||
"Use an integration manager to manage bots, widgets, and sticker packs.": "Use an integration manager to manage bots, widgets, and sticker packs.",
|
||||
"Manage integrations": "Manage integrations",
|
||||
"Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.",
|
||||
"Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.",
|
||||
"Add": "Add",
|
||||
"Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).",
|
||||
"Checking for an update...": "Checking for an update...",
|
||||
|
@ -1292,7 +1285,7 @@
|
|||
"%(brand)s version:": "%(brand)s version:",
|
||||
"olm version:": "olm version:",
|
||||
"Homeserver is": "Homeserver is",
|
||||
"Identity Server is": "Identity Server is",
|
||||
"Identity server is": "Identity server is",
|
||||
"Access Token": "Access Token",
|
||||
"Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.",
|
||||
"Copy": "Copy",
|
||||
|
@ -1660,7 +1653,6 @@
|
|||
"Show %(count)s more|other": "Show %(count)s more",
|
||||
"Show %(count)s more|one": "Show %(count)s more",
|
||||
"Show less": "Show less",
|
||||
"Use default": "Use default",
|
||||
"All messages": "All messages",
|
||||
"Mentions & Keywords": "Mentions & Keywords",
|
||||
"Notification options": "Notification options",
|
||||
|
@ -1669,6 +1661,7 @@
|
|||
"Favourite": "Favourite",
|
||||
"Low Priority": "Low Priority",
|
||||
"Invite People": "Invite People",
|
||||
"Copy Room Link": "Copy Room Link",
|
||||
"Leave Room": "Leave Room",
|
||||
"Room options": "Room options",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||
|
@ -1971,7 +1964,7 @@
|
|||
"%(brand)s URL": "%(brand)s URL",
|
||||
"Room ID": "Room ID",
|
||||
"Widget ID": "Widget ID",
|
||||
"Using this widget may share data <helpIcon /> with %(widgetDomain)s & your Integration Manager.": "Using this widget may share data <helpIcon /> with %(widgetDomain)s & your Integration Manager.",
|
||||
"Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.": "Using this widget may share data <helpIcon /> with %(widgetDomain)s & your integration manager.",
|
||||
"Using this widget may share data <helpIcon /> with %(widgetDomain)s.": "Using this widget may share data <helpIcon /> with %(widgetDomain)s.",
|
||||
"Widgets do not use message encryption.": "Widgets do not use message encryption.",
|
||||
"Widget added by": "Widget added by",
|
||||
|
@ -2289,7 +2282,7 @@
|
|||
"Integrations are disabled": "Integrations are disabled",
|
||||
"Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.",
|
||||
"Integrations not allowed": "Integrations not allowed",
|
||||
"Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.",
|
||||
"Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.",
|
||||
"To continue, use Single Sign On to prove your identity.": "To continue, use Single Sign On to prove your identity.",
|
||||
"Confirm to continue": "Confirm to continue",
|
||||
"Click the button below to confirm your identity.": "Click the button below to confirm your identity.",
|
||||
|
@ -2298,7 +2291,6 @@
|
|||
"Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.",
|
||||
"We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.",
|
||||
"A call can only be transferred to a single user.": "A call can only be transferred to a single user.",
|
||||
"Failed to transfer call": "Failed to transfer call",
|
||||
"Failed to find the following users": "Failed to find the following users",
|
||||
"The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s",
|
||||
"Recent Conversations": "Recent Conversations",
|
||||
|
@ -2321,6 +2313,8 @@
|
|||
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
|
||||
"Transfer": "Transfer",
|
||||
"Consult first": "Consult first",
|
||||
"User Directory": "User Directory",
|
||||
"Dial pad": "Dial pad",
|
||||
"a new master key signature": "a new master key signature",
|
||||
"a new cross-signing key signature": "a new cross-signing key signature",
|
||||
"a device cross-signing signature": "a device cross-signing signature",
|
||||
|
@ -2444,7 +2438,7 @@
|
|||
"Missing session data": "Missing session data",
|
||||
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.",
|
||||
"Your browser likely removed this data when running low on disk space.": "Your browser likely removed this data when running low on disk space.",
|
||||
"Integration Manager": "Integration Manager",
|
||||
"Integration manager": "Integration manager",
|
||||
"Find others by phone or email": "Find others by phone or email",
|
||||
"Be found by phone or email": "Be found by phone or email",
|
||||
"Use bots, bridges, widgets and sticker packs": "Use bots, bridges, widgets and sticker packs",
|
||||
|
@ -2675,6 +2669,8 @@
|
|||
"Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?",
|
||||
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
|
||||
"Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s",
|
||||
"Unable to copy room link": "Unable to copy room link",
|
||||
"Unable to copy a link to the room to the clipboard.": "Unable to copy a link to the room to the clipboard.",
|
||||
"Signed Out": "Signed Out",
|
||||
"For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.",
|
||||
"Terms and Conditions": "Terms and Conditions",
|
||||
|
|
|
@ -14,47 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IMatrixProfile, IEventWithRoomId as IMatrixEvent, IResultRoomEvents } from "matrix-js-sdk/src/@types/search";
|
||||
import { Direction } from "matrix-js-sdk/src";
|
||||
|
||||
// The following interfaces take their names and member names from seshat and the spec
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
export interface IMatrixEvent {
|
||||
type: string;
|
||||
sender: string;
|
||||
content: {};
|
||||
event_id: string;
|
||||
origin_server_ts: number;
|
||||
unsigned?: {};
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface IMatrixProfile {
|
||||
avatar_url: string;
|
||||
displayname: string;
|
||||
}
|
||||
|
||||
export interface ICrawlerCheckpoint {
|
||||
roomId: string;
|
||||
token: string;
|
||||
fullCrawl?: boolean;
|
||||
direction: string;
|
||||
}
|
||||
|
||||
export interface IResultContext {
|
||||
events_before: [IMatrixEvent];
|
||||
events_after: [IMatrixEvent];
|
||||
profile_info: Map<string, IMatrixProfile>;
|
||||
}
|
||||
|
||||
export interface IResultsElement {
|
||||
rank: number;
|
||||
result: IMatrixEvent;
|
||||
context: IResultContext;
|
||||
}
|
||||
|
||||
export interface ISearchResult {
|
||||
count: number;
|
||||
results: [IResultsElement];
|
||||
highlights: [string];
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
export interface ISearchArgs {
|
||||
|
@ -63,6 +32,8 @@ export interface ISearchArgs {
|
|||
after_limit: number;
|
||||
order_by_recency: boolean;
|
||||
room_id?: string;
|
||||
limit: number;
|
||||
next_batch?: string;
|
||||
}
|
||||
|
||||
export interface IEventAndProfile {
|
||||
|
@ -205,10 +176,10 @@ export default abstract class BaseEventIndexManager {
|
|||
* @param {ISearchArgs} searchArgs The search configuration for the search,
|
||||
* sets the search term and determines the search result contents.
|
||||
*
|
||||
* @return {Promise<[ISearchResult]>} A promise that will resolve to an array
|
||||
* @return {Promise<IResultRoomEvents[]>} A promise that will resolve to an array
|
||||
* of search results once the search is done.
|
||||
*/
|
||||
async searchEventIndex(searchArgs: ISearchArgs): Promise<ISearchResult> {
|
||||
async searchEventIndex(searchArgs: ISearchArgs): Promise<IResultRoomEvents> {
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
|
|||
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
|
||||
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { IResultRoomEvents } from "matrix-js-sdk/src/@types/search";
|
||||
|
||||
import PlatformPeg from "../PlatformPeg";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
|
@ -66,7 +67,6 @@ export default class EventIndex extends EventEmitter {
|
|||
|
||||
client.on('sync', this.onSync);
|
||||
client.on('Room.timeline', this.onRoomTimeline);
|
||||
client.on('Event.decrypted', this.onEventDecrypted);
|
||||
client.on('Room.timelineReset', this.onTimelineReset);
|
||||
client.on('Room.redaction', this.onRedaction);
|
||||
client.on('RoomState.events', this.onRoomStateEvent);
|
||||
|
@ -81,7 +81,6 @@ export default class EventIndex extends EventEmitter {
|
|||
|
||||
client.removeListener('sync', this.onSync);
|
||||
client.removeListener('Room.timeline', this.onRoomTimeline);
|
||||
client.removeListener('Event.decrypted', this.onEventDecrypted);
|
||||
client.removeListener('Room.timelineReset', this.onTimelineReset);
|
||||
client.removeListener('Room.redaction', this.onRedaction);
|
||||
client.removeListener('RoomState.events', this.onRoomStateEvent);
|
||||
|
@ -114,14 +113,14 @@ export default class EventIndex extends EventEmitter {
|
|||
const backCheckpoint: ICrawlerCheckpoint = {
|
||||
roomId: room.roomId,
|
||||
token: token,
|
||||
direction: "b",
|
||||
direction: Direction.Backward,
|
||||
fullCrawl: true,
|
||||
};
|
||||
|
||||
const forwardCheckpoint: ICrawlerCheckpoint = {
|
||||
roomId: room.roomId,
|
||||
token: token,
|
||||
direction: "f",
|
||||
direction: Direction.Forward,
|
||||
};
|
||||
|
||||
try {
|
||||
|
@ -220,18 +219,6 @@ export default class EventIndex extends EventEmitter {
|
|||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* The Event.decrypted listener.
|
||||
*
|
||||
* Checks if the event was marked for addition in the Room.timeline
|
||||
* listener, if so queues it up to be added to the index.
|
||||
*/
|
||||
private onEventDecrypted = async (ev: MatrixEvent, err: Error) => {
|
||||
// If the event isn't in our live event set, ignore it.
|
||||
if (err) return;
|
||||
await this.addLiveEventToIndex(ev);
|
||||
};
|
||||
|
||||
/*
|
||||
* The Room.redaction listener.
|
||||
*
|
||||
|
@ -384,7 +371,7 @@ export default class EventIndex extends EventEmitter {
|
|||
roomId: room.roomId,
|
||||
token: token,
|
||||
fullCrawl: fullCrawl,
|
||||
direction: "b",
|
||||
direction: Direction.Backward,
|
||||
};
|
||||
|
||||
console.log("EventIndex: Adding checkpoint", checkpoint);
|
||||
|
@ -671,10 +658,10 @@ export default class EventIndex extends EventEmitter {
|
|||
* @param {ISearchArgs} searchArgs The search configuration for the search,
|
||||
* sets the search term and determines the search result contents.
|
||||
*
|
||||
* @return {Promise<[SearchResult]>} A promise that will resolve to an array
|
||||
* @return {Promise<IResultRoomEvents[]>} A promise that will resolve to an array
|
||||
* of search results once the search is done.
|
||||
*/
|
||||
public async search(searchArgs: ISearchArgs) {
|
||||
public async search(searchArgs: ISearchArgs): Promise<IResultRoomEvents> {
|
||||
const indexManager = PlatformPeg.get().getEventIndexingManager();
|
||||
return indexManager.searchEventIndex(searchArgs);
|
||||
}
|
||||
|
|
|
@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IAbortablePromise } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
export interface IUpload {
|
||||
fileName: string;
|
||||
roomId: string;
|
||||
total: number;
|
||||
loaded: number;
|
||||
promise: Promise<any>;
|
||||
promise: IAbortablePromise<any>;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket 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.
|
||||
|
@ -15,13 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { PushRuleVectorState, State } from "./PushRuleVectorState";
|
||||
import { IExtendedPushRule, IRuleSets } from "./types";
|
||||
import { PushRuleVectorState, VectorState } from "./PushRuleVectorState";
|
||||
import { IAnnotatedPushRule, IPushRules, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
|
||||
|
||||
export interface IContentRules {
|
||||
vectorState: State;
|
||||
rules: IExtendedPushRule[];
|
||||
externalRules: IExtendedPushRule[];
|
||||
vectorState: VectorState;
|
||||
rules: IAnnotatedPushRule[];
|
||||
externalRules: IAnnotatedPushRule[];
|
||||
}
|
||||
|
||||
export const SCOPE = "global";
|
||||
|
@ -39,9 +38,9 @@ export class ContentRules {
|
|||
* externalRules: a list of other keyword rules, with states other than
|
||||
* vectorState
|
||||
*/
|
||||
static parseContentRules(rulesets: IRuleSets): IContentRules {
|
||||
public static parseContentRules(rulesets: IPushRules): IContentRules {
|
||||
// first categorise the keyword rules in terms of their actions
|
||||
const contentRules = this._categoriseContentRules(rulesets);
|
||||
const contentRules = ContentRules.categoriseContentRules(rulesets);
|
||||
|
||||
// Decide which content rules to display in Vector UI.
|
||||
// Vector displays a single global rule for a list of keywords
|
||||
|
@ -59,7 +58,7 @@ export class ContentRules {
|
|||
|
||||
if (contentRules.loud.length) {
|
||||
return {
|
||||
vectorState: State.Loud,
|
||||
vectorState: VectorState.Loud,
|
||||
rules: contentRules.loud,
|
||||
externalRules: [
|
||||
...contentRules.loud_but_disabled,
|
||||
|
@ -70,33 +69,33 @@ export class ContentRules {
|
|||
};
|
||||
} else if (contentRules.loud_but_disabled.length) {
|
||||
return {
|
||||
vectorState: State.Off,
|
||||
vectorState: VectorState.Off,
|
||||
rules: contentRules.loud_but_disabled,
|
||||
externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other],
|
||||
};
|
||||
} else if (contentRules.on.length) {
|
||||
return {
|
||||
vectorState: State.On,
|
||||
vectorState: VectorState.On,
|
||||
rules: contentRules.on,
|
||||
externalRules: [...contentRules.on_but_disabled, ...contentRules.other],
|
||||
};
|
||||
} else if (contentRules.on_but_disabled.length) {
|
||||
return {
|
||||
vectorState: State.Off,
|
||||
vectorState: VectorState.Off,
|
||||
rules: contentRules.on_but_disabled,
|
||||
externalRules: contentRules.other,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
vectorState: State.On,
|
||||
vectorState: VectorState.On,
|
||||
rules: [],
|
||||
externalRules: contentRules.other,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static _categoriseContentRules(rulesets: IRuleSets) {
|
||||
const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = {
|
||||
private static categoriseContentRules(rulesets: IPushRules) {
|
||||
const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IAnnotatedPushRule[]> = {
|
||||
on: [],
|
||||
on_but_disabled: [],
|
||||
loud: [],
|
||||
|
@ -109,7 +108,7 @@ export class ContentRules {
|
|||
const r = rulesets.global[kind][i];
|
||||
|
||||
// check it's not a default rule
|
||||
if (r.rule_id[0] === '.' || kind !== "content") {
|
||||
if (r.rule_id[0] === '.' || kind !== PushRuleKind.ContentSpecific) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -117,14 +116,14 @@ export class ContentRules {
|
|||
r.kind = kind;
|
||||
|
||||
switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
|
||||
case State.On:
|
||||
case VectorState.On:
|
||||
if (r.enabled) {
|
||||
contentRules.on.push(r);
|
||||
} else {
|
||||
contentRules.on_but_disabled.push(r);
|
||||
}
|
||||
break;
|
||||
case State.Loud:
|
||||
case VectorState.Loud:
|
||||
if (r.enabled) {
|
||||
contentRules.loud.push(r);
|
||||
} else {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket 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.
|
||||
|
@ -15,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Action, Actions } from "./types";
|
||||
import { PushRuleAction, PushRuleActionName, TweakHighlight, TweakSound } from "matrix-js-sdk/src/@types/PushRules";
|
||||
|
||||
interface IEncodedActions {
|
||||
notify: boolean;
|
||||
|
@ -30,23 +29,23 @@ export class NotificationUtils {
|
|||
// "highlight: true/false,
|
||||
// }
|
||||
// to a list of push actions.
|
||||
static encodeActions(action: IEncodedActions) {
|
||||
static encodeActions(action: IEncodedActions): PushRuleAction[] {
|
||||
const notify = action.notify;
|
||||
const sound = action.sound;
|
||||
const highlight = action.highlight;
|
||||
if (notify) {
|
||||
const actions: Action[] = [Actions.Notify];
|
||||
const actions: PushRuleAction[] = [PushRuleActionName.Notify];
|
||||
if (sound) {
|
||||
actions.push({ "set_tweak": "sound", "value": sound });
|
||||
actions.push({ "set_tweak": "sound", "value": sound } as TweakSound);
|
||||
}
|
||||
if (highlight) {
|
||||
actions.push({ "set_tweak": "highlight" });
|
||||
actions.push({ "set_tweak": "highlight" } as TweakHighlight);
|
||||
} else {
|
||||
actions.push({ "set_tweak": "highlight", "value": false });
|
||||
actions.push({ "set_tweak": "highlight", "value": false } as TweakHighlight);
|
||||
}
|
||||
return actions;
|
||||
} else {
|
||||
return [Actions.DontNotify];
|
||||
return [PushRuleActionName.DontNotify];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,16 +55,16 @@ export class NotificationUtils {
|
|||
// "highlight: true/false,
|
||||
// }
|
||||
// If the actions couldn't be decoded then returns null.
|
||||
static decodeActions(actions: Action[]): IEncodedActions {
|
||||
static decodeActions(actions: PushRuleAction[]): IEncodedActions {
|
||||
let notify = false;
|
||||
let sound = null;
|
||||
let highlight = false;
|
||||
|
||||
for (let i = 0; i < actions.length; ++i) {
|
||||
const action = actions[i];
|
||||
if (action === Actions.Notify) {
|
||||
if (action === PushRuleActionName.Notify) {
|
||||
notify = true;
|
||||
} else if (action === Actions.DontNotify) {
|
||||
} else if (action === PushRuleActionName.DontNotify) {
|
||||
notify = false;
|
||||
} else if (typeof action === "object") {
|
||||
if (action.set_tweak === "sound") {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket 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.
|
||||
|
@ -17,9 +16,9 @@ limitations under the License.
|
|||
|
||||
import { StandardActions } from "./StandardActions";
|
||||
import { NotificationUtils } from "./NotificationUtils";
|
||||
import { IPushRule } from "./types";
|
||||
import { IPushRule } from "matrix-js-sdk/src/@types/PushRules";
|
||||
|
||||
export enum State {
|
||||
export enum VectorState {
|
||||
/** The push rule is disabled */
|
||||
Off = "off",
|
||||
/** The user will receive push notification for this rule */
|
||||
|
@ -31,26 +30,26 @@ export enum State {
|
|||
|
||||
export class PushRuleVectorState {
|
||||
// Backwards compatibility (things should probably be using the enum above instead)
|
||||
static OFF = State.Off;
|
||||
static ON = State.On;
|
||||
static LOUD = State.Loud;
|
||||
static OFF = VectorState.Off;
|
||||
static ON = VectorState.On;
|
||||
static LOUD = VectorState.Loud;
|
||||
|
||||
/**
|
||||
* Enum for state of a push rule as defined by the Vector UI.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
static states = State;
|
||||
static states = VectorState;
|
||||
|
||||
/**
|
||||
* Convert a PushRuleVectorState to a list of actions
|
||||
*
|
||||
* @return [object] list of push-rule actions
|
||||
*/
|
||||
static actionsFor(pushRuleVectorState: State) {
|
||||
if (pushRuleVectorState === State.On) {
|
||||
static actionsFor(pushRuleVectorState: VectorState) {
|
||||
if (pushRuleVectorState === VectorState.On) {
|
||||
return StandardActions.ACTION_NOTIFY;
|
||||
} else if (pushRuleVectorState === State.Loud) {
|
||||
} else if (pushRuleVectorState === VectorState.Loud) {
|
||||
return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
|
||||
}
|
||||
}
|
||||
|
@ -62,7 +61,7 @@ export class PushRuleVectorState {
|
|||
* category or in PushRuleVectorState.LOUD, regardless of its enabled
|
||||
* state. Returns null if it does not match these categories.
|
||||
*/
|
||||
static contentRuleVectorStateKind(rule: IPushRule): State {
|
||||
static contentRuleVectorStateKind(rule: IPushRule): VectorState {
|
||||
const decoded = NotificationUtils.decodeActions(rule.actions);
|
||||
|
||||
if (!decoded) {
|
||||
|
@ -80,10 +79,10 @@ export class PushRuleVectorState {
|
|||
let stateKind = null;
|
||||
switch (tweaks) {
|
||||
case 0:
|
||||
stateKind = State.On;
|
||||
stateKind = VectorState.On;
|
||||
break;
|
||||
case 2:
|
||||
stateKind = State.Loud;
|
||||
stateKind = VectorState.Loud;
|
||||
break;
|
||||
}
|
||||
return stateKind;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue