Merge remote-tracking branch 'upstream/develop' into fix/auto-avatars
This commit is contained in:
commit
2571de29bb
1191 changed files with 60895 additions and 47896 deletions
|
@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { JSXElementConstructor } from "react";
|
||||
import React, { JSXElementConstructor } from "react";
|
||||
|
||||
// Based on https://stackoverflow.com/a/53229857/3532235
|
||||
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
|
||||
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
|
||||
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
|
||||
export type ReactAnyComponent = React.Component | React.ExoticComponent;
|
||||
|
|
38
src/@types/diff-dom.ts
Normal file
38
src/@types/diff-dom.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
declare module "diff-dom" {
|
||||
export interface IDiff {
|
||||
action: string;
|
||||
name: string;
|
||||
text?: string;
|
||||
route: number[];
|
||||
value: string;
|
||||
element: unknown;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
interface IOpts {
|
||||
}
|
||||
|
||||
export class DiffDOM {
|
||||
public constructor(opts?: IOpts);
|
||||
public apply(tree: unknown, diffs: IDiff[]): unknown;
|
||||
public undo(tree: unknown, diffs: IDiff[]): unknown;
|
||||
public diff(a: HTMLElement | string, b: HTMLElement | string): IDiff[];
|
||||
}
|
||||
}
|
114
src/@types/global.d.ts
vendored
114
src/@types/global.d.ts
vendored
|
@ -15,7 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||
import * as ModernizrStatic from "modernizr";
|
||||
// Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
|
||||
import "@types/css-font-loading-module";
|
||||
import "@types/modernizr";
|
||||
|
||||
import ContentMessages from "../ContentMessages";
|
||||
import { IMatrixClientPeg } from "../MatrixClientPeg";
|
||||
import ToastStore from "../stores/ToastStore";
|
||||
|
@ -23,31 +26,37 @@ import DeviceListener from "../DeviceListener";
|
|||
import { RoomListStoreClass } from "../stores/room-list/RoomListStore";
|
||||
import { PlatformPeg } from "../PlatformPeg";
|
||||
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
|
||||
import {IntegrationManagers} from "../integrations/IntegrationManagers";
|
||||
import {ModalManager} from "../Modal";
|
||||
import { IntegrationManagers } from "../integrations/IntegrationManagers";
|
||||
import { ModalManager } from "../Modal";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import {ActiveRoomObserver} from "../ActiveRoomObserver";
|
||||
import {Notifier} from "../Notifier";
|
||||
import type {Renderer} from "react-dom";
|
||||
import { ActiveRoomObserver } from "../ActiveRoomObserver";
|
||||
import { Notifier } from "../Notifier";
|
||||
import type { Renderer } from "react-dom";
|
||||
import RightPanelStore from "../stores/RightPanelStore";
|
||||
import WidgetStore from "../stores/WidgetStore";
|
||||
import CallHandler from "../CallHandler";
|
||||
import {Analytics} from "../Analytics";
|
||||
import { Analytics } from "../Analytics";
|
||||
import CountlyAnalytics from "../CountlyAnalytics";
|
||||
import UserActivity from "../UserActivity";
|
||||
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
|
||||
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
|
||||
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
|
||||
import VoipUserMapper from "../VoipUserMapper";
|
||||
import {SpaceStoreClass} from "../stores/SpaceStore";
|
||||
import { SpaceStoreClass } from "../stores/SpaceStore";
|
||||
import TypingStore from "../stores/TypingStore";
|
||||
import { EventIndexPeg } from "../indexing/EventIndexPeg";
|
||||
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
|
||||
import { VoiceRecordingStore } from "../stores/VoiceRecordingStore";
|
||||
import PerformanceMonitor from "../performance";
|
||||
import UIStore from "../stores/UIStore";
|
||||
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
|
||||
import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
|
||||
import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake";
|
||||
import ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
||||
import { Skinner } from "../Skinner";
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Modernizr: ModernizrStatic;
|
||||
matrixChat: ReturnType<Renderer>;
|
||||
mxMatrixClientPeg: IMatrixClientPeg;
|
||||
Olm: {
|
||||
|
@ -84,6 +93,31 @@ declare global {
|
|||
mxPerformanceMonitor: PerformanceMonitor;
|
||||
mxPerformanceEntryNames: any;
|
||||
mxUIStore: UIStore;
|
||||
mxSetupEncryptionStore?: SetupEncryptionStore;
|
||||
mxRoomScrollStateStore?: RoomScrollStateStore;
|
||||
mxActiveWidgetStore?: ActiveWidgetStore;
|
||||
mxSkinner?: Skinner;
|
||||
mxOnRecaptchaLoaded?: () => void;
|
||||
electron?: Electron;
|
||||
}
|
||||
|
||||
interface DesktopCapturerSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnailURL: string;
|
||||
}
|
||||
|
||||
interface GetSourcesOptions {
|
||||
types: Array<string>;
|
||||
thumbnailSize?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
fetchWindowIcons?: boolean;
|
||||
}
|
||||
|
||||
interface Electron {
|
||||
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
|
@ -108,20 +142,7 @@ declare global {
|
|||
}
|
||||
|
||||
interface StorageEstimate {
|
||||
usageDetails?: {[key: string]: number};
|
||||
}
|
||||
|
||||
export interface ISettledFulfilled<T> {
|
||||
status: "fulfilled";
|
||||
value: T;
|
||||
}
|
||||
export interface ISettledRejected {
|
||||
status: "rejected";
|
||||
reason: any;
|
||||
}
|
||||
|
||||
interface PromiseConstructor {
|
||||
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
|
||||
usageDetails?: { [key: string]: number };
|
||||
}
|
||||
|
||||
interface HTMLAudioElement {
|
||||
|
@ -138,11 +159,28 @@ declare global {
|
|||
setSinkId(outputId: string);
|
||||
}
|
||||
|
||||
interface HTMLStyleElement {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Add Chrome-specific `instant` ScrollBehaviour
|
||||
type _ScrollBehavior = ScrollBehavior | "instant";
|
||||
|
||||
interface _ScrollOptions {
|
||||
behavior?: _ScrollBehavior;
|
||||
}
|
||||
|
||||
interface _ScrollIntoViewOptions extends _ScrollOptions {
|
||||
block?: ScrollLogicalPosition;
|
||||
inline?: ScrollLogicalPosition;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
// Safari & IE11 only have this prefixed: we used prefixed versions
|
||||
// previously so let's continue to support them for now
|
||||
webkitRequestFullScreen(options?: FullscreenOptions): Promise<void>;
|
||||
msRequestFullscreen(options?: FullscreenOptions): Promise<void>;
|
||||
scrollIntoView(arg?: boolean | _ScrollIntoViewOptions): void;
|
||||
}
|
||||
|
||||
interface Error {
|
||||
|
@ -179,4 +217,30 @@ declare global {
|
|||
parameterDescriptors?: AudioParamDescriptor[];
|
||||
}
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
var grecaptcha:
|
||||
| undefined
|
||||
| {
|
||||
reset: (id: string) => void;
|
||||
render: (
|
||||
divId: string,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (response: string) => void;
|
||||
},
|
||||
) => string;
|
||||
isReady: () => boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_logger: ConsoleLogger;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initPromise: Promise<void>;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initStoragePromise: Promise<void>;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_store: IndexedDBLogStore;
|
||||
}
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
|
20
src/@types/raw-loader.d.ts
vendored
Normal file
20
src/@types/raw-loader.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
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 '!!raw-loader!*' {
|
||||
const contents: string;
|
||||
export default contents;
|
||||
}
|
20
src/@types/svg.d.ts
vendored
Normal file
20
src/@types/svg.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
declare module "*.svg" {
|
||||
const path: string;
|
||||
export default path;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ALL_MESSAGES,
|
||||
ALL_MESSAGES_LOUD,
|
||||
MENTIONS_ONLY,
|
||||
MUTE,
|
||||
} from "./RoomNotifs";
|
||||
declare module "*.worker.ts" {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
||||
export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;
|
||||
export default WebpackWorker;
|
||||
}
|
|
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
|
||||
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.
|
||||
|
|
|
@ -16,14 +16,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import * as sdk from './index';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
import {SSOAuthEntry} from "./components/views/auth/InteractiveAuthEntryComponents";
|
||||
import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents";
|
||||
import { IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk";
|
||||
import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog";
|
||||
|
||||
function getIdServerDomain() {
|
||||
function getIdServerDomain(): string {
|
||||
return MatrixClientPeg.get().idBaseUrl.split("://")[1];
|
||||
}
|
||||
|
||||
|
@ -40,10 +41,13 @@ function getIdServerDomain() {
|
|||
* https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928
|
||||
*/
|
||||
export default class AddThreepid {
|
||||
private sessionId: string;
|
||||
private submitUrl: string;
|
||||
private clientSecret: string;
|
||||
private bind: boolean;
|
||||
|
||||
constructor() {
|
||||
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
|
||||
this.sessionId = null;
|
||||
this.submitUrl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -52,7 +56,7 @@ export default class AddThreepid {
|
|||
* @param {string} emailAddress The email address to add
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
addEmailAddress(emailAddress) {
|
||||
public addEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
|
||||
return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
return res;
|
||||
|
@ -72,7 +76,7 @@ export default class AddThreepid {
|
|||
* @param {string} emailAddress The email address to add
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
async bindEmailAddress(emailAddress) {
|
||||
public async bindEmailAddress(emailAddress: string): Promise<IRequestTokenResponse> {
|
||||
this.bind = true;
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
// For separate bind, request a token directly from the IS.
|
||||
|
@ -105,7 +109,7 @@ export default class AddThreepid {
|
|||
* @param {string} phoneNumber The national or international formatted phone number to add
|
||||
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
|
||||
*/
|
||||
addMsisdn(phoneCountry, phoneNumber) {
|
||||
public addMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
|
||||
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
|
||||
phoneCountry, phoneNumber, this.clientSecret, 1,
|
||||
).then((res) => {
|
||||
|
@ -129,7 +133,7 @@ export default class AddThreepid {
|
|||
* @param {string} phoneNumber The national or international formatted phone number to add
|
||||
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
|
||||
*/
|
||||
async bindMsisdn(phoneCountry, phoneNumber) {
|
||||
public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise<IRequestMsisdnTokenResponse> {
|
||||
this.bind = true;
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
// For separate bind, request a token directly from the IS.
|
||||
|
@ -161,7 +165,7 @@ export default class AddThreepid {
|
|||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
async checkEmailLinkClicked() {
|
||||
public async checkEmailLinkClicked(): Promise<any[]> {
|
||||
try {
|
||||
if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) {
|
||||
if (this.bind) {
|
||||
|
@ -175,7 +179,7 @@ export default class AddThreepid {
|
|||
});
|
||||
} else {
|
||||
try {
|
||||
await this._makeAddThreepidOnlyRequest();
|
||||
await this.makeAddThreepidOnlyRequest();
|
||||
|
||||
// The spec has always required this to use UI auth but synapse briefly
|
||||
// implemented it without, so this may just succeed and that's OK.
|
||||
|
@ -186,10 +190,6 @@ export default class AddThreepid {
|
|||
throw e;
|
||||
}
|
||||
|
||||
// pop up an interactive auth dialog
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
|
||||
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("Use Single Sign On to continue"),
|
||||
|
@ -209,7 +209,7 @@ export default class AddThreepid {
|
|||
title: _t("Add Email Address"),
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
authData: e.data,
|
||||
makeRequest: this._makeAddThreepidOnlyRequest,
|
||||
makeRequest: this.makeAddThreepidOnlyRequest,
|
||||
aestheticsForStagePhases: {
|
||||
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
|
||||
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
|
||||
|
@ -236,26 +236,26 @@ export default class AddThreepid {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Object} auth UI auth object
|
||||
* @param {{type: string, session?: string}} auth UI auth object
|
||||
* @return {Promise<Object>} Response from /3pid/add call (in current spec, an empty object)
|
||||
*/
|
||||
_makeAddThreepidOnlyRequest = (auth) => {
|
||||
private makeAddThreepidOnlyRequest = (auth?: {type: string, session?: string}): Promise<{}> => {
|
||||
return MatrixClientPeg.get().addThreePidOnly({
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the request failed.
|
||||
*/
|
||||
async haveMsisdnToken(msisdnToken) {
|
||||
public async haveMsisdnToken(msisdnToken: string): Promise<any[]> {
|
||||
const authClient = new IdentityAuthClient();
|
||||
const supportsSeparateAddAndBind =
|
||||
await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind();
|
||||
|
@ -292,7 +292,7 @@ export default class AddThreepid {
|
|||
});
|
||||
} else {
|
||||
try {
|
||||
await this._makeAddThreepidOnlyRequest();
|
||||
await this.makeAddThreepidOnlyRequest();
|
||||
|
||||
// The spec has always required this to use UI auth but synapse briefly
|
||||
// implemented it without, so this may just succeed and that's OK.
|
||||
|
@ -303,9 +303,6 @@ export default class AddThreepid {
|
|||
throw e;
|
||||
}
|
||||
|
||||
// pop up an interactive auth dialog
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
|
||||
const dialogAesthetics = {
|
||||
[SSOAuthEntry.PHASE_PREAUTH]: {
|
||||
title: _t("Use Single Sign On to continue"),
|
||||
|
@ -325,7 +322,7 @@ export default class AddThreepid {
|
|||
title: _t("Add Phone Number"),
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
authData: e.data,
|
||||
makeRequest: this._makeAddThreepidOnlyRequest,
|
||||
makeRequest: this.makeAddThreepidOnlyRequest,
|
||||
aestheticsForStagePhases: {
|
||||
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
|
||||
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
|
||||
import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import Modal from './Modal';
|
||||
|
@ -270,7 +270,7 @@ export class Analytics {
|
|||
localStorage.removeItem(LAST_VISIT_TS_KEY);
|
||||
}
|
||||
|
||||
private async _track(data: IData) {
|
||||
private async track(data: IData) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const now = new Date();
|
||||
|
@ -304,7 +304,7 @@ export class Analytics {
|
|||
}
|
||||
|
||||
public ping() {
|
||||
this._track({
|
||||
this.track({
|
||||
ping: "1",
|
||||
});
|
||||
localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
|
||||
|
@ -324,14 +324,14 @@ export class Analytics {
|
|||
// But continue anyway because we still want to track the change
|
||||
}
|
||||
|
||||
this._track({
|
||||
this.track({
|
||||
gt_ms: String(generationTimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
public trackEvent(category: string, action: string, name?: string, value?: string) {
|
||||
if (this.disabled) return;
|
||||
this._track({
|
||||
this.track({
|
||||
e_c: category,
|
||||
e_a: action,
|
||||
e_n: name,
|
||||
|
@ -390,21 +390,22 @@ export class Analytics {
|
|||
{ expl: _td('Your device resolution'), value: resolution },
|
||||
];
|
||||
|
||||
// FIXME: Using an import will result in test failures
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
|
||||
title: _t('Analytics'),
|
||||
description: <div className="mx_AnalyticsModal">
|
||||
<div>{_t('The information being sent to us to help make %(brand)s better includes:', {
|
||||
<div>{ _t('The information being sent to us to help make %(brand)s better includes:', {
|
||||
brand: SdkConfig.get().brand,
|
||||
})}</div>
|
||||
}) }</div>
|
||||
<table>
|
||||
{ rows.map((row) => <tr key={row[0]}>
|
||||
<td>{_t(
|
||||
<td>{ _t(
|
||||
customVariables[row[0]].expl,
|
||||
customVariables[row[0]].getTextVariables ?
|
||||
customVariables[row[0]].getTextVariables() :
|
||||
null,
|
||||
)}</td>
|
||||
) }</td>
|
||||
{ row[1] !== undefined && <td><code>{ row[1] }</code></td> }
|
||||
</tr>) }
|
||||
{ otherVariables.map((item, index) =>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,52 +14,63 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { ComponentType } from "react";
|
||||
|
||||
import * as sdk from './index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from './languageHandler';
|
||||
import { IDialogProps } from "./components/views/dialogs/IDialogProps";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
type AsyncImport<T> = { default: T };
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
// A promise which resolves with the real component
|
||||
prom: Promise<ComponentType | AsyncImport<ComponentType>>;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
component?: ComponentType;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an asynchronous loader function with a react component which shows a
|
||||
* spinner until the real component loads.
|
||||
*/
|
||||
export default class AsyncWrapper extends React.Component {
|
||||
static propTypes = {
|
||||
/** A promise which resolves with the real component
|
||||
*/
|
||||
prom: PropTypes.object.isRequired,
|
||||
};
|
||||
export default class AsyncWrapper extends React.Component<IProps, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
state = {
|
||||
public state = {
|
||||
component: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
// XXX: temporary logging to try to diagnose
|
||||
// https://github.com/vector-im/element-web/issues/3148
|
||||
console.log('Starting load of AsyncWrapper for modal');
|
||||
logger.log('Starting load of AsyncWrapper for modal');
|
||||
this.props.prom.then((result) => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Take the 'default' member if it's there, then we support
|
||||
// passing in just an import()ed module, since ES6 async import
|
||||
// always returns a module *namespace*.
|
||||
const component = result.default ? result.default : result;
|
||||
this.setState({component});
|
||||
const component = (result as AsyncImport<ComponentType>).default
|
||||
? (result as AsyncImport<ComponentType>).default
|
||||
: result as ComponentType;
|
||||
this.setState({ component });
|
||||
}).catch((e) => {
|
||||
console.warn('AsyncWrapper promise failed', e);
|
||||
this.setState({error: e});
|
||||
this.setState({ error: e });
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
_onWrapperCancelClick = () => {
|
||||
private onWrapperCancelClick = () => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
|
@ -69,14 +79,13 @@ export default class AsyncWrapper extends React.Component {
|
|||
const Component = this.state.component;
|
||||
return <Component {...this.props} />;
|
||||
} else if (this.state.error) {
|
||||
// FIXME: Using an import will result in test failures
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <BaseDialog onFinished={this.props.onFinished}
|
||||
title={_t("Error")}
|
||||
>
|
||||
{_t("Unable to load! Check your network connectivity and try again.")}
|
||||
return <BaseDialog onFinished={this.props.onFinished} title={_t("Error")}>
|
||||
{ _t("Unable to load! Check your network connectivity and try again.") }
|
||||
<DialogButtons primaryButton={_t("Dismiss")}
|
||||
onPrimaryButtonClick={this._onWrapperCancelClick}
|
||||
onPrimaryButtonClick={this.onWrapperCancelClick}
|
||||
hasCancel={false}
|
||||
/>
|
||||
</BaseDialog>;
|
|
@ -14,18 +14,23 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import {User} from "matrix-js-sdk/src/models/user";
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
|
||||
import { split } from "lodash";
|
||||
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import {mediaFromMxc} from "./customisations/Media";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
|
||||
export type ResizeMethod = "crop" | "scale";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import SpaceStore from "./stores/SpaceStore";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
|
||||
export function avatarUrlForMember(
|
||||
member: RoomMember,
|
||||
width: number,
|
||||
height: number,
|
||||
resizeMethod: ResizeMethod,
|
||||
): string {
|
||||
let url: string;
|
||||
if (member?.getMxcAvatarUrl()) {
|
||||
url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
|
@ -39,7 +44,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu
|
|||
return url;
|
||||
}
|
||||
|
||||
export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
export function avatarUrlForUser(
|
||||
user: Pick<User, "avatarUrl">,
|
||||
width: number,
|
||||
height: number,
|
||||
resizeMethod?: ResizeMethod,
|
||||
): string | null {
|
||||
if (!user.avatarUrl) return null;
|
||||
return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
}
|
||||
|
@ -113,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) {
|
||||
|
@ -144,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;
|
||||
|
||||
// If the room is not a DM don't fallback to a member avatar
|
||||
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null;
|
||||
|
|
|
@ -17,16 +17,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
|
||||
import {ActionPayload} from "./dispatcher/payloads";
|
||||
import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
|
||||
import {Action} from "./dispatcher/actions";
|
||||
import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
|
||||
|
||||
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
|
||||
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
|
||||
|
@ -335,7 +335,7 @@ export default abstract class BasePlatform {
|
|||
|
||||
try {
|
||||
const key = await crypto.subtle.decrypt(
|
||||
{name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey,
|
||||
{ name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey,
|
||||
data.encrypted,
|
||||
);
|
||||
return encodeUnpaddedBase64(key);
|
||||
|
@ -348,7 +348,7 @@ export default abstract class BasePlatform {
|
|||
/**
|
||||
* Create and store a pickle key for encrypting libolm objects.
|
||||
* @param {string} userId the user ID for the user that the pickle key is for.
|
||||
* @param {string} userId the device ID that the pickle key is for.
|
||||
* @param {string} deviceId the device ID that the pickle key is for.
|
||||
* @returns {string|null} the pickle key, or null if the platform does not
|
||||
* support storing pickle keys.
|
||||
*/
|
||||
|
@ -360,7 +360,7 @@ export default abstract class BasePlatform {
|
|||
const randomArray = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomArray);
|
||||
const cryptoKey = await crypto.subtle.generateKey(
|
||||
{name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"],
|
||||
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
|
||||
);
|
||||
const iv = new Uint8Array(32);
|
||||
crypto.getRandomValues(iv);
|
||||
|
@ -375,11 +375,11 @@ export default abstract class BasePlatform {
|
|||
}
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray,
|
||||
{ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray,
|
||||
);
|
||||
|
||||
try {
|
||||
await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey});
|
||||
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
|
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -55,19 +56,17 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import {Jitsi} from "./widgets/Jitsi";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
import {base32} from "rfc4648";
|
||||
import { base32 } from "rfc4648";
|
||||
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
@ -77,10 +76,9 @@ import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
|||
import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import Analytics from './Analytics';
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import {UIFeature} from "./settings/UIFeature";
|
||||
import { UIFeature } from "./settings/UIFeature";
|
||||
import { CallError } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"
|
||||
import { Action } from './dispatcher/actions';
|
||||
import VoipUserMapper from './VoipUserMapper';
|
||||
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
|
||||
|
@ -88,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
|
|||
import EventEmitter from 'events';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import { ensureDMExists, findDMForUser } from './createRoom';
|
||||
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
||||
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
|
||||
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
||||
import ToastStore from './stores/ToastStore';
|
||||
import IncomingCallToast from "./toasts/IncomingCallToast";
|
||||
|
||||
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
||||
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
||||
|
@ -124,24 +128,20 @@ interface ThirdpartyLookupResponseFields {
|
|||
}
|
||||
|
||||
interface ThirdpartyLookupResponse {
|
||||
userid: string,
|
||||
protocol: string,
|
||||
fields: ThirdpartyLookupResponseFields,
|
||||
userid: string;
|
||||
protocol: string;
|
||||
fields: ThirdpartyLookupResponseFields;
|
||||
}
|
||||
|
||||
// Unlike 'CallType' in js-sdk, this one includes screen sharing
|
||||
// (because a screen sharing call is only a screen sharing call to the caller,
|
||||
// to the callee it's just a video call, at least as far as the current impl
|
||||
// is concerned).
|
||||
export enum PlaceCallType {
|
||||
Voice = 'voice',
|
||||
Video = 'video',
|
||||
ScreenSharing = 'screensharing',
|
||||
}
|
||||
|
||||
export enum CallHandlerEvent {
|
||||
CallsChanged = "calls_changed",
|
||||
CallChangeRoom = "call_change_room",
|
||||
SilencedCallsChanged = "silenced_calls_changed",
|
||||
}
|
||||
|
||||
export default class CallHandler extends EventEmitter {
|
||||
|
@ -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;
|
||||
|
@ -164,9 +164,11 @@ export default class CallHandler extends EventEmitter {
|
|||
// do the async lookup when we get new information and then store these mappings here
|
||||
private assertedIdentityNativeUsers = new Map<string, string>();
|
||||
|
||||
private silencedCalls = new Set<string>(); // callIds
|
||||
|
||||
static sharedInstance() {
|
||||
if (!window.mxCallHandler) {
|
||||
window.mxCallHandler = new CallHandler()
|
||||
window.mxCallHandler = new CallHandler();
|
||||
}
|
||||
|
||||
return window.mxCallHandler;
|
||||
|
@ -185,7 +187,7 @@ export default class CallHandler extends EventEmitter {
|
|||
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
|
||||
if (nativeUser) {
|
||||
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
|
||||
if (room) return room.roomId
|
||||
if (room) return room.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -224,6 +226,41 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
public silenceCall(callId: string) {
|
||||
this.silencedCalls.add(callId);
|
||||
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
|
||||
|
||||
// Don't pause audio if we have calls which are still ringing
|
||||
if (this.areAnyCallsUnsilenced()) return;
|
||||
this.pause(AudioID.Ring);
|
||||
}
|
||||
|
||||
public unSilenceCall(callId: string) {
|
||||
this.silencedCalls.delete(callId);
|
||||
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
|
||||
this.play(AudioID.Ring);
|
||||
}
|
||||
|
||||
public isCallSilenced(callId: string): boolean {
|
||||
return this.silencedCalls.has(callId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is at least one unsilenced call
|
||||
* @returns {boolean}
|
||||
*/
|
||||
private areAnyCallsUnsilenced(): boolean {
|
||||
for (const call of this.calls.values()) {
|
||||
if (
|
||||
call.state === CallState.Ringing &&
|
||||
!this.isCallSilenced(call.callId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkProtocols(maxTries) {
|
||||
try {
|
||||
const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
|
||||
|
@ -238,7 +275,7 @@ export default class CallHandler extends EventEmitter {
|
|||
this.supportsPstnProtocol = null;
|
||||
}
|
||||
|
||||
dis.dispatch({action: Action.PstnSupportUpdated});
|
||||
dis.dispatch({ action: Action.PstnSupportUpdated });
|
||||
|
||||
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
|
||||
this.supportsSipNativeVirtual = Boolean(
|
||||
|
@ -246,12 +283,12 @@ export default class CallHandler extends EventEmitter {
|
|||
);
|
||||
}
|
||||
|
||||
dis.dispatch({action: Action.VirtualRoomSupportUpdated});
|
||||
dis.dispatch({ action: Action.VirtualRoomSupportUpdated });
|
||||
} catch (e) {
|
||||
if (maxTries === 1) {
|
||||
console.log("Failed to check for protocol support and no retries remain: assuming no support", e);
|
||||
logger.log("Failed to check for protocol support and no retries remain: assuming no support", e);
|
||||
} else {
|
||||
console.log("Failed to check for protocol support: will retry", e);
|
||||
logger.log("Failed to check for protocol support: will retry", e);
|
||||
this.pstnSupportCheckTimer = setTimeout(() => {
|
||||
this.checkProtocols(maxTries - 1);
|
||||
}, 10000);
|
||||
|
@ -299,6 +336,13 @@ export default class CallHandler extends EventEmitter {
|
|||
action: 'incoming_call',
|
||||
call: call,
|
||||
}, true);
|
||||
};
|
||||
|
||||
public getCallById(callId: string): MatrixCall {
|
||||
for (const call of this.calls.values()) {
|
||||
if (call.callId === callId) return call;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getCallForRoom(roomId: string): MatrixCall {
|
||||
|
@ -355,7 +399,7 @@ export default class CallHandler extends EventEmitter {
|
|||
// or chrome doesn't think so and is denying the request. Not sure what
|
||||
// we can really do here...
|
||||
// https://github.com/vector-im/element-web/issues/7657
|
||||
console.log("Unable to play audio clip", e);
|
||||
logger.log("Unable to play audio clip", e);
|
||||
}
|
||||
};
|
||||
if (this.audioPromises.has(audioId)) {
|
||||
|
@ -394,7 +438,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;
|
||||
|
@ -428,78 +472,12 @@ export default class CallHandler extends EventEmitter {
|
|||
this.removeCallForRoom(mappedRoomId);
|
||||
});
|
||||
call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
case CallState.Ringing:
|
||||
this.pause(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.pause(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
{
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && (
|
||||
call.hangupParty === CallParty.Remote ||
|
||||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
|
||||
)) {
|
||||
this.play(AudioID.Busy);
|
||||
let title;
|
||||
let description;
|
||||
if (call.hangupReason === CallErrorCode.UserHangup) {
|
||||
title = _t("Call Declined");
|
||||
description = _t("The other party declined the call.");
|
||||
} else if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
title = _t("User Busy");
|
||||
description = _t("The user you called is busy.");
|
||||
} else if (call.hangupReason === CallErrorCode.InviteTimeout) {
|
||||
title = _t("Call Failed");
|
||||
// XXX: full stop appended as some relic here, but these
|
||||
// strings need proper input from design anyway, so let's
|
||||
// not change this string until we have a proper one.
|
||||
description = _t('The remote side failed to pick up') + '.';
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else if (
|
||||
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
description: _t("The call was answered on another device."),
|
||||
});
|
||||
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
|
||||
// don't play the end-call sound for calls that never got off the ground
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
|
||||
this.logCallStats(call, mappedRoomId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.onCallStateChanged(newState, oldState, call);
|
||||
});
|
||||
call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
|
||||
logger.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
|
||||
|
||||
if (call.state === CallState.Ringing) {
|
||||
this.pause(AudioID.Ring);
|
||||
|
@ -507,15 +485,15 @@ export default class CallHandler extends EventEmitter {
|
|||
this.pause(AudioID.Ringback);
|
||||
}
|
||||
|
||||
this.calls.set(mappedRoomId, newCall);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
this.addCallForRoom(mappedRoomId, newCall);
|
||||
this.setCallListeners(newCall);
|
||||
this.setCallState(newCall, newCall.state);
|
||||
});
|
||||
call.on(CallEvent.AssertedIdentityChanged, async () => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
|
||||
logger.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
|
||||
|
||||
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
|
||||
let newNativeAssertedIdentity = newAssertedIdentity;
|
||||
|
@ -525,7 +503,7 @@ export default class CallHandler extends EventEmitter {
|
|||
newNativeAssertedIdentity = response[0].userid;
|
||||
}
|
||||
}
|
||||
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
|
||||
logger.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
|
||||
|
||||
if (newNativeAssertedIdentity) {
|
||||
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
|
||||
|
@ -538,17 +516,100 @@ export default class CallHandler extends EventEmitter {
|
|||
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
|
||||
|
||||
const newMappedRoomId = this.roomIdForCall(call);
|
||||
console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
|
||||
logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
|
||||
if (newMappedRoomId !== mappedRoomId) {
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
mappedRoomId = newMappedRoomId;
|
||||
this.calls.set(mappedRoomId, call);
|
||||
this.emit(CallHandlerEvent.CallChangeRoom, call);
|
||||
logger.log("Moving call to room " + mappedRoomId);
|
||||
this.addCallForRoom(mappedRoomId, call, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onCallStateChanged = (newState: CallState, oldState: CallState, call: MatrixCall): void => {
|
||||
if (!this.matchesCallForThisRoom(call)) return;
|
||||
|
||||
const mappedRoomId = this.roomIdForCall(call);
|
||||
this.setCallState(call, newState);
|
||||
|
||||
switch (oldState) {
|
||||
case CallState.Ringing:
|
||||
this.pause(AudioID.Ring);
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
this.pause(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
|
||||
if (newState !== CallState.Ringing) {
|
||||
this.silencedCalls.delete(call.callId);
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing: {
|
||||
const incomingCallPushRule = (
|
||||
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall)
|
||||
);
|
||||
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
||||
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
||||
action.set_tweak === TweakName.Sound &&
|
||||
action.value === "ring"
|
||||
));
|
||||
|
||||
if (pushRuleEnabled && tweakSetToRing) {
|
||||
this.play(AudioID.Ring);
|
||||
} else {
|
||||
this.silenceCall(call.callId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallState.InviteSent: {
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
}
|
||||
case CallState.Ended: {
|
||||
const hangupReason = call.hangupReason;
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
||||
this.play(AudioID.Busy);
|
||||
|
||||
// Don't show a modal when we got rejected/the call was hung up
|
||||
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
|
||||
|
||||
let title;
|
||||
let description;
|
||||
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
||||
if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
title = _t("User Busy");
|
||||
description = _t("The user you called is busy.");
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title, description,
|
||||
});
|
||||
} else if (
|
||||
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
|
||||
) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
|
||||
title: _t("Answered Elsewhere"),
|
||||
description: _t("The call was answered on another device."),
|
||||
});
|
||||
} else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
|
||||
// don't play the end-call sound for calls that never got off the ground
|
||||
this.play(AudioID.CallEnd);
|
||||
}
|
||||
|
||||
this.logCallStats(call, mappedRoomId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
|
||||
const stats = await call.getCurrentCallStats();
|
||||
logger.debug(
|
||||
|
@ -595,10 +656,23 @@ export default class CallHandler extends EventEmitter {
|
|||
private setCallState(call: MatrixCall, status: CallState) {
|
||||
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
`Call state in ${mappedRoomId} changed to ${status}`,
|
||||
);
|
||||
|
||||
const toastKey = getIncomingCallToastKey(call.callId);
|
||||
if (status === CallState.Ringing) {
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key: toastKey,
|
||||
priority: 100,
|
||||
component: IncomingCallToast,
|
||||
bodyClassName: "mx_IncomingCallToast",
|
||||
props: { call },
|
||||
});
|
||||
} else {
|
||||
ToastStore.sharedInstance().dismissToast(toastKey);
|
||||
}
|
||||
|
||||
dis.dispatch({
|
||||
action: 'call_state',
|
||||
room_id: mappedRoomId,
|
||||
|
@ -607,29 +681,30 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
private removeCallForRoom(roomId: string) {
|
||||
logger.log("Removing call for room ", roomId);
|
||||
this.calls.delete(roomId);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
}
|
||||
|
||||
private showICEFallbackPrompt() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const code = sub => <code>{sub}</code>;
|
||||
const code = sub => <code>{ sub }</code>;
|
||||
Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
|
||||
title: _t("Call failed due to misconfigured server"),
|
||||
description: <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Please ask the administrator of your homeserver " +
|
||||
"(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
|
||||
"order for calls to work reliably.",
|
||||
{ homeserverDomain: cli.getDomain() }, { code },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Alternatively, you can try to use the public server at " +
|
||||
"<code>turn.matrix.org</code>, but this will not be as reliable, and " +
|
||||
"it will share your IP address with that server. You can also manage " +
|
||||
"this in Settings.",
|
||||
null, { code },
|
||||
)}</p>
|
||||
) }</p>
|
||||
</div>,
|
||||
button: _t('Try using turn.matrix.org'),
|
||||
cancelButton: _t('OK'),
|
||||
|
@ -647,19 +722,19 @@ export default class CallHandler extends EventEmitter {
|
|||
if (call.type === CallType.Voice) {
|
||||
title = _t("Unable to access microphone");
|
||||
description = <div>
|
||||
{_t(
|
||||
{ _t(
|
||||
"Call failed because microphone could not be accessed. " +
|
||||
"Check that a microphone is plugged in and set up correctly.",
|
||||
)}
|
||||
) }
|
||||
</div>;
|
||||
} else if (call.type === CallType.Video) {
|
||||
title = _t("Unable to access webcam / microphone");
|
||||
description = <div>
|
||||
{_t("Call failed because webcam or microphone could not be accessed. Check that:")}
|
||||
{ _t("Call failed because webcam or microphone could not be accessed. Check that:") }
|
||||
<ul>
|
||||
<li>{_t("A microphone and webcam are plugged in and set up correctly")}</li>
|
||||
<li>{_t("Permission is granted to use the webcam")}</li>
|
||||
<li>{_t("No other application is using the webcam")}</li>
|
||||
<li>{ _t("A microphone and webcam are plugged in and set up correctly") }</li>
|
||||
<li>{ _t("Permission is granted to use the webcam") }</li>
|
||||
<li>{ _t("No other application is using the webcam") }</li>
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
|
@ -677,11 +752,18 @@ export default class CallHandler extends EventEmitter {
|
|||
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
|
||||
|
||||
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
|
||||
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
const call = MatrixClientPeg.get().createCall(mappedRoomId);
|
||||
|
||||
this.calls.set(roomId, call);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
try {
|
||||
this.addCallForRoom(roomId, call);
|
||||
} catch (e) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
|
||||
title: _t('Already in call'),
|
||||
description: _t("You're already in a call with this person."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (transferee) {
|
||||
this.transferees[call.callId] = transferee;
|
||||
}
|
||||
|
@ -694,25 +776,6 @@ export default class CallHandler extends EventEmitter {
|
|||
call.placeVoiceCall();
|
||||
} else if (type === 'video') {
|
||||
call.placeVideoCall();
|
||||
} else if (type === PlaceCallType.ScreenSharing) {
|
||||
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
|
||||
if (screenCapErrorString) {
|
||||
this.removeCallForRoom(roomId);
|
||||
console.log("Can't capture screen: " + screenCapErrorString);
|
||||
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
|
||||
title: _t('Unable to capture screen'),
|
||||
description: screenCapErrorString,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
call.placeScreenSharingCall(
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error("Unknown conf call type: " + type);
|
||||
}
|
||||
|
@ -752,13 +815,8 @@ export default class CallHandler extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.getCallForRoom(room.roomId)) {
|
||||
Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
|
||||
title: _t('Already in call'),
|
||||
description: _t("You're already in a call with this person."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We leave the check for whether there's already a call in this room until later,
|
||||
// otherwise it can race.
|
||||
|
||||
const members = room.getJoinedMembers();
|
||||
if (members.length <= 1) {
|
||||
|
@ -804,7 +862,7 @@ export default class CallHandler extends EventEmitter {
|
|||
|
||||
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
|
||||
if (this.getCallForRoom(mappedRoomId)) {
|
||||
console.log(
|
||||
logger.log(
|
||||
"Got incoming call for room " + mappedRoomId +
|
||||
" but there's already a call for this room: ignoring",
|
||||
);
|
||||
|
@ -812,9 +870,11 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
|
||||
this.calls.set(mappedRoomId, call)
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
|
||||
this.addCallForRoom(mappedRoomId, call);
|
||||
this.setCallListeners(call);
|
||||
// Explicitly handle first state change
|
||||
this.onCallStateChanged(call.state, null, call);
|
||||
|
||||
// get ready to send encrypted events in the room, so if the user does answer
|
||||
// the call, we'll be ready to send. NB. This is the protocol-level room ID not
|
||||
|
@ -825,6 +885,8 @@ export default class CallHandler extends EventEmitter {
|
|||
break;
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
||||
|
||||
if (!this.calls.get(payload.room_id)) {
|
||||
return; // no call to hangup
|
||||
}
|
||||
|
@ -837,11 +899,15 @@ export default class CallHandler extends EventEmitter {
|
|||
// the hangup event away)
|
||||
break;
|
||||
case 'hangup_all':
|
||||
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
||||
|
||||
for (const call of this.calls.values()) {
|
||||
call.hangup(CallErrorCode.UserHangup, false);
|
||||
}
|
||||
break;
|
||||
case 'answer': {
|
||||
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
|
||||
|
||||
if (!this.calls.has(payload.room_id)) {
|
||||
return; // no call to answer
|
||||
}
|
||||
|
@ -867,7 +933,19 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
private stopRingingIfPossible(callId: string): void {
|
||||
this.silencedCalls.delete(callId);
|
||||
if (this.areAnyCallsUnsilenced()) return;
|
||||
this.pause(AudioID.Ring);
|
||||
}
|
||||
|
||||
private async dialNumber(number: string) {
|
||||
|
@ -888,7 +966,7 @@ export default class CallHandler extends EventEmitter {
|
|||
const nativeLookupResults = await this.sipNativeLookup(userId);
|
||||
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
|
||||
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
|
||||
console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
|
||||
logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
|
||||
} else {
|
||||
nativeUserId = userId;
|
||||
}
|
||||
|
@ -899,6 +977,50 @@ export default class CallHandler extends EventEmitter {
|
|||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
|
||||
await this.placeCall(roomId, PlaceCallType.Voice, null);
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.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) {
|
||||
|
@ -936,14 +1058,10 @@ export default class CallHandler extends EventEmitter {
|
|||
|
||||
// prevent double clicking the call button
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
||||
const hasJitsi = currentJitsiWidgets.length > 0
|
||||
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
|
||||
if (hasJitsi) {
|
||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
||||
title: _t('Call in Progress'),
|
||||
description: _t('A call is currently being placed!'),
|
||||
});
|
||||
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
|
||||
if (jitsiWidget) {
|
||||
// If there already is a Jitsi widget pin it
|
||||
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -962,7 +1080,7 @@ export default class CallHandler extends EventEmitter {
|
|||
confId = 'Jitsi' + random;
|
||||
}
|
||||
|
||||
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
|
||||
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth });
|
||||
|
||||
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
|
||||
const parsedUrl = new URL(widgetUrl);
|
||||
|
@ -986,7 +1104,7 @@ export default class CallHandler extends EventEmitter {
|
|||
);
|
||||
|
||||
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
|
||||
console.log('Jitsi widget added');
|
||||
logger.log('Jitsi widget added');
|
||||
}).catch((e) => {
|
||||
if (e.errcode === 'M_FORBIDDEN') {
|
||||
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
|
||||
|
@ -1031,4 +1149,21 @@ export default class CallHandler extends EventEmitter {
|
|||
messaging.transport.send(ElementWidgetActions.HangupCall, {});
|
||||
});
|
||||
}
|
||||
|
||||
private addCallForRoom(roomId: string, call: MatrixCall, changedRooms = false): void {
|
||||
if (this.calls.has(roomId)) {
|
||||
logger.log(`Couldn't add call to room ${roomId}: already have a call for this room`);
|
||||
throw new Error("Already have a call for room " + roomId);
|
||||
}
|
||||
|
||||
logger.log("setting call for room " + roomId);
|
||||
this.calls.set(roomId, call);
|
||||
|
||||
// Should we always emit CallsChanged too?
|
||||
if (changedRooms) {
|
||||
this.emit(CallHandlerEvent.CallChangeRoom, call);
|
||||
} else {
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
export default {
|
||||
hasAnyLabeledDevices: async function() {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.some(d => !!d.label);
|
||||
},
|
||||
|
||||
getDevices: function() {
|
||||
// Only needed for Electron atm, though should work in modern browsers
|
||||
// once permission has been granted to the webapp
|
||||
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
|
||||
const audiooutput = [];
|
||||
const audioinput = [];
|
||||
const videoinput = [];
|
||||
|
||||
devices.forEach((device) => {
|
||||
switch (device.kind) {
|
||||
case 'audiooutput': audiooutput.push(device); break;
|
||||
case 'audioinput': audioinput.push(device); break;
|
||||
case 'videoinput': videoinput.push(device); break;
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("Loaded WebRTC Devices", mediaDevices);
|
||||
return {
|
||||
audiooutput,
|
||||
audioinput,
|
||||
videoinput,
|
||||
};
|
||||
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
|
||||
},
|
||||
|
||||
loadDevices: function() {
|
||||
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
|
||||
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
|
||||
|
||||
setMatrixCallAudioInput(audioDeviceId);
|
||||
setMatrixCallVideoInput(videoDeviceId);
|
||||
},
|
||||
|
||||
setAudioOutput: function(deviceId) {
|
||||
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
|
||||
},
|
||||
|
||||
setAudioInput: function(deviceId) {
|
||||
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
|
||||
setMatrixCallAudioInput(deviceId);
|
||||
},
|
||||
|
||||
setVideoInput: function(deviceId) {
|
||||
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
|
||||
setMatrixCallVideoInput(deviceId);
|
||||
},
|
||||
|
||||
getAudioOutput: function() {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
|
||||
},
|
||||
|
||||
getAudioInput: function() {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
|
||||
},
|
||||
|
||||
getVideoInput: function() {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
|
||||
},
|
||||
};
|
|
@ -17,9 +17,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import Modal from './Modal';
|
||||
|
@ -27,9 +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";
|
||||
|
||||
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
|
||||
import "blueimp-canvas-to-blob";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import {
|
||||
|
@ -39,8 +36,13 @@ import {
|
|||
UploadProgressPayload,
|
||||
UploadStartedPayload,
|
||||
} from "./dispatcher/payloads/UploadPayload";
|
||||
import {IUpload} from "./models/IUpload";
|
||||
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IUpload } from "./models/IUpload";
|
||||
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { BlurhashEncoder } from "./BlurhashEncoder";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -49,6 +51,8 @@ const MAX_HEIGHT = 600;
|
|||
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
|
||||
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
|
||||
|
||||
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
|
||||
|
||||
export class UploadCanceledError extends Error {}
|
||||
|
||||
type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
|
||||
|
@ -79,14 +83,11 @@ interface IThumbnail {
|
|||
};
|
||||
w: number;
|
||||
h: number;
|
||||
[BLURHASH_FIELD]: string;
|
||||
};
|
||||
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.
|
||||
|
@ -105,44 +106,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;
|
||||
canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
},
|
||||
thumbnail: 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -191,9 +210,17 @@ async function loadImageElement(imageFile: File) {
|
|||
const [hidpi] = await Promise.all([parsePromise, imgPromise]);
|
||||
const width = hidpi ? (img.width >> 1) : img.width;
|
||||
const height = hidpi ? (img.height >> 1) : img.height;
|
||||
return {width, height, img};
|
||||
return { width, height, img };
|
||||
}
|
||||
|
||||
// Minimum size for image files before we generate a thumbnail for them.
|
||||
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
|
||||
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
|
||||
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
|
||||
// We don't apply these thresholds to video thumbnails as a poster image is always useful
|
||||
// and videos tend to be much larger.
|
||||
|
||||
/**
|
||||
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
||||
*
|
||||
|
@ -202,27 +229,38 @@ async function loadImageElement(imageFile: File) {
|
|||
* @param {File} imageFile The image to read and thumbnail.
|
||||
* @return {Promise} A promise that resolves with the attachment info.
|
||||
*/
|
||||
function infoForImageFile(matrixClient, roomId, imageFile) {
|
||||
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
|
||||
let thumbnailType = "image/png";
|
||||
if (imageFile.type === "image/jpeg") {
|
||||
thumbnailType = "image/jpeg";
|
||||
}
|
||||
|
||||
let imageInfo;
|
||||
return loadImageElement(imageFile).then((r) => {
|
||||
return createThumbnail(r.img, r.width, r.height, thumbnailType);
|
||||
}).then((result) => {
|
||||
imageInfo = result.info;
|
||||
return uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
}).then((result) => {
|
||||
imageInfo.thumbnail_url = result.url;
|
||||
imageInfo.thumbnail_file = result.file;
|
||||
const imageElement = await loadImageElement(imageFile);
|
||||
|
||||
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
|
||||
const imageInfo = result.info;
|
||||
|
||||
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
|
||||
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
|
||||
if (
|
||||
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
|
||||
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
|
||||
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
|
||||
) {
|
||||
delete imageInfo["thumbnail_info"];
|
||||
return imageInfo;
|
||||
});
|
||||
}
|
||||
|
||||
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
|
||||
|
||||
imageInfo["thumbnail_url"] = uploadResult.url;
|
||||
imageInfo["thumbnail_file"] = uploadResult.file;
|
||||
return imageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a file into a newly created video element.
|
||||
* Load a file into a newly created video element and pull some strings
|
||||
* in an attempt to guarantee the first frame will be showing.
|
||||
*
|
||||
* @param {File} videoFile The file to load in an video element.
|
||||
* @return {Promise} A promise that resolves with the video image element.
|
||||
|
@ -231,20 +269,25 @@ function loadVideoElement(videoFile): Promise<HTMLVideoElement> {
|
|||
return new Promise((resolve, reject) => {
|
||||
// Load the file into an html element
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.playsInline = true;
|
||||
video.muted = true;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(ev) {
|
||||
video.src = ev.target.result as string;
|
||||
|
||||
// Once ready, returns its size
|
||||
// Wait until we have enough data to thumbnail the first frame.
|
||||
video.onloadeddata = function() {
|
||||
video.onloadeddata = async function() {
|
||||
resolve(video);
|
||||
video.pause();
|
||||
};
|
||||
video.onerror = function(e) {
|
||||
reject(e);
|
||||
};
|
||||
|
||||
video.src = ev.target.result as string;
|
||||
video.load();
|
||||
video.play();
|
||||
};
|
||||
reader.onerror = function(e) {
|
||||
reject(e);
|
||||
|
@ -309,12 +352,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
|
|||
* If the file is unencrypted then the object will have a "url" key.
|
||||
* If the file is encrypted then the object will have a "file" key.
|
||||
*/
|
||||
function uploadFile(
|
||||
export function uploadFile(
|
||||
matrixClient: MatrixClient,
|
||||
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.
|
||||
|
@ -345,11 +388,11 @@ function uploadFile(
|
|||
if (file.type) {
|
||||
encryptInfo.mimetype = file.type;
|
||||
}
|
||||
return {"file": encryptInfo};
|
||||
});
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
return { "file": encryptInfo };
|
||||
}) as IAbortablePromise<{ file: any }>;
|
||||
prom.abort = () => {
|
||||
canceled = true;
|
||||
if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise);
|
||||
if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
|
||||
};
|
||||
return prom;
|
||||
} else {
|
||||
|
@ -359,11 +402,11 @@ function uploadFile(
|
|||
const promise1 = basePromise.then(function(url) {
|
||||
if (canceled) throw new UploadCanceledError();
|
||||
// If the attachment isn't encrypted then include the URL directly.
|
||||
return {"url": url};
|
||||
});
|
||||
(promise1 as any).abort = () => {
|
||||
return { url };
|
||||
}) as IAbortablePromise<{ url: string }>;
|
||||
promise1.abort = () => {
|
||||
canceled = true;
|
||||
MatrixClientPeg.get().cancelUpload(basePromise);
|
||||
matrixClient.cancelUpload(basePromise);
|
||||
};
|
||||
return promise1;
|
||||
}
|
||||
|
@ -375,11 +418,11 @@ export default class ContentMessages {
|
|||
|
||||
sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
|
||||
const startTime = CountlyAnalytics.getTimestamp();
|
||||
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
|
||||
const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => {
|
||||
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
|
||||
throw e;
|
||||
});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"});
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, { msgtype: "m.sticker" });
|
||||
return prom;
|
||||
}
|
||||
|
||||
|
@ -393,20 +436,21 @@ export default class ContentMessages {
|
|||
|
||||
async sendContentListToRoom(files: File[], roomId: string, matrixClient: MatrixClient) {
|
||||
if (matrixClient.isGuest()) {
|
||||
dis.dispatch({action: 'require_registration'});
|
||||
dis.dispatch({ action: 'require_registration' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
|
||||
if (isQuoting) {
|
||||
// FIXME: Using an import will result in Element crashing
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, {
|
||||
const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, {
|
||||
title: _t('Replying With Files'),
|
||||
description: (
|
||||
<div>{_t(
|
||||
<div>{ _t(
|
||||
'At this time it is not possible to reply with a file. ' +
|
||||
'Would you like to upload this file without replying?',
|
||||
)}</div>
|
||||
) }</div>
|
||||
),
|
||||
hasCancelButton: true,
|
||||
button: _t("Continue"),
|
||||
|
@ -417,7 +461,7 @@ export default class ContentMessages {
|
|||
|
||||
if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to
|
||||
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
|
||||
await this.ensureMediaConfigFetched();
|
||||
await this.ensureMediaConfigFetched(matrixClient);
|
||||
modal.close();
|
||||
}
|
||||
|
||||
|
@ -433,8 +477,9 @@ export default class ContentMessages {
|
|||
}
|
||||
|
||||
if (tooBigFiles.length > 0) {
|
||||
// FIXME: Using an import will result in Element crashing
|
||||
const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog");
|
||||
const {finished} = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, {
|
||||
const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, {
|
||||
badFiles: tooBigFiles,
|
||||
totalFiles: files.length,
|
||||
contentMessages: this,
|
||||
|
@ -443,7 +488,6 @@ export default class ContentMessages {
|
|||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
|
||||
let uploadAll = false;
|
||||
// Promise to complete before sending next file into room, used for synchronisation of file-sending
|
||||
// to match the order the files were specified in
|
||||
|
@ -451,7 +495,9 @@ export default class ContentMessages {
|
|||
for (let i = 0; i < okFiles.length; ++i) {
|
||||
const file = okFiles[i];
|
||||
if (!uploadAll) {
|
||||
const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation',
|
||||
// FIXME: Using an import will result in Element crashing
|
||||
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
|
||||
const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation',
|
||||
'', UploadConfirmDialog, {
|
||||
file,
|
||||
currentIndex: i,
|
||||
|
@ -472,7 +518,7 @@ export default class ContentMessages {
|
|||
return this.inprogress.filter(u => !u.canceled);
|
||||
}
|
||||
|
||||
cancelUpload(promise: Promise<any>) {
|
||||
cancelUpload(promise: Promise<any>, matrixClient: MatrixClient) {
|
||||
let upload: IUpload;
|
||||
for (let i = 0; i < this.inprogress.length; ++i) {
|
||||
if (this.inprogress[i].promise === promise) {
|
||||
|
@ -482,8 +528,8 @@ export default class ContentMessages {
|
|||
}
|
||||
if (upload) {
|
||||
upload.canceled = true;
|
||||
MatrixClientPeg.get().cancelUpload(upload.promise);
|
||||
dis.dispatch<UploadCanceledPayload>({action: Action.UploadCanceled, upload});
|
||||
matrixClient.cancelUpload(upload.promise);
|
||||
dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -497,6 +543,10 @@ export default class ContentMessages {
|
|||
msgtype: "", // set later
|
||||
};
|
||||
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
decorateStartSendingTime(content);
|
||||
}
|
||||
|
||||
// if we have a mime type for the file, add it to the message metadata
|
||||
if (file.type) {
|
||||
content.info.mimetype = file.type;
|
||||
|
@ -529,10 +579,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;
|
||||
};
|
||||
|
||||
|
@ -544,15 +594,15 @@ export default class ContentMessages {
|
|||
promise: prom,
|
||||
};
|
||||
this.inprogress.push(upload);
|
||||
dis.dispatch<UploadStartedPayload>({action: Action.UploadStarted, upload});
|
||||
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
|
||||
|
||||
// Focus the composer view
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
|
||||
function onProgress(ev) {
|
||||
upload.total = ev.total;
|
||||
upload.loaded = ev.loaded;
|
||||
dis.dispatch<UploadProgressPayload>({action: Action.UploadProgress, upload});
|
||||
dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload });
|
||||
}
|
||||
|
||||
let error;
|
||||
|
@ -561,9 +611,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;
|
||||
|
@ -574,18 +622,24 @@ export default class ContentMessages {
|
|||
}).then(function() {
|
||||
if (upload.canceled) throw new UploadCanceledError();
|
||||
const prom = matrixClient.sendMessage(roomId, content);
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
prom.then(resp => {
|
||||
sendRoundTripMetric(matrixClient, roomId, resp.event_id);
|
||||
});
|
||||
}
|
||||
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content);
|
||||
return prom;
|
||||
}, function(err) {
|
||||
error = err;
|
||||
if (!upload.canceled) {
|
||||
let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName});
|
||||
let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName });
|
||||
if (err.http_status === 413) {
|
||||
desc = _t(
|
||||
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
|
||||
{fileName: upload.fileName},
|
||||
{ fileName: upload.fileName },
|
||||
);
|
||||
}
|
||||
// FIXME: Using an import will result in Element crashing
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
|
||||
title: _t('Upload Failed'),
|
||||
|
@ -606,10 +660,10 @@ export default class ContentMessages {
|
|||
if (error && error.http_status === 413) {
|
||||
this.mediaConfig = null;
|
||||
}
|
||||
dis.dispatch<UploadErrorPayload>({action: Action.UploadFailed, upload, error});
|
||||
dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error });
|
||||
} else {
|
||||
dis.dispatch<UploadFinishedPayload>({action: Action.UploadFinished, upload});
|
||||
dis.dispatch({action: 'message_sent'});
|
||||
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
|
||||
dis.dispatch({ action: 'message_sent' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -623,16 +677,16 @@ export default class ContentMessages {
|
|||
return true;
|
||||
}
|
||||
|
||||
private ensureMediaConfigFetched() {
|
||||
private ensureMediaConfigFetched(matrixClient: MatrixClient) {
|
||||
if (this.mediaConfig !== null) return;
|
||||
|
||||
console.log("[Media Config] Fetching");
|
||||
return MatrixClientPeg.get().getMediaConfig().then((config) => {
|
||||
console.log("[Media Config] Fetched config:", config);
|
||||
logger.log("[Media Config] Fetching");
|
||||
return matrixClient.getMediaConfig().then((config) => {
|
||||
logger.log("[Media Config] Fetched config:", config);
|
||||
return config;
|
||||
}).catch(() => {
|
||||
// Media repo can't or won't report limits, so provide an empty object (no limits).
|
||||
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
|
||||
logger.log("[Media Config] Could not fetch config, so not limiting uploads.");
|
||||
return {};
|
||||
}).then((config) => {
|
||||
this.mediaConfig = config;
|
||||
|
|
|
@ -14,28 +14,24 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {randomString} from "matrix-js-sdk/src/randomstring";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { IContent } from "matrix-js-sdk/src/models/event";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import {getCurrentLanguage} from './languageHandler';
|
||||
import { getCurrentLanguage } from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import {sleep} from "./utils/promise";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
|
||||
// polyfill textencoder if necessary
|
||||
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
|
||||
let TextEncoder = window.TextEncoder;
|
||||
if (!TextEncoder) {
|
||||
TextEncoder = TextEncodingUtf8.TextEncoder;
|
||||
}
|
||||
|
||||
const INACTIVITY_TIME = 20; // seconds
|
||||
const HEARTBEAT_INTERVAL = 5_000; // ms
|
||||
const SESSION_UPDATE_INTERVAL = 60; // seconds
|
||||
const MAX_PENDING_EVENTS = 1000;
|
||||
|
||||
export type Rating = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
enum Orientation {
|
||||
Landscape = "landscape",
|
||||
Portrait = "portrait",
|
||||
|
@ -262,7 +258,7 @@ interface ICreateRoomEvent extends IEvent {
|
|||
num_users: number;
|
||||
is_encrypted: boolean;
|
||||
is_public: boolean;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface IJoinRoomEvent extends IEvent {
|
||||
|
@ -345,8 +341,8 @@ const getRoomStats = (roomId: string) => {
|
|||
"is_encrypted": cli?.isRoomEncrypted(roomId),
|
||||
// eslint-disable-next-line camelcase
|
||||
"is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public",
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// async wrapper for regex-powered String.prototype.replace
|
||||
const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise<string>) => {
|
||||
|
@ -370,8 +366,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;
|
||||
|
@ -421,7 +417,7 @@ export default class CountlyAnalytics {
|
|||
|
||||
this.anonymous = anonymous;
|
||||
if (anonymous) {
|
||||
await this.changeUserKey(randomString(64))
|
||||
await this.changeUserKey(randomString(64));
|
||||
} else {
|
||||
await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true);
|
||||
}
|
||||
|
@ -445,7 +441,7 @@ export default class CountlyAnalytics {
|
|||
await this.track("Opt-Out" );
|
||||
this.endSession();
|
||||
window.clearInterval(this.heartbeatIntervalId);
|
||||
window.clearTimeout(this.activityIntervalId)
|
||||
window.clearTimeout(this.activityIntervalId);
|
||||
this.baseUrl = null;
|
||||
// remove listeners bound in trackSessions()
|
||||
window.removeEventListener("beforeunload", this.endSession);
|
||||
|
@ -457,7 +453,7 @@ export default class CountlyAnalytics {
|
|||
window.removeEventListener("scroll", this.onUserActivity);
|
||||
}
|
||||
|
||||
public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) {
|
||||
public reportFeedback(rating: Rating, comment: string) {
|
||||
this.track<IStarRatingEvent>("[CLY]_star_rating", { rating, comment }, null, {}, true);
|
||||
}
|
||||
|
||||
|
@ -542,7 +538,7 @@ export default class CountlyAnalytics {
|
|||
|
||||
// sanitize the error from identifiers
|
||||
error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => {
|
||||
return glyph + await hashHex(substring.substring(1));
|
||||
return glyph + (await hashHex(substring.substring(1)));
|
||||
});
|
||||
|
||||
const metrics = this.getMetrics();
|
||||
|
@ -669,14 +665,14 @@ export default class CountlyAnalytics {
|
|||
}
|
||||
|
||||
private queue(args: Omit<IEvent, "timestamp" | "hour" | "dow" | "count"> & Partial<Pick<IEvent, "count">>) {
|
||||
const {count = 1, ...rest} = args;
|
||||
const { count = 1, ...rest } = args;
|
||||
const ev = {
|
||||
...this.getTimeParams(),
|
||||
...rest,
|
||||
count,
|
||||
platform: this.appPlatform,
|
||||
app_version: this.appVersion,
|
||||
}
|
||||
};
|
||||
|
||||
this.pendingEvents.push(ev);
|
||||
if (this.pendingEvents.length > MAX_PENDING_EVENTS) {
|
||||
|
@ -687,7 +683,7 @@ export default class CountlyAnalytics {
|
|||
private getOrientation = (): Orientation => {
|
||||
return window.matchMedia("(orientation: landscape)").matches
|
||||
? Orientation.Landscape
|
||||
: Orientation.Portrait
|
||||
: Orientation.Portrait;
|
||||
};
|
||||
|
||||
private reportOrientation = () => {
|
||||
|
@ -756,7 +752,7 @@ export default class CountlyAnalytics {
|
|||
const request: Parameters<typeof CountlyAnalytics.prototype.request>[0] = {
|
||||
begin_session: 1,
|
||||
user_details: JSON.stringify(userDetails),
|
||||
}
|
||||
};
|
||||
|
||||
const metrics = this.getMetrics();
|
||||
if (metrics) {
|
||||
|
@ -780,7 +776,7 @@ export default class CountlyAnalytics {
|
|||
|
||||
private endSession = () => {
|
||||
if (this.sessionStarted) {
|
||||
window.removeEventListener("resize", this.reportOrientation)
|
||||
window.removeEventListener("resize", this.reportOrientation);
|
||||
|
||||
this.reportViewDuration();
|
||||
this.request({
|
||||
|
@ -875,7 +871,7 @@ export default class CountlyAnalytics {
|
|||
roomId: string,
|
||||
isEdit: boolean,
|
||||
isReply: boolean,
|
||||
content: {format?: string, msgtype: string},
|
||||
content: IContent,
|
||||
) {
|
||||
if (this.disabled) return;
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
|
|
@ -123,6 +123,31 @@ export function formatTime(date: Date, showTwelveHour = false): string {
|
|||
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||
}
|
||||
|
||||
export function formatCallTime(delta: Date): string {
|
||||
const hours = delta.getUTCHours();
|
||||
const minutes = delta.getUTCMinutes();
|
||||
const seconds = delta.getUTCSeconds();
|
||||
|
||||
let output = "";
|
||||
if (hours) output += `${hours}h `;
|
||||
if (minutes || output) output += `${minutes}m `;
|
||||
if (seconds || output) output += `${seconds}s`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatSeconds(inSeconds: number): string {
|
||||
const hours = Math.floor(inSeconds / (60 * 60)).toFixed(0).padStart(2, '0');
|
||||
const minutes = Math.floor((inSeconds % (60 * 60)) / 60).toFixed(0).padStart(2, '0');
|
||||
const seconds = Math.floor(((inSeconds % (60 * 60)) % 60)).toFixed(0).padStart(2, '0');
|
||||
|
||||
let output = "";
|
||||
if (hours !== "00") output += `${hours}:`;
|
||||
output += `${minutes}:${seconds}`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const MILLIS_IN_DAY = 86400000;
|
||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||
if (!nextEventDate || !prevEventDate) {
|
||||
|
@ -136,3 +161,20 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo
|
|||
// Compare weekdays
|
||||
return prevEventDate.getDay() !== nextEventDate.getDay();
|
||||
}
|
||||
|
||||
export function formatFullDateNoDay(date: Date) {
|
||||
return _t("%(date)s at %(time)s", {
|
||||
date: date.toLocaleDateString().replace(/\//g, '-'),
|
||||
time: date.toLocaleTimeString().replace(/:/g, '-'),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFullDateNoDayNoTime(date: Date) {
|
||||
return (
|
||||
date.getFullYear() +
|
||||
"/" +
|
||||
pad(date.getMonth() + 1) +
|
||||
"/" +
|
||||
pad(date.getDate())
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,34 +14,40 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
export class DecryptionFailure {
|
||||
constructor(failedEventId, errorCode) {
|
||||
this.failedEventId = failedEventId;
|
||||
this.errorCode = errorCode;
|
||||
public readonly ts: number;
|
||||
|
||||
constructor(public readonly failedEventId: string, public readonly errorCode: string) {
|
||||
this.ts = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
type TrackingFn = (count: number, trackedErrCode: string) => void;
|
||||
type ErrCodeMapFn = (errcode: string) => string;
|
||||
|
||||
export class DecryptionFailureTracker {
|
||||
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
|
||||
// is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
|
||||
// are accumulated in `failureCounts`.
|
||||
failures = [];
|
||||
public failures: DecryptionFailure[] = [];
|
||||
|
||||
// A histogram of the number of failures that will be tracked at the next tracking
|
||||
// interval, split by failure error code.
|
||||
failureCounts = {
|
||||
public failureCounts: Record<string, number> = {
|
||||
// [errorCode]: 42
|
||||
};
|
||||
|
||||
// Event IDs of failures that were tracked previously
|
||||
trackedEventHashMap = {
|
||||
public trackedEventHashMap: Record<string, boolean> = {
|
||||
// [eventId]: true
|
||||
};
|
||||
|
||||
// Set to an interval ID when `start` is called
|
||||
checkInterval = null;
|
||||
trackInterval = null;
|
||||
public checkInterval: 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;
|
||||
|
@ -67,7 +73,7 @@ export class DecryptionFailureTracker {
|
|||
* @param {function?} errorCodeMapFn The function used to map error codes to the
|
||||
* trackedErrorCode. If not provided, the `.code` of errors will be used.
|
||||
*/
|
||||
constructor(fn, errorCodeMapFn) {
|
||||
constructor(private readonly fn: TrackingFn, private readonly errorCodeMapFn?: ErrCodeMapFn) {
|
||||
if (!fn || typeof fn !== 'function') {
|
||||
throw new Error('DecryptionFailureTracker requires tracking function');
|
||||
}
|
||||
|
@ -75,9 +81,6 @@ export class DecryptionFailureTracker {
|
|||
if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
|
||||
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
|
||||
}
|
||||
|
||||
this._trackDecryptionFailure = fn;
|
||||
this._mapErrorCode = errorCodeMapFn;
|
||||
}
|
||||
|
||||
// loadTrackedEventHashMap() {
|
||||
|
@ -88,7 +91,7 @@ export class DecryptionFailureTracker {
|
|||
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
|
||||
// }
|
||||
|
||||
eventDecrypted(e, err) {
|
||||
public eventDecrypted(e: MatrixEvent, err: MatrixError | Error): void {
|
||||
if (err) {
|
||||
this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
|
||||
} else {
|
||||
|
@ -97,18 +100,18 @@ export class DecryptionFailureTracker {
|
|||
}
|
||||
}
|
||||
|
||||
addDecryptionFailure(failure) {
|
||||
public addDecryptionFailure(failure: DecryptionFailure): void {
|
||||
this.failures.push(failure);
|
||||
}
|
||||
|
||||
removeDecryptionFailuresForEvent(e) {
|
||||
public removeDecryptionFailuresForEvent(e: MatrixEvent): void {
|
||||
this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking for and tracking failures.
|
||||
*/
|
||||
start() {
|
||||
public start(): void {
|
||||
this.checkInterval = setInterval(
|
||||
() => this.checkFailures(Date.now()),
|
||||
DecryptionFailureTracker.CHECK_INTERVAL_MS,
|
||||
|
@ -123,7 +126,7 @@ export class DecryptionFailureTracker {
|
|||
/**
|
||||
* Clear state and stop checking for and tracking failures.
|
||||
*/
|
||||
stop() {
|
||||
public stop(): void {
|
||||
clearInterval(this.checkInterval);
|
||||
clearInterval(this.trackInterval);
|
||||
|
||||
|
@ -132,11 +135,11 @@ export class DecryptionFailureTracker {
|
|||
}
|
||||
|
||||
/**
|
||||
* Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be
|
||||
* Mark failures that occurred before nowTs - GRACE_PERIOD_MS as failures that should be
|
||||
* tracked. Only mark one failure per event ID.
|
||||
* @param {number} nowTs the timestamp that represents the time now.
|
||||
*/
|
||||
checkFailures(nowTs) {
|
||||
public checkFailures(nowTs: number): void {
|
||||
const failuresGivenGrace = [];
|
||||
const failuresNotReady = [];
|
||||
while (this.failures.length > 0) {
|
||||
|
@ -165,7 +168,7 @@ export class DecryptionFailureTracker {
|
|||
const trackedEventIds = [...dedupedFailuresMap.keys()];
|
||||
|
||||
this.trackedEventHashMap = trackedEventIds.reduce(
|
||||
(result, eventId) => ({...result, [eventId]: true}),
|
||||
(result, eventId) => ({ ...result, [eventId]: true }),
|
||||
this.trackedEventHashMap,
|
||||
);
|
||||
|
||||
|
@ -175,10 +178,10 @@ export class DecryptionFailureTracker {
|
|||
|
||||
const dedupedFailures = dedupedFailuresMap.values();
|
||||
|
||||
this._aggregateFailures(dedupedFailures);
|
||||
this.aggregateFailures(dedupedFailures);
|
||||
}
|
||||
|
||||
_aggregateFailures(failures) {
|
||||
private aggregateFailures(failures: DecryptionFailure[]): void {
|
||||
for (const failure of failures) {
|
||||
const errorCode = failure.errorCode;
|
||||
this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1;
|
||||
|
@ -189,12 +192,12 @@ export class DecryptionFailureTracker {
|
|||
* If there are failures that should be tracked, call the given trackDecryptionFailure
|
||||
* function with the number of failures that should be tracked.
|
||||
*/
|
||||
trackFailures() {
|
||||
public trackFailures(): void {
|
||||
for (const errorCode of Object.keys(this.failureCounts)) {
|
||||
if (this.failureCounts[errorCode] > 0) {
|
||||
const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode;
|
||||
const trackedErrorCode = this.errorCodeMapFn ? this.errorCodeMapFn(errorCode) : errorCode;
|
||||
|
||||
this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode);
|
||||
this.fn(this.failureCounts[errorCode], trackedErrorCode);
|
||||
this.failureCounts[errorCode] = 0;
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import {
|
||||
hideToast as hideBulkUnverifiedSessionsToast,
|
||||
|
@ -33,6 +33,9 @@ import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityMan
|
|||
import { isSecureBackupRequired } from './utils/WellKnownUtils';
|
||||
import { isLoggedIn } from './components/structures/MatrixChat';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
|
@ -58,28 +61,28 @@ export default class DeviceListener {
|
|||
}
|
||||
|
||||
start() {
|
||||
MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices);
|
||||
MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated);
|
||||
MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged);
|
||||
MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged);
|
||||
MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().on('accountData', this._onAccountData);
|
||||
MatrixClientPeg.get().on('sync', this._onSync);
|
||||
MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents);
|
||||
this.dispatcherRef = dis.register(this._onAction);
|
||||
this._recheck();
|
||||
MatrixClientPeg.get().on('crypto.willUpdateDevices', this.onWillUpdateDevices);
|
||||
MatrixClientPeg.get().on('crypto.devicesUpdated', this.onDevicesUpdated);
|
||||
MatrixClientPeg.get().on('deviceVerificationChanged', this.onDeviceVerificationChanged);
|
||||
MatrixClientPeg.get().on('userTrustStatusChanged', this.onUserTrustStatusChanged);
|
||||
MatrixClientPeg.get().on('crossSigning.keysChanged', this.onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().on('accountData', this.onAccountData);
|
||||
MatrixClientPeg.get().on('sync', this.onSync);
|
||||
MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.recheck();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices);
|
||||
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated);
|
||||
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged);
|
||||
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged);
|
||||
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().removeListener('accountData', this._onAccountData);
|
||||
MatrixClientPeg.get().removeListener('sync', this._onSync);
|
||||
MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents);
|
||||
MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this.onWillUpdateDevices);
|
||||
MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this.onDevicesUpdated);
|
||||
MatrixClientPeg.get().removeListener('deviceVerificationChanged', this.onDeviceVerificationChanged);
|
||||
MatrixClientPeg.get().removeListener('userTrustStatusChanged', this.onUserTrustStatusChanged);
|
||||
MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this.onCrossSingingKeysChanged);
|
||||
MatrixClientPeg.get().removeListener('accountData', this.onAccountData);
|
||||
MatrixClientPeg.get().removeListener('sync', this.onSync);
|
||||
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
|
||||
}
|
||||
if (this.dispatcherRef) {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
|
@ -99,19 +102,20 @@ export default class DeviceListener {
|
|||
* @param {String[]} deviceIds List of device IDs to dismiss notifications for
|
||||
*/
|
||||
async dismissUnverifiedSessions(deviceIds: Iterable<string>) {
|
||||
logger.log("Dismissing unverified sessions: " + Array.from(deviceIds).join(','));
|
||||
for (const d of deviceIds) {
|
||||
this.dismissed.add(d);
|
||||
}
|
||||
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
}
|
||||
|
||||
dismissEncryptionSetup() {
|
||||
this.dismissedThisDeviceToast = true;
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
}
|
||||
|
||||
_ensureDeviceIdsAtStartPopulated() {
|
||||
private ensureDeviceIdsAtStartPopulated() {
|
||||
if (this.ourDeviceIdsAtStart === null) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.ourDeviceIdsAtStart = new Set(
|
||||
|
@ -120,39 +124,39 @@ export default class DeviceListener {
|
|||
}
|
||||
}
|
||||
|
||||
_onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
|
||||
private onWillUpdateDevices = async (users: string[], initialFetch?: boolean) => {
|
||||
// If we didn't know about *any* devices before (ie. it's fresh login),
|
||||
// then they are all pre-existing devices, so ignore this and set the
|
||||
// devicesAtStart list to the devices that we see after the fetch.
|
||||
if (initialFetch) return;
|
||||
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated();
|
||||
if (users.includes(myUserId)) this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
// No need to do a recheck here: we just need to get a snapshot of our devices
|
||||
// before we download any new ones.
|
||||
};
|
||||
|
||||
_onDevicesUpdated = (users: string[]) => {
|
||||
private onDevicesUpdated = (users: string[]) => {
|
||||
if (!users.includes(MatrixClientPeg.get().getUserId())) return;
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
_onDeviceVerificationChanged = (userId: string) => {
|
||||
private onDeviceVerificationChanged = (userId: string) => {
|
||||
if (userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
_onUserTrustStatusChanged = (userId: string) => {
|
||||
private onUserTrustStatusChanged = (userId: string) => {
|
||||
if (userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
_onCrossSingingKeysChanged = () => {
|
||||
this._recheck();
|
||||
private onCrossSingingKeysChanged = () => {
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
_onAccountData = (ev) => {
|
||||
private onAccountData = (ev: MatrixEvent) => {
|
||||
// User may have:
|
||||
// * migrated SSSS to symmetric
|
||||
// * uploaded keys to secret storage
|
||||
|
@ -160,34 +164,35 @@ export default class DeviceListener {
|
|||
// which result in account data changes affecting checks below.
|
||||
if (
|
||||
ev.getType().startsWith('m.secret_storage.') ||
|
||||
ev.getType().startsWith('m.cross_signing.')
|
||||
ev.getType().startsWith('m.cross_signing.') ||
|
||||
ev.getType() === 'm.megolm_backup.v1'
|
||||
) {
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
}
|
||||
};
|
||||
|
||||
_onSync = (state, prevState) => {
|
||||
if (state === 'PREPARED' && prevState === null) this._recheck();
|
||||
private onSync = (state, prevState) => {
|
||||
if (state === 'PREPARED' && prevState === null) this.recheck();
|
||||
};
|
||||
|
||||
_onRoomStateEvents = (ev: MatrixEvent) => {
|
||||
private onRoomStateEvents = (ev: MatrixEvent) => {
|
||||
if (ev.getType() !== "m.room.encryption") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a room changes to encrypted, re-check as it may be our first
|
||||
// encrypted room. This also catches encrypted room creation as well.
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
_onAction = ({ action }) => {
|
||||
private onAction = ({ action }: ActionPayload) => {
|
||||
if (action !== "on_logged_in") return;
|
||||
this._recheck();
|
||||
this.recheck();
|
||||
};
|
||||
|
||||
// The server doesn't tell us when key backup is set up, so we poll
|
||||
// & cache the result
|
||||
async _getKeyBackupInfo() {
|
||||
private async getKeyBackupInfo() {
|
||||
const now = (new Date()).getTime();
|
||||
if (!this.keyBackupInfo || this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL) {
|
||||
this.keyBackupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
|
@ -205,10 +210,10 @@ export default class DeviceListener {
|
|||
return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId));
|
||||
}
|
||||
|
||||
async _recheck() {
|
||||
private async recheck() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
|
||||
if (!await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) return;
|
||||
if (!(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))) return;
|
||||
|
||||
if (!cli.isCryptoEnabled()) return;
|
||||
// don't recheck until the initial sync is complete: lots of account data events will fire
|
||||
|
@ -234,7 +239,7 @@ export default class DeviceListener {
|
|||
// Cross-signing on account but this device doesn't trust the master key (verify this session)
|
||||
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
|
||||
} else {
|
||||
const backupInfo = await this._getKeyBackupInfo();
|
||||
const backupInfo = await this.getKeyBackupInfo();
|
||||
if (backupInfo) {
|
||||
// No cross-signing on account but key backup available (upgrade encryption)
|
||||
showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION);
|
||||
|
@ -255,7 +260,7 @@ export default class DeviceListener {
|
|||
|
||||
// This needs to be done after awaiting on downloadKeys() above, so
|
||||
// we make sure we get the devices after the fetch is done.
|
||||
this._ensureDeviceIdsAtStartPopulated();
|
||||
this.ensureDeviceIdsAtStartPopulated();
|
||||
|
||||
// Unverified devices that were there last time the app ran
|
||||
// (technically could just be a boolean: we don't actually
|
||||
|
@ -283,6 +288,9 @@ export default class DeviceListener {
|
|||
}
|
||||
}
|
||||
|
||||
logger.log("Old unverified sessions: " + Array.from(oldUnverifiedDeviceIds).join(','));
|
||||
logger.log("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(','));
|
||||
|
||||
// Display or hide the batch toast for old unverified sessions
|
||||
if (oldUnverifiedDeviceIds.size > 0) {
|
||||
showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds);
|
||||
|
|
|
@ -19,7 +19,7 @@ import Modal from './Modal';
|
|||
import * as sdk from './';
|
||||
import MultiInviter from './utils/MultiInviter';
|
||||
import { _t } from './languageHandler';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import GroupStore from './stores/GroupStore';
|
||||
import StyledCheckbox from './components/views/elements/StyledCheckbox';
|
||||
|
||||
|
@ -103,7 +103,7 @@ function _onGroupInviteFinished(groupId, addrs) {
|
|||
if (errorList.length > 0) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, {
|
||||
title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}),
|
||||
title: _t("Failed to invite the following users to %(groupId)s:", { groupId: groupId }),
|
||||
description: errorList.join(", "),
|
||||
});
|
||||
}
|
||||
|
@ -111,7 +111,7 @@ function _onGroupInviteFinished(groupId, addrs) {
|
|||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, {
|
||||
title: _t("Failed to invite users to community"),
|
||||
description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}),
|
||||
description: _t("Failed to invite users to %(groupId)s", { groupId: groupId }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -137,7 +137,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
|
|||
// Add this group as related
|
||||
if (!groups.includes(groupId)) {
|
||||
groups.push(groupId);
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', { groups }, '');
|
||||
}
|
||||
});
|
||||
})).then(() => {
|
||||
|
@ -152,7 +152,7 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
|
|||
{
|
||||
title: _t(
|
||||
"Failed to add the following rooms to %(groupId)s:",
|
||||
{groupId},
|
||||
{ groupId },
|
||||
),
|
||||
description: errorList.join(", "),
|
||||
},
|
||||
|
|
|
@ -17,25 +17,25 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
|
||||
import cheerio from 'cheerio';
|
||||
import * as linkify from 'linkifyjs';
|
||||
import linkifyMatrix from './linkify-matrix';
|
||||
import _linkifyElement from 'linkifyjs/element';
|
||||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import url from 'url';
|
||||
import katex from 'katex';
|
||||
import { AllHtmlEntities } from 'html-entities';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import cheerio from 'cheerio';
|
||||
import { IContent } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
||||
import { IExtendedSanitizeOptions } from './@types/sanitize-html';
|
||||
import linkifyMatrix from './linkify-matrix';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { getEmojiFromUnicode } from "./emoji";
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
import {mediaFromMxc} from "./customisations/Media";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
|
@ -57,7 +57,35 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
|||
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
export const PERMITTED_URL_SCHEMES = [
|
||||
"bitcoin",
|
||||
"ftp",
|
||||
"geo",
|
||||
"http",
|
||||
"https",
|
||||
"im",
|
||||
"irc",
|
||||
"ircs",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"mms",
|
||||
"news",
|
||||
"nntp",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sftp",
|
||||
"sms",
|
||||
"smsto",
|
||||
"ssh",
|
||||
"tel",
|
||||
"urn",
|
||||
"webcal",
|
||||
"wtai",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||
|
||||
/*
|
||||
* Return true if the given string contains emoji
|
||||
|
@ -66,7 +94,7 @@ export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'
|
|||
* need emojification.
|
||||
* unicodeToImage uses this function.
|
||||
*/
|
||||
function mightContainEmoji(str: string) {
|
||||
function mightContainEmoji(str: string): boolean {
|
||||
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
||||
}
|
||||
|
||||
|
@ -76,21 +104,9 @@ function mightContainEmoji(str: string) {
|
|||
* @param {String} char The emoji character
|
||||
* @return {String} The shortcode (such as :thumbup:)
|
||||
*/
|
||||
export function unicodeToShortcode(char: string) {
|
||||
const data = getEmojiFromUnicode(char);
|
||||
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unicode character for an emoji shortcode
|
||||
*
|
||||
* @param {String} shortcode The shortcode (such as :thumbup:)
|
||||
* @return {String} The emoji character; null if none exists
|
||||
*/
|
||||
export function shortcodeToUnicode(shortcode: string) {
|
||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
||||
return data ? data.unicode : null;
|
||||
export function unicodeToShortcode(char: string): string {
|
||||
const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
|
||||
return shortcodes?.length ? `:${shortcodes[0]}:` : '';
|
||||
}
|
||||
|
||||
export function processHtmlForSending(html: string): string {
|
||||
|
@ -124,20 +140,20 @@ export function processHtmlForSending(html: string): string {
|
|||
* Given an untrusted HTML string, return a React node with an sanitized version
|
||||
* of that HTML.
|
||||
*/
|
||||
export function sanitizedHtmlNode(insaneHtml: string) {
|
||||
export function sanitizedHtmlNode(insaneHtml: string): ReactNode {
|
||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||
}
|
||||
|
||||
export function getHtmlText(insaneHtml: string) {
|
||||
export function getHtmlText(insaneHtml: string): string {
|
||||
return sanitizeHtml(insaneHtml, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
selfClosing: [],
|
||||
allowedSchemes: [],
|
||||
disallowedTagsMode: 'discard',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,12 +164,10 @@ export function getHtmlText(insaneHtml: string) {
|
|||
* other places we need to sanitise URLs.
|
||||
* @return true if permitted, otherwise false
|
||||
*/
|
||||
export function isUrlPermitted(inputUrl: string) {
|
||||
export function isUrlPermitted(inputUrl: string): boolean {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
// 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;
|
||||
}
|
||||
|
@ -175,18 +189,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
|
|||
return { tagName, attribs };
|
||||
},
|
||||
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
let src = attribs.src;
|
||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||
// we don't want to allow images with `https?` `src`s.
|
||||
// We also drop inline images (as if they were not present at all) when the "show
|
||||
// images" preference is disabled. Future work might expose some UI to reveal them
|
||||
// like standalone image events have.
|
||||
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
|
||||
return { tagName, attribs: {}};
|
||||
if (!src || !SettingsStore.getValue("showImages")) {
|
||||
return { tagName, attribs: {} };
|
||||
}
|
||||
|
||||
if (!src.startsWith("mxc://")) {
|
||||
const match = MEDIA_API_MXC_REGEX.exec(src);
|
||||
if (match) {
|
||||
src = `mxc://${match[1]}/${match[2]}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!src.startsWith("mxc://")) {
|
||||
return { tagName, attribs: {} };
|
||||
}
|
||||
|
||||
const width = Number(attribs.width) || 800;
|
||||
const height = Number(attribs.height) || 600;
|
||||
attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height);
|
||||
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height);
|
||||
return { tagName, attribs };
|
||||
},
|
||||
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
|
@ -351,20 +378,21 @@ class HtmlHighlighter extends BaseHighlighter<string> {
|
|||
}
|
||||
}
|
||||
|
||||
interface IContent {
|
||||
format?: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
formatted_body?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface IOpts {
|
||||
highlightLink?: string;
|
||||
disableBigEmoji?: boolean;
|
||||
stripReplyFallback?: boolean;
|
||||
returnString?: boolean;
|
||||
forComposerQuote?: boolean;
|
||||
ref?: React.Ref<any>;
|
||||
ref?: React.Ref<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
export interface IOptsReturnNode extends IOpts {
|
||||
returnString: false | undefined;
|
||||
}
|
||||
|
||||
export interface IOptsReturnString extends IOpts {
|
||||
returnString: true;
|
||||
}
|
||||
|
||||
/* turn a matrix event body into html
|
||||
|
@ -380,6 +408,8 @@ interface IOpts {
|
|||
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
||||
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
||||
*/
|
||||
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnString): string;
|
||||
export function bodyToHtml(content: IContent, highlights: string[], opts: IOptsReturnNode): ReactNode;
|
||||
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
|
||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||
let bodyHasEmoji = false;
|
||||
|
@ -399,9 +429,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
|||
try {
|
||||
if (highlights && highlights.length > 0) {
|
||||
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
|
||||
const safeHighlights = highlights.map(function(highlight) {
|
||||
return sanitizeHtml(highlight, sanitizeParams);
|
||||
});
|
||||
const safeHighlights = highlights
|
||||
// sanitizeHtml can hang if an unclosed HTML tag is thrown at it
|
||||
// A search for `<foo` will make the browser crash
|
||||
// an alternative would be to escape HTML special characters
|
||||
// but that would bring no additional benefit as the highlighter
|
||||
// does not work with those special chars
|
||||
.filter((highlight: string): boolean => !highlight.includes("<"))
|
||||
.map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams));
|
||||
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
|
||||
sanitizeParams.textFilter = function(safeText) {
|
||||
return highlighter.applyHighlights(safeText, safeHighlights).join('');
|
||||
|
@ -501,7 +536,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
|||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string} Linkified string
|
||||
*/
|
||||
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
||||
export function linkifyString(str: string, options = linkifyMatrix.options): string {
|
||||
return _linkifyString(str, options);
|
||||
}
|
||||
|
||||
|
@ -512,7 +547,7 @@ export function linkifyString(str: string, options = linkifyMatrix.options) {
|
|||
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
|
||||
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options): HTMLElement {
|
||||
return _linkifyElement(element, options);
|
||||
}
|
||||
|
||||
|
@ -523,7 +558,7 @@ export function linkifyElement(element: HTMLElement, options = linkifyMatrix.opt
|
|||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options): string {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
|
@ -534,7 +569,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri
|
|||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node: Node) {
|
||||
export function checkBlockNode(node: Node): boolean {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
||||
|
|
|
@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import { Service, startTermsFlow, TermsNotSignedError } from './Terms';
|
||||
import {
|
||||
|
@ -27,21 +27,25 @@ import {
|
|||
doesIdentityServerHaveTerms,
|
||||
useDefaultIdentityServer,
|
||||
} from './utils/IdentityServerUtils';
|
||||
import { abbreviateUrl } from './utils/UrlUtils';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import { abbreviateUrl } from "./utils/UrlUtils";
|
||||
|
||||
export class AbortedIdentityActionError extends Error {}
|
||||
|
||||
export default class IdentityAuthClient {
|
||||
private accessToken: string;
|
||||
private tempClient: MatrixClient;
|
||||
private authEnabled = true;
|
||||
|
||||
/**
|
||||
* Creates a new identity auth client
|
||||
* @param {string} identityUrl The URL to contact the identity server with.
|
||||
* When provided, this class will operate solely within memory, refusing to
|
||||
* persist any information such as tokens. Default null (not provided).
|
||||
*/
|
||||
constructor(identityUrl = null) {
|
||||
this.accessToken = null;
|
||||
this.authEnabled = true;
|
||||
|
||||
constructor(identityUrl?: string) {
|
||||
if (identityUrl) {
|
||||
// XXX: We shouldn't have to create a whole new MatrixClient just to
|
||||
// do identity server auth. The functions don't take an identity URL
|
||||
|
@ -52,32 +56,29 @@ export default class IdentityAuthClient {
|
|||
baseUrl: "", // invalid by design
|
||||
idBaseUrl: identityUrl,
|
||||
});
|
||||
} else {
|
||||
// Indicates that we're using the real client, not some workaround.
|
||||
this.tempClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
get _matrixClient() {
|
||||
private get matrixClient(): MatrixClient {
|
||||
return this.tempClient ? this.tempClient : MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
_writeToken() {
|
||||
private writeToken(): void {
|
||||
if (this.tempClient) return; // temporary client: ignore
|
||||
window.localStorage.setItem("mx_is_access_token", this.accessToken);
|
||||
}
|
||||
|
||||
_readToken() {
|
||||
private readToken(): string {
|
||||
if (this.tempClient) return null; // temporary client: ignore
|
||||
return window.localStorage.getItem("mx_is_access_token");
|
||||
}
|
||||
|
||||
hasCredentials() {
|
||||
return this.accessToken != null; // undef or null
|
||||
public hasCredentials(): boolean {
|
||||
return Boolean(this.accessToken);
|
||||
}
|
||||
|
||||
// Returns a promise that resolves to the access_token string from the IS
|
||||
async getAccessToken({ check = true } = {}) {
|
||||
public async getAccessToken({ check = true } = {}): Promise<string> {
|
||||
if (!this.authEnabled) {
|
||||
// The current IS doesn't support authentication
|
||||
return null;
|
||||
|
@ -85,21 +86,21 @@ export default class IdentityAuthClient {
|
|||
|
||||
let token = this.accessToken;
|
||||
if (!token) {
|
||||
token = this._readToken();
|
||||
token = this.readToken();
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
token = await this.registerForToken(check);
|
||||
if (token) {
|
||||
this.accessToken = token;
|
||||
this._writeToken();
|
||||
this.writeToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
if (check) {
|
||||
try {
|
||||
await this._checkToken(token);
|
||||
await this.checkToken(token);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof TermsNotSignedError ||
|
||||
|
@ -112,7 +113,7 @@ export default class IdentityAuthClient {
|
|||
token = await this.registerForToken();
|
||||
if (token) {
|
||||
this.accessToken = token;
|
||||
this._writeToken();
|
||||
this.writeToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,14 +121,14 @@ export default class IdentityAuthClient {
|
|||
return token;
|
||||
}
|
||||
|
||||
async _checkToken(token) {
|
||||
const identityServerUrl = this._matrixClient.getIdentityServerUrl();
|
||||
private async checkToken(token: string): Promise<void> {
|
||||
const identityServerUrl = this.matrixClient.getIdentityServerUrl();
|
||||
|
||||
try {
|
||||
await this._matrixClient.getIdentityAccount(token);
|
||||
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");
|
||||
logger.log("Identity server requires new terms to be agreed to");
|
||||
await startTermsFlow([new Service(
|
||||
SERVICE_TYPES.IS,
|
||||
identityServerUrl,
|
||||
|
@ -141,28 +142,28 @@ export default class IdentityAuthClient {
|
|||
if (
|
||||
!this.tempClient &&
|
||||
!doesAccountDataHaveIdentityServer() &&
|
||||
!await doesIdentityServerHaveTerms(identityServerUrl)
|
||||
!(await doesIdentityServerHaveTerms(identityServerUrl))
|
||||
) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
|
||||
const { finished } = Modal.createTrackedDialog(
|
||||
'Default identity server terms warning', '',
|
||||
QuestionDialog, {
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{_t(
|
||||
"This action requires accessing the default identity server " +
|
||||
title: _t("Identity server has no terms of service"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"This action requires accessing the default identity server " +
|
||||
"<server /> to validate an email address or phone number, " +
|
||||
"but the server does not have any terms of service.", {},
|
||||
{
|
||||
server: () => <b>{abbreviateUrl(identityServerUrl)}</b>,
|
||||
},
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
)}</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
{
|
||||
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
|
||||
},
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Only continue if you trust the owner of the server.",
|
||||
) }</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("Trust"),
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (confirmed) {
|
||||
|
@ -182,13 +183,13 @@ export default class IdentityAuthClient {
|
|||
// See also https://github.com/vector-im/element-web/issues/10455.
|
||||
}
|
||||
|
||||
async registerForToken(check=true) {
|
||||
public async registerForToken(check = true): Promise<string> {
|
||||
const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken();
|
||||
// XXX: The spec is `token`, but we used `access_token` for a Sydent release.
|
||||
const { access_token: accessToken, token } =
|
||||
await this._matrixClient.registerWithIdentityServer(hsOpenIdToken);
|
||||
await this.matrixClient.registerWithIdentityServer(hsOpenIdToken);
|
||||
const identityAccessToken = token ? token : accessToken;
|
||||
if (check) await this._checkToken(identityAccessToken);
|
||||
if (check) await this.checkToken(identityAccessToken);
|
||||
return identityAccessToken;
|
||||
}
|
||||
}
|
|
@ -156,36 +156,34 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
|
|||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
};
|
||||
|
||||
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
||||
return [
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrNextSelection,
|
||||
action: AutocompleteAction.ForceComplete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
shiftKey: true,
|
||||
key: Key.ENTER,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AutocompleteAction.CompleteOrPrevSelection,
|
||||
action: AutocompleteAction.Complete,
|
||||
keyCombo: {
|
||||
key: Key.TAB,
|
||||
key: Key.ENTER,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -207,7 +205,7 @@ const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
|
|||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const roomListBindings = (): KeyBinding<RoomListAction>[] => {
|
||||
return [
|
||||
|
@ -248,7 +246,7 @@ const roomListBindings = (): KeyBinding<RoomListAction>[] => {
|
|||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const roomBindings = (): KeyBinding<RoomAction>[] => {
|
||||
const bindings: KeyBinding<RoomAction>[] = [
|
||||
|
@ -312,7 +310,7 @@ const roomBindings = (): KeyBinding<RoomAction>[] => {
|
|||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
};
|
||||
|
||||
const navigationBindings = (): KeyBinding<NavigationAction>[] => {
|
||||
return [
|
||||
|
@ -396,7 +394,7 @@ const navigationBindings = (): KeyBinding<NavigationAction>[] => {
|
|||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultBindingsProvider: IKeyBindingsProvider = {
|
||||
getMessageComposerBindings: messageComposerBindings,
|
||||
|
@ -404,4 +402,4 @@ export const defaultBindingsProvider: IKeyBindingsProvider = {
|
|||
getRoomListBindings: roomListBindings,
|
||||
getRoomBindings: roomBindings,
|
||||
getNavigationBindings: navigationBindings,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -52,13 +52,11 @@ export enum MessageComposerAction {
|
|||
|
||||
/** Actions for text editing autocompletion */
|
||||
export enum AutocompleteAction {
|
||||
/**
|
||||
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
|
||||
* selection.
|
||||
*/
|
||||
CompleteOrPrevSelection = 'ApplySelection',
|
||||
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
|
||||
CompleteOrNextSelection = 'CompleteOrNextSelection',
|
||||
/** Accepts chosen autocomplete selection */
|
||||
Complete = 'Complete',
|
||||
/** Accepts chosen autocomplete selection or,
|
||||
* if the autocompletion window is not shown, open the window and select the first selection */
|
||||
ForceComplete = 'ForceComplete',
|
||||
/** Move to the previous autocomplete selection */
|
||||
PrevSelection = 'PrevSelection',
|
||||
/** Move to the next autocomplete selection */
|
||||
|
@ -140,12 +138,12 @@ export type KeyCombo = {
|
|||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export type KeyBinding<T extends string> = {
|
||||
action: T;
|
||||
keyCombo: KeyCombo;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method to check if a KeyboardEvent matches a KeyCombo
|
||||
|
|
106
src/Lifecycle.ts
106
src/Lifecycle.ts
|
@ -20,9 +20,10 @@ limitations under the License.
|
|||
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} from "matrix-js-sdk/src/crypto/aes";
|
||||
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 { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
import EventIndexPeg from './indexing/EventIndexPeg';
|
||||
import createMatrixClient from './utils/createMatrixClient';
|
||||
|
@ -33,7 +34,6 @@ import Presence from './Presence';
|
|||
import dis from './dispatcher/dispatcher';
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import ActiveWidgetStore from './stores/ActiveWidgetStore';
|
||||
import PlatformPeg from "./PlatformPeg";
|
||||
import { sendLoginRequest } from "./Login";
|
||||
|
@ -41,17 +41,24 @@ import * as StorageManager from './utils/StorageManager';
|
|||
import SettingsStore from "./settings/SettingsStore";
|
||||
import TypingStore from "./stores/TypingStore";
|
||||
import ToastStore from "./stores/ToastStore";
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import {Mjolnir} from "./mjolnir/Mjolnir";
|
||||
import { IntegrationManagers } from "./integrations/IntegrationManagers";
|
||||
import { Mjolnir } from "./mjolnir/Mjolnir";
|
||||
import DeviceListener from "./DeviceListener";
|
||||
import {Jitsi} from "./widgets/Jitsi";
|
||||
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY} from "./BasePlatform";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
|
||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import CallHandler from './CallHandler';
|
||||
import LifecycleCustomisations from "./customisations/Lifecycle";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
import {_t} from "./languageHandler";
|
||||
import { _t } from "./languageHandler";
|
||||
import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog";
|
||||
import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog";
|
||||
import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog";
|
||||
import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const HOMESERVER_URL_KEY = "mx_hs_url";
|
||||
const ID_SERVER_URL_KEY = "mx_is_url";
|
||||
|
@ -62,7 +69,7 @@ interface ILoadSessionOpts {
|
|||
guestIsUrl?: string;
|
||||
ignoreGuest?: boolean;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentQueryParams?: Record<string, string>;
|
||||
fragmentQueryParams?: QueryDict;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -113,10 +120,10 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
|
|||
fragmentQueryParams.guest_user_id &&
|
||||
fragmentQueryParams.guest_access_token
|
||||
) {
|
||||
console.log("Using guest access credentials");
|
||||
logger.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,
|
||||
|
@ -154,7 +161,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
|
|||
* return [null, null].
|
||||
*/
|
||||
export async function getStoredSessionOwner(): Promise<[string, boolean]> {
|
||||
const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars();
|
||||
const { hsUrl, userId, hasAccessToken, isGuest } = await getStoredSessionVars();
|
||||
return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null];
|
||||
}
|
||||
|
||||
|
@ -170,7 +177,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> {
|
||||
|
@ -195,11 +202,11 @@ export function attemptTokenLogin(
|
|||
homeserver,
|
||||
identityServer,
|
||||
"m.login.token", {
|
||||
token: queryParams.loginToken,
|
||||
token: queryParams.loginToken as string,
|
||||
initial_device_display_name: defaultDeviceDisplayName,
|
||||
},
|
||||
).then(function(creds) {
|
||||
console.log("Logged in with token");
|
||||
logger.log("Logged in with token");
|
||||
return clearStorage().then(async () => {
|
||||
await persistCredentials(creds);
|
||||
// remember that we just logged in
|
||||
|
@ -238,8 +245,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
|
|||
return Promise.resolve().then(() => {
|
||||
const lazyLoadEnabled = e.value;
|
||||
if (lazyLoadEnabled) {
|
||||
const LazyLoadingResyncDialog =
|
||||
sdk.getComponent("views.dialogs.LazyLoadingResyncDialog");
|
||||
return new Promise((resolve) => {
|
||||
Modal.createDialog(LazyLoadingResyncDialog, {
|
||||
onFinished: resolve,
|
||||
|
@ -250,8 +255,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise<void> {
|
|||
// between LL/non-LL version on same host.
|
||||
// as disabling LL when previously enabled
|
||||
// is a strong indicator of this (/develop & /app)
|
||||
const LazyLoadingDisabledDialog =
|
||||
sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog");
|
||||
return new Promise((resolve) => {
|
||||
Modal.createDialog(LazyLoadingDisabledDialog, {
|
||||
onFinished: resolve,
|
||||
|
@ -272,7 +275,7 @@ function registerAsGuest(
|
|||
isUrl: string,
|
||||
defaultDeviceDisplayName: string,
|
||||
): Promise<boolean> {
|
||||
console.log(`Doing guest login on ${hsUrl}`);
|
||||
logger.log(`Doing guest login on ${hsUrl}`);
|
||||
|
||||
// create a temporary MatrixClient to do the login
|
||||
const client = createClient({
|
||||
|
@ -284,7 +287,7 @@ function registerAsGuest(
|
|||
initial_device_display_name: defaultDeviceDisplayName,
|
||||
},
|
||||
}).then((creds) => {
|
||||
console.log(`Registered as guest: ${creds.user_id}`);
|
||||
logger.log(`Registered as guest: ${creds.user_id}`);
|
||||
return doSetLoggedIn({
|
||||
userId: creds.user_id,
|
||||
deviceId: creds.device_id,
|
||||
|
@ -303,7 +306,7 @@ export interface IStoredSession {
|
|||
hsUrl: string;
|
||||
isUrl: string;
|
||||
hasAccessToken: boolean;
|
||||
accessToken: string | object;
|
||||
accessToken: string | IEncryptedPayload;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
isGuest: boolean;
|
||||
|
@ -346,11 +349,11 @@ export async function getStoredSessionVars(): Promise<IStoredSession> {
|
|||
isGuest = localStorage.getItem("matrix-is-guest") === "true";
|
||||
}
|
||||
|
||||
return {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest};
|
||||
return { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest };
|
||||
}
|
||||
|
||||
// The pickle key is a string of unspecified length and format. For AES, we
|
||||
// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES
|
||||
// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES
|
||||
// key. The AES key should be zeroed after it is used.
|
||||
async function pickleKeyToAesKey(pickleKey: string): Promise<Uint8Array> {
|
||||
const pickleKeyBuffer = new Uint8Array(pickleKey.length);
|
||||
|
@ -402,7 +405,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
return false;
|
||||
}
|
||||
|
||||
const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars();
|
||||
const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars();
|
||||
|
||||
if (hasAccessToken && !accessToken) {
|
||||
abortLogin();
|
||||
|
@ -410,27 +413,27 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
|
||||
if (accessToken && userId && hsUrl) {
|
||||
if (ignoreGuest && isGuest) {
|
||||
console.log("Ignoring stored guest account: " + userId);
|
||||
logger.log("Ignoring stored guest account: " + userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
let decryptedAccessToken = accessToken;
|
||||
const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId);
|
||||
if (pickleKey) {
|
||||
console.log("Got pickle key");
|
||||
logger.log("Got pickle key");
|
||||
if (typeof accessToken !== "string") {
|
||||
const encrKey = await pickleKeyToAesKey(pickleKey);
|
||||
decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token");
|
||||
encrKey.fill(0);
|
||||
}
|
||||
} else {
|
||||
console.log("No pickle key available");
|
||||
logger.log("No pickle key available");
|
||||
}
|
||||
|
||||
const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true";
|
||||
sessionStorage.removeItem("mx_fresh_login");
|
||||
|
||||
console.log(`Restoring session for ${userId}`);
|
||||
logger.log(`Restoring session for ${userId}`);
|
||||
await doSetLoggedIn({
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
|
@ -443,7 +446,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
}, false);
|
||||
return true;
|
||||
} else {
|
||||
console.log("No previous session found.");
|
||||
logger.log("No previous session found.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -451,9 +454,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }):
|
|||
async function handleLoadSessionFailure(e: Error): Promise<boolean> {
|
||||
console.error("Unable to load session", e);
|
||||
|
||||
const SessionRestoreErrorDialog =
|
||||
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
|
||||
|
||||
const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, {
|
||||
error: e.message,
|
||||
});
|
||||
|
@ -490,12 +490,12 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
|
|||
: null;
|
||||
|
||||
if (pickleKey) {
|
||||
console.log("Created pickle key");
|
||||
logger.log("Created pickle key");
|
||||
} else {
|
||||
console.log("Pickle key not created");
|
||||
logger.log("Pickle key not created");
|
||||
}
|
||||
|
||||
return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true);
|
||||
return doSetLoggedIn(Object.assign({}, credentials, { pickleKey }), true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -546,7 +546,7 @@ async function doSetLoggedIn(
|
|||
|
||||
const softLogout = isSoftLogout();
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"setLoggedIn: mxid: " + credentials.userId +
|
||||
" deviceId: " + credentials.deviceId +
|
||||
" guest: " + credentials.guest +
|
||||
|
@ -562,7 +562,7 @@ async function doSetLoggedIn(
|
|||
//
|
||||
// we fire it *synchronously* to make sure it fires before on_logged_in.
|
||||
// (dis.dispatch uses `setTimeout`, which does not guarantee ordering.)
|
||||
dis.dispatch({action: 'on_logging_in'}, true);
|
||||
dis.dispatch({ action: 'on_logging_in' }, true);
|
||||
|
||||
if (clearStorageEnabled) {
|
||||
await clearStorage();
|
||||
|
@ -579,6 +579,9 @@ async function doSetLoggedIn(
|
|||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||
|
||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
|
||||
|
@ -612,7 +615,6 @@ async function doSetLoggedIn(
|
|||
}
|
||||
|
||||
function showStorageEvictedDialog(): Promise<boolean> {
|
||||
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
|
||||
return new Promise(resolve => {
|
||||
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
|
||||
onFinished: resolve,
|
||||
|
@ -689,7 +691,7 @@ async function persistCredentials(credentials: IMatrixClientCreds): Promise<void
|
|||
|
||||
SecurityCustomisations.persistCredentials?.(credentials);
|
||||
|
||||
console.log(`Session persisted for ${credentials.userId}`);
|
||||
logger.log(`Session persisted for ${credentials.userId}`);
|
||||
}
|
||||
|
||||
let _isLoggingOut = false;
|
||||
|
@ -704,6 +706,8 @@ export function logout(): void {
|
|||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.logout();
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// logout doesn't work for guest sessions
|
||||
// Also we sometimes want to re-log in a guest session if we abort the login.
|
||||
|
@ -724,7 +728,7 @@ export function logout(): void {
|
|||
// token still valid, but we should fix this by having access
|
||||
// tokens expire (and if you really think you've been compromised,
|
||||
// change your password).
|
||||
console.log("Failed to call logout API: token will not be invalidated");
|
||||
logger.log("Failed to call logout API: token will not be invalidated");
|
||||
onLoggedOut();
|
||||
},
|
||||
);
|
||||
|
@ -740,12 +744,12 @@ export function softLogout(): void {
|
|||
|
||||
// Dev note: please keep this log line around. It can be useful for track down
|
||||
// random clients stopping in the middle of the logs.
|
||||
console.log("Soft logout initiated");
|
||||
logger.log("Soft logout initiated");
|
||||
_isLoggingOut = true; // to avoid repeated flags
|
||||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({action: 'on_client_not_viable'}); // generic version of on_logged_out
|
||||
dis.dispatch({ action: 'on_client_not_viable' }); // generic version of on_logged_out
|
||||
stopMatrixClient(/*unsetClient=*/false);
|
||||
|
||||
// DO NOT CALL LOGOUT. A soft logout preserves data, logout does not.
|
||||
|
@ -766,13 +770,13 @@ export function isLoggingOut(): boolean {
|
|||
* syncing the client.
|
||||
*/
|
||||
async function startMatrixClient(startSyncing = true): Promise<void> {
|
||||
console.log(`Lifecycle: Starting MatrixClient`);
|
||||
logger.log(`Lifecycle: Starting MatrixClient`);
|
||||
|
||||
// dispatch this before starting the matrix client: it's used
|
||||
// to add listeners for the 'sync' event so otherwise we'd have
|
||||
// a race condition (and we need to dispatch synchronously for this
|
||||
// to work).
|
||||
dis.dispatch({action: 'will_start_client'}, true);
|
||||
dis.dispatch({ action: 'will_start_client' }, true);
|
||||
|
||||
// reset things first just in case
|
||||
TypingStore.sharedInstance().reset();
|
||||
|
@ -782,7 +786,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
|
|||
UserActivity.sharedInstance().start();
|
||||
DMRoomMap.makeShared().start();
|
||||
IntegrationManagers.sharedInstance().startWatching();
|
||||
ActiveWidgetStore.start();
|
||||
ActiveWidgetStore.instance.start();
|
||||
CallHandler.sharedInstance().start();
|
||||
|
||||
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
|
||||
|
@ -814,7 +818,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
|
|||
|
||||
// dispatch that we finished starting up to wire up any other bits
|
||||
// of the matrix client that cannot be set prior to starting up.
|
||||
dis.dispatch({action: 'client_started'});
|
||||
dis.dispatch({ action: 'client_started' });
|
||||
|
||||
if (isSoftLogout()) {
|
||||
softLogout();
|
||||
|
@ -830,9 +834,9 @@ export async function onLoggedOut(): Promise<void> {
|
|||
// Ensure that we dispatch a view change **before** stopping the client so
|
||||
// so that React components unmount first. This avoids React soft crashes
|
||||
// that can occur when components try to use a null client.
|
||||
dis.dispatch({action: 'on_logged_out'}, true);
|
||||
dis.dispatch({ action: 'on_logged_out' }, true);
|
||||
stopMatrixClient();
|
||||
await clearStorage({deleteEverything: true});
|
||||
await clearStorage({ deleteEverything: true });
|
||||
LifecycleCustomisations.onLoggedOutAndStorageCleared?.();
|
||||
}
|
||||
|
||||
|
@ -888,7 +892,7 @@ export function stopMatrixClient(unsetClient = true): void {
|
|||
UserActivity.sharedInstance().stop();
|
||||
TypingStore.sharedInstance().reset();
|
||||
Presence.stop();
|
||||
ActiveWidgetStore.stop();
|
||||
ActiveWidgetStore.instance.stop();
|
||||
IntegrationManagers.sharedInstance().stopWatching();
|
||||
Mjolnir.sharedInstance().stop();
|
||||
DeviceListener.sharedInstance().stop();
|
||||
|
|
13
src/Login.ts
13
src/Login.ts
|
@ -16,11 +16,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
|
||||
import {createClient} from "matrix-js-sdk/src/matrix";
|
||||
import { createClient } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { IMatrixClientCreds } from "./MatrixClientPeg";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface ILoginOptions {
|
||||
defaultDeviceDisplayName?: string;
|
||||
}
|
||||
|
@ -166,7 +168,7 @@ export default class Login {
|
|||
return sendLoginRequest(
|
||||
this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams,
|
||||
).catch((fallbackError) => {
|
||||
console.log("fallback HS login failed", fallbackError);
|
||||
logger.log("fallback HS login failed", fallbackError);
|
||||
// throw the original error
|
||||
throw originalError;
|
||||
});
|
||||
|
@ -184,13 +186,12 @@ export default class Login {
|
|||
}
|
||||
throw originalLoginError;
|
||||
}).catch((error) => {
|
||||
console.log("Login failed", error);
|
||||
logger.log("Login failed", error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send a login request to the given server, and format the response
|
||||
* as a MatrixClientCreds
|
||||
|
@ -219,12 +220,12 @@ export async function sendLoginRequest(
|
|||
if (wellknown) {
|
||||
if (wellknown["m.homeserver"] && wellknown["m.homeserver"]["base_url"]) {
|
||||
hsUrl = wellknown["m.homeserver"]["base_url"];
|
||||
console.log(`Overrode homeserver setting with ${hsUrl} from login response`);
|
||||
logger.log(`Overrode homeserver setting with ${hsUrl} from login response`);
|
||||
}
|
||||
if (wellknown["m.identity_server"] && wellknown["m.identity_server"]["base_url"]) {
|
||||
// TODO: should we prompt here?
|
||||
isUrl = wellknown["m.identity_server"]["base_url"];
|
||||
console.log(`Overrode IS setting with ${isUrl} from login response`);
|
||||
logger.log(`Overrode IS setting with ${isUrl} from login response`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
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.
|
||||
|
@ -15,16 +16,24 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as commonmark from 'commonmark';
|
||||
import {escape} from "lodash";
|
||||
import { escape } from "lodash";
|
||||
|
||||
const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
|
||||
|
||||
// These types of node are definitely text
|
||||
const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document'];
|
||||
|
||||
function is_allowed_html_tag(node) {
|
||||
// As far as @types/commonmark is concerned, these are not public, so add them
|
||||
interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer {
|
||||
paragraph: (node: commonmark.Node, entering: boolean) => void;
|
||||
link: (node: commonmark.Node, entering: boolean) => void;
|
||||
html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase
|
||||
html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase
|
||||
}
|
||||
|
||||
function isAllowedHtmlTag(node: commonmark.Node): boolean {
|
||||
if (node.literal != null &&
|
||||
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) {
|
||||
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -39,21 +48,12 @@ function is_allowed_html_tag(node) {
|
|||
return false;
|
||||
}
|
||||
|
||||
function html_if_tag_allowed(node) {
|
||||
if (is_allowed_html_tag(node)) {
|
||||
this.lit(node.literal);
|
||||
return;
|
||||
} else {
|
||||
this.lit(escape(node.literal));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns true if the parse output containing the node
|
||||
* comprises multiple block level elements (ie. lines),
|
||||
* or false if it is only a single line.
|
||||
*/
|
||||
function is_multi_line(node) {
|
||||
function isMultiLine(node: commonmark.Node): boolean {
|
||||
let par = node;
|
||||
while (par.parent) {
|
||||
par = par.parent;
|
||||
|
@ -67,6 +67,9 @@ function is_multi_line(node) {
|
|||
* it's plain text.
|
||||
*/
|
||||
export default class Markdown {
|
||||
private input: string;
|
||||
private parsed: commonmark.Node;
|
||||
|
||||
constructor(input) {
|
||||
this.input = input;
|
||||
|
||||
|
@ -74,7 +77,7 @@ export default class Markdown {
|
|||
this.parsed = parser.parse(this.input);
|
||||
}
|
||||
|
||||
isPlainText() {
|
||||
isPlainText(): boolean {
|
||||
const walker = this.parsed.walker();
|
||||
|
||||
let ev;
|
||||
|
@ -87,7 +90,7 @@ export default class Markdown {
|
|||
// if it's an allowed html tag, we need to render it and therefore
|
||||
// we will need to use HTML. If it's not allowed, it's not HTML since
|
||||
// we'll just be treating it as text.
|
||||
if (is_allowed_html_tag(node)) {
|
||||
if (isAllowedHtmlTag(node)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
|
@ -97,7 +100,7 @@ export default class Markdown {
|
|||
return true;
|
||||
}
|
||||
|
||||
toHTML({ externalLinks = false } = {}) {
|
||||
toHTML({ externalLinks = false } = {}): string {
|
||||
const renderer = new commonmark.HtmlRenderer({
|
||||
safe: false,
|
||||
|
||||
|
@ -107,7 +110,7 @@ export default class Markdown {
|
|||
// block quote ends up all on one line
|
||||
// (https://github.com/vector-im/element-web/issues/3154)
|
||||
softbreak: '<br />',
|
||||
});
|
||||
}) as CommonmarkHtmlRendererInternal;
|
||||
|
||||
// Trying to strip out the wrapping <p/> causes a lot more complication
|
||||
// than it's worth, i think. For instance, this code will go and strip
|
||||
|
@ -118,16 +121,16 @@ export default class Markdown {
|
|||
//
|
||||
// Let's try sending with <p/>s anyway for now, though.
|
||||
|
||||
const real_paragraph = renderer.paragraph;
|
||||
const realParagraph = renderer.paragraph;
|
||||
|
||||
renderer.paragraph = function(node, entering) {
|
||||
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
|
||||
// If there is only one top level node, just return the
|
||||
// bare text: it's a single line of text and so should be
|
||||
// 'inline', rather than unnecessarily wrapped in its own
|
||||
// p tag. If, however, we have multiple nodes, each gets
|
||||
// its own p tag to keep them as separate paragraphs.
|
||||
if (is_multi_line(node)) {
|
||||
real_paragraph.call(this, node, entering);
|
||||
if (isMultiLine(node)) {
|
||||
realParagraph.call(this, node, entering);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -150,19 +153,26 @@ export default class Markdown {
|
|||
}
|
||||
};
|
||||
|
||||
renderer.html_inline = html_if_tag_allowed;
|
||||
renderer.html_inline = function(node: commonmark.Node) {
|
||||
if (isAllowedHtmlTag(node)) {
|
||||
this.lit(node.literal);
|
||||
return;
|
||||
} else {
|
||||
this.lit(escape(node.literal));
|
||||
}
|
||||
};
|
||||
|
||||
renderer.html_block = function(node) {
|
||||
/*
|
||||
renderer.html_block = function(node: commonmark.Node) {
|
||||
/*
|
||||
// as with `paragraph`, we only insert line breaks
|
||||
// if there are multiple lines in the markdown.
|
||||
const isMultiLine = is_multi_line(node);
|
||||
if (isMultiLine) this.cr();
|
||||
*/
|
||||
html_if_tag_allowed.call(this, node);
|
||||
/*
|
||||
*/
|
||||
renderer.html_inline(node);
|
||||
/*
|
||||
if (isMultiLine) this.cr();
|
||||
*/
|
||||
*/
|
||||
};
|
||||
|
||||
return renderer.render(this.parsed);
|
||||
|
@ -177,23 +187,22 @@ export default class Markdown {
|
|||
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
|
||||
* which has no formatting. Otherwise it emits HTML(!).
|
||||
*/
|
||||
toPlaintext() {
|
||||
const renderer = new commonmark.HtmlRenderer({safe: false});
|
||||
const real_paragraph = renderer.paragraph;
|
||||
toPlaintext(): string {
|
||||
const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal;
|
||||
|
||||
renderer.paragraph = function(node, entering) {
|
||||
renderer.paragraph = function(node: commonmark.Node, entering: boolean) {
|
||||
// as with toHTML, only append lines to paragraphs if there are
|
||||
// multiple paragraphs
|
||||
if (is_multi_line(node)) {
|
||||
if (isMultiLine(node)) {
|
||||
if (!entering && node.next) {
|
||||
this.lit('\n\n');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderer.html_block = function(node) {
|
||||
renderer.html_block = function(node: commonmark.Node) {
|
||||
this.lit(node.literal);
|
||||
if (is_multi_line(node) && node.next) this.lit('\n\n');
|
||||
if (isMultiLine(node) && node.next) this.lit('\n\n');
|
||||
};
|
||||
|
||||
return renderer.render(this.parsed);
|
|
@ -17,25 +17,27 @@ 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 {MemoryStore} from 'matrix-js-sdk/src/store/memory';
|
||||
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';
|
||||
import {EventTimelineSet} from 'matrix-js-sdk/src/models/event-timeline-set';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
|
||||
import * as sdk from './index';
|
||||
import createMatrixClient from './utils/createMatrixClient';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import MatrixActionCreators from './actions/MatrixActionCreators';
|
||||
import Modal from './Modal';
|
||||
import {verificationMethods} from 'matrix-js-sdk/src/crypto';
|
||||
import { verificationMethods } from 'matrix-js-sdk/src/crypto';
|
||||
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
|
||||
import * as StorageManager from './utils/StorageManager';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
|
||||
import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||
import { SHOW_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export interface IMatrixClientCreds {
|
||||
homeserverUrl: string;
|
||||
identityServerUrl: string;
|
||||
|
@ -47,25 +49,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
|
||||
|
@ -122,12 +107,12 @@ export interface IMatrixClientPeg {
|
|||
* This module provides a singleton instance of this class so the 'current'
|
||||
* Matrix Client object is available easily.
|
||||
*/
|
||||
class _MatrixClientPeg implements IMatrixClientPeg {
|
||||
class MatrixClientPegClass implements IMatrixClientPeg {
|
||||
// These are the default options used when when the
|
||||
// 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 +126,6 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
constructor() {
|
||||
}
|
||||
|
||||
public setIndexedDbWorkerScript(script: string): void {
|
||||
createMatrixClient.indexedDbWorkerScript = script;
|
||||
}
|
||||
|
||||
public get(): MatrixClient {
|
||||
return this.matrixClient;
|
||||
}
|
||||
|
@ -187,7 +168,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
for (const dbType of ['indexeddb', 'memory']) {
|
||||
try {
|
||||
const promise = this.matrixClient.store.startup();
|
||||
console.log("MatrixClientPeg: waiting for MatrixClient store to initialise");
|
||||
logger.log("MatrixClientPeg: waiting for MatrixClient store to initialise");
|
||||
await promise;
|
||||
break;
|
||||
} catch (err) {
|
||||
|
@ -219,6 +200,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
} catch (e) {
|
||||
if (e && e.name === 'InvalidCryptoStoreError') {
|
||||
// The js-sdk found a crypto DB too new for it to use
|
||||
// FIXME: Using an import will result in test failures
|
||||
const CryptoStoreTooNewDialog =
|
||||
sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog");
|
||||
Modal.createDialog(CryptoStoreTooNewDialog);
|
||||
|
@ -230,9 +212,10 @@ 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
|
||||
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
|
||||
|
||||
// Connect the matrix client to the dispatcher and setting handlers
|
||||
MatrixActionCreators.start(this.matrixClient);
|
||||
|
@ -244,9 +227,9 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
public async start(): Promise<any> {
|
||||
const opts = await this.assign();
|
||||
|
||||
console.log(`MatrixClientPeg: really starting MatrixClient`);
|
||||
logger.log(`MatrixClientPeg: really starting MatrixClient`);
|
||||
await this.get().startClient(opts);
|
||||
console.log(`MatrixClientPeg: MatrixClient started`);
|
||||
logger.log(`MatrixClientPeg: MatrixClient started`);
|
||||
}
|
||||
|
||||
public getCredentials(): IMatrixClientCreds {
|
||||
|
@ -320,7 +303,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
|
|||
}
|
||||
|
||||
if (!window.mxMatrixClientPeg) {
|
||||
window.mxMatrixClientPeg = new _MatrixClientPeg();
|
||||
window.mxMatrixClientPeg = new MatrixClientPegClass();
|
||||
}
|
||||
|
||||
export const MatrixClientPeg = window.mxMatrixClientPeg;
|
||||
|
|
125
src/MediaDeviceHandler.ts
Normal file
125
src/MediaDeviceHandler.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import EventEmitter from 'events';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
// XXX: MediaDeviceKind is a union type, so we make our own enum
|
||||
export enum MediaDeviceKindEnum {
|
||||
AudioOutput = "audiooutput",
|
||||
AudioInput = "audioinput",
|
||||
VideoInput = "videoinput",
|
||||
}
|
||||
|
||||
export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
|
||||
|
||||
export enum MediaDeviceHandlerEvent {
|
||||
AudioOutputChanged = "audio_output_changed",
|
||||
}
|
||||
|
||||
export default class MediaDeviceHandler extends EventEmitter {
|
||||
private static internalInstance;
|
||||
|
||||
public static get instance(): MediaDeviceHandler {
|
||||
if (!MediaDeviceHandler.internalInstance) {
|
||||
MediaDeviceHandler.internalInstance = new MediaDeviceHandler();
|
||||
}
|
||||
return MediaDeviceHandler.internalInstance;
|
||||
}
|
||||
|
||||
public static async hasAnyLabeledDevices(): Promise<boolean> {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.some(d => Boolean(d.label));
|
||||
}
|
||||
|
||||
public static async getDevices(): Promise<IMediaDevices> {
|
||||
// Only needed for Electron atm, though should work in modern browsers
|
||||
// once permission has been granted to the webapp
|
||||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const output = {
|
||||
[MediaDeviceKindEnum.AudioOutput]: [],
|
||||
[MediaDeviceKindEnum.AudioInput]: [],
|
||||
[MediaDeviceKindEnum.VideoInput]: [],
|
||||
};
|
||||
|
||||
devices.forEach((device) => output[device.kind].push(device));
|
||||
return output;
|
||||
} catch (error) {
|
||||
console.warn('Unable to refresh WebRTC Devices: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
|
||||
*/
|
||||
public static loadDevices(): void {
|
||||
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
|
||||
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
|
||||
|
||||
MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
|
||||
}
|
||||
|
||||
public setAudioOutput(deviceId: string): void {
|
||||
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
|
||||
this.emit(MediaDeviceHandlerEvent.AudioOutputChanged, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will not change the device that a potential call uses. The call will
|
||||
* need to be ended and started again for this change to take effect
|
||||
* @param {string} deviceId
|
||||
*/
|
||||
public setAudioInput(deviceId: string): void {
|
||||
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* This will not change the device that a potential call uses. The call will
|
||||
* need to be ended and started again for this change to take effect
|
||||
* @param {string} deviceId
|
||||
*/
|
||||
public setVideoInput(deviceId: string): void {
|
||||
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
|
||||
}
|
||||
|
||||
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
|
||||
switch (kind) {
|
||||
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
|
||||
case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
|
||||
case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
|
||||
}
|
||||
}
|
||||
|
||||
public static getAudioOutput(): string {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
|
||||
}
|
||||
|
||||
public static getAudioInput(): string {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
|
||||
}
|
||||
|
||||
public static getVideoInput(): string {
|
||||
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
|
||||
}
|
||||
}
|
|
@ -15,14 +15,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import { defer } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import Analytics from './Analytics';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import {defer} from './utils/promise';
|
||||
import AsyncWrapper from './AsyncWrapper';
|
||||
|
||||
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
|
||||
|
@ -193,7 +192,7 @@ export class ModalManager {
|
|||
modal.elem = <AsyncWrapper key={modalCount} prom={prom} {...props} onFinished={closeDialog} />;
|
||||
modal.close = closeDialog;
|
||||
|
||||
return {modal, closeDialog, onFinishedProm};
|
||||
return { modal, closeDialog, onFinishedProm };
|
||||
}
|
||||
|
||||
private getCloseFn<T extends any[]>(
|
||||
|
@ -282,7 +281,7 @@ export class ModalManager {
|
|||
isStaticModal = false,
|
||||
options: IOptions<T> = {},
|
||||
): IHandle<T> {
|
||||
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, options);
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, options);
|
||||
if (isPriorityModal) {
|
||||
// XXX: This is destructive
|
||||
this.priorityModal = modal;
|
||||
|
@ -305,7 +304,7 @@ export class ModalManager {
|
|||
props?: IProps<T>,
|
||||
className?: string,
|
||||
): IHandle<T> {
|
||||
const {modal, closeDialog, onFinishedProm} = this.buildModal<T>(prom, props, className, {});
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<T>(prom, props, className, {});
|
||||
|
||||
this.modals.push(modal);
|
||||
this.reRender();
|
||||
|
@ -379,13 +378,13 @@ export class ModalManager {
|
|||
const dialog = (
|
||||
<div className={classes}>
|
||||
<div className="mx_Dialog">
|
||||
{modal.elem}
|
||||
{ modal.elem }
|
||||
</div>
|
||||
<div className="mx_Dialog_background" onClick={this.onBackgroundClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
ReactDOM.render(dialog, ModalManager.getOrCreateContainer());
|
||||
setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()));
|
||||
} else {
|
||||
// This is safe to call repeatedly if we happen to do that
|
||||
ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer());
|
||||
|
|
|
@ -1,6 +1,21 @@
|
|||
import React from "react";
|
||||
import ReactDom from "react-dom";
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
interface IChildProps {
|
||||
style: React.CSSProperties;
|
||||
ref: (node: React.ReactInstance) => void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// either a list of child nodes, or a single child.
|
||||
children: React.ReactNode;
|
||||
|
||||
// optional transition information for changing existing children
|
||||
transition?: object;
|
||||
|
||||
// a list of state objects to apply to each child node in turn
|
||||
startStyles: React.CSSProperties[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The NodeAnimator contains components and animates transitions.
|
||||
|
@ -9,55 +24,45 @@ import PropTypes from 'prop-types';
|
|||
* from DOM order. This makes it a lot simpler and lighter: if you need fully
|
||||
* automatic positional animation, look at react-shuffle or similar libraries.
|
||||
*/
|
||||
export default class NodeAnimator extends React.Component {
|
||||
static propTypes = {
|
||||
// either a list of child nodes, or a single child.
|
||||
children: PropTypes.any,
|
||||
|
||||
// optional transition information for changing existing children
|
||||
transition: PropTypes.object,
|
||||
|
||||
// a list of state objects to apply to each child node in turn
|
||||
startStyles: PropTypes.array,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class NodeAnimator extends React.Component<IProps> {
|
||||
private nodes = {};
|
||||
private children: { [key: string]: React.DetailedReactHTMLElement<any, HTMLElement> };
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
startStyles: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.nodes = {};
|
||||
this._updateChildren(this.props.children);
|
||||
this.updateChildren(this.props.children);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._updateChildren(this.props.children);
|
||||
public componentDidUpdate(): void {
|
||||
this.updateChildren(this.props.children);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} node element to apply styles to
|
||||
* @param {object} styles a key/value pair of CSS properties
|
||||
* @param {React.CSSProperties} styles a key/value pair of CSS properties
|
||||
* @returns {void}
|
||||
*/
|
||||
_applyStyles(node, styles) {
|
||||
private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
|
||||
Object.entries(styles).forEach(([property, value]) => {
|
||||
node.style[property] = value;
|
||||
});
|
||||
}
|
||||
|
||||
_updateChildren(newChildren) {
|
||||
private updateChildren(newChildren: React.ReactNode): void {
|
||||
const oldChildren = this.children || {};
|
||||
this.children = {};
|
||||
React.Children.toArray(newChildren).forEach((c) => {
|
||||
React.Children.toArray(newChildren).forEach((c: any) => {
|
||||
if (oldChildren[c.key]) {
|
||||
const old = oldChildren[c.key];
|
||||
const oldNode = ReactDom.findDOMNode(this.nodes[old.key]);
|
||||
|
||||
if (oldNode && oldNode.style.left !== c.props.style.left) {
|
||||
this._applyStyles(oldNode, { left: c.props.style.left });
|
||||
if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) {
|
||||
this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left });
|
||||
// console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
|
||||
}
|
||||
// clone the old element with the props (and children) of the new element
|
||||
|
@ -66,7 +71,7 @@ export default class NodeAnimator extends React.Component {
|
|||
} else {
|
||||
// new element. If we have a startStyle, use that as the style and go through
|
||||
// the enter animations
|
||||
const newProps = {};
|
||||
const newProps: Partial<IChildProps> = {};
|
||||
const restingStyle = c.props.style;
|
||||
|
||||
const startStyles = this.props.startStyles;
|
||||
|
@ -76,7 +81,7 @@ export default class NodeAnimator extends React.Component {
|
|||
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
|
||||
}
|
||||
|
||||
newProps.ref = ((n) => this._collectNode(
|
||||
newProps.ref = ((n) => this.collectNode(
|
||||
c.key, n, restingStyle,
|
||||
));
|
||||
|
||||
|
@ -85,7 +90,7 @@ export default class NodeAnimator extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_collectNode(k, node, restingStyle) {
|
||||
private collectNode(k: string, node: React.ReactInstance, restingStyle: React.CSSProperties): void {
|
||||
if (
|
||||
node &&
|
||||
this.nodes[k] === undefined &&
|
||||
|
@ -96,7 +101,7 @@ export default class NodeAnimator extends React.Component {
|
|||
// start from startStyle 1: 0 is the one we gave it
|
||||
// to start with, so now we animate 1 etc.
|
||||
for (let i = 1; i < startStyles.length; ++i) {
|
||||
this._applyStyles(domNode, startStyles[i]);
|
||||
this.applyStyles(domNode as HTMLElement, startStyles[i]);
|
||||
// console.log("start:"
|
||||
// JSON.stringify(startStyles[i]),
|
||||
// );
|
||||
|
@ -104,7 +109,7 @@ export default class NodeAnimator extends React.Component {
|
|||
|
||||
// and then we animate to the resting state
|
||||
setTimeout(() => {
|
||||
this._applyStyles(domNode, restingStyle);
|
||||
this.applyStyles(domNode as HTMLElement, restingStyle);
|
||||
}, 0);
|
||||
|
||||
// console.log("enter:",
|
||||
|
@ -113,7 +118,7 @@ export default class NodeAnimator extends React.Component {
|
|||
this.nodes[k] = node;
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<>{ Object.values(this.children) }</>
|
||||
);
|
|
@ -27,16 +27,18 @@ import * as TextForEvent from './TextForEvent';
|
|||
import Analytics from './Analytics';
|
||||
import * as Avatar from './Avatar';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import * as sdk from './index';
|
||||
import { _t } from './languageHandler';
|
||||
import Modal from './Modal';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast";
|
||||
import {SettingLevel} from "./settings/SettingLevel";
|
||||
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import UserActivity from "./UserActivity";
|
||||
import {mediaFromMxc} from "./customisations/Media";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/*
|
||||
* Dispatches:
|
||||
|
@ -68,7 +70,7 @@ export const Notifier = {
|
|||
// or not
|
||||
pendingEncryptedEventIds: [],
|
||||
|
||||
notificationMessageForEvent: function(ev: MatrixEvent) {
|
||||
notificationMessageForEvent: function(ev: MatrixEvent): string {
|
||||
if (typehandlers.hasOwnProperty(ev.getContent().msgtype)) {
|
||||
return typehandlers[ev.getContent().msgtype](ev);
|
||||
}
|
||||
|
@ -160,7 +162,7 @@ export const Notifier = {
|
|||
|
||||
_playAudioNotification: async function(ev: MatrixEvent, room: Room) {
|
||||
const sound = this.getSoundForRoom(room.roomId);
|
||||
console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
|
||||
logger.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
|
||||
|
||||
try {
|
||||
const selector =
|
||||
|
@ -240,7 +242,6 @@ export const Notifier = {
|
|||
? _t('%(brand)s does not have permission to send you notifications - ' +
|
||||
'please check your browser settings', { brand })
|
||||
: _t('%(brand)s was not given permission to send notifications - please try again', { brand });
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, {
|
||||
title: _t('Unable to enable Notifications'),
|
||||
description,
|
||||
|
@ -329,7 +330,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);
|
||||
|
||||
|
|
|
@ -16,11 +16,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
/** The types of page which can be shown by the LoggedInView */
|
||||
export default {
|
||||
HomePage: "home_page",
|
||||
RoomView: "room_view",
|
||||
RoomDirectory: "room_directory",
|
||||
UserView: "user_view",
|
||||
GroupView: "group_view",
|
||||
MyGroups: "my_groups",
|
||||
};
|
||||
enum PageType {
|
||||
HomePage = "home_page",
|
||||
RoomView = "room_view",
|
||||
RoomDirectory = "room_directory",
|
||||
UserView = "user_view",
|
||||
GroupView = "group_view",
|
||||
MyGroups = "my_groups",
|
||||
}
|
||||
|
||||
export default PageType;
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { createClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
/**
|
||||
|
@ -26,12 +26,18 @@ import { _t } from './languageHandler';
|
|||
* API on the homeserver in question with the new password.
|
||||
*/
|
||||
export default class PasswordReset {
|
||||
private client: MatrixClient;
|
||||
private clientSecret: string;
|
||||
private identityServerDomain: string;
|
||||
private password: string;
|
||||
private sessionId: string;
|
||||
|
||||
/**
|
||||
* Configure the endpoints for password resetting.
|
||||
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
|
||||
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
|
||||
*/
|
||||
constructor(homeserverUrl, identityUrl) {
|
||||
constructor(homeserverUrl: string, identityUrl: string) {
|
||||
this.client = createClient({
|
||||
baseUrl: homeserverUrl,
|
||||
idBaseUrl: identityUrl,
|
||||
|
@ -47,7 +53,7 @@ export default class PasswordReset {
|
|||
* @param {string} newPassword The new password for the account.
|
||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||
*/
|
||||
resetPassword(emailAddress, newPassword) {
|
||||
public resetPassword(emailAddress: string, newPassword: string): Promise<IRequestTokenResponse> {
|
||||
this.password = newPassword;
|
||||
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
||||
this.sessionId = res.sid;
|
||||
|
@ -69,7 +75,7 @@ export default class PasswordReset {
|
|||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
|
||||
*/
|
||||
async checkEmailLinkClicked() {
|
||||
public async checkEmailLinkClicked(): Promise<void> {
|
||||
const creds = {
|
||||
sid: this.sessionId,
|
||||
client_secret: this.clientSecret,
|
361
src/PosthogAnalytics.ts
Normal file
361
src/PosthogAnalytics.ts
Normal file
|
@ -0,0 +1,361 @@
|
|||
/*
|
||||
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 posthog, { PostHog } from 'posthog-js';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
*
|
||||
* Anonymity behaviour is as follows:
|
||||
*
|
||||
* - If Posthog isn't configured in `config.json`, events are not sent.
|
||||
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
|
||||
* enabled, events are not sent (this detection is built into posthog and turned on via the
|
||||
* `respect_dnt` flag being passed to `posthog.init`).
|
||||
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
|
||||
* a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
|
||||
identify the user.
|
||||
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user
|
||||
using any identifier that would be consistent across devices.
|
||||
* - If both flags are false or not set, events are not sent.
|
||||
*/
|
||||
|
||||
interface IEvent {
|
||||
// The event name that will be used by PostHog. Event names should use snake_case.
|
||||
eventName: string;
|
||||
|
||||
// The properties of the event that will be stored in PostHog. This is just a placeholder,
|
||||
// extending interfaces must override this with a concrete definition to do type validation.
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export enum Anonymity {
|
||||
Disabled,
|
||||
Anonymous,
|
||||
Pseudonymous
|
||||
}
|
||||
|
||||
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
|
||||
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
|
||||
// For example, it might contain hashed user IDs or room IDs.
|
||||
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
|
||||
export interface IPseudonymousEvent extends IEvent {}
|
||||
|
||||
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
|
||||
// i.e. no identifiers that can be associated with the user.
|
||||
export interface IAnonymousEvent extends IEvent {}
|
||||
|
||||
export interface IRoomEvent extends IPseudonymousEvent {
|
||||
hashedRoomId: string;
|
||||
}
|
||||
|
||||
interface IPageView extends IAnonymousEvent {
|
||||
eventName: "$pageview";
|
||||
properties: {
|
||||
durationMs?: number;
|
||||
screen?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const whitelistedScreens = new Set([
|
||||
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||
]);
|
||||
|
||||
export async function getRedactedCurrentLocation(
|
||||
origin: string,
|
||||
hash: string,
|
||||
pathname: string,
|
||||
anonymity: Anonymity,
|
||||
): Promise<string> {
|
||||
// Redact PII from the current location.
|
||||
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
|
||||
if (origin.startsWith('file://')) {
|
||||
pathname = "/<redacted_file_scheme_url>/";
|
||||
}
|
||||
|
||||
let hashStr;
|
||||
if (hash == "") {
|
||||
hashStr = "";
|
||||
} else {
|
||||
let [beforeFirstSlash, screen] = hash.split("/");
|
||||
|
||||
if (!whitelistedScreens.has(screen)) {
|
||||
screen = "<redacted_screen_name>";
|
||||
}
|
||||
|
||||
hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
|
||||
}
|
||||
return origin + pathname + hashStr;
|
||||
}
|
||||
|
||||
interface PlatformProperties {
|
||||
appVersion: string;
|
||||
appPlatform: string;
|
||||
}
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
* - Anonymity.Disabled means *no data* is passed to posthog
|
||||
* - Anonymity.Anonymous means no identifier is passed to posthog
|
||||
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
|
||||
* is passed to posthog.
|
||||
*
|
||||
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
|
||||
*
|
||||
* To pass an event to Posthog:
|
||||
*
|
||||
* 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent.
|
||||
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
|
||||
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
||||
*/
|
||||
|
||||
private anonymity = Anonymity.Disabled;
|
||||
// set true during the constructor if posthog config is present, otherwise false
|
||||
private enabled = false;
|
||||
private static _instance = null;
|
||||
private platformSuperProperties = {};
|
||||
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this._instance) {
|
||||
this._instance = new PosthogAnalytics(posthog);
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
constructor(private readonly posthog: PostHog) {
|
||||
const posthogConfig = SdkConfig.get()["posthog"];
|
||||
if (posthogConfig) {
|
||||
this.posthog.init(posthogConfig.projectApiKey, {
|
||||
api_host: posthogConfig.apiHost,
|
||||
autocapture: false,
|
||||
mask_all_text: true,
|
||||
mask_all_element_attributes: true,
|
||||
// This only triggers on page load, which for our SPA isn't particularly useful.
|
||||
// Plus, the .capture call originating from somewhere in posthog makes it hard
|
||||
// to redact URLs, which requires async code.
|
||||
//
|
||||
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
|
||||
capture_pageview: false,
|
||||
sanitize_properties: this.sanitizeProperties,
|
||||
respect_dnt: true,
|
||||
});
|
||||
this.enabled = true;
|
||||
} else {
|
||||
this.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => {
|
||||
// Callback from posthog to sanitize properties before sending them to the server.
|
||||
//
|
||||
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
||||
// See utils.js _.info.properties in posthog-js.
|
||||
|
||||
// Replace the $current_url with a redacted version.
|
||||
// $redacted_current_url is injected by this class earlier in capture(), as its generation
|
||||
// is async and can't be done in this non-async callback.
|
||||
if (!properties['$redacted_current_url']) {
|
||||
logger.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
|
||||
}
|
||||
properties['$current_url'] = properties['$redacted_current_url'];
|
||||
delete properties['$redacted_current_url'];
|
||||
|
||||
if (this.anonymity == Anonymity.Anonymous) {
|
||||
// drop referrer information for anonymous users
|
||||
properties['$referrer'] = null;
|
||||
properties['$referring_domain'] = null;
|
||||
properties['$initial_referrer'] = null;
|
||||
properties['$initial_referring_domain'] = null;
|
||||
|
||||
// drop device ID, which is a UUID persisted in local storage
|
||||
properties['$device_id'] = null;
|
||||
}
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
private static getAnonymityFromSettings(): Anonymity {
|
||||
// determine the current anonymity level based on current user settings
|
||||
|
||||
// "Send anonymous usage data which helps us improve Element. This will use a cookie."
|
||||
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
|
||||
|
||||
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
|
||||
//
|
||||
// TODO: Currently, this is only a labs flag, for testing purposes.
|
||||
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true);
|
||||
|
||||
let anonymity;
|
||||
if (pseudonumousOptIn) {
|
||||
anonymity = Anonymity.Pseudonymous;
|
||||
} else if (analyticsOptIn) {
|
||||
anonymity = Anonymity.Anonymous;
|
||||
} else {
|
||||
anonymity = Anonymity.Disabled;
|
||||
}
|
||||
|
||||
return anonymity;
|
||||
}
|
||||
|
||||
private registerSuperProperties(properties: posthog.Properties) {
|
||||
if (this.enabled) {
|
||||
this.posthog.register(properties);
|
||||
}
|
||||
}
|
||||
|
||||
private static async getPlatformProperties(): Promise<PlatformProperties> {
|
||||
const platform = PlatformPeg.get();
|
||||
let appVersion;
|
||||
try {
|
||||
appVersion = await platform.getAppVersion();
|
||||
} catch (e) {
|
||||
// this happens if no version is set i.e. in dev
|
||||
appVersion = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
appVersion,
|
||||
appPlatform: platform.getHumanReadableName(),
|
||||
};
|
||||
}
|
||||
|
||||
private async capture(eventName: string, properties: posthog.Properties) {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
const { origin, hash, pathname } = window.location;
|
||||
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
|
||||
origin, hash, pathname, this.anonymity);
|
||||
this.posthog.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
public setAnonymity(anonymity: Anonymity): void {
|
||||
// Update this.anonymity.
|
||||
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
|
||||
// to ensure this value is in step with the user's settings.
|
||||
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
|
||||
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
|
||||
// set in posthog e.g. distinct ID
|
||||
this.posthog.reset();
|
||||
// Restore any previously set platform super properties
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
this.anonymity = anonymity;
|
||||
}
|
||||
|
||||
private static getRandomAnalyticsId(): string {
|
||||
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
|
||||
}
|
||||
|
||||
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||
// Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
|
||||
// different devices to send the same ID.
|
||||
try {
|
||||
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE);
|
||||
let analyticsID = accountData?.id;
|
||||
if (!analyticsID) {
|
||||
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
||||
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
||||
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
||||
// until the next time account data is refreshed and this function is called (most likely on next
|
||||
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
||||
analyticsID = analyticsIdGenerator();
|
||||
await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID });
|
||||
}
|
||||
this.posthog.identify(analyticsID);
|
||||
} catch (e) {
|
||||
// The above could fail due to network requests, but not essential to starting the application,
|
||||
// so swallow it.
|
||||
logger.log("Unable to identify user for tracking" + e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getAnonymity(): Anonymity {
|
||||
return this.anonymity;
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
if (this.enabled) {
|
||||
this.posthog.reset();
|
||||
}
|
||||
this.setAnonymity(Anonymity.Anonymous);
|
||||
}
|
||||
|
||||
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
|
||||
eventName: E["eventName"],
|
||||
properties: E["properties"] = {},
|
||||
) {
|
||||
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
|
||||
await this.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
||||
eventName: E["eventName"],
|
||||
properties: E["properties"] = {},
|
||||
): Promise<void> {
|
||||
if (this.anonymity == Anonymity.Disabled) return;
|
||||
await this.capture(eventName, properties);
|
||||
}
|
||||
|
||||
public async trackPageView(durationMs: number): Promise<void> {
|
||||
const hash = window.location.hash;
|
||||
|
||||
let screen = null;
|
||||
const split = hash.split("/");
|
||||
if (split.length >= 2) {
|
||||
screen = split[1];
|
||||
}
|
||||
|
||||
await this.trackAnonymousEvent<IPageView>("$pageview", {
|
||||
durationMs,
|
||||
screen,
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatformSuperProperties(): Promise<void> {
|
||||
// Update super properties in posthog with our platform (app version, platform).
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
// is async and can involve a network request if we are running in a browser.
|
||||
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
|
||||
this.registerSuperProperties(this.platformSuperProperties);
|
||||
}
|
||||
|
||||
public async updateAnonymityFromSettings(userId?: string): Promise<void> {
|
||||
// Update this.anonymity based on the user's analytics opt-in settings
|
||||
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
|
||||
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
|
||||
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
|
||||
await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,10 +16,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import dis from "./dispatcher/dispatcher";
|
||||
import Timer from './utils/Timer';
|
||||
import {ActionPayload} from "./dispatcher/payloads";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
|
||||
// Time in ms after that a user is considered as unavailable/away
|
||||
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
|
||||
|
@ -78,7 +78,7 @@ class Presence {
|
|||
this.setState(State.Online);
|
||||
this.unavailableTimer.restart();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the presence state.
|
||||
|
@ -98,7 +98,7 @@ class Presence {
|
|||
}
|
||||
|
||||
try {
|
||||
await MatrixClientPeg.get().setPresence({presence: this.state});
|
||||
await MatrixClientPeg.get().setPresence({ presence: this.state });
|
||||
console.info("Presence:", newState);
|
||||
} catch (err) {
|
||||
console.error("Failed to set presence:", err);
|
||||
|
|
|
@ -20,10 +20,11 @@ limitations under the License.
|
|||
* registration code.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import * as sdk from './index';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
|
||||
// Regex for what a "safe" or "Matrix-looking" localpart would be.
|
||||
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
|
||||
|
@ -41,9 +42,11 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
|
|||
* @param {bool} options.screen_after
|
||||
* If present the screen to redirect to after a successful login or register.
|
||||
*/
|
||||
export async function startAnyRegistrationFlow(options) {
|
||||
export async function startAnyRegistrationFlow(
|
||||
// eslint-disable-next-line camelcase
|
||||
options: { go_home_on_cancel?: boolean, go_welcome_on_cancel?: boolean, screen_after?: boolean},
|
||||
): Promise<void> {
|
||||
if (options === undefined) options = {};
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
|
||||
hasCancelButton: true,
|
||||
quitOnly: true,
|
||||
|
@ -51,18 +54,23 @@ export async function startAnyRegistrationFlow(options) {
|
|||
description: _t("Use your account or create a new one to continue."),
|
||||
button: _t("Create Account"),
|
||||
extraButtons: [
|
||||
<button key="start_login" onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({action: 'start_login', screenAfterLogin: options.screen_after});
|
||||
}}>{ _t('Sign In') }</button>,
|
||||
<button
|
||||
key="start_login"
|
||||
onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({ action: 'start_login', screenAfterLogin: options.screen_after });
|
||||
}}
|
||||
>
|
||||
{ _t('Sign In') }
|
||||
</button>,
|
||||
],
|
||||
onFinished: (proceed) => {
|
||||
if (proceed) {
|
||||
dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after});
|
||||
dis.dispatch({ action: 'start_registration', screenAfterLogin: options.screen_after });
|
||||
} else if (options.go_home_on_cancel) {
|
||||
dis.dispatch({action: 'view_home_page'});
|
||||
dis.dispatch({ action: 'view_home_page' });
|
||||
} else if (options.go_welcome_on_cancel) {
|
||||
dis.dispatch({action: 'view_welcome_page'});
|
||||
dis.dispatch({ action: 'view_welcome_page' });
|
||||
}
|
||||
},
|
||||
});
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,47 +14,46 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import { EventStatus } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export default class Resend {
|
||||
static resendUnsentEvents(room) {
|
||||
return Promise.all(room.getPendingEvents().filter(function(ev) {
|
||||
static resendUnsentEvents(room: Room): Promise<void[]> {
|
||||
return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
}).map(function(event) {
|
||||
}).map(function(event: MatrixEvent) {
|
||||
return Resend.resend(event);
|
||||
}));
|
||||
}
|
||||
|
||||
static cancelUnsentEvents(room) {
|
||||
room.getPendingEvents().filter(function(ev) {
|
||||
static cancelUnsentEvents(room: Room): void {
|
||||
room.getPendingEvents().filter(function(ev: MatrixEvent) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
}).forEach(function(event) {
|
||||
}).forEach(function(event: MatrixEvent) {
|
||||
Resend.removeFromQueue(event);
|
||||
});
|
||||
}
|
||||
|
||||
static resend(event) {
|
||||
static resend(event: MatrixEvent): Promise<void> {
|
||||
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
|
||||
return MatrixClientPeg.get().resendEvent(event, room).then(function(res) {
|
||||
dis.dispatch({
|
||||
action: 'message_sent',
|
||||
event: event,
|
||||
});
|
||||
}, function(err) {
|
||||
}, function(err: Error) {
|
||||
// XXX: temporary logging to try to diagnose
|
||||
// https://github.com/vector-im/element-web/issues/3148
|
||||
console.log('Resend got send failure: ' + err.name + '(' + err + ')');
|
||||
|
||||
dis.dispatch({
|
||||
action: 'message_send_failed',
|
||||
event: event,
|
||||
});
|
||||
logger.log('Resend got send failure: ' + err.name + '(' + err + ')');
|
||||
});
|
||||
}
|
||||
|
||||
static removeFromQueue(event) {
|
||||
static removeFromQueue(event: MatrixEvent): void {
|
||||
MatrixClientPeg.get().cancelPendingEvent(event);
|
||||
}
|
||||
}
|
|
@ -31,6 +31,6 @@ export function textualPowerLevel(level: number, usersDefault: number): string {
|
|||
if (LEVEL_ROLE_MAP[level]) {
|
||||
return LEVEL_ROLE_MAP[level];
|
||||
} else {
|
||||
return _t("Custom (%(level)s)", {level});
|
||||
return _t("Custom (%(level)s)", { level });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -24,12 +24,12 @@ limitations under the License.
|
|||
* A similar thing could also be achieved via `pushState` with a state object,
|
||||
* but keeping it separate like this seems easier in case we do want to extend.
|
||||
*/
|
||||
const aliasToIDMap = new Map();
|
||||
const aliasToIDMap = new Map<string, string>();
|
||||
|
||||
export function storeRoomAliasInCache(alias, id) {
|
||||
export function storeRoomAliasInCache(alias: string, id: string): void {
|
||||
aliasToIDMap.set(alias, id);
|
||||
}
|
||||
|
||||
export function getCachedRoomIDForAlias(alias) {
|
||||
export function getCachedRoomIDForAlias(alias: string): string {
|
||||
return aliasToIDMap.get(alias);
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,15 +14,26 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import MultiInviter from './utils/MultiInviter';
|
||||
import React from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import MultiInviter, { CompletionStates } from './utils/MultiInviter';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './';
|
||||
import { _t } from './languageHandler';
|
||||
import InviteDialog, {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
|
||||
import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog";
|
||||
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
|
||||
import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
|
||||
import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore";
|
||||
import BaseAvatar from "./components/views/avatars/BaseAvatar";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
||||
export interface IInviteResult {
|
||||
states: CompletionStates;
|
||||
inviter: MultiInviter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites multiple addresses to a room
|
||||
|
@ -32,24 +41,23 @@ import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
|
|||
* no option to cancel.
|
||||
*
|
||||
* @param {string} roomId The ID of the room to invite to
|
||||
* @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
||||
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
||||
* @returns {Promise} Promise
|
||||
*/
|
||||
export function inviteMultipleToRoom(roomId, addrs) {
|
||||
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
|
||||
const inviter = new MultiInviter(roomId);
|
||||
return inviter.invite(addrs).then(states => Promise.resolve({states, inviter}));
|
||||
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
|
||||
}
|
||||
|
||||
export function showStartChatInviteDialog(initialText) {
|
||||
export function showStartChatInviteDialog(initialText = ""): void {
|
||||
// This dialog handles the room creation internally - we don't need to worry about it.
|
||||
const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
|
||||
Modal.createTrackedDialog(
|
||||
'Start DM', '', InviteDialog, {kind: KIND_DM, initialText},
|
||||
'Start DM', '', InviteDialog, { kind: KIND_DM, initialText },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
}
|
||||
|
||||
export function showRoomInviteDialog(roomId, initialText = "") {
|
||||
export function showRoomInviteDialog(roomId: string, initialText = ""): void {
|
||||
// This dialog handles the room creation internally - we don't need to worry about it.
|
||||
Modal.createTrackedDialog(
|
||||
"Invite Users", "", InviteDialog, {
|
||||
|
@ -61,14 +69,14 @@ export function showRoomInviteDialog(roomId, initialText = "") {
|
|||
);
|
||||
}
|
||||
|
||||
export function showCommunityRoomInviteDialog(roomId, communityName) {
|
||||
export function showCommunityRoomInviteDialog(roomId: string, communityName: string): void {
|
||||
Modal.createTrackedDialog(
|
||||
'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId},
|
||||
'Invite Users to Community', '', CommunityPrototypeInviteDialog, { communityName, roomId },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
}
|
||||
|
||||
export function showCommunityInviteDialog(communityId) {
|
||||
export function showCommunityInviteDialog(communityId: string): void {
|
||||
const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId);
|
||||
if (chat) {
|
||||
const name = CommunityPrototypeStore.instance.getCommunityName(communityId);
|
||||
|
@ -83,7 +91,7 @@ export function showCommunityInviteDialog(communityId) {
|
|||
* @param {MatrixEvent} event The event to check
|
||||
* @returns {boolean} True if valid, false otherwise
|
||||
*/
|
||||
export function isValid3pidInvite(event) {
|
||||
export function isValid3pidInvite(event: MatrixEvent): boolean {
|
||||
if (!event || event.getType() !== "m.room.third_party_invite") return false;
|
||||
|
||||
// any events without these keys are not valid 3pid invites, so we ignore them
|
||||
|
@ -96,13 +104,12 @@ export function isValid3pidInvite(event) {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function inviteUsersToRoom(roomId, userIds) {
|
||||
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
|
||||
return inviteMultipleToRoom(roomId, userIds).then((result) => {
|
||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||
showAnyInviteErrors(result.states, room, result.inviter);
|
||||
}).catch((err) => {
|
||||
console.error(err.stack);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
|
||||
title: _t("Failed to invite"),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
|
@ -110,35 +117,66 @@ export function inviteUsersToRoom(roomId, userIds) {
|
|||
});
|
||||
}
|
||||
|
||||
export function showAnyInviteErrors(addrs, room, inviter) {
|
||||
export function showAnyInviteErrors(
|
||||
states: CompletionStates,
|
||||
room: Room,
|
||||
inviter: MultiInviter,
|
||||
userMap?: Map<string, Member>,
|
||||
): boolean {
|
||||
// Show user any errors
|
||||
const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error');
|
||||
const failedUsers = Object.keys(states).filter(a => states[a] === 'error');
|
||||
if (failedUsers.length === 1 && inviter.fatal) {
|
||||
// Just get the first message because there was a fatal problem on the first
|
||||
// user. This usually means that no other users were attempted, making it
|
||||
// pointless for us to list who failed exactly.
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, {
|
||||
title: _t("Failed to invite users to the room:", {roomName: room.name}),
|
||||
title: _t("Failed to invite users to the room:", { roomName: room.name }),
|
||||
description: inviter.getErrorText(failedUsers[0]),
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
const errorList = [];
|
||||
for (const addr of failedUsers) {
|
||||
if (addrs[addr] === "error") {
|
||||
if (states[addr] === "error") {
|
||||
const reason = inviter.getErrorText(addr);
|
||||
errorList.push(addr + ": " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (errorList.length > 0) {
|
||||
// React 16 doesn't let us use `errorList.join(<br />)` anymore, so this is our solution
|
||||
const description = <div>{errorList.map(e => <div key={e}>{e}</div>)}</div>;
|
||||
const description = <div className="mx_InviteDialog_multiInviterError">
|
||||
<h4>{ _t("We sent the others, but the below people couldn't be invited to <RoomName/>", {}, {
|
||||
RoomName: () => <b>{ room.name }</b>,
|
||||
}) }</h4>
|
||||
<div>
|
||||
{ failedUsers.map(addr => {
|
||||
const user = userMap?.get(addr) || cli.getUser(addr);
|
||||
const name = (user as Member).name || (user as User).rawDisplayName;
|
||||
const avatarUrl = (user as Member).getMxcAvatarUrl?.() || (user as User).avatarUrl;
|
||||
return <div key={addr} className="mx_InviteDialog_multiInviterError_entry">
|
||||
<div className="mx_InviteDialog_multiInviterError_entry_userProfile">
|
||||
<BaseAvatar
|
||||
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null}
|
||||
name={name}
|
||||
idName={user.userId}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="mx_InviteDialog_multiInviterError_entry_name">{ name }</span>
|
||||
<span className="mx_InviteDialog_multiInviterError_entry_userId">{ user.userId }</span>
|
||||
</div>
|
||||
<div className="mx_InviteDialog_multiInviterError_entry_error">
|
||||
{ inviter.getErrorText(addr) }
|
||||
</div>
|
||||
</div>;
|
||||
}) }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, {
|
||||
title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}),
|
||||
Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
|
||||
title: _t("Some invites couldn't be sent"),
|
||||
description,
|
||||
});
|
||||
return false;
|
|
@ -15,29 +15,33 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {PushProcessor} from 'matrix-js-sdk/src/pushprocessor';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { IAnnotatedPushRule, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
|
||||
|
||||
export const ALL_MESSAGES_LOUD = 'all_messages_loud';
|
||||
export const ALL_MESSAGES = 'all_messages';
|
||||
export const MENTIONS_ONLY = 'mentions_only';
|
||||
export const MUTE = 'mute';
|
||||
export enum RoomNotifState {
|
||||
AllMessagesLoud = 'all_messages_loud',
|
||||
AllMessages = 'all_messages',
|
||||
MentionsOnly = 'mentions_only',
|
||||
Mute = 'mute',
|
||||
}
|
||||
|
||||
export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
|
||||
export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY];
|
||||
export const BADGE_STATES = [RoomNotifState.AllMessages, RoomNotifState.AllMessagesLoud];
|
||||
export const MENTION_BADGE_STATES = [...BADGE_STATES, RoomNotifState.MentionsOnly];
|
||||
|
||||
export function shouldShowNotifBadge(roomNotifState) {
|
||||
export function shouldShowNotifBadge(roomNotifState: RoomNotifState): boolean {
|
||||
return BADGE_STATES.includes(roomNotifState);
|
||||
}
|
||||
|
||||
export function shouldShowMentionBadge(roomNotifState) {
|
||||
export function shouldShowMentionBadge(roomNotifState: RoomNotifState): boolean {
|
||||
return MENTION_BADGE_STATES.includes(roomNotifState);
|
||||
}
|
||||
|
||||
export function aggregateNotificationCount(rooms) {
|
||||
return rooms.reduce((result, room) => {
|
||||
export function aggregateNotificationCount(rooms: Room[]): {count: number, highlight: boolean} {
|
||||
return rooms.reduce<{count: number, highlight: boolean}>((result, room) => {
|
||||
const roomNotifState = getRoomNotifsState(room.roomId);
|
||||
const highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0;
|
||||
// use helper method to include highlights in the previous version of the room
|
||||
const notificationCount = getUnreadNotificationCount(room);
|
||||
|
||||
|
@ -52,12 +56,12 @@ export function aggregateNotificationCount(rooms) {
|
|||
}
|
||||
}
|
||||
return result;
|
||||
}, {count: 0, highlight: false});
|
||||
}, { count: 0, highlight: false });
|
||||
}
|
||||
|
||||
export function getRoomHasBadge(room) {
|
||||
export function getRoomHasBadge(room: Room): boolean {
|
||||
const roomNotifState = getRoomNotifsState(room.roomId);
|
||||
const highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0;
|
||||
const notificationCount = room.getUnreadNotificationCount();
|
||||
|
||||
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
|
||||
|
@ -66,14 +70,14 @@ export function getRoomHasBadge(room) {
|
|||
return notifBadges || mentionBadges;
|
||||
}
|
||||
|
||||
export function getRoomNotifsState(roomId) {
|
||||
if (MatrixClientPeg.get().isGuest()) return ALL_MESSAGES;
|
||||
export function getRoomNotifsState(roomId: string): RoomNotifState {
|
||||
if (MatrixClientPeg.get().isGuest()) return RoomNotifState.AllMessages;
|
||||
|
||||
// look through the override rules for a rule affecting this room:
|
||||
// if one exists, it will take precedence.
|
||||
const muteRule = findOverrideMuteRule(roomId);
|
||||
if (muteRule) {
|
||||
return MUTE;
|
||||
return RoomNotifState.Mute;
|
||||
}
|
||||
|
||||
// for everything else, look at the room rule.
|
||||
|
@ -89,27 +93,27 @@ export function getRoomNotifsState(roomId) {
|
|||
// XXX: We have to assume the default is to notify for all messages
|
||||
// (in particular this will be 'wrong' for one to one rooms because
|
||||
// they will notify loudly for all messages)
|
||||
if (!roomRule || !roomRule.enabled) return ALL_MESSAGES;
|
||||
if (!roomRule || !roomRule.enabled) return RoomNotifState.AllMessages;
|
||||
|
||||
// a mute at the room level will still allow mentions
|
||||
// to notify
|
||||
if (isMuteRule(roomRule)) return MENTIONS_ONLY;
|
||||
if (isMuteRule(roomRule)) return RoomNotifState.MentionsOnly;
|
||||
|
||||
const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions);
|
||||
if (actionsObject.tweaks.sound) return ALL_MESSAGES_LOUD;
|
||||
if (actionsObject.tweaks.sound) return RoomNotifState.AllMessagesLoud;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setRoomNotifsState(roomId, newState) {
|
||||
if (newState === MUTE) {
|
||||
export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise<void> {
|
||||
if (newState === RoomNotifState.Mute) {
|
||||
return setRoomNotifsStateMuted(roomId);
|
||||
} else {
|
||||
return setRoomNotifsStateUnmuted(roomId, newState);
|
||||
}
|
||||
}
|
||||
|
||||
export function getUnreadNotificationCount(room, type=null) {
|
||||
export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number {
|
||||
let notificationCount = room.getUnreadNotificationCount(type);
|
||||
|
||||
// Check notification counts in the old room just in case there's some lost
|
||||
|
@ -124,21 +128,21 @@ export function getUnreadNotificationCount(room, type=null) {
|
|||
// notifying the user for unread messages because they would have extreme
|
||||
// difficulty changing their notification preferences away from "All Messages"
|
||||
// and "Noisy".
|
||||
notificationCount += oldRoom.getUnreadNotificationCount("highlight");
|
||||
notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||
}
|
||||
}
|
||||
|
||||
return notificationCount;
|
||||
}
|
||||
|
||||
function setRoomNotifsStateMuted(roomId) {
|
||||
function setRoomNotifsStateMuted(roomId: string): Promise<any> {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const promises = [];
|
||||
|
||||
// delete the room rule
|
||||
const roomRule = cli.getRoomPushRule('global', roomId);
|
||||
if (roomRule) {
|
||||
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id));
|
||||
promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
|
||||
}
|
||||
|
||||
// add/replace an override rule to squelch everything in this room
|
||||
|
@ -146,7 +150,7 @@ function setRoomNotifsStateMuted(roomId) {
|
|||
// is an override rule, not a room rule: it still pertains to this room
|
||||
// though, so using the room ID as the rule ID is logical and prevents
|
||||
// duplicate copies of the rule.
|
||||
promises.push(cli.addPushRule('global', 'override', roomId, {
|
||||
promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, {
|
||||
conditions: [
|
||||
{
|
||||
kind: 'event_match',
|
||||
|
@ -162,30 +166,30 @@ function setRoomNotifsStateMuted(roomId) {
|
|||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
function setRoomNotifsStateUnmuted(roomId, newState) {
|
||||
function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise<any> {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const promises = [];
|
||||
|
||||
const overrideMuteRule = findOverrideMuteRule(roomId);
|
||||
if (overrideMuteRule) {
|
||||
promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id));
|
||||
promises.push(cli.deletePushRule('global', PushRuleKind.Override, overrideMuteRule.rule_id));
|
||||
}
|
||||
|
||||
if (newState === 'all_messages') {
|
||||
if (newState === RoomNotifState.AllMessages) {
|
||||
const roomRule = cli.getRoomPushRule('global', roomId);
|
||||
if (roomRule) {
|
||||
promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id));
|
||||
promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id));
|
||||
}
|
||||
} else if (newState === 'mentions_only') {
|
||||
promises.push(cli.addPushRule('global', 'room', roomId, {
|
||||
} else if (newState === RoomNotifState.MentionsOnly) {
|
||||
promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [
|
||||
'dont_notify',
|
||||
],
|
||||
}));
|
||||
// https://matrix.org/jira/browse/SPEC-400
|
||||
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
|
||||
} else if ('all_messages_loud') {
|
||||
promises.push(cli.addPushRule('global', 'room', roomId, {
|
||||
promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
|
||||
} else if (newState === RoomNotifState.AllMessagesLoud) {
|
||||
promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [
|
||||
'notify',
|
||||
{
|
||||
|
@ -195,13 +199,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
|
|||
],
|
||||
}));
|
||||
// https://matrix.org/jira/browse/SPEC-400
|
||||
promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true));
|
||||
promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
function findOverrideMuteRule(roomId) {
|
||||
function findOverrideMuteRule(roomId: string): IAnnotatedPushRule {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli.pushRules ||
|
||||
!cli.pushRules['global'] ||
|
||||
|
@ -218,7 +222,7 @@ function findOverrideMuteRule(roomId) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function isRuleForRoom(roomId, rule) {
|
||||
function isRuleForRoom(roomId: string, rule: IAnnotatedPushRule): boolean {
|
||||
if (rule.conditions.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
@ -226,6 +230,6 @@ function isRuleForRoom(roomId, rule) {
|
|||
return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId);
|
||||
}
|
||||
|
||||
function isMuteRule(rule) {
|
||||
function isMuteRule(rule: IAnnotatedPushRule): boolean {
|
||||
return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import AliasCustomisations from './customisations/Alias';
|
||||
|
||||
/**
|
||||
* Given a room object, return the alias we should use for it,
|
||||
|
@ -25,11 +28,22 @@ import {MatrixClientPeg} from './MatrixClientPeg';
|
|||
* @param {Object} room The room object
|
||||
* @returns {string} A display alias for the given room
|
||||
*/
|
||||
export function getDisplayAliasForRoom(room) {
|
||||
return room.getCanonicalAlias() || room.getAltAliases()[0];
|
||||
export function getDisplayAliasForRoom(room: Room): string {
|
||||
return getDisplayAliasForAliasSet(
|
||||
room.getCanonicalAlias(), room.getAltAliases(),
|
||||
);
|
||||
}
|
||||
|
||||
export function looksLikeDirectMessageRoom(room, myUserId) {
|
||||
// The various display alias getters should all feed through this one path so
|
||||
// there's a single place to change the logic.
|
||||
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
|
||||
if (AliasCustomisations.getDisplayAliasForAliasSet) {
|
||||
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
|
||||
}
|
||||
return canonicalAlias || altAliases?.[0];
|
||||
}
|
||||
|
||||
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {
|
||||
const myMembership = room.getMyMembership();
|
||||
const me = room.getMember(myUserId);
|
||||
|
||||
|
@ -48,7 +62,7 @@ export function looksLikeDirectMessageRoom(room, myUserId) {
|
|||
return false;
|
||||
}
|
||||
|
||||
export function guessAndSetDMRoom(room, isDirect) {
|
||||
export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void> {
|
||||
let newTarget;
|
||||
if (isDirect) {
|
||||
const guessedUserId = guessDMRoomTargetId(
|
||||
|
@ -70,10 +84,8 @@ export function guessAndSetDMRoom(room, isDirect) {
|
|||
this room as a DM room
|
||||
* @returns {object} A promise
|
||||
*/
|
||||
export function setDMRoom(roomId, userId) {
|
||||
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 = {};
|
||||
|
@ -102,8 +114,7 @@ export function setDMRoom(roomId, userId) {
|
|||
dmRoomMap[userId] = roomList;
|
||||
}
|
||||
|
||||
|
||||
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -114,7 +125,7 @@ export function setDMRoom(roomId, userId) {
|
|||
* @param {string} myUserId User ID of the current user
|
||||
* @returns {string} User ID of the user that the room is probably a DM with
|
||||
*/
|
||||
function guessDMRoomTargetId(room, myUserId) {
|
||||
function guessDMRoomTargetId(room: Room, myUserId: string): string {
|
||||
let oldestTs;
|
||||
let oldestUser;
|
||||
|
|
@ -17,14 +17,16 @@ limitations under the License.
|
|||
import url from 'url';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import request from "browser-request";
|
||||
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
// The version of the integration manager API we're intending to work with
|
||||
const imApiVersion = "1.1";
|
||||
|
||||
|
@ -109,7 +111,7 @@ export default class ScalarAuthClient {
|
|||
request({
|
||||
method: "GET",
|
||||
uri: url,
|
||||
qs: {scalar_token: token, v: imApiVersion},
|
||||
qs: { scalar_token: token, v: imApiVersion },
|
||||
json: true,
|
||||
}, (err, response, body) => {
|
||||
if (err) {
|
||||
|
@ -136,7 +138,7 @@ export default class ScalarAuthClient {
|
|||
return token;
|
||||
}).catch((e) => {
|
||||
if (e instanceof TermsNotSignedError) {
|
||||
console.log("Integration manager requires new terms to be agreed to");
|
||||
logger.log("Integration manager requires new terms to be agreed to");
|
||||
// The terms endpoints are new and so live on standard _matrix prefixes,
|
||||
// but IM rest urls are currently configured with paths, so remove the
|
||||
// path from the base URL before passing it to the js-sdk
|
||||
|
@ -189,7 +191,7 @@ export default class ScalarAuthClient {
|
|||
request({
|
||||
method: 'POST',
|
||||
uri: scalarRestUrl + '/register',
|
||||
qs: {v: imApiVersion},
|
||||
qs: { v: imApiVersion },
|
||||
body: openidTokenObject,
|
||||
json: true,
|
||||
}, (err, response, body) => {
|
||||
|
|
|
@ -208,7 +208,6 @@ Example:
|
|||
]
|
||||
}
|
||||
|
||||
|
||||
membership_state AND bot_options
|
||||
--------------------------------
|
||||
Get the content of the "m.room.member" or "m.room.bot.options" state event respectively.
|
||||
|
@ -236,23 +235,43 @@ Example:
|
|||
}
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import WidgetUtils from './utils/WidgetUtils';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
import { _t } from './languageHandler';
|
||||
import {IntegrationManagers} from "./integrations/IntegrationManagers";
|
||||
import {WidgetType} from "./widgets/WidgetType";
|
||||
import {objectClone} from "./utils/objects";
|
||||
import { IntegrationManagers } from "./integrations/IntegrationManagers";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { objectClone } from "./utils/objects";
|
||||
|
||||
function sendResponse(event, res) {
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
enum Action {
|
||||
CloseScalar = "close_scalar",
|
||||
GetWidgets = "get_widgets",
|
||||
SetWidgets = "set_widgets",
|
||||
SetWidget = "set_widget",
|
||||
JoinRulesState = "join_rules_state",
|
||||
SetPlumbingState = "set_plumbing_state",
|
||||
GetMembershipCount = "get_membership_count",
|
||||
GetRoomEncryptionState = "get_room_enc_state",
|
||||
CanSendEvent = "can_send_event",
|
||||
MembershipState = "membership_state",
|
||||
invite = "invite",
|
||||
BotOptions = "bot_options",
|
||||
SetBotOptions = "set_bot_options",
|
||||
SetBotPower = "set_bot_power",
|
||||
}
|
||||
|
||||
function sendResponse(event: MessageEvent<any>, res: any): void {
|
||||
const data = objectClone(event.data);
|
||||
data.response = res;
|
||||
// @ts-ignore
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
function sendError(event, msg, nestedError) {
|
||||
function sendError(event: MessageEvent<any>, msg: string, nestedError?: Error): void {
|
||||
console.error("Action:" + event.data.action + " failed with message: " + msg);
|
||||
const data = objectClone(event.data);
|
||||
data.response = {
|
||||
|
@ -263,11 +282,12 @@ function sendError(event, msg, nestedError) {
|
|||
if (nestedError) {
|
||||
data.response.error._error = nestedError;
|
||||
}
|
||||
// @ts-ignore
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
function inviteUser(event, roomId, userId) {
|
||||
console.log(`Received request to invite ${userId} into room ${roomId}`);
|
||||
function inviteUser(event: MessageEvent<any>, roomId: string, userId: string): void {
|
||||
logger.log(`Received request to invite ${userId} into room ${roomId}`);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -294,7 +314,7 @@ function inviteUser(event, roomId, userId) {
|
|||
});
|
||||
}
|
||||
|
||||
function setWidget(event, roomId) {
|
||||
function setWidget(event: MessageEvent<any>, roomId: string): void {
|
||||
const widgetId = event.data.widget_id;
|
||||
let widgetType = event.data.type;
|
||||
const widgetUrl = event.data.url;
|
||||
|
@ -355,7 +375,7 @@ function setWidget(event, roomId) {
|
|||
}
|
||||
}
|
||||
|
||||
function getWidgets(event, roomId) {
|
||||
function getWidgets(event: MessageEvent<any>, roomId: string): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -381,7 +401,7 @@ function getWidgets(event, roomId) {
|
|||
sendResponse(event, widgetStateEvents);
|
||||
}
|
||||
|
||||
function getRoomEncState(event, roomId) {
|
||||
function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -397,11 +417,11 @@ function getRoomEncState(event, roomId) {
|
|||
sendResponse(event, roomIsEncrypted);
|
||||
}
|
||||
|
||||
function setPlumbingState(event, roomId, status) {
|
||||
function setPlumbingState(event: MessageEvent<any>, roomId: string, status: string): void {
|
||||
if (typeof status !== 'string') {
|
||||
throw new Error('Plumbing state status should be a string');
|
||||
}
|
||||
console.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`);
|
||||
logger.log(`Received request to set plumbing state to status "${status}" in room ${roomId}`);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -416,8 +436,8 @@ function setPlumbingState(event, roomId, status) {
|
|||
});
|
||||
}
|
||||
|
||||
function setBotOptions(event, roomId, userId) {
|
||||
console.log(`Received request to set options for bot ${userId} in room ${roomId}`);
|
||||
function setBotOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
|
||||
logger.log(`Received request to set options for bot ${userId} in room ${roomId}`);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -432,13 +452,13 @@ function setBotOptions(event, roomId, userId) {
|
|||
});
|
||||
}
|
||||
|
||||
function setBotPower(event, roomId, userId, level) {
|
||||
function setBotPower(event: MessageEvent<any>, roomId: string, userId: string, level: number): void {
|
||||
if (!(Number.isInteger(level) && level >= 0)) {
|
||||
sendError(event, _t('Power level must be positive integer.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`);
|
||||
logger.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -463,22 +483,22 @@ function setBotPower(event, roomId, userId, level) {
|
|||
});
|
||||
}
|
||||
|
||||
function getMembershipState(event, roomId, userId) {
|
||||
console.log(`membership_state of ${userId} in room ${roomId} requested.`);
|
||||
function getMembershipState(event: MessageEvent<any>, roomId: string, userId: string): void {
|
||||
logger.log(`membership_state of ${userId} in room ${roomId} requested.`);
|
||||
returnStateEvent(event, roomId, "m.room.member", userId);
|
||||
}
|
||||
|
||||
function getJoinRules(event, roomId) {
|
||||
console.log(`join_rules of ${roomId} requested.`);
|
||||
function getJoinRules(event: MessageEvent<any>, roomId: string): void {
|
||||
logger.log(`join_rules of ${roomId} requested.`);
|
||||
returnStateEvent(event, roomId, "m.room.join_rules", "");
|
||||
}
|
||||
|
||||
function botOptions(event, roomId, userId) {
|
||||
console.log(`bot_options of ${userId} in room ${roomId} requested.`);
|
||||
function botOptions(event: MessageEvent<any>, roomId: string, userId: string): void {
|
||||
logger.log(`bot_options of ${userId} in room ${roomId} requested.`);
|
||||
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
|
||||
}
|
||||
|
||||
function getMembershipCount(event, roomId) {
|
||||
function getMembershipCount(event: MessageEvent<any>, roomId: string): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -493,7 +513,7 @@ function getMembershipCount(event, roomId) {
|
|||
sendResponse(event, count);
|
||||
}
|
||||
|
||||
function canSendEvent(event, roomId) {
|
||||
function canSendEvent(event: MessageEvent<any>, roomId: string): void {
|
||||
const evType = "" + event.data.event_type; // force stringify
|
||||
const isState = Boolean(event.data.is_state);
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -527,7 +547,7 @@ function canSendEvent(event, roomId) {
|
|||
sendResponse(event, true);
|
||||
}
|
||||
|
||||
function returnStateEvent(event, roomId, eventType, stateKey) {
|
||||
function returnStateEvent(event: MessageEvent<any>, roomId: string, eventType: string, stateKey: string): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t('You need to be logged in.'));
|
||||
|
@ -546,8 +566,9 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
|
|||
sendResponse(event, stateEvent.getContent());
|
||||
}
|
||||
|
||||
const onMessage = function(event) {
|
||||
const onMessage = function(event: MessageEvent<any>): void {
|
||||
if (!event.origin) { // stupid chrome
|
||||
// @ts-ignore
|
||||
event.origin = event.originalEvent.origin;
|
||||
}
|
||||
|
||||
|
@ -581,8 +602,8 @@ const onMessage = function(event) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.data.action === "close_scalar") {
|
||||
dis.dispatch({ action: "close_scalar" });
|
||||
if (event.data.action === Action.CloseScalar) {
|
||||
dis.dispatch({ action: Action.CloseScalar });
|
||||
sendResponse(event, null);
|
||||
return;
|
||||
}
|
||||
|
@ -595,10 +616,10 @@ const onMessage = function(event) {
|
|||
// Get and set user widgets (not associated with a specific room)
|
||||
// If roomId is specified, it must be validated, so room-based widgets agreed
|
||||
// handled further down.
|
||||
if (event.data.action === "get_widgets") {
|
||||
if (event.data.action === Action.GetWidgets) {
|
||||
getWidgets(event, null);
|
||||
return;
|
||||
} else if (event.data.action === "set_widget") {
|
||||
} else if (event.data.action === Action.SetWidgets) {
|
||||
setWidget(event, null);
|
||||
return;
|
||||
} else {
|
||||
|
@ -608,33 +629,33 @@ const onMessage = function(event) {
|
|||
}
|
||||
|
||||
if (roomId !== RoomViewStore.getRoomId()) {
|
||||
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
|
||||
sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get and set room-based widgets
|
||||
if (event.data.action === "get_widgets") {
|
||||
if (event.data.action === Action.GetWidgets) {
|
||||
getWidgets(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "set_widget") {
|
||||
} else if (event.data.action === Action.SetWidget) {
|
||||
setWidget(event, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// These APIs don't require userId
|
||||
if (event.data.action === "join_rules_state") {
|
||||
if (event.data.action === Action.JoinRulesState) {
|
||||
getJoinRules(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "set_plumbing_state") {
|
||||
} else if (event.data.action === Action.SetPlumbingState) {
|
||||
setPlumbingState(event, roomId, event.data.status);
|
||||
return;
|
||||
} else if (event.data.action === "get_membership_count") {
|
||||
} else if (event.data.action === Action.GetMembershipCount) {
|
||||
getMembershipCount(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "get_room_enc_state") {
|
||||
} else if (event.data.action === Action.GetRoomEncryptionState) {
|
||||
getRoomEncState(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "can_send_event") {
|
||||
} else if (event.data.action === Action.CanSendEvent) {
|
||||
canSendEvent(event, roomId);
|
||||
return;
|
||||
}
|
||||
|
@ -644,19 +665,19 @@ const onMessage = function(event) {
|
|||
return;
|
||||
}
|
||||
switch (event.data.action) {
|
||||
case "membership_state":
|
||||
case Action.MembershipState:
|
||||
getMembershipState(event, roomId, userId);
|
||||
break;
|
||||
case "invite":
|
||||
case Action.invite:
|
||||
inviteUser(event, roomId, userId);
|
||||
break;
|
||||
case "bot_options":
|
||||
case Action.BotOptions:
|
||||
botOptions(event, roomId, userId);
|
||||
break;
|
||||
case "set_bot_options":
|
||||
case Action.SetBotOptions:
|
||||
setBotOptions(event, roomId, userId);
|
||||
break;
|
||||
case "set_bot_power":
|
||||
case Action.SetBotPower:
|
||||
setBotPower(event, roomId, userId, event.data.level);
|
||||
break;
|
||||
default:
|
||||
|
@ -666,16 +687,16 @@ const onMessage = function(event) {
|
|||
};
|
||||
|
||||
let listenerCount = 0;
|
||||
let openManagerUrl = null;
|
||||
let openManagerUrl: string = null;
|
||||
|
||||
export function startListening() {
|
||||
export function startListening(): void {
|
||||
if (listenerCount === 0) {
|
||||
window.addEventListener("message", onMessage, false);
|
||||
}
|
||||
listenerCount += 1;
|
||||
}
|
||||
|
||||
export function stopListening() {
|
||||
export function stopListening(): void {
|
||||
listenerCount -= 1;
|
||||
if (listenerCount === 0) {
|
||||
window.removeEventListener("message", onMessage);
|
||||
|
@ -690,6 +711,6 @@ export function stopListening() {
|
|||
}
|
||||
}
|
||||
|
||||
export function setOpenManagerUrl(url) {
|
||||
export function setOpenManagerUrl(url: string): void {
|
||||
openManagerUrl = url;
|
||||
}
|
|
@ -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 { 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,
|
||||
|
@ -43,33 +59,28 @@ async function serverSideSearch(term, roomId = undefined) {
|
|||
},
|
||||
};
|
||||
|
||||
const response = await client.search({body: body});
|
||||
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);
|
||||
|
@ -498,11 +542,11 @@ async function combinedPagination(searchResult) {
|
|||
// Fetch events from the server if we have a token for it and if it's the
|
||||
// local indexes turn or the local index has exhausted its results.
|
||||
if (searchResult.serverSideNextBatch && (oldestEventFrom === "local" || !searchArgs.next_batch)) {
|
||||
const body = {body: searchResult._query, next_batch: searchResult.serverSideNextBatch};
|
||||
const body = { body: searchResult._query, next_batch: searchResult.serverSideNextBatch };
|
||||
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);
|
|
@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix';
|
||||
import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix';
|
||||
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import Modal from './Modal';
|
||||
import * as sdk from './index';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey';
|
||||
import { _t } from './languageHandler';
|
||||
|
@ -28,6 +29,9 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
|
|||
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
// This stores the secret storage private keys in memory for the JS SDK. This is
|
||||
// only meant to act as a cache to avoid prompting the user multiple times
|
||||
|
@ -41,8 +45,8 @@ let secretStorageBeingAccessed = false;
|
|||
let nonInteractive = false;
|
||||
|
||||
let dehydrationCache: {
|
||||
key?: Uint8Array,
|
||||
keyInfo?: ISecretStorageKeyInfo,
|
||||
key?: Uint8Array;
|
||||
keyInfo?: ISecretStorageKeyInfo;
|
||||
} = {};
|
||||
|
||||
function isCachingAllowed(): boolean {
|
||||
|
@ -134,7 +138,7 @@ async function getSecretStorageKey(
|
|||
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (secret storage)")
|
||||
logger.log("Using key from security customisations (secret storage)");
|
||||
cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations);
|
||||
return [keyId, keyFromCustomisations];
|
||||
}
|
||||
|
@ -184,7 +188,7 @@ export async function getDehydrationKey(
|
|||
): Promise<Uint8Array> {
|
||||
const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Using key from security customisations (dehydration)")
|
||||
logger.log("Using key from security customisations (dehydration)");
|
||||
return keyFromCustomisations;
|
||||
}
|
||||
|
||||
|
@ -223,7 +227,7 @@ export async function getDehydrationKey(
|
|||
const key = await inputToKey(input);
|
||||
|
||||
// need to copy the key because rehydration (unpickling) will clobber it
|
||||
dehydrationCache = {key: new Uint8Array(key), keyInfo};
|
||||
dehydrationCache = { key: new Uint8Array(key), keyInfo };
|
||||
|
||||
return key;
|
||||
}
|
||||
|
@ -244,15 +248,15 @@ async function onSecretRequested(
|
|||
deviceId: string,
|
||||
requestId: string,
|
||||
name: string,
|
||||
deviceTrust: IDeviceTrustLevel,
|
||||
deviceTrust: DeviceTrustLevel,
|
||||
): Promise<string> {
|
||||
console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
const client = MatrixClientPeg.get();
|
||||
if (userId !== client.getUserId()) {
|
||||
return;
|
||||
}
|
||||
if (!deviceTrust || !deviceTrust.isVerified()) {
|
||||
console.log(`Ignoring secret request from untrusted device ${deviceId}`);
|
||||
logger.log(`Ignoring secret request from untrusted device ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
|
@ -265,7 +269,7 @@ async function onSecretRequested(
|
|||
const keyId = name.replace("m.cross_signing.", "");
|
||||
const key = await callbacks.getCrossSigningKeyCache(keyId);
|
||||
if (!key) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`${keyId} requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
|
@ -273,7 +277,7 @@ async function onSecretRequested(
|
|||
} else if (name === "m.megolm_backup.v1") {
|
||||
const key = await client.crypto.getSessionBackupPrivateKey();
|
||||
if (!key) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`session backup key requested by ${deviceId}, but not found in cache`,
|
||||
);
|
||||
}
|
||||
|
@ -327,7 +331,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
|||
const cli = MatrixClientPeg.get();
|
||||
secretStorageBeingAccessed = true;
|
||||
try {
|
||||
if (!await cli.hasSecretStorageKey() || forceReset) {
|
||||
if (!(await cli.hasSecretStorageKey()) || forceReset) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
|
||||
|
@ -353,6 +357,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
|||
throw new Error("Secret storage creation canceled");
|
||||
}
|
||||
} else {
|
||||
// FIXME: Using an import will result in test failures
|
||||
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
|
||||
await cli.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (makeRequest) => {
|
||||
|
@ -380,12 +385,12 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
|
|||
if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) {
|
||||
dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase };
|
||||
}
|
||||
console.log("Setting dehydration key");
|
||||
logger.log("Setting dehydration key");
|
||||
await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
|
||||
} else if (!keyId) {
|
||||
console.warn("Not setting dehydration key: no SSSS key found");
|
||||
} else {
|
||||
console.log("Not setting dehydration key: feature disabled");
|
||||
logger.log("Not setting dehydration key: feature disabled");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -413,8 +418,8 @@ export async function tryToUnlockSecretStorageWithDehydrationKey(
|
|||
): Promise<void> {
|
||||
const key = dehydrationCache.key;
|
||||
let restoringBackup = false;
|
||||
if (key && await client.isSecretStorageReady()) {
|
||||
console.log("Trying to set up cross-signing using dehydration key");
|
||||
if (key && (await client.isSecretStorageReady())) {
|
||||
logger.log("Trying to set up cross-signing using dehydration key");
|
||||
secretStorageBeingAccessed = true;
|
||||
nonInteractive = true;
|
||||
try {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
//@flow
|
||||
/*
|
||||
Copyright 2017 Aviral Dasgupta
|
||||
|
||||
|
@ -15,10 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {clamp} from "lodash";
|
||||
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
|
||||
import { clamp } from "lodash";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
import {SerializedPart} from "./editor/parts";
|
||||
import { SerializedPart } from "./editor/parts";
|
||||
import EditorModel from "./editor/model";
|
||||
|
||||
interface IHistoryItem {
|
||||
|
|
|
@ -14,12 +14,20 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
class Skinner {
|
||||
constructor() {
|
||||
this.components = null;
|
||||
}
|
||||
import React from "react";
|
||||
|
||||
getComponent(name) {
|
||||
export interface IComponents {
|
||||
[key: string]: React.Component;
|
||||
}
|
||||
|
||||
export interface ISkinObject {
|
||||
components: IComponents;
|
||||
}
|
||||
|
||||
export class Skinner {
|
||||
public components: IComponents = null;
|
||||
|
||||
public getComponent(name: string): React.Component {
|
||||
if (!name) throw new Error(`Invalid component name: ${name}`);
|
||||
if (this.components === null) {
|
||||
throw new Error(
|
||||
|
@ -30,7 +38,7 @@ class Skinner {
|
|||
);
|
||||
}
|
||||
|
||||
const doLookup = (components) => {
|
||||
const doLookup = (components: IComponents): React.Component => {
|
||||
if (!components) return null;
|
||||
let comp = components[name];
|
||||
// XXX: Temporarily also try 'views.' as we're currently
|
||||
|
@ -58,7 +66,7 @@ class Skinner {
|
|||
return comp;
|
||||
}
|
||||
|
||||
load(skinObject) {
|
||||
public load(skinObject: ISkinObject): void {
|
||||
if (this.components !== null) {
|
||||
throw new Error(
|
||||
"Attempted to load a skin while a skin is already loaded"+
|
||||
|
@ -72,6 +80,7 @@ class Skinner {
|
|||
}
|
||||
|
||||
// Now that we have a skin, load our components too
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const idx = require("./component-index");
|
||||
if (!idx || !idx.components) throw new Error("Invalid react-sdk component index");
|
||||
for (const c in idx.components) {
|
||||
|
@ -79,7 +88,7 @@ class Skinner {
|
|||
}
|
||||
}
|
||||
|
||||
addComponent(name, comp) {
|
||||
public addComponent(name: string, comp: any) {
|
||||
let slot = name;
|
||||
if (comp.replaces !== undefined) {
|
||||
if (comp.replaces.indexOf('.') > -1) {
|
||||
|
@ -91,7 +100,7 @@ class Skinner {
|
|||
this.components[slot] = comp;
|
||||
}
|
||||
|
||||
reset() {
|
||||
public reset(): void {
|
||||
this.components = null;
|
||||
}
|
||||
}
|
||||
|
@ -105,8 +114,8 @@ class Skinner {
|
|||
// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/
|
||||
// or https://nodejs.org/api/modules.html#modules_module_caching_caveats
|
||||
// ("Modules are cached based on their resolved filename")
|
||||
if (global.mxSkinner === undefined) {
|
||||
global.mxSkinner = new Skinner();
|
||||
if (window.mxSkinner === undefined) {
|
||||
window.mxSkinner = new Skinner();
|
||||
}
|
||||
export default global.mxSkinner;
|
||||
export default window.mxSkinner;
|
||||
|
|
@ -17,25 +17,23 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
import * as React from 'react';
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
|
||||
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import * as sdk from './index';
|
||||
import {_t, _td} from './languageHandler';
|
||||
import { _t, _td } from './languageHandler';
|
||||
import Modal from './Modal';
|
||||
import MultiInviter from './utils/MultiInviter';
|
||||
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||
import WidgetUtils from "./utils/WidgetUtils";
|
||||
import {textToHtmlRainbow} from "./utils/colour";
|
||||
import { textToHtmlRainbow } from "./utils/colour";
|
||||
import { getAddressType } from './UserAddress';
|
||||
import { abbreviateUrl } from './utils/UrlUtils';
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from './utils/IdentityServerUtils';
|
||||
import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
|
||||
import {inviteUsersToRoom} from "./RoomInvite";
|
||||
import { isPermalinkHost, parsePermalink } from "./utils/permalinks/Permalinks";
|
||||
import { WidgetType } from "./widgets/WidgetType";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
|
||||
|
@ -46,10 +44,18 @@ import { Action } from "./dispatcher/actions";
|
|||
import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {UIFeature} from "./settings/UIFeature";
|
||||
import {CHAT_EFFECTS} from "./effects"
|
||||
import { UIFeature } from "./settings/UIFeature";
|
||||
import { CHAT_EFFECTS } from "./effects";
|
||||
import CallHandler from "./CallHandler";
|
||||
import {guessAndSetDMRoom} from "./Rooms";
|
||||
import { guessAndSetDMRoom } from "./Rooms";
|
||||
import { upgradeRoom } from './utils/RoomUpgrade';
|
||||
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
|
||||
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
|
||||
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
|
||||
import InfoDialog from "./components/views/dialogs/InfoDialog";
|
||||
import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||
interface HTMLInputEvent extends Event {
|
||||
|
@ -63,7 +69,6 @@ const singleMxcUpload = async (): Promise<any> => {
|
|||
fileSelector.onchange = (ev: HTMLInputEvent) => {
|
||||
const file = ev.target.files[0];
|
||||
|
||||
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
|
||||
file,
|
||||
onFinished: (shouldContinue) => {
|
||||
|
@ -143,11 +148,15 @@ export class Command {
|
|||
}
|
||||
|
||||
function reject(error) {
|
||||
return {error};
|
||||
return { error };
|
||||
}
|
||||
|
||||
function success(promise?: Promise<any>) {
|
||||
return {promise};
|
||||
return { promise };
|
||||
}
|
||||
|
||||
function successSync(value: any) {
|
||||
return success(Promise.resolve(value));
|
||||
}
|
||||
|
||||
/* Disable the "unexpected this" error for these commands - all of the run
|
||||
|
@ -160,7 +169,7 @@ export const Commands = [
|
|||
args: '<message>',
|
||||
description: _td('Sends the given message as a spoiler'),
|
||||
runFn: function(roomId, message) {
|
||||
return success(ContentHelpers.makeHtmlMessage(
|
||||
return successSync(ContentHelpers.makeHtmlMessage(
|
||||
message,
|
||||
`<span data-mx-spoiler>${message}</span>`,
|
||||
));
|
||||
|
@ -176,7 +185,7 @@ export const Commands = [
|
|||
if (args) {
|
||||
message = message + ' ' + args;
|
||||
}
|
||||
return success(ContentHelpers.makeTextMessage(message));
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -189,7 +198,7 @@ export const Commands = [
|
|||
if (args) {
|
||||
message = message + ' ' + args;
|
||||
}
|
||||
return success(ContentHelpers.makeTextMessage(message));
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -202,7 +211,7 @@ export const Commands = [
|
|||
if (args) {
|
||||
message = message + ' ' + args;
|
||||
}
|
||||
return success(ContentHelpers.makeTextMessage(message));
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -215,7 +224,7 @@ export const Commands = [
|
|||
if (args) {
|
||||
message = message + ' ' + args;
|
||||
}
|
||||
return success(ContentHelpers.makeTextMessage(message));
|
||||
return successSync(ContentHelpers.makeTextMessage(message));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -224,7 +233,7 @@ export const Commands = [
|
|||
args: '<message>',
|
||||
description: _td('Sends a message as plain text, without interpreting it as markdown'),
|
||||
runFn: function(roomId, messages) {
|
||||
return success(ContentHelpers.makeTextMessage(messages));
|
||||
return successSync(ContentHelpers.makeTextMessage(messages));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -233,26 +242,10 @@ export const Commands = [
|
|||
args: '<message>',
|
||||
description: _td('Sends a message as html, without interpreting it as markdown'),
|
||||
runFn: function(roomId, messages) {
|
||||
return success(ContentHelpers.makeHtmlMessage(messages, messages));
|
||||
return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
new Command({
|
||||
command: 'ddg',
|
||||
args: '<query>',
|
||||
description: _td('Searches DuckDuckGo for results'),
|
||||
runFn: function() {
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
// TODO Don't explain this away, actually show a search UI here.
|
||||
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
|
||||
title: _t('/ddg is not a command'),
|
||||
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
|
||||
});
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
hideCompletionAfterSpace: true,
|
||||
}),
|
||||
new Command({
|
||||
command: 'upgraderoom',
|
||||
args: '<new_version>',
|
||||
|
@ -265,58 +258,13 @@ export const Commands = [
|
|||
return reject(_t("You do not have the required permissions to use this command."));
|
||||
}
|
||||
|
||||
const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog");
|
||||
|
||||
const {finished} = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
|
||||
RoomUpgradeWarningDialog, {roomId: roomId, targetVersion: args}, /*className=*/null,
|
||||
const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
|
||||
RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null,
|
||||
/*isPriority=*/false, /*isStatic=*/true);
|
||||
|
||||
return success(finished.then(async ([resp]) => {
|
||||
if (!resp.continue) return;
|
||||
|
||||
let checkForUpgradeFn;
|
||||
try {
|
||||
const upgradePromise = cli.upgradeRoom(roomId, args);
|
||||
|
||||
// We have to wait for the js-sdk to give us the room back so
|
||||
// we can more effectively abuse the MultiInviter behaviour
|
||||
// which heavily relies on the Room object being available.
|
||||
if (resp.invite) {
|
||||
checkForUpgradeFn = async (newRoom) => {
|
||||
// The upgradePromise should be done by the time we await it here.
|
||||
const {replacement_room: newRoomId} = await upgradePromise;
|
||||
if (newRoom.roomId !== newRoomId) return;
|
||||
|
||||
const toInvite = [
|
||||
...room.getMembersWithMembership("join"),
|
||||
...room.getMembersWithMembership("invite"),
|
||||
].map(m => m.userId).filter(m => m !== cli.getUserId());
|
||||
|
||||
if (toInvite.length > 0) {
|
||||
// Errors are handled internally to this function
|
||||
await inviteUsersToRoom(newRoomId, toInvite);
|
||||
}
|
||||
|
||||
cli.removeListener('Room', checkForUpgradeFn);
|
||||
};
|
||||
cli.on('Room', checkForUpgradeFn);
|
||||
}
|
||||
|
||||
// We have to await after so that the checkForUpgradesFn has a proper reference
|
||||
// to the new room's ID.
|
||||
await upgradePromise;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn);
|
||||
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, {
|
||||
title: _t('Error upgrading room'),
|
||||
description: _t(
|
||||
'Double check that your server supports the room version chosen and try again.'),
|
||||
});
|
||||
}
|
||||
if (!resp?.continue) return;
|
||||
await upgradeRoom(room, args, resp.invite);
|
||||
}));
|
||||
}
|
||||
return reject(this.getUsage());
|
||||
|
@ -345,7 +293,7 @@ export const Commands = [
|
|||
const cli = MatrixClientPeg.get();
|
||||
const ev = cli.getRoom(roomId).currentState.getStateEvents('m.room.member', cli.getUserId());
|
||||
const content = {
|
||||
...ev ? ev.getContent() : { membership: 'join' },
|
||||
...(ev ? ev.getContent() : { membership: 'join' }),
|
||||
displayname: args,
|
||||
};
|
||||
return success(cli.sendStateEvent(roomId, 'm.room.member', content, cli.getUserId()));
|
||||
|
@ -366,7 +314,7 @@ export const Commands = [
|
|||
|
||||
return success(promise.then((url) => {
|
||||
if (!url) return;
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', {url}, '');
|
||||
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||
}));
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
|
@ -389,7 +337,7 @@ export const Commands = [
|
|||
if (!url) return;
|
||||
const ev = room.currentState.getStateEvents('m.room.member', userId);
|
||||
const content = {
|
||||
...ev ? ev.getContent() : { membership: 'join' },
|
||||
...(ev ? ev.getContent() : { membership: 'join' }),
|
||||
avatar_url: url,
|
||||
};
|
||||
return cli.sendStateEvent(roomId, 'm.room.member', content, userId);
|
||||
|
@ -430,7 +378,6 @@ export const Commands = [
|
|||
const topic = topicEvents && topicEvents.getContent().topic;
|
||||
const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.');
|
||||
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
|
||||
title: room.name,
|
||||
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
|
||||
|
@ -477,14 +424,14 @@ export const Commands = [
|
|||
'Identity server',
|
||||
QuestionDialog, {
|
||||
title: _t("Use an identity server"),
|
||||
description: <p>{_t(
|
||||
description: <p>{ _t(
|
||||
"Use an identity server to invite by email. " +
|
||||
"Click continue to use the default identity server " +
|
||||
"(%(defaultIdentityServerName)s) or manage in Settings.",
|
||||
{
|
||||
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
|
||||
},
|
||||
)}</p>,
|
||||
) }</p>,
|
||||
button: _t("Continue"),
|
||||
},
|
||||
);
|
||||
|
@ -519,7 +466,7 @@ export const Commands = [
|
|||
aliases: ['j', 'goto'],
|
||||
args: '<room-address>',
|
||||
description: _td('Joins room with given address'),
|
||||
runFn: function(_, args) {
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
// Note: we support 2 versions of this command. The first is
|
||||
// the public-facing one for most users and the other is a
|
||||
|
@ -733,11 +680,10 @@ export const Commands = [
|
|||
ignoredUsers.push(userId); // de-duped internally in the js-sdk
|
||||
return success(
|
||||
cli.setIgnoredUsers(ignoredUsers).then(() => {
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, {
|
||||
title: _t('Ignored user'),
|
||||
description: <div>
|
||||
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
|
||||
<p>{ _t('You are now ignoring %(userId)s', { userId }) }</p>
|
||||
</div>,
|
||||
});
|
||||
}),
|
||||
|
@ -764,11 +710,10 @@ export const Commands = [
|
|||
if (index !== -1) ignoredUsers.splice(index, 1);
|
||||
return success(
|
||||
cli.setIgnoredUsers(ignoredUsers).then(() => {
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, {
|
||||
title: _t('Unignored user'),
|
||||
description: <div>
|
||||
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
|
||||
<p>{ _t('You are no longer ignoring %(userId)s', { userId }) }</p>
|
||||
</div>,
|
||||
});
|
||||
}),
|
||||
|
@ -834,8 +779,7 @@ export const Commands = [
|
|||
command: 'devtools',
|
||||
description: _td('Opens the Developer Tools dialog'),
|
||||
runFn: function(roomId) {
|
||||
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
|
||||
Modal.createDialog(DevtoolsDialog, {roomId});
|
||||
Modal.createDialog(DevtoolsDialog, { roomId });
|
||||
return success();
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
|
@ -859,7 +803,7 @@ export const Commands = [
|
|||
const iframe = embed.childNodes[0] as ChildElement;
|
||||
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
|
||||
const srcAttr = iframe.attrs.find(a => a.name === 'src');
|
||||
console.log("Pulling URL out of iframe (embed code)");
|
||||
logger.log("Pulling URL out of iframe (embed code)");
|
||||
widgetUrl = srcAttr.value;
|
||||
}
|
||||
}
|
||||
|
@ -879,7 +823,7 @@ export const Commands = [
|
|||
// Make the widget a Jitsi widget if it looks like a Jitsi widget
|
||||
const jitsiData = Jitsi.getInstance().parsePreferredConferenceUrl(widgetUrl);
|
||||
if (jitsiData) {
|
||||
console.log("Making /addwidget widget a Jitsi conference");
|
||||
logger.log("Making /addwidget widget a Jitsi conference");
|
||||
type = WidgetType.JITSI;
|
||||
name = "Jitsi Conference";
|
||||
data = jitsiData;
|
||||
|
@ -939,7 +883,6 @@ export const Commands = [
|
|||
await cli.setDeviceVerified(userId, deviceId, true);
|
||||
|
||||
// Tell the user we verified everything
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
|
||||
title: _t('Verified key'),
|
||||
description: <div>
|
||||
|
@ -947,7 +890,7 @@ export const Commands = [
|
|||
{
|
||||
_t('The signing key you provided matches the signing key you received ' +
|
||||
'from %(userId)s\'s session %(deviceId)s. Session marked as verified.',
|
||||
{userId, deviceId})
|
||||
{ userId, deviceId })
|
||||
}
|
||||
</p>
|
||||
</div>,
|
||||
|
@ -978,7 +921,7 @@ export const Commands = [
|
|||
args: '<message>',
|
||||
runFn: function(roomId, args) {
|
||||
if (!args) return reject(this.getUserId());
|
||||
return success(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
|
||||
return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -988,7 +931,7 @@ export const Commands = [
|
|||
args: '<message>',
|
||||
runFn: function(roomId, args) {
|
||||
if (!args) return reject(this.getUserId());
|
||||
return success(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
|
||||
return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
|
||||
},
|
||||
category: CommandCategories.messages,
|
||||
}),
|
||||
|
@ -996,8 +939,6 @@ export const Commands = [
|
|||
command: "help",
|
||||
description: _td("Displays list of commands with usages and descriptions"),
|
||||
runFn: function() {
|
||||
const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog');
|
||||
|
||||
Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog);
|
||||
return success();
|
||||
},
|
||||
|
@ -1015,9 +956,8 @@ export const Commands = [
|
|||
const member = MatrixClientPeg.get().getRoom(roomId).getMember(userId);
|
||||
dis.dispatch<ViewUserPayload>({
|
||||
action: Action.ViewUser,
|
||||
// XXX: We should be using a real member object and not assuming what the
|
||||
// receiver wants.
|
||||
member: member || {userId},
|
||||
// XXX: We should be using a real member object and not assuming what the receiver wants.
|
||||
member: member || { userId } as User,
|
||||
});
|
||||
return success();
|
||||
},
|
||||
|
@ -1073,7 +1013,7 @@ export const Commands = [
|
|||
command: "msg",
|
||||
description: _td("Sends a message to the given user"),
|
||||
args: "<user-id> <message>",
|
||||
runFn: function(_, args) {
|
||||
runFn: function(roomId, args) {
|
||||
if (args) {
|
||||
// matches the first whitespace delimited group and then the rest of the string
|
||||
const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
|
||||
|
@ -1169,16 +1109,16 @@ export const Commands = [
|
|||
};
|
||||
MatrixClientPeg.get().sendMessage(roomId, content);
|
||||
}
|
||||
dis.dispatch({action: `effects.${effect.command}`});
|
||||
dis.dispatch({ action: `effects.${effect.command}` });
|
||||
})());
|
||||
},
|
||||
category: CommandCategories.effects,
|
||||
})
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
// build a map from names and aliases to the Command objects.
|
||||
export const CommandMap = new Map();
|
||||
export const CommandMap = new Map<string, Command>();
|
||||
Commands.forEach(cmd => {
|
||||
CommandMap.set(cmd.command, cmd);
|
||||
cmd.aliases.forEach(alias => {
|
||||
|
@ -1186,15 +1126,15 @@ Commands.forEach(cmd => {
|
|||
});
|
||||
});
|
||||
|
||||
export function parseCommandString(input: string) {
|
||||
export function parseCommandString(input: string): { cmd?: string, args?: string } {
|
||||
// trim any trailing whitespace, as it can confuse the parser for
|
||||
// IRC-style commands
|
||||
input = input.replace(/\s+$/, '');
|
||||
if (input[0] !== '/') return {}; // not a command
|
||||
|
||||
const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
|
||||
let cmd;
|
||||
let args;
|
||||
let cmd: string;
|
||||
let args: string;
|
||||
if (bits) {
|
||||
cmd = bits[1].substring(1).toLowerCase();
|
||||
args = bits[2];
|
||||
|
@ -1202,7 +1142,12 @@ export function parseCommandString(input: string) {
|
|||
cmd = input;
|
||||
}
|
||||
|
||||
return {cmd, args};
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
interface ICmd {
|
||||
cmd?: Command;
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1213,8 +1158,8 @@ export function parseCommandString(input: string) {
|
|||
* processing the command, or 'promise' if a request was sent out.
|
||||
* Returns null if the input didn't match a command.
|
||||
*/
|
||||
export function getCommand(input: string) {
|
||||
const {cmd, args} = parseCommandString(input);
|
||||
export function getCommand(input: string): ICmd {
|
||||
const { cmd, args } = parseCommandString(input);
|
||||
|
||||
if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) {
|
||||
return {
|
||||
|
|
30
src/Terms.ts
30
src/Terms.ts
|
@ -15,11 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types';
|
||||
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import * as sdk from '.';
|
||||
import Modal from './Modal';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export class TermsNotSignedError extends Error {}
|
||||
|
||||
/**
|
||||
|
@ -32,7 +35,7 @@ export class Service {
|
|||
* @param {string} baseUrl The Base URL of the service (ie. before '/_matrix')
|
||||
* @param {string} accessToken The user's access token for the service
|
||||
*/
|
||||
constructor(public serviceType: string, public baseUrl: string, public accessToken: string) {
|
||||
constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,13 +51,13 @@ export interface Policy {
|
|||
}
|
||||
|
||||
export type Policies = {
|
||||
[policy: string]: Policy,
|
||||
[policy: string]: Policy;
|
||||
};
|
||||
|
||||
export type TermsInteractionCallback = (
|
||||
policiesAndServicePairs: {
|
||||
service: Service,
|
||||
policies: Policies,
|
||||
service: Service;
|
||||
policies: Policies;
|
||||
}[],
|
||||
agreedUrls: string[],
|
||||
extraClassNames?: string,
|
||||
|
@ -117,7 +120,7 @@ export async function startTermsFlow(
|
|||
// but that is not a thing the API supports, so probably best to just show
|
||||
// things they've not agreed to yet.
|
||||
const unagreedPoliciesAndServicePairs = [];
|
||||
for (const {service, policies} of policiesAndServicePairs) {
|
||||
for (const { service, policies } of policiesAndServicePairs) {
|
||||
const unagreedPolicies = {};
|
||||
for (const [policyName, policy] of Object.entries(policies)) {
|
||||
let policyAgreed = false;
|
||||
|
@ -131,7 +134,7 @@ export async function startTermsFlow(
|
|||
if (!policyAgreed) unagreedPolicies[policyName] = policy;
|
||||
}
|
||||
if (Object.keys(unagreedPolicies).length > 0) {
|
||||
unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies});
|
||||
unagreedPoliciesAndServicePairs.push({ service, policies: unagreedPolicies });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -139,16 +142,16 @@ export async function startTermsFlow(
|
|||
const numAcceptedBeforeAgreement = agreedUrlSet.size;
|
||||
if (unagreedPoliciesAndServicePairs.length > 0) {
|
||||
const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]);
|
||||
console.log("User has agreed to URLs", newlyAgreedUrls);
|
||||
logger.log("User has agreed to URLs", newlyAgreedUrls);
|
||||
// Merge with previously agreed URLs
|
||||
newlyAgreedUrls.forEach(url => agreedUrlSet.add(url));
|
||||
} else {
|
||||
console.log("User has already agreed to all required policies");
|
||||
logger.log("User has already agreed to all required policies");
|
||||
}
|
||||
|
||||
// We only ever add to the set of URLs, so if anything has changed then we'd see a different length
|
||||
if (agreedUrlSet.size !== numAcceptedBeforeAgreement) {
|
||||
const newAcceptedTerms = {accepted: Array.from(agreedUrlSet)};
|
||||
const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) };
|
||||
await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms);
|
||||
}
|
||||
|
||||
|
@ -180,14 +183,15 @@ export async function startTermsFlow(
|
|||
|
||||
export function dialogTermsInteractionCallback(
|
||||
policiesAndServicePairs: {
|
||||
service: Service,
|
||||
policies: { [policy: string]: Policy },
|
||||
service: Service;
|
||||
policies: { [policy: string]: Policy };
|
||||
}[],
|
||||
agreedUrls: string[],
|
||||
extraClassNames?: string,
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("Terms that need agreement", policiesAndServicePairs);
|
||||
logger.log("Terms that need agreement", policiesAndServicePairs);
|
||||
// FIXME: Using an import will result in test failures
|
||||
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
|
||||
|
||||
Modal.createTrackedDialog('Terms of Service', '', TermsDialog, {
|
||||
|
|
|
@ -1,611 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
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 {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import { _t } from './languageHandler';
|
||||
import * as Roles from './Roles';
|
||||
import {isValid3pidInvite} from "./RoomInvite";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
|
||||
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";
|
||||
|
||||
function textForMemberEvent(ev) {
|
||||
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
||||
const senderName = ev.sender ? ev.sender.name : ev.getSender();
|
||||
const targetName = ev.target ? ev.target.name : ev.getStateKey();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const content = ev.getContent();
|
||||
|
||||
const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
|
||||
switch (content.membership) {
|
||||
case 'invite': {
|
||||
const threePidContent = content.third_party_invite;
|
||||
if (threePidContent) {
|
||||
if (threePidContent.display_name) {
|
||||
return _t('%(targetName)s accepted the invitation for %(displayName)s.', {
|
||||
targetName,
|
||||
displayName: threePidContent.display_name,
|
||||
});
|
||||
} else {
|
||||
return _t('%(targetName)s accepted an invitation.', {targetName});
|
||||
}
|
||||
} else {
|
||||
return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
|
||||
}
|
||||
}
|
||||
case 'ban':
|
||||
return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason;
|
||||
case 'join':
|
||||
if (prevContent && prevContent.membership === 'join') {
|
||||
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
|
||||
return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
|
||||
oldDisplayName: prevContent.displayname,
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (!prevContent.displayname && content.displayname) {
|
||||
return _t('%(senderName)s set their display name to %(displayName)s.', {
|
||||
senderName: ev.getSender(),
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (prevContent.displayname && !content.displayname) {
|
||||
return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', {
|
||||
senderName,
|
||||
oldDisplayName: prevContent.displayname,
|
||||
});
|
||||
} else if (prevContent.avatar_url && !content.avatar_url) {
|
||||
return _t('%(senderName)s removed their profile picture.', {senderName});
|
||||
} else if (prevContent.avatar_url && content.avatar_url &&
|
||||
prevContent.avatar_url !== content.avatar_url) {
|
||||
return _t('%(senderName)s changed their profile picture.', {senderName});
|
||||
} else if (!prevContent.avatar_url && content.avatar_url) {
|
||||
return _t('%(senderName)s set a profile picture.', {senderName});
|
||||
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
// This is a null rejoin, it will only be visible if the Labs option is enabled
|
||||
return _t("%(senderName)s made no change.", {senderName});
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
|
||||
return _t('%(targetName)s joined the room.', {targetName});
|
||||
}
|
||||
case 'leave':
|
||||
if (ev.getSender() === ev.getStateKey()) {
|
||||
if (prevContent.membership === "invite") {
|
||||
return _t('%(targetName)s rejected the invitation.', {targetName});
|
||||
} else {
|
||||
return _t('%(targetName)s left the room.', {targetName});
|
||||
}
|
||||
} else if (prevContent.membership === "ban") {
|
||||
return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName});
|
||||
} else if (prevContent.membership === "invite") {
|
||||
return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', {
|
||||
senderName,
|
||||
targetName,
|
||||
}) + ' ' + reason;
|
||||
} else if (prevContent.membership === "join") {
|
||||
return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function textForTopicEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
|
||||
senderDisplayName,
|
||||
topic: ev.getContent().topic,
|
||||
});
|
||||
}
|
||||
|
||||
function textForRoomNameEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
|
||||
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
|
||||
return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName});
|
||||
}
|
||||
if (ev.getPrevContent().name) {
|
||||
return _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
|
||||
senderDisplayName,
|
||||
oldRoomName: ev.getPrevContent().name,
|
||||
newRoomName: ev.getContent().name,
|
||||
});
|
||||
}
|
||||
return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
|
||||
senderDisplayName,
|
||||
roomName: ev.getContent().name,
|
||||
});
|
||||
}
|
||||
|
||||
function textForTombstoneEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName});
|
||||
}
|
||||
|
||||
function textForJoinRulesEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().join_rule) {
|
||||
case "public":
|
||||
return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName});
|
||||
case "invite":
|
||||
return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName});
|
||||
default:
|
||||
// The spec supports "knock" and "private", however nothing implements these.
|
||||
return _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
|
||||
senderDisplayName,
|
||||
rule: ev.getContent().join_rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function textForGuestAccessEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().guest_access) {
|
||||
case "can_join":
|
||||
return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName});
|
||||
case "forbidden":
|
||||
return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName});
|
||||
default:
|
||||
// There's no other options we can expect, however just for safety's sake we'll do this.
|
||||
return _t('%(senderDisplayName)s changed guest access to %(rule)s', {
|
||||
senderDisplayName,
|
||||
rule: ev.getContent().guest_access,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function textForRelatedGroupsEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const groups = ev.getContent().groups || [];
|
||||
const prevGroups = ev.getPrevContent().groups || [];
|
||||
const added = groups.filter((g) => !prevGroups.includes(g));
|
||||
const removed = prevGroups.filter((g) => !groups.includes(g));
|
||||
|
||||
if (added.length && !removed.length) {
|
||||
return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
|
||||
senderDisplayName,
|
||||
groups: added.join(', '),
|
||||
});
|
||||
} else if (!added.length && removed.length) {
|
||||
return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
|
||||
senderDisplayName,
|
||||
groups: removed.join(', '),
|
||||
});
|
||||
} else if (added.length && removed.length) {
|
||||
return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
|
||||
'%(oldGroups)s in this room.', {
|
||||
senderDisplayName,
|
||||
newGroups: added.join(', '),
|
||||
oldGroups: removed.join(', '),
|
||||
});
|
||||
} else {
|
||||
// Don't bother rendering this change (because there were no changes)
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function textForServerACLEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const current = ev.getContent();
|
||||
const prev = {
|
||||
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
|
||||
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
|
||||
allow_ip_literals: !(prevContent.allow_ip_literals === false),
|
||||
};
|
||||
|
||||
let text = "";
|
||||
if (prev.deny.length === 0 && prev.allow.length === 0) {
|
||||
text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName});
|
||||
} else {
|
||||
text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName});
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.allow)) {
|
||||
current.allow = [];
|
||||
}
|
||||
|
||||
// If we know for sure everyone is banned, mark the room as obliterated
|
||||
if (current.allow.length === 0) {
|
||||
return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used.");
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev) {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = "* " + senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName});
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function textForCanonicalAliasEvent(ev) {
|
||||
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const oldAlias = ev.getPrevContent().alias;
|
||||
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
|
||||
const newAlias = ev.getContent().alias;
|
||||
const newAltAliases = ev.getContent().alt_aliases || [];
|
||||
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
|
||||
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
|
||||
|
||||
if (!removedAltAliases.length && !addedAltAliases.length) {
|
||||
if (newAlias) {
|
||||
return _t('%(senderName)s set the main address for this room to %(address)s.', {
|
||||
senderName: senderName,
|
||||
address: ev.getContent().alias,
|
||||
});
|
||||
} else if (oldAlias) {
|
||||
return _t('%(senderName)s removed the main address for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else if (newAlias === oldAlias) {
|
||||
if (addedAltAliases.length && !removedAltAliases.length) {
|
||||
return _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: addedAltAliases.join(", "),
|
||||
count: addedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && !addedAltAliases.length) {
|
||||
return _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: removedAltAliases.join(", "),
|
||||
count: removedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && addedAltAliases.length) {
|
||||
return _t('%(senderName)s changed the alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// both alias and alt_aliases where modified
|
||||
return _t('%(senderName)s changed the main and alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
// in case there is no difference between the two events,
|
||||
// say something as we can't simply hide the tile from here
|
||||
return _t('%(senderName)s changed the addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
|
||||
function textForCallAnswerEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
|
||||
return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported;
|
||||
}
|
||||
|
||||
function textForCallHangupEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
const eventContent = event.getContent();
|
||||
let reason = "";
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
reason = _t('(not supported by this browser)');
|
||||
} else if (eventContent.reason) {
|
||||
if (eventContent.reason === "ice_failed") {
|
||||
// We couldn't establish a connection at all
|
||||
reason = _t('(could not connect media)');
|
||||
} else if (eventContent.reason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
reason = _t('(connection failed)');
|
||||
} else if (eventContent.reason === "user_media_failed") {
|
||||
// The other side couldn't open capture devices
|
||||
reason = _t("(their device couldn't start the camera / microphone)");
|
||||
} else if (eventContent.reason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
reason = _t("(an error occurred)");
|
||||
} else if (eventContent.reason === "invite_timeout") {
|
||||
reason = _t('(no answer)');
|
||||
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
|
||||
// workaround for https://github.com/vector-im/element-web/issues/5178
|
||||
// it seems Android randomly sets a reason of "user hangup" which is
|
||||
// interpreted as an error code :(
|
||||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
reason = '';
|
||||
} else {
|
||||
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
|
||||
}
|
||||
}
|
||||
return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason;
|
||||
}
|
||||
|
||||
function textForCallRejectEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
return _t('%(senderName)s declined the call.', {senderName});
|
||||
}
|
||||
|
||||
function textForCallInviteEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
let isVoice = true;
|
||||
if (event.getContent().offer && event.getContent().offer.sdp &&
|
||||
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
|
||||
isVoice = false;
|
||||
}
|
||||
const isSupported = MatrixClientPeg.get().supportsVoip();
|
||||
|
||||
// This ladder could be reduced down to a couple string variables, however other languages
|
||||
// can have a hard time translating those strings. In an effort to make translations easier
|
||||
// and more accurate, we break out the string-based variables to a couple booleans.
|
||||
if (isVoice && isSupported) {
|
||||
return _t("%(senderName)s placed a voice call.", {senderName});
|
||||
} else if (isVoice && !isSupported) {
|
||||
return _t("%(senderName)s placed a voice call. (not supported by this browser)", {senderName});
|
||||
} else if (!isVoice && isSupported) {
|
||||
return _t("%(senderName)s placed a video call.", {senderName});
|
||||
} else if (!isVoice && !isSupported) {
|
||||
return _t("%(senderName)s placed a video call. (not supported by this browser)", {senderName});
|
||||
}
|
||||
}
|
||||
|
||||
function textForThreePidInviteEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
|
||||
if (!isValid3pidInvite(event)) {
|
||||
const targetDisplayName = event.getPrevContent().display_name || _t("Someone");
|
||||
return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
|
||||
senderName,
|
||||
targetDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
|
||||
senderName,
|
||||
targetDisplayName: event.getContent().display_name,
|
||||
});
|
||||
}
|
||||
|
||||
function textForHistoryVisibilityEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
switch (event.getContent().history_visibility) {
|
||||
case 'invited':
|
||||
return _t('%(senderName)s made future room history visible to all room members, '
|
||||
+ 'from the point they are invited.', {senderName});
|
||||
case 'joined':
|
||||
return _t('%(senderName)s made future room history visible to all room members, '
|
||||
+ 'from the point they joined.', {senderName});
|
||||
case 'shared':
|
||||
return _t('%(senderName)s made future room history visible to all room members.', {senderName});
|
||||
case 'world_readable':
|
||||
return _t('%(senderName)s made future room history visible to anyone.', {senderName});
|
||||
default:
|
||||
return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
|
||||
senderName,
|
||||
visibility: event.getContent().history_visibility,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Currently will only display a change if a user's power level is changed
|
||||
function textForPowerEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
if (!event.getPrevContent() || !event.getPrevContent().users ||
|
||||
!event.getContent() || !event.getContent().users) {
|
||||
return '';
|
||||
}
|
||||
const userDefault = event.getContent().users_default || 0;
|
||||
// Construct set of userIds
|
||||
const users = [];
|
||||
Object.keys(event.getContent().users).forEach(
|
||||
(userId) => {
|
||||
if (users.indexOf(userId) === -1) users.push(userId);
|
||||
},
|
||||
);
|
||||
Object.keys(event.getPrevContent().users).forEach(
|
||||
(userId) => {
|
||||
if (users.indexOf(userId) === -1) users.push(userId);
|
||||
},
|
||||
);
|
||||
const diff = [];
|
||||
// XXX: This is also surely broken for i18n
|
||||
users.forEach((userId) => {
|
||||
// Previous power level
|
||||
const from = event.getPrevContent().users[userId];
|
||||
// Current power level
|
||||
const to = event.getContent().users[userId];
|
||||
if (to !== from) {
|
||||
diff.push(
|
||||
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
|
||||
userId,
|
||||
fromPowerLevel: Roles.textualPowerLevel(from, userDefault),
|
||||
toPowerLevel: Roles.textualPowerLevel(to, userDefault),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!diff.length) {
|
||||
return '';
|
||||
}
|
||||
return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
|
||||
senderName,
|
||||
powerLevelDiffText: diff.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
function textForPinnedEvent(event) {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
return _t("%(senderName)s changed the pinned messages for the room.", {senderName});
|
||||
}
|
||||
|
||||
function textForWidgetEvent(event) {
|
||||
const senderName = event.getSender();
|
||||
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
|
||||
const {name, type, url} = event.getContent() || {};
|
||||
|
||||
let widgetName = name || prevName || type || prevType || '';
|
||||
// Apply sentence case to widget name
|
||||
if (widgetName && widgetName.length > 0) {
|
||||
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
|
||||
}
|
||||
|
||||
// If the widget was removed, its content should be {}, but this is sufficiently
|
||||
// equivalent to that condition.
|
||||
if (url) {
|
||||
if (prevUrl) {
|
||||
return _t('%(widgetName)s widget modified by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
} else {
|
||||
return _t('%(widgetName)s widget added by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return _t('%(widgetName)s widget removed by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function textForWidgetLayoutEvent(event) {
|
||||
const senderName = event.sender?.name || event.getSender();
|
||||
return _t("%(senderName)s has updated the widget layout", {senderName});
|
||||
}
|
||||
|
||||
function textForMjolnirEvent(event) {
|
||||
const senderName = event.getSender();
|
||||
const {entity: prevEntity} = event.getPrevContent();
|
||||
const {entity, recommendation, reason} = event.getContent();
|
||||
|
||||
// Rule removed
|
||||
if (!entity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s removed the rule banning users matching %(glob)s",
|
||||
{senderName, glob: prevEntity});
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
|
||||
{senderName, glob: prevEntity});
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s removed the rule banning servers matching %(glob)s",
|
||||
{senderName, glob: prevEntity});
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something, but we shouldn't end up here.
|
||||
return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity});
|
||||
}
|
||||
|
||||
// Invalid rule
|
||||
if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName});
|
||||
|
||||
// Rule updated
|
||||
if (entity === prevEntity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
}
|
||||
|
||||
// New rule
|
||||
if (!prevEntity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
|
||||
{senderName, glob: entity, reason});
|
||||
}
|
||||
|
||||
// else the entity !== prevEntity - count as a removal & add
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t(
|
||||
"%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{senderName, oldGlob: prevEntity, newGlob: entity, reason},
|
||||
);
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return _t(
|
||||
"%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{senderName, oldGlob: prevEntity, newGlob: entity, reason},
|
||||
);
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return _t(
|
||||
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{senderName, oldGlob: prevEntity, newGlob: entity, reason},
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
|
||||
"for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason});
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
'm.call.answer': textForCallAnswerEvent,
|
||||
'm.call.hangup': textForCallHangupEvent,
|
||||
'm.call.reject': textForCallRejectEvent,
|
||||
};
|
||||
|
||||
const stateHandlers = {
|
||||
'm.room.canonical_alias': textForCanonicalAliasEvent,
|
||||
'm.room.name': textForRoomNameEvent,
|
||||
'm.room.topic': textForTopicEvent,
|
||||
'm.room.member': textForMemberEvent,
|
||||
'm.room.third_party_invite': textForThreePidInviteEvent,
|
||||
'm.room.history_visibility': textForHistoryVisibilityEvent,
|
||||
'm.room.power_levels': textForPowerEvent,
|
||||
'm.room.pinned_events': textForPinnedEvent,
|
||||
'm.room.server_acl': textForServerACLEvent,
|
||||
'm.room.tombstone': textForTombstoneEvent,
|
||||
'm.room.join_rules': textForJoinRulesEvent,
|
||||
'm.room.guest_access': textForGuestAccessEvent,
|
||||
'm.room.related_groups': textForRelatedGroupsEvent,
|
||||
|
||||
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
|
||||
'im.vector.modular.widgets': textForWidgetEvent,
|
||||
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
|
||||
};
|
||||
|
||||
// Add all the Mjolnir stuff to the renderer
|
||||
for (const evType of ALL_RULE_TYPES) {
|
||||
stateHandlers[evType] = textForMjolnirEvent;
|
||||
}
|
||||
|
||||
export function textForEvent(ev) {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
if (handler) return handler(ev);
|
||||
return '';
|
||||
}
|
746
src/TextForEvent.tsx
Normal file
746
src/TextForEvent.tsx
Normal file
|
@ -0,0 +1,746 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { _t } from './languageHandler';
|
||||
import * as Roles from './Roles';
|
||||
import { isValid3pidInvite } from "./RoomInvite";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./mjolnir/BanList";
|
||||
import { WIDGET_LAYOUT_EVENT_TYPE } from "./stores/widgets/WidgetLayoutStore";
|
||||
import { RightPanelPhases } from './stores/RightPanelStorePhases';
|
||||
import { Action } from './dispatcher/actions';
|
||||
import defaultDispatcher from './dispatcher/dispatcher';
|
||||
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
// These functions are frequently used just to check whether an event has
|
||||
// any text to display at all. For this reason they return deferred values
|
||||
// to avoid the expense of looking up translations when they're not needed.
|
||||
|
||||
function 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;
|
||||
if (event.getContent().offer && event.getContent().offer.sdp &&
|
||||
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
|
||||
isVoice = false;
|
||||
}
|
||||
const isSupported = MatrixClientPeg.get().supportsVoip();
|
||||
|
||||
// This ladder could be reduced down to a couple string variables, however other languages
|
||||
// can have a hard time translating those strings. In an effort to make translations easier
|
||||
// and more accurate, we break out the string-based variables to a couple booleans.
|
||||
if (isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call.", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
} else if (!isVoice && !isSupported) {
|
||||
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
|
||||
senderName: getSenderName(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const content = ev.getContent();
|
||||
const reason = content.reason;
|
||||
|
||||
switch (content.membership) {
|
||||
case 'invite': {
|
||||
const threePidContent = content.third_party_invite;
|
||||
if (threePidContent) {
|
||||
if (threePidContent.display_name) {
|
||||
return () => _t('%(targetName)s accepted the invitation for %(displayName)s', {
|
||||
targetName,
|
||||
displayName: threePidContent.display_name,
|
||||
});
|
||||
} else {
|
||||
return () => _t('%(targetName)s accepted an invitation', { targetName });
|
||||
}
|
||||
} else {
|
||||
return () => _t('%(senderName)s invited %(targetName)s', { senderName, targetName });
|
||||
}
|
||||
}
|
||||
case 'ban':
|
||||
return () => reason
|
||||
? _t('%(senderName)s banned %(targetName)s: %(reason)s', { senderName, targetName, reason })
|
||||
: _t('%(senderName)s banned %(targetName)s', { senderName, targetName });
|
||||
case 'join':
|
||||
if (prevContent && prevContent.membership === 'join') {
|
||||
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
|
||||
return () => _t('%(oldDisplayName)s changed their display name to %(displayName)s', {
|
||||
oldDisplayName: prevContent.displayname,
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (!prevContent.displayname && content.displayname) {
|
||||
return () => _t('%(senderName)s set their display name to %(displayName)s', {
|
||||
senderName: ev.getSender(),
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (prevContent.displayname && !content.displayname) {
|
||||
return () => _t('%(senderName)s removed their display name (%(oldDisplayName)s)', {
|
||||
senderName,
|
||||
oldDisplayName: prevContent.displayname,
|
||||
});
|
||||
} else if (prevContent.avatar_url && !content.avatar_url) {
|
||||
return () => _t('%(senderName)s removed their profile picture', { senderName });
|
||||
} else if (prevContent.avatar_url && content.avatar_url &&
|
||||
prevContent.avatar_url !== content.avatar_url) {
|
||||
return () => _t('%(senderName)s changed their profile picture', { senderName });
|
||||
} else if (!prevContent.avatar_url && content.avatar_url) {
|
||||
return () => _t('%(senderName)s set a profile picture', { senderName });
|
||||
} 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 {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
|
||||
return () => _t('%(targetName)s joined the room', { targetName });
|
||||
}
|
||||
case 'leave':
|
||||
if (ev.getSender() === ev.getStateKey()) {
|
||||
if (prevContent.membership === "invite") {
|
||||
return () => _t('%(targetName)s rejected the invitation', { targetName });
|
||||
} else {
|
||||
return () => reason
|
||||
? _t('%(targetName)s left the room: %(reason)s', { targetName, reason })
|
||||
: _t('%(targetName)s left the room', { targetName });
|
||||
}
|
||||
} else if (prevContent.membership === "ban") {
|
||||
return () => _t('%(senderName)s unbanned %(targetName)s', { senderName, targetName });
|
||||
} else if (prevContent.membership === "invite") {
|
||||
return () => reason
|
||||
? _t('%(senderName)s withdrew %(targetName)s\'s invitation: %(reason)s', {
|
||||
senderName,
|
||||
targetName,
|
||||
reason,
|
||||
})
|
||||
: _t('%(senderName)s withdrew %(targetName)s\'s invitation', { senderName, targetName });
|
||||
} else if (prevContent.membership === "join") {
|
||||
return () => reason
|
||||
? _t('%(senderName)s kicked %(targetName)s: %(reason)s', {
|
||||
senderName,
|
||||
targetName,
|
||||
reason,
|
||||
})
|
||||
: _t('%(senderName)s kicked %(targetName)s', { senderName, targetName });
|
||||
} else {
|
||||
return 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,
|
||||
topic: ev.getContent().topic,
|
||||
});
|
||||
}
|
||||
|
||||
function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev?.sender?.name || ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName });
|
||||
}
|
||||
|
||||
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) {
|
||||
return () => _t('%(senderDisplayName)s removed the room name.', { senderDisplayName });
|
||||
}
|
||||
if (ev.getPrevContent().name) {
|
||||
return () => _t('%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.', {
|
||||
senderDisplayName,
|
||||
oldRoomName: ev.getPrevContent().name,
|
||||
newRoomName: ev.getContent().name,
|
||||
});
|
||||
}
|
||||
return () => _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {
|
||||
senderDisplayName,
|
||||
roomName: ev.getContent().name,
|
||||
});
|
||||
}
|
||||
|
||||
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: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().join_rule) {
|
||||
case "public":
|
||||
return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', {
|
||||
senderDisplayName,
|
||||
});
|
||||
case "invite":
|
||||
return () => _t('%(senderDisplayName)s made the room invite only.', {
|
||||
senderDisplayName,
|
||||
});
|
||||
default:
|
||||
// The spec supports "knock" and "private", however nothing implements these.
|
||||
return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
|
||||
senderDisplayName,
|
||||
rule: ev.getContent().join_rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName });
|
||||
case "forbidden":
|
||||
return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName });
|
||||
default:
|
||||
// There's no other options we can expect, however just for safety's sake we'll do this.
|
||||
return () => _t('%(senderDisplayName)s changed guest access to %(rule)s', {
|
||||
senderDisplayName,
|
||||
rule: ev.getContent().guest_access,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const added = groups.filter((g) => !prevGroups.includes(g));
|
||||
const removed = prevGroups.filter((g) => !groups.includes(g));
|
||||
|
||||
if (added.length && !removed.length) {
|
||||
return () => _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
|
||||
senderDisplayName,
|
||||
groups: added.join(', '),
|
||||
});
|
||||
} else if (!added.length && removed.length) {
|
||||
return () => _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
|
||||
senderDisplayName,
|
||||
groups: removed.join(', '),
|
||||
});
|
||||
} else if (added.length && removed.length) {
|
||||
return () => _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
|
||||
'%(oldGroups)s in this room.', {
|
||||
senderDisplayName,
|
||||
newGroups: added.join(', '),
|
||||
oldGroups: removed.join(', '),
|
||||
});
|
||||
} else {
|
||||
// Don't bother rendering this change (because there were no changes)
|
||||
return 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();
|
||||
const prev = {
|
||||
deny: Array.isArray(prevContent.deny) ? prevContent.deny : [],
|
||||
allow: Array.isArray(prevContent.allow) ? prevContent.allow : [],
|
||||
allow_ip_literals: !(prevContent.allow_ip_literals === false),
|
||||
};
|
||||
|
||||
let getText = null;
|
||||
if (prev.deny.length === 0 && prev.allow.length === 0) {
|
||||
getText = () => _t("%(senderDisplayName)s set the server ACLs for this room.", { senderDisplayName });
|
||||
} else {
|
||||
getText = () => _t("%(senderDisplayName)s changed the server ACLs for this room.", { senderDisplayName });
|
||||
}
|
||||
|
||||
if (!Array.isArray(current.allow)) {
|
||||
current.allow = [];
|
||||
}
|
||||
|
||||
// If we know for sure everyone is banned, mark the room as obliterated
|
||||
if (current.allow.length === 0) {
|
||||
return () => getText() + " " +
|
||||
_t("🎉 All servers are banned from participating! This room can no longer be used.");
|
||||
}
|
||||
|
||||
return getText;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = ev.getContent().body;
|
||||
if (ev.isRedacted()) {
|
||||
message = _t("Message deleted");
|
||||
const unsigned = ev.getUnsigned();
|
||||
const redactedBecauseUserId = unsigned?.redacted_because?.sender;
|
||||
if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) {
|
||||
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
|
||||
const sender = room?.getMember(redactedBecauseUserId);
|
||||
message = _t("Message deleted by %(name)s", { name: sender?.name
|
||||
|| redactedBecauseUserId });
|
||||
}
|
||||
}
|
||||
if (ev.getContent().msgtype === "m.emote") {
|
||||
message = "* " + senderDisplayName + " " + message;
|
||||
} else if (ev.getContent().msgtype === "m.image") {
|
||||
message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName });
|
||||
} else if (ev.getType() == "m.sticker") {
|
||||
message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName });
|
||||
} else {
|
||||
// in this case, parse it as a plain text message
|
||||
message = senderDisplayName + ': ' + message;
|
||||
}
|
||||
return message;
|
||||
};
|
||||
}
|
||||
|
||||
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 || [];
|
||||
const newAlias = ev.getContent().alias;
|
||||
const newAltAliases = ev.getContent().alt_aliases || [];
|
||||
const removedAltAliases = oldAltAliases.filter(alias => !newAltAliases.includes(alias));
|
||||
const addedAltAliases = newAltAliases.filter(alias => !oldAltAliases.includes(alias));
|
||||
|
||||
if (!removedAltAliases.length && !addedAltAliases.length) {
|
||||
if (newAlias) {
|
||||
return () => _t('%(senderName)s set the main address for this room to %(address)s.', {
|
||||
senderName: senderName,
|
||||
address: ev.getContent().alias,
|
||||
});
|
||||
} else if (oldAlias) {
|
||||
return () => _t('%(senderName)s removed the main address for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else if (newAlias === oldAlias) {
|
||||
if (addedAltAliases.length && !removedAltAliases.length) {
|
||||
return () => _t('%(senderName)s added the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: addedAltAliases.join(", "),
|
||||
count: addedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && !addedAltAliases.length) {
|
||||
return () => _t('%(senderName)s removed the alternative addresses %(addresses)s for this room.', {
|
||||
senderName: senderName,
|
||||
addresses: removedAltAliases.join(", "),
|
||||
count: removedAltAliases.length,
|
||||
});
|
||||
} if (removedAltAliases.length && addedAltAliases.length) {
|
||||
return () => _t('%(senderName)s changed the alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// both alias and alt_aliases where modified
|
||||
return () => _t('%(senderName)s changed the main and alternative addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
// in case there is no difference between the two events,
|
||||
// say something as we can't simply hide the tile from here
|
||||
return () => _t('%(senderName)s changed the addresses for this room.', {
|
||||
senderName: senderName,
|
||||
});
|
||||
}
|
||||
|
||||
function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
|
||||
if (!isValid3pidInvite(event)) {
|
||||
return () => _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
|
||||
senderName,
|
||||
targetDisplayName: event.getPrevContent().display_name || _t("Someone"),
|
||||
});
|
||||
}
|
||||
|
||||
return () => _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
|
||||
senderName,
|
||||
targetDisplayName: event.getContent().display_name,
|
||||
});
|
||||
}
|
||||
|
||||
function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
switch (event.getContent().history_visibility) {
|
||||
case 'invited':
|
||||
return () => _t('%(senderName)s made future room history visible to all room members, '
|
||||
+ 'from the point they are invited.', { senderName });
|
||||
case 'joined':
|
||||
return () => _t('%(senderName)s made future room history visible to all room members, '
|
||||
+ 'from the point they joined.', { senderName });
|
||||
case 'shared':
|
||||
return () => _t('%(senderName)s made future room history visible to all room members.', { senderName });
|
||||
case 'world_readable':
|
||||
return () => _t('%(senderName)s made future room history visible to anyone.', { senderName });
|
||||
default:
|
||||
return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
|
||||
senderName,
|
||||
visibility: event.getContent().history_visibility,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Currently will only display a change if a user's power level is changed
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const previousUserDefault = event.getPrevContent().users_default || 0;
|
||||
const currentUserDefault = event.getContent().users_default || 0;
|
||||
// Construct set of userIds
|
||||
const users = [];
|
||||
Object.keys(event.getContent().users).forEach(
|
||||
(userId) => {
|
||||
if (users.indexOf(userId) === -1) users.push(userId);
|
||||
},
|
||||
);
|
||||
Object.keys(event.getPrevContent().users).forEach(
|
||||
(userId) => {
|
||||
if (users.indexOf(userId) === -1) users.push(userId);
|
||||
},
|
||||
);
|
||||
const diffs = [];
|
||||
users.forEach((userId) => {
|
||||
// Previous power level
|
||||
let from = event.getPrevContent().users[userId];
|
||||
if (!Number.isInteger(from)) {
|
||||
from = previousUserDefault;
|
||||
}
|
||||
// Current power level
|
||||
let to = event.getContent().users[userId];
|
||||
if (!Number.isInteger(to)) {
|
||||
to = currentUserDefault;
|
||||
}
|
||||
if (from === previousUserDefault && to === currentUserDefault) { return; }
|
||||
if (to !== from) {
|
||||
diffs.push({ userId, from, to });
|
||||
}
|
||||
});
|
||||
if (!diffs.length) {
|
||||
return null;
|
||||
}
|
||||
// XXX: This is also surely broken for i18n
|
||||
return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', {
|
||||
senderName,
|
||||
powerLevelDiffText: diffs.map(diff =>
|
||||
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
|
||||
userId: diff.userId,
|
||||
fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
|
||||
toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
|
||||
}),
|
||||
).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
const onPinnedOrUnpinnedMessageClick = (messageId: string, roomId: string): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: messageId,
|
||||
highlighted: true,
|
||||
room_id: roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const onPinnedMessagesClick = (): void => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.PinnedMessages,
|
||||
allowClose: false,
|
||||
});
|
||||
};
|
||||
|
||||
function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null {
|
||||
if (!SettingsStore.getValue("feature_pinning")) return null;
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
const pinned = event.getContent().pinned ?? [];
|
||||
const previouslyPinned = event.getPrevContent().pinned ?? [];
|
||||
const newlyPinned = pinned.filter(item => previouslyPinned.indexOf(item) < 0);
|
||||
const newlyUnpinned = previouslyPinned.filter(item => pinned.indexOf(item) < 0);
|
||||
|
||||
if (newlyPinned.length === 1 && newlyUnpinned.length === 0) {
|
||||
// A single message was pinned, include a link to that message.
|
||||
if (allowJSX) {
|
||||
const messageId = newlyPinned.pop();
|
||||
|
||||
return () => (
|
||||
<span>
|
||||
{ _t(
|
||||
"%(senderName)s pinned <a>a message</a> to this room. See all <b>pinned messages</b>.",
|
||||
{ senderName },
|
||||
{
|
||||
"a": (sub) =>
|
||||
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
|
||||
{ sub }
|
||||
</a>,
|
||||
"b": (sub) =>
|
||||
<a onClick={onPinnedMessagesClick}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return () => _t("%(senderName)s pinned a message to this room. See all pinned messages.", { senderName });
|
||||
}
|
||||
|
||||
if (newlyUnpinned.length === 1 && newlyPinned.length === 0) {
|
||||
// A single message was unpinned, include a link to that message.
|
||||
if (allowJSX) {
|
||||
const messageId = newlyUnpinned.pop();
|
||||
|
||||
return () => (
|
||||
<span>
|
||||
{ _t(
|
||||
"%(senderName)s unpinned <a>a message</a> from this room. See all <b>pinned messages</b>.",
|
||||
{ senderName },
|
||||
{
|
||||
"a": (sub) =>
|
||||
<a onClick={(e) => onPinnedOrUnpinnedMessageClick(messageId, roomId)}>
|
||||
{ sub }
|
||||
</a>,
|
||||
"b": (sub) =>
|
||||
<a onClick={onPinnedMessagesClick}>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return () => _t("%(senderName)s unpinned a message from this room. See all pinned messages.", { senderName });
|
||||
}
|
||||
|
||||
if (allowJSX) {
|
||||
return () => (
|
||||
<span>
|
||||
{ _t(
|
||||
"%(senderName)s changed the <a>pinned messages</a> for the room.",
|
||||
{ senderName },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
|
||||
}
|
||||
|
||||
function textForWidgetEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
|
||||
const { name, type, url } = event.getContent() || {};
|
||||
|
||||
let widgetName = name || prevName || type || prevType || '';
|
||||
// Apply sentence case to widget name
|
||||
if (widgetName && widgetName.length > 0) {
|
||||
widgetName = widgetName[0].toUpperCase() + widgetName.slice(1);
|
||||
}
|
||||
|
||||
// If the widget was removed, its content should be {}, but this is sufficiently
|
||||
// equivalent to that condition.
|
||||
if (url) {
|
||||
if (prevUrl) {
|
||||
return () => _t('%(widgetName)s widget modified by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
} else {
|
||||
return () => _t('%(widgetName)s widget added by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return () => _t('%(widgetName)s widget removed by %(senderName)s', {
|
||||
widgetName, senderName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { entity: prevEntity } = event.getPrevContent();
|
||||
const { entity, recommendation, reason } = event.getContent();
|
||||
|
||||
// Rule removed
|
||||
if (!entity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s removed the rule banning users matching %(glob)s",
|
||||
{ senderName, glob: prevEntity });
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s removed the rule banning rooms matching %(glob)s",
|
||||
{ senderName, glob: prevEntity });
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s removed the rule banning servers matching %(glob)s",
|
||||
{ senderName, glob: prevEntity });
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something, but we shouldn't end up here.
|
||||
return () => _t("%(senderName)s removed a ban rule matching %(glob)s", { senderName, glob: prevEntity });
|
||||
}
|
||||
|
||||
// Invalid rule
|
||||
if (!recommendation || !reason) return () => _t(`%(senderName)s updated an invalid ban rule`, { senderName });
|
||||
|
||||
// Rule updated
|
||||
if (entity === prevEntity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return () => _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
}
|
||||
|
||||
// New rule
|
||||
if (!prevEntity) {
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return () => _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s",
|
||||
{ senderName, glob: entity, reason });
|
||||
}
|
||||
|
||||
// else the entity !== prevEntity - count as a removal & add
|
||||
if (USER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t(
|
||||
"%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
|
||||
);
|
||||
} else if (ROOM_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t(
|
||||
"%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
|
||||
);
|
||||
} else if (SERVER_RULE_TYPES.includes(event.getType())) {
|
||||
return () => _t(
|
||||
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " +
|
||||
"%(newGlob)s for %(reason)s",
|
||||
{ senderName, oldGlob: prevEntity, newGlob: entity, reason },
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown type. We'll say something but we shouldn't end up here.
|
||||
return () => _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " +
|
||||
"for %(reason)s", { senderName, oldGlob: prevEntity, newGlob: entity, reason });
|
||||
}
|
||||
|
||||
interface IHandlers {
|
||||
[type: string]:
|
||||
(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
|
||||
(() => string | JSX.Element | null);
|
||||
}
|
||||
|
||||
const handlers: IHandlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.sticker': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
};
|
||||
|
||||
const stateHandlers: IHandlers = {
|
||||
'm.room.canonical_alias': textForCanonicalAliasEvent,
|
||||
'm.room.name': textForRoomNameEvent,
|
||||
'm.room.topic': textForTopicEvent,
|
||||
'm.room.member': textForMemberEvent,
|
||||
"m.room.avatar": textForRoomAvatarEvent,
|
||||
'm.room.third_party_invite': textForThreePidInviteEvent,
|
||||
'm.room.history_visibility': textForHistoryVisibilityEvent,
|
||||
'm.room.power_levels': textForPowerEvent,
|
||||
'm.room.pinned_events': textForPinnedEvent,
|
||||
'm.room.server_acl': textForServerACLEvent,
|
||||
'm.room.tombstone': textForTombstoneEvent,
|
||||
'm.room.join_rules': textForJoinRulesEvent,
|
||||
'm.room.guest_access': textForGuestAccessEvent,
|
||||
'm.room.related_groups': textForRelatedGroupsEvent,
|
||||
|
||||
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
|
||||
'im.vector.modular.widgets': textForWidgetEvent,
|
||||
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
|
||||
};
|
||||
|
||||
// Add all the Mjolnir stuff to the renderer
|
||||
for (const evType of ALL_RULE_TYPES) {
|
||||
stateHandlers[evType] = textForMjolnirEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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, 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, showHiddenEvents)?.() || '';
|
||||
}
|
458
src/Tinter.js
458
src/Tinter.js
|
@ -1,458 +0,0 @@
|
|||
/*
|
||||
Copyright 2015 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
const DEBUG = 0;
|
||||
|
||||
// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
|
||||
function colorToRgb(color) {
|
||||
if (!color) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
if (color[0] === '#') {
|
||||
color = color.slice(1);
|
||||
if (color.length === 3) {
|
||||
color = color[0] + color[0] +
|
||||
color[1] + color[1] +
|
||||
color[2] + color[2];
|
||||
}
|
||||
const val = parseInt(color, 16);
|
||||
const r = (val >> 16) & 255;
|
||||
const g = (val >> 8) & 255;
|
||||
const b = val & 255;
|
||||
return [r, g, b];
|
||||
} else {
|
||||
const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
|
||||
if (match) {
|
||||
return [
|
||||
parseInt(match[1]),
|
||||
parseInt(match[2]),
|
||||
parseInt(match[3]),
|
||||
];
|
||||
}
|
||||
}
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
// utility to turn [red,green,blue] into #rrggbb
|
||||
function rgbToColor(rgb) {
|
||||
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
|
||||
return '#' + (0x1000000 + val).toString(16).slice(1);
|
||||
}
|
||||
|
||||
class Tinter {
|
||||
constructor() {
|
||||
// The default colour keys to be replaced as referred to in CSS
|
||||
// (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor)
|
||||
this.keyRgb = [
|
||||
"rgb(118, 207, 166)", // Vector Green
|
||||
"rgb(234, 245, 240)", // Vector Light Green
|
||||
"rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
|
||||
];
|
||||
|
||||
// Some algebra workings for calculating the tint % of Vector Green & Light Green
|
||||
// x * 118 + (1 - x) * 255 = 234
|
||||
// x * 118 + 255 - 255 * x = 234
|
||||
// x * 118 - x * 255 = 234 - 255
|
||||
// (255 - 118) x = 255 - 234
|
||||
// x = (255 - 234) / (255 - 118) = 0.16
|
||||
|
||||
// The colour keys to be replaced as referred to in SVGs
|
||||
this.keyHex = [
|
||||
"#76CFA6", // Vector Green
|
||||
"#EAF5F0", // Vector Light Green
|
||||
"#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
|
||||
"#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
|
||||
"#000000", // black lowlights of the SVGs (for switching to dark theme)
|
||||
];
|
||||
|
||||
// track the replacement colours actually being used
|
||||
// defaults to our keys.
|
||||
this.colors = [
|
||||
this.keyHex[0],
|
||||
this.keyHex[1],
|
||||
this.keyHex[2],
|
||||
this.keyHex[3],
|
||||
this.keyHex[4],
|
||||
];
|
||||
|
||||
// track the most current tint request inputs (which may differ from the
|
||||
// end result stored in this.colors
|
||||
this.currentTint = [
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
];
|
||||
|
||||
this.cssFixups = [
|
||||
// { theme: {
|
||||
// style: a style object that should be fixed up taken from a stylesheet
|
||||
// attr: name of the attribute to be clobbered, e.g. 'color'
|
||||
// index: ordinal of primary, secondary or tertiary
|
||||
// },
|
||||
// }
|
||||
];
|
||||
|
||||
// CSS attributes to be fixed up
|
||||
this.cssAttrs = [
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"borderColor",
|
||||
"borderTopColor",
|
||||
"borderBottomColor",
|
||||
"borderLeftColor",
|
||||
];
|
||||
|
||||
this.svgAttrs = [
|
||||
"fill",
|
||||
"stroke",
|
||||
];
|
||||
|
||||
// List of functions to call when the tint changes.
|
||||
this.tintables = [];
|
||||
|
||||
// the currently loaded theme (if any)
|
||||
this.theme = undefined;
|
||||
|
||||
// whether to force a tint (e.g. after changing theme)
|
||||
this.forceTint = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to fire when the tint changes.
|
||||
* This is used to rewrite the tintable SVGs with the new tint.
|
||||
*
|
||||
* It's not possible to unregister a tintable callback. So this can only be
|
||||
* used to register a static callback. If a set of tintables will change
|
||||
* over time then the best bet is to register a single callback for the
|
||||
* entire set.
|
||||
*
|
||||
* To ensure the tintable work happens at least once, it is also called as
|
||||
* part of registration.
|
||||
*
|
||||
* @param {Function} tintable Function to call when the tint changes.
|
||||
*/
|
||||
registerTintable(tintable) {
|
||||
this.tintables.push(tintable);
|
||||
tintable();
|
||||
}
|
||||
|
||||
getKeyRgb() {
|
||||
return this.keyRgb;
|
||||
}
|
||||
|
||||
tint(primaryColor, secondaryColor, tertiaryColor) {
|
||||
return;
|
||||
// eslint-disable-next-line no-unreachable
|
||||
this.currentTint[0] = primaryColor;
|
||||
this.currentTint[1] = secondaryColor;
|
||||
this.currentTint[2] = tertiaryColor;
|
||||
|
||||
this.calcCssFixups();
|
||||
|
||||
if (DEBUG) {
|
||||
console.log("Tinter.tint(" + primaryColor + ", " +
|
||||
secondaryColor + ", " +
|
||||
tertiaryColor + ")");
|
||||
}
|
||||
|
||||
if (!primaryColor) {
|
||||
primaryColor = this.keyRgb[0];
|
||||
secondaryColor = this.keyRgb[1];
|
||||
tertiaryColor = this.keyRgb[2];
|
||||
}
|
||||
|
||||
if (!secondaryColor) {
|
||||
const x = 0.16; // average weighting factor calculated from vector green & light green
|
||||
const rgb = colorToRgb(primaryColor);
|
||||
rgb[0] = x * rgb[0] + (1 - x) * 255;
|
||||
rgb[1] = x * rgb[1] + (1 - x) * 255;
|
||||
rgb[2] = x * rgb[2] + (1 - x) * 255;
|
||||
secondaryColor = rgbToColor(rgb);
|
||||
}
|
||||
|
||||
if (!tertiaryColor) {
|
||||
const x = 0.19;
|
||||
const rgb1 = colorToRgb(primaryColor);
|
||||
const rgb2 = colorToRgb(secondaryColor);
|
||||
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
|
||||
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
|
||||
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
|
||||
tertiaryColor = rgbToColor(rgb1);
|
||||
}
|
||||
|
||||
if (this.forceTint == false &&
|
||||
this.colors[0] === primaryColor &&
|
||||
this.colors[1] === secondaryColor &&
|
||||
this.colors[2] === tertiaryColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.forceTint = false;
|
||||
|
||||
this.colors[0] = primaryColor;
|
||||
this.colors[1] = secondaryColor;
|
||||
this.colors[2] = tertiaryColor;
|
||||
|
||||
if (DEBUG) {
|
||||
console.log("Tinter.tint final: (" + primaryColor + ", " +
|
||||
secondaryColor + ", " +
|
||||
tertiaryColor + ")");
|
||||
}
|
||||
|
||||
// go through manually fixing up the stylesheets.
|
||||
this.applyCssFixups();
|
||||
|
||||
// tell all the SVGs to go fix themselves up
|
||||
// we don't do this as a dispatch otherwise it will visually lag
|
||||
this.tintables.forEach(function(tintable) {
|
||||
tintable();
|
||||
});
|
||||
}
|
||||
|
||||
tintSvgWhite(whiteColor) {
|
||||
this.currentTint[3] = whiteColor;
|
||||
|
||||
if (!whiteColor) {
|
||||
whiteColor = this.colors[3];
|
||||
}
|
||||
if (this.colors[3] === whiteColor) {
|
||||
return;
|
||||
}
|
||||
this.colors[3] = whiteColor;
|
||||
this.tintables.forEach(function(tintable) {
|
||||
tintable();
|
||||
});
|
||||
}
|
||||
|
||||
tintSvgBlack(blackColor) {
|
||||
this.currentTint[4] = blackColor;
|
||||
|
||||
if (!blackColor) {
|
||||
blackColor = this.colors[4];
|
||||
}
|
||||
if (this.colors[4] === blackColor) {
|
||||
return;
|
||||
}
|
||||
this.colors[4] = blackColor;
|
||||
this.tintables.forEach(function(tintable) {
|
||||
tintable();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
|
||||
// update keyRgb from the current theme CSS itself, if it defines it
|
||||
if (document.getElementById('mx_theme_accentColor')) {
|
||||
this.keyRgb[0] = window.getComputedStyle(
|
||||
document.getElementById('mx_theme_accentColor')).color;
|
||||
}
|
||||
if (document.getElementById('mx_theme_secondaryAccentColor')) {
|
||||
this.keyRgb[1] = window.getComputedStyle(
|
||||
document.getElementById('mx_theme_secondaryAccentColor')).color;
|
||||
}
|
||||
if (document.getElementById('mx_theme_tertiaryAccentColor')) {
|
||||
this.keyRgb[2] = window.getComputedStyle(
|
||||
document.getElementById('mx_theme_tertiaryAccentColor')).color;
|
||||
}
|
||||
|
||||
this.calcCssFixups();
|
||||
this.forceTint = true;
|
||||
|
||||
this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]);
|
||||
|
||||
if (theme === 'dark') {
|
||||
// abuse the tinter to change all the SVG's #fff to #2d2d2d
|
||||
// XXX: obviously this shouldn't be hardcoded here.
|
||||
this.tintSvgWhite('#2d2d2d');
|
||||
this.tintSvgBlack('#dddddd');
|
||||
} else {
|
||||
this.tintSvgWhite('#ffffff');
|
||||
this.tintSvgBlack('#000000');
|
||||
}
|
||||
}
|
||||
|
||||
calcCssFixups() {
|
||||
// cache our fixups
|
||||
if (this.cssFixups[this.theme]) return;
|
||||
|
||||
if (DEBUG) {
|
||||
console.debug("calcCssFixups start for " + this.theme + " (checking " +
|
||||
document.styleSheets.length +
|
||||
" stylesheets)");
|
||||
}
|
||||
|
||||
this.cssFixups[this.theme] = [];
|
||||
|
||||
for (let i = 0; i < document.styleSheets.length; i++) {
|
||||
const ss = document.styleSheets[i];
|
||||
try {
|
||||
if (!ss) continue; // well done safari >:(
|
||||
// Chromium apparently sometimes returns null here; unsure why.
|
||||
// see $14534907369972FRXBx:matrix.org in HQ
|
||||
// ...ah, it's because there's a third party extension like
|
||||
// privacybadger inserting its own stylesheet in there with a
|
||||
// resource:// URI or something which results in a XSS error.
|
||||
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
|
||||
// ...except some browsers apparently return stylesheets without
|
||||
// hrefs, which we have no choice but ignore right now
|
||||
|
||||
// XXX seriously? we are hardcoding the name of vector's CSS file in
|
||||
// here?
|
||||
//
|
||||
// Why do we need to limit it to vector's CSS file anyway - if there
|
||||
// are other CSS files affecting the doc don't we want to apply the
|
||||
// same transformations to them?
|
||||
//
|
||||
// Iterating through the CSS looking for matches to hack on feels
|
||||
// pretty horrible anyway. And what if the application skin doesn't use
|
||||
// Vector Green as its primary color?
|
||||
// --richvdh
|
||||
|
||||
// Yes, tinting assumes that you are using the Element skin for now.
|
||||
// The right solution will be to move the CSS over to react-sdk.
|
||||
// And yes, the default assets for the base skin might as well use
|
||||
// Vector Green as any other colour.
|
||||
// --matthew
|
||||
|
||||
// stylesheets we don't have permission to access (eg. ones from extensions) have a null
|
||||
// href and will throw exceptions if we try to access their rules.
|
||||
if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
|
||||
if (ss.disabled) continue;
|
||||
if (!ss.cssRules) continue;
|
||||
|
||||
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
|
||||
|
||||
for (let j = 0; j < ss.cssRules.length; j++) {
|
||||
const rule = ss.cssRules[j];
|
||||
if (!rule.style) continue;
|
||||
if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
|
||||
for (let k = 0; k < this.cssAttrs.length; k++) {
|
||||
const attr = this.cssAttrs[k];
|
||||
for (let l = 0; l < this.keyRgb.length; l++) {
|
||||
if (rule.style[attr] === this.keyRgb[l]) {
|
||||
this.cssFixups[this.theme].push({
|
||||
style: rule.style,
|
||||
attr: attr,
|
||||
index: l,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Catch any random exceptions that happen here: all sorts of things can go
|
||||
// wrong with this (nulls, SecurityErrors) and mostly it's for other
|
||||
// stylesheets that we don't want to proces anyway. We should not propagate an
|
||||
// exception out since this will cause the app to fail to start.
|
||||
console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e);
|
||||
}
|
||||
}
|
||||
if (DEBUG) {
|
||||
console.log("calcCssFixups end (" +
|
||||
this.cssFixups[this.theme].length +
|
||||
" fixups)");
|
||||
}
|
||||
}
|
||||
|
||||
applyCssFixups() {
|
||||
if (DEBUG) {
|
||||
console.log("applyCssFixups start (" +
|
||||
this.cssFixups[this.theme].length +
|
||||
" fixups)");
|
||||
}
|
||||
for (let i = 0; i < this.cssFixups[this.theme].length; i++) {
|
||||
const cssFixup = this.cssFixups[this.theme][i];
|
||||
try {
|
||||
cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index];
|
||||
} catch (e) {
|
||||
// Firefox Quantum explodes if you manually edit the CSS in the
|
||||
// inspector and then try to do a tint, as apparently all the
|
||||
// fixups are then stale.
|
||||
console.error("Failed to apply cssFixup in Tinter! ", e.name);
|
||||
}
|
||||
}
|
||||
if (DEBUG) console.log("applyCssFixups end");
|
||||
}
|
||||
|
||||
// XXX: we could just move this all into TintableSvg, but as it's so similar
|
||||
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
|
||||
// keeping it here for now.
|
||||
calcSvgFixups(svgs) {
|
||||
// go through manually fixing up SVG colours.
|
||||
// we could do this by stylesheets, but keeping the stylesheets
|
||||
// updated would be a PITA, so just brute-force search for the
|
||||
// key colour; cache the element and apply.
|
||||
|
||||
if (DEBUG) console.log("calcSvgFixups start for " + svgs);
|
||||
const fixups = [];
|
||||
for (let i = 0; i < svgs.length; i++) {
|
||||
let svgDoc;
|
||||
try {
|
||||
svgDoc = svgs[i].contentDocument;
|
||||
} catch (e) {
|
||||
let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
|
||||
if (e.message) {
|
||||
msg += e.message;
|
||||
}
|
||||
if (e.stack) {
|
||||
msg += ' | stack: ' + e.stack;
|
||||
}
|
||||
console.error(msg);
|
||||
}
|
||||
if (!svgDoc) continue;
|
||||
const tags = svgDoc.getElementsByTagName("*");
|
||||
for (let j = 0; j < tags.length; j++) {
|
||||
const tag = tags[j];
|
||||
for (let k = 0; k < this.svgAttrs.length; k++) {
|
||||
const attr = this.svgAttrs[k];
|
||||
for (let l = 0; l < this.keyHex.length; l++) {
|
||||
if (tag.getAttribute(attr) &&
|
||||
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
|
||||
fixups.push({
|
||||
node: tag,
|
||||
attr: attr,
|
||||
index: l,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (DEBUG) console.log("calcSvgFixups end");
|
||||
|
||||
return fixups;
|
||||
}
|
||||
|
||||
applySvgFixups(fixups) {
|
||||
if (DEBUG) console.log("applySvgFixups start for " + fixups);
|
||||
for (let i = 0; i < fixups.length; i++) {
|
||||
const svgFixup = fixups[i];
|
||||
svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
|
||||
}
|
||||
if (DEBUG) console.log("applySvgFixups end");
|
||||
}
|
||||
}
|
||||
|
||||
if (global.singletonTinter === undefined) {
|
||||
global.singletonTinter = new Tinter();
|
||||
}
|
||||
export default global.singletonTinter;
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,9 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import shouldHideEvent from './shouldHideEvent';
|
||||
import {haveTileForEvent} from "./components/views/rooms/EventTile";
|
||||
import { haveTileForEvent } from "./components/views/rooms/EventTile";
|
||||
|
||||
/**
|
||||
* Returns true iff this event arriving in a room should affect the room's
|
||||
|
@ -25,28 +29,27 @@ import {haveTileForEvent} from "./components/views/rooms/EventTile";
|
|||
* @param {Object} ev The event
|
||||
* @returns {boolean} True if the given event should affect the unread message count
|
||||
*/
|
||||
export function eventTriggersUnreadCount(ev) {
|
||||
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.member') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.third_party_invite') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.aliases' || ev.getType() == 'm.room.canonical_alias') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.server_acl') {
|
||||
return false;
|
||||
} else if (ev.isRedacted()) {
|
||||
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
|
||||
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (ev.getType()) {
|
||||
case EventType.RoomMember:
|
||||
case EventType.RoomThirdPartyInvite:
|
||||
case EventType.CallAnswer:
|
||||
case EventType.CallHangup:
|
||||
case EventType.RoomAliases:
|
||||
case EventType.RoomCanonicalAlias:
|
||||
case EventType.RoomServerAcl:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ev.isRedacted()) return false;
|
||||
return haveTileForEvent(ev);
|
||||
}
|
||||
|
||||
export function doesRoomHaveUnreadMessages(room) {
|
||||
export function doesRoomHaveUnreadMessages(room: Room): boolean {
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
|
||||
// get the most recent read receipt sent by our account.
|
||||
|
@ -60,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room) {
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -191,10 +191,10 @@ export default class UserActivity {
|
|||
this.lastScreenY = event.screenY;
|
||||
}
|
||||
|
||||
dis.dispatch({action: 'user_activity'});
|
||||
dis.dispatch({ action: 'user_activity' });
|
||||
if (!this.activeNowTimeout.isRunning()) {
|
||||
this.activeNowTimeout.start();
|
||||
dis.dispatch({action: 'user_activity_start'});
|
||||
dis.dispatch({ action: 'user_activity_start' });
|
||||
|
||||
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
|
||||
} else {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,43 +15,40 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
const emailRegex = /^\S+@\S+\.\S+$/;
|
||||
|
||||
const mxUserIdRegex = /^@\S+:\S+$/;
|
||||
const mxRoomIdRegex = /^!\S+:\S+$/;
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
export const addressTypes = [
|
||||
'mx-user-id', 'mx-room-id', 'email',
|
||||
];
|
||||
export enum AddressType {
|
||||
Email = "email",
|
||||
MatrixUserId = "mx-user-id",
|
||||
MatrixRoomId = "mx-room-id",
|
||||
}
|
||||
|
||||
export const addressTypes = [AddressType.Email, AddressType.MatrixRoomId, AddressType.MatrixUserId];
|
||||
|
||||
// PropType definition for an object describing
|
||||
// an address that can be invited to a room (which
|
||||
// could be a third party identifier or a matrix ID)
|
||||
// along with some additional information about the
|
||||
// address / target.
|
||||
export const UserAddressType = PropTypes.shape({
|
||||
addressType: PropTypes.oneOf(addressTypes).isRequired,
|
||||
address: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string,
|
||||
avatarMxc: PropTypes.string,
|
||||
export interface IUserAddress {
|
||||
addressType: AddressType;
|
||||
address: string;
|
||||
displayName?: string;
|
||||
avatarMxc?: string;
|
||||
// true if the address is known to be a valid address (eg. is a real
|
||||
// user we've seen) or false otherwise (eg. is just an address the
|
||||
// user has entered)
|
||||
isKnown: PropTypes.bool,
|
||||
});
|
||||
isKnown?: boolean;
|
||||
}
|
||||
|
||||
export function getAddressType(inputText) {
|
||||
const isEmailAddress = emailRegex.test(inputText);
|
||||
const isUserId = mxUserIdRegex.test(inputText);
|
||||
const isRoomId = mxRoomIdRegex.test(inputText);
|
||||
|
||||
// sanity check the input for user IDs
|
||||
if (isEmailAddress) {
|
||||
return 'email';
|
||||
} else if (isUserId) {
|
||||
return 'mx-user-id';
|
||||
} else if (isRoomId) {
|
||||
return 'mx-room-id';
|
||||
export function getAddressType(inputText: string): AddressType | null {
|
||||
if (emailRegex.test(inputText)) {
|
||||
return AddressType.Email;
|
||||
} else if (mxUserIdRegex.test(inputText)) {
|
||||
return AddressType.MatrixUserId;
|
||||
} else if (mxRoomIdRegex.test(inputText)) {
|
||||
return AddressType.MatrixRoomId;
|
||||
} else {
|
||||
return null;
|
||||
}
|
|
@ -20,11 +20,15 @@ import DMRoomMap from "./utils/DMRoomMap";
|
|||
import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
// Functions for mapping virtual users & rooms. Currently the only lookup
|
||||
// is sip virtual: there could be others in the future.
|
||||
|
||||
export default class VoipUserMapper {
|
||||
private virtualRoomIdCache = new Set<string>();
|
||||
// We store mappings of virtual -> native room IDs here until the local echo for the
|
||||
// account data arrives.
|
||||
private virtualToNativeRoomIdCache = new Map<string, string>();
|
||||
|
||||
public static sharedInstance(): VoipUserMapper {
|
||||
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
|
||||
|
@ -49,10 +53,20 @@ export default class VoipUserMapper {
|
|||
native_room: roomId,
|
||||
});
|
||||
|
||||
this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId);
|
||||
|
||||
return virtualRoomId;
|
||||
}
|
||||
|
||||
public nativeRoomForVirtualRoom(roomId: string): string {
|
||||
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
|
||||
if (cachedNativeRoomId) {
|
||||
logger.log(
|
||||
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
|
||||
);
|
||||
return cachedNativeRoomId;
|
||||
}
|
||||
|
||||
const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
|
||||
if (!virtualRoom) return null;
|
||||
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
|
||||
|
@ -67,7 +81,7 @@ export default class VoipUserMapper {
|
|||
public isVirtualRoom(room: Room): boolean {
|
||||
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
|
||||
|
||||
if (this.virtualRoomIdCache.has(room.roomId)) return true;
|
||||
if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
|
||||
|
||||
// also look in the create event for the claimed native room ID, which is the only
|
||||
// way we can recognise a virtual room we've created when it first arrives down
|
||||
|
@ -86,7 +100,7 @@ export default class VoipUserMapper {
|
|||
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
|
||||
|
||||
const inviterId = invitedRoom.getDMInviter();
|
||||
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
|
||||
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
|
||||
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
|
@ -110,7 +124,7 @@ export default class VoipUserMapper {
|
|||
|
||||
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
|
||||
// in however long it takes for the echo of setAccountData to come down the sync
|
||||
this.virtualRoomIdCache.add(invitedRoom.roomId);
|
||||
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
|
||||
import {MatrixClientPeg} from "./MatrixClientPeg";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] {
|
||||
|
@ -61,7 +61,7 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str
|
|||
if (whoIsTyping.length === 0) {
|
||||
return '';
|
||||
} else if (whoIsTyping.length === 1) {
|
||||
return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name});
|
||||
return _t('%(displayName)s is typing …', { displayName: whoIsTyping[0].name });
|
||||
}
|
||||
|
||||
const names = whoIsTyping.map(m => m.name);
|
||||
|
@ -73,6 +73,6 @@ export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): str
|
|||
});
|
||||
} else {
|
||||
const lastPerson = names.pop();
|
||||
return _t('%(names)s and %(lastPerson)s are typing …', {names: names.join(', '), lastPerson: lastPerson});
|
||||
return _t('%(names)s and %(lastPerson)s are typing …', { names: names.join(', '), lastPerson: lastPerson });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,10 @@ limitations under the License.
|
|||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import * as sdk from "../index";
|
||||
import Modal from "../Modal";
|
||||
import { _t, _td } from "../languageHandler";
|
||||
import {isMac, Key} from "../Keyboard";
|
||||
import { isMac, Key } from "../Keyboard";
|
||||
import InfoDialog from "../components/views/dialogs/InfoDialog";
|
||||
|
||||
// TS: once languageHandler is TS we can probably inline this into the enum
|
||||
_td("Navigation");
|
||||
|
@ -57,6 +57,8 @@ export enum Modifiers {
|
|||
|
||||
// Meta-modifier: isMac ? CMD : CONTROL
|
||||
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
|
||||
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
|
||||
export const DIGITS = "digits";
|
||||
|
||||
interface IKeybind {
|
||||
modifiers?: Modifiers[];
|
||||
|
@ -161,7 +163,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
|
|||
modifiers: [Modifiers.SHIFT],
|
||||
key: Key.PAGE_UP,
|
||||
}],
|
||||
description: _td("Jump to oldest unread message"),
|
||||
description: _td("Jump to oldest unread message"),
|
||||
}, {
|
||||
keybinds: [{
|
||||
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],
|
||||
|
@ -319,6 +321,7 @@ const alternateKeyName: Record<string, string> = {
|
|||
[Key.SPACE]: _td("Space"),
|
||||
[Key.HOME]: _td("Home"),
|
||||
[Key.END]: _td("End"),
|
||||
[DIGITS]: _td("[number]"),
|
||||
};
|
||||
const keyIcon: Record<string, string> = {
|
||||
[Key.ARROW_UP]: "↑",
|
||||
|
@ -329,7 +332,7 @@ const keyIcon: Record<string, string> = {
|
|||
|
||||
const Shortcut: React.FC<{
|
||||
shortcut: IShortcut;
|
||||
}> = ({shortcut}) => {
|
||||
}> = ({ shortcut }) => {
|
||||
const classes = classNames({
|
||||
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
|
||||
});
|
||||
|
@ -367,12 +370,11 @@ export const toggleDialog = () => {
|
|||
const sections = categoryOrder.map(category => {
|
||||
const list = shortcuts[category];
|
||||
return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
|
||||
<h3>{_t(category)}</h3>
|
||||
<div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
|
||||
<h3>{ _t(category) }</h3>
|
||||
<div>{ list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />) }</div>
|
||||
</div>;
|
||||
});
|
||||
|
||||
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
|
||||
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
|
||||
className: "mx_KeyboardShortcutsDialog",
|
||||
title: _t("Keyboard Shortcuts"),
|
||||
|
|
|
@ -26,8 +26,8 @@ import React, {
|
|||
Dispatch,
|
||||
} from "react";
|
||||
|
||||
import {Key} from "../Keyboard";
|
||||
import {FocusHandler, Ref} from "./roving/types";
|
||||
import { Key } from "../Keyboard";
|
||||
import { FocusHandler, Ref } from "./roving/types";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
|
@ -150,38 +150,68 @@ const reducer = (state: IState, action: IAction) => {
|
|||
|
||||
interface IProps {
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
children(renderProps: {
|
||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||
});
|
||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||
}
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
});
|
||||
|
||||
const context = useMemo<IContext>(() => ({state, dispatch}), [state]);
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback((ev) => {
|
||||
let handled = false;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.END:
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx > 0) {
|
||||
context.state.refs[idx - 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_DOWN:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx < context.state.refs.length - 1) {
|
||||
context.state.refs[idx + 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -193,10 +223,10 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEn
|
|||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{ children({onKeyDownHandler}) }
|
||||
{ children({ onKeyDownHandler }) }
|
||||
</RovingTabIndexContext.Provider>;
|
||||
};
|
||||
|
||||
|
@ -218,13 +248,13 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref]
|
|||
useLayoutEffect(() => {
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: {ref},
|
||||
payload: { ref },
|
||||
});
|
||||
// teardown
|
||||
return () => {
|
||||
context.dispatch({
|
||||
type: Type.Unregister,
|
||||
payload: {ref},
|
||||
payload: { ref },
|
||||
});
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
@ -232,7 +262,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref]
|
|||
const onFocus = useCallback(() => {
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: {ref},
|
||||
payload: { ref },
|
||||
});
|
||||
}, [ref, context]);
|
||||
|
||||
|
@ -241,6 +271,6 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref]
|
|||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export {RovingTabIndexWrapper} from "./roving/RovingTabIndexWrapper";
|
||||
export {RovingAccessibleButton} from "./roving/RovingAccessibleButton";
|
||||
export {RovingAccessibleTooltipButton} from "./roving/RovingAccessibleTooltipButton";
|
||||
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
||||
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
||||
export { RovingAccessibleTooltipButton } from "./roving/RovingAccessibleTooltipButton";
|
||||
|
|
|
@ -16,8 +16,8 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import {IState, RovingTabIndexProvider} from "./RovingTabIndex";
|
||||
import {Key} from "../Keyboard";
|
||||
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
|
||||
import { Key } from "../Keyboard";
|
||||
|
||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
|||
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
|
||||
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||
const Toolbar: React.FC<IProps> = ({children, ...props}) => {
|
||||
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
|
@ -62,9 +62,9 @@ const Toolbar: React.FC<IProps> = ({children, ...props}) => {
|
|||
};
|
||||
|
||||
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||
{({onKeyDownHandler}) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
|
||||
{ ({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
|
||||
{ children }
|
||||
</div>}
|
||||
</div> }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ interface IProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
|
||||
export const MenuGroup: React.FC<IProps> = ({ children, label, ...props }) => {
|
||||
return <div {...props} role="group" aria-label={label}>
|
||||
{ children }
|
||||
</div>;
|
||||
|
|
|
@ -27,7 +27,7 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem: React.FC<IProps> = ({children, label, tooltip, ...props}) => {
|
||||
export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props }) => {
|
||||
const ariaLabel = props["aria-label"] || label;
|
||||
|
||||
if (tooltip) {
|
||||
|
|
|
@ -26,7 +26,7 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
|
||||
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
|
|
|
@ -26,7 +26,7 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
|
||||
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import {Key} from "../../Keyboard";
|
||||
import { Key } from "../../Keyboard";
|
||||
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
||||
|
@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a styled role=menuitemcheckbox
|
||||
export const StyledMenuItemCheckbox: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
|
||||
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import {Key} from "../../Keyboard";
|
||||
import { Key } from "../../Keyboard";
|
||||
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
||||
|
@ -28,7 +28,7 @@ interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
|||
}
|
||||
|
||||
// Semantic component for representing a styled role=menuitemradio
|
||||
export const StyledMenuItemRadio: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
|
||||
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -17,15 +17,15 @@ limitations under the License.
|
|||
import React from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import {useRovingTabIndex} from "../RovingTabIndex";
|
||||
import {Ref} from "./types";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { Ref } from "./types";
|
||||
|
||||
interface IProps extends Omit<React.ComponentProps<typeof AccessibleButton>, "onFocus" | "inputRef" | "tabIndex"> {
|
||||
inputRef?: Ref;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
|
||||
export const RovingAccessibleButton: React.FC<IProps> = ({inputRef, ...props}) => {
|
||||
export const RovingAccessibleButton: React.FC<IProps> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
|
||||
};
|
||||
|
|
|
@ -17,8 +17,8 @@ limitations under the License.
|
|||
import React from "react";
|
||||
|
||||
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
|
||||
import {useRovingTabIndex} from "../RovingTabIndex";
|
||||
import {Ref} from "./types";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { Ref } from "./types";
|
||||
|
||||
type ATBProps = React.ComponentProps<typeof AccessibleTooltipButton>;
|
||||
interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
|
||||
|
@ -26,7 +26,7 @@ interface IProps extends Omit<ATBProps, "onFocus" | "inputRef" | "tabIndex"> {
|
|||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components.
|
||||
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({inputRef, ...props}) => {
|
||||
export const RovingAccessibleTooltipButton: React.FC<IProps> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
|
||||
};
|
||||
|
|
|
@ -16,8 +16,8 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import {useRovingTabIndex} from "../RovingTabIndex";
|
||||
import {FocusHandler, Ref} from "./types";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { FocusHandler, Ref } from "./types";
|
||||
|
||||
interface IProps {
|
||||
inputRef?: Ref;
|
||||
|
@ -29,7 +29,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({children, inputRef}) => {
|
||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return children({onFocus, isActive, ref});
|
||||
return children({ onFocus, isActive, ref });
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {RefObject} from "react";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type Ref = RefObject<HTMLElement>;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import dis from '../dispatcher/dispatcher';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
|
||||
|
||||
import dis from "../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../dispatcher/payloads";
|
||||
|
||||
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
|
||||
// become dispatches in the same place.
|
||||
|
@ -27,7 +33,7 @@ import dis from '../dispatcher/dispatcher';
|
|||
* @param {string} prevState the previous sync state.
|
||||
* @returns {Object} an action of type MatrixActions.sync.
|
||||
*/
|
||||
function createSyncAction(matrixClient, state, prevState) {
|
||||
function createSyncAction(matrixClient: MatrixClient, state: string, prevState: string): ActionPayload {
|
||||
return {
|
||||
action: 'MatrixActions.sync',
|
||||
state,
|
||||
|
@ -53,7 +59,7 @@ function createSyncAction(matrixClient, state, prevState) {
|
|||
* @param {MatrixEvent} accountDataEvent the account data event.
|
||||
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
|
||||
*/
|
||||
function createAccountDataAction(matrixClient, accountDataEvent) {
|
||||
function createAccountDataAction(matrixClient: MatrixClient, accountDataEvent: MatrixEvent): ActionPayload {
|
||||
return {
|
||||
action: 'MatrixActions.accountData',
|
||||
event: accountDataEvent,
|
||||
|
@ -81,7 +87,11 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
|
|||
* @param {Room} room the room where account data was changed
|
||||
* @returns {RoomAccountDataAction} an action of type MatrixActions.Room.accountData.
|
||||
*/
|
||||
function createRoomAccountDataAction(matrixClient, accountDataEvent, room) {
|
||||
function createRoomAccountDataAction(
|
||||
matrixClient: MatrixClient,
|
||||
accountDataEvent: MatrixEvent,
|
||||
room: Room,
|
||||
): ActionPayload {
|
||||
return {
|
||||
action: 'MatrixActions.Room.accountData',
|
||||
event: accountDataEvent,
|
||||
|
@ -106,7 +116,7 @@ function createRoomAccountDataAction(matrixClient, accountDataEvent, room) {
|
|||
* @param {Room} room the Room that was stored.
|
||||
* @returns {RoomAction} an action of type `MatrixActions.Room`.
|
||||
*/
|
||||
function createRoomAction(matrixClient, room) {
|
||||
function createRoomAction(matrixClient: MatrixClient, room: Room): ActionPayload {
|
||||
return { action: 'MatrixActions.Room', room };
|
||||
}
|
||||
|
||||
|
@ -127,7 +137,7 @@ function createRoomAction(matrixClient, room) {
|
|||
* @param {Room} room the Room whose tags were changed.
|
||||
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
|
||||
*/
|
||||
function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
|
||||
function createRoomTagsAction(matrixClient: MatrixClient, roomTagsEvent: MatrixEvent, room: Room): ActionPayload {
|
||||
return { action: 'MatrixActions.Room.tags', room };
|
||||
}
|
||||
|
||||
|
@ -140,7 +150,7 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
|
|||
* @param {Room} room the room the receipt happened in.
|
||||
* @returns {Object} an action of type MatrixActions.Room.receipt.
|
||||
*/
|
||||
function createRoomReceiptAction(matrixClient, event, room) {
|
||||
function createRoomReceiptAction(matrixClient: MatrixClient, event: MatrixEvent, room: Room): ActionPayload {
|
||||
return {
|
||||
action: 'MatrixActions.Room.receipt',
|
||||
event,
|
||||
|
@ -178,7 +188,17 @@ function createRoomReceiptAction(matrixClient, event, room) {
|
|||
* @param {EventTimeline} data.timeline the timeline being altered.
|
||||
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
|
||||
*/
|
||||
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) {
|
||||
function createRoomTimelineAction(
|
||||
matrixClient: MatrixClient,
|
||||
timelineEvent: MatrixEvent,
|
||||
room: Room,
|
||||
toStartOfTimeline: boolean,
|
||||
removed: boolean,
|
||||
data: {
|
||||
liveEvent: boolean;
|
||||
timeline: EventTimeline;
|
||||
},
|
||||
): ActionPayload {
|
||||
return {
|
||||
action: 'MatrixActions.Room.timeline',
|
||||
event: timelineEvent,
|
||||
|
@ -208,8 +228,13 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi
|
|||
* @param {string} oldMembership the previous membership, can be null.
|
||||
* @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`.
|
||||
*/
|
||||
function createSelfMembershipAction(matrixClient, room, membership, oldMembership) {
|
||||
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership};
|
||||
function createSelfMembershipAction(
|
||||
matrixClient: MatrixClient,
|
||||
room: Room,
|
||||
membership: string,
|
||||
oldMembership: string,
|
||||
): ActionPayload {
|
||||
return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -228,61 +253,65 @@ function createSelfMembershipAction(matrixClient, room, membership, oldMembershi
|
|||
* @param {MatrixEvent} event the matrix event that was decrypted.
|
||||
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
|
||||
*/
|
||||
function createEventDecryptedAction(matrixClient, event) {
|
||||
function createEventDecryptedAction(matrixClient: MatrixClient, event: MatrixEvent): ActionPayload {
|
||||
return { action: 'MatrixActions.Event.decrypted', event };
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
type ActionCreator = (matrixClient: MatrixClient, ...args: any) => ActionPayload;
|
||||
|
||||
// A list of callbacks to call to unregister all listeners added
|
||||
let matrixClientListenersStop: Listener[] = [];
|
||||
|
||||
/**
|
||||
* Start listening to events of type eventName on matrixClient and when they are emitted,
|
||||
* dispatch an action created by the actionCreator function.
|
||||
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
|
||||
* @param {string} eventName the event to listen to on MatrixClient.
|
||||
* @param {function} actionCreator a function that should return an action to dispatch
|
||||
* when given the MatrixClient as an argument as well as
|
||||
* arguments emitted in the MatrixClient event.
|
||||
*/
|
||||
function addMatrixClientListener(matrixClient: MatrixClient, eventName: string, actionCreator: ActionCreator): void {
|
||||
const listener: Listener = (...args) => {
|
||||
const payload = actionCreator(matrixClient, ...args);
|
||||
if (payload) {
|
||||
dis.dispatch(payload, true);
|
||||
}
|
||||
};
|
||||
matrixClient.on(eventName, listener);
|
||||
matrixClientListenersStop.push(() => {
|
||||
matrixClient.removeListener(eventName, listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is responsible for dispatching actions when certain events are emitted by
|
||||
* the given MatrixClient.
|
||||
*/
|
||||
export default {
|
||||
// A list of callbacks to call to unregister all listeners added
|
||||
_matrixClientListenersStop: [],
|
||||
|
||||
/**
|
||||
* Start listening to certain events from the MatrixClient and dispatch actions when
|
||||
* they are emitted.
|
||||
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from
|
||||
*/
|
||||
start(matrixClient) {
|
||||
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
|
||||
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start listening to events of type eventName on matrixClient and when they are emitted,
|
||||
* dispatch an action created by the actionCreator function.
|
||||
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
|
||||
* @param {string} eventName the event to listen to on MatrixClient.
|
||||
* @param {function} actionCreator a function that should return an action to dispatch
|
||||
* when given the MatrixClient as an argument as well as
|
||||
* arguments emitted in the MatrixClient event.
|
||||
*/
|
||||
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
|
||||
const listener = (...args) => {
|
||||
const payload = actionCreator(matrixClient, ...args);
|
||||
if (payload) {
|
||||
dis.dispatch(payload, true);
|
||||
}
|
||||
};
|
||||
matrixClient.on(eventName, listener);
|
||||
this._matrixClientListenersStop.push(() => {
|
||||
matrixClient.removeListener(eventName, listener);
|
||||
});
|
||||
start(matrixClient: MatrixClient) {
|
||||
addMatrixClientListener(matrixClient, 'sync', createSyncAction);
|
||||
addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
|
||||
addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
|
||||
addMatrixClientListener(matrixClient, 'Room', createRoomAction);
|
||||
addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
|
||||
addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
|
||||
addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
|
||||
addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
|
||||
addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop listening to events.
|
||||
*/
|
||||
stop() {
|
||||
this._matrixClientListenersStop.forEach((stopListener) => stopListener());
|
||||
matrixClientListenersStop.forEach((stopListener) => stopListener());
|
||||
matrixClientListenersStop = [];
|
||||
},
|
||||
};
|
|
@ -19,13 +19,13 @@ import { asyncAction } from './actionCreators';
|
|||
import Modal from '../Modal';
|
||||
import * as Rooms from '../Rooms';
|
||||
import { _t } from '../languageHandler';
|
||||
import * as sdk from '../index';
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { AsyncActionPayload } from "../dispatcher/payloads";
|
||||
import RoomListStore from "../stores/room-list/RoomListStore";
|
||||
import { SortAlgorithm } from "../stores/room-list/algorithms/models";
|
||||
import { DefaultTagID } from "../stores/room-list/models";
|
||||
import ErrorDialog from '../components/views/dialogs/ErrorDialog';
|
||||
|
||||
export default class RoomListActions {
|
||||
/**
|
||||
|
@ -88,7 +88,6 @@ export default class RoomListActions {
|
|||
return Rooms.guessAndSetDMRoom(
|
||||
room, newTag === DefaultTagID.DM,
|
||||
).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to set direct chat tag " + err);
|
||||
Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
|
||||
title: _t('Failed to set direct chat tag'),
|
||||
|
@ -109,10 +108,9 @@ export default class RoomListActions {
|
|||
const promiseToDelete = matrixClient.deleteRoomTag(
|
||||
roomId, oldTag,
|
||||
).catch(function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to remove tag " + oldTag + " from room: " + err);
|
||||
Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, {
|
||||
title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}),
|
||||
title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
});
|
||||
|
@ -129,10 +127,9 @@ export default class RoomListActions {
|
|||
metaData = metaData || {};
|
||||
|
||||
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to add tag " + newTag + " to room: " + err);
|
||||
Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, {
|
||||
title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}),
|
||||
title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
|
||||
|
|
|
@ -53,11 +53,11 @@ export default class TagOrderActions {
|
|||
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
|
||||
return matrixClient.setAccountData(
|
||||
'im.vector.web.tag_ordering',
|
||||
{tags, removedTags, _storeId: storeId},
|
||||
{ tags, removedTags, _storeId: storeId },
|
||||
);
|
||||
}, () => {
|
||||
// For an optimistic update
|
||||
return {tags, removedTags};
|
||||
return { tags, removedTags };
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -100,11 +100,11 @@ export default class TagOrderActions {
|
|||
Analytics.trackEvent('TagOrderActions', 'removeTag');
|
||||
return matrixClient.setAccountData(
|
||||
'im.vector.web.tag_ordering',
|
||||
{tags, removedTags, _storeId: storeId},
|
||||
{ tags, removedTags, _storeId: storeId },
|
||||
);
|
||||
}, () => {
|
||||
// For an optimistic update
|
||||
return {removedTags};
|
||||
return { removedTags };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,9 +51,9 @@ export function asyncAction(id: string, fn: () => Promise<any>, pendingFn: () =>
|
|||
request: typeof pendingFn === 'function' ? pendingFn() : undefined,
|
||||
});
|
||||
fn().then((result) => {
|
||||
dispatch({action: id + '.success', result});
|
||||
dispatch({ action: id + '.success', result });
|
||||
}).catch((err) => {
|
||||
dispatch({action: id + '.failure', err});
|
||||
dispatch({ action: id + '.failure', err });
|
||||
});
|
||||
};
|
||||
return new AsyncActionPayload(helper);
|
||||
|
|
|
@ -15,55 +15,55 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
import Spinner from "../../../../components/views/elements/Spinner";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from '../../../../languageHandler';
|
||||
|
||||
import SettingsStore from "../../../../settings/SettingsStore";
|
||||
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
|
||||
import {Action} from "../../../../dispatcher/actions";
|
||||
import {SettingLevel} from "../../../../settings/SettingLevel";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../settings/SettingLevel";
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
disabling: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* Allows the user to disable the Event Index.
|
||||
*/
|
||||
export default class DisableEventIndexDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
export default class DisableEventIndexDialog extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
disabling: false,
|
||||
};
|
||||
}
|
||||
|
||||
_onDisable = async () => {
|
||||
private onDisable = async (): Promise<void> => {
|
||||
this.setState({
|
||||
disabling: true,
|
||||
});
|
||||
|
||||
await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false);
|
||||
await EventIndexPeg.deleteEventIndex();
|
||||
this.props.onFinished();
|
||||
this.props.onFinished(true);
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<BaseDialog onFinished={this.props.onFinished} title={_t("Are you sure?")}>
|
||||
{_t("If disabled, messages from encrypted rooms won't appear in search results.")}
|
||||
{this.state.disabling ? <Spinner /> : <div />}
|
||||
{ _t("If disabled, messages from encrypted rooms won't appear in search results.") }
|
||||
{ this.state.disabling ? <Spinner /> : <div /> }
|
||||
<DialogButtons
|
||||
primaryButton={_t('Disable')}
|
||||
onPrimaryButtonClick={this._onDisable}
|
||||
onPrimaryButtonClick={this.onDisable}
|
||||
primaryButtonClass="danger"
|
||||
cancelButtonClass="warning"
|
||||
onCancel={this.props.onFinished}
|
|
@ -15,19 +15,20 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../../index';
|
||||
import { _t } from '../../../../languageHandler';
|
||||
import SdkConfig from '../../../../SdkConfig';
|
||||
import SettingsStore from "../../../../settings/SettingsStore";
|
||||
|
||||
import Modal from '../../../../Modal';
|
||||
import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils";
|
||||
import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils";
|
||||
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
|
||||
import {SettingLevel} from "../../../../settings/SettingLevel";
|
||||
import { SettingLevel } from "../../../../settings/SettingLevel";
|
||||
import Field from '../../../../components/views/elements/Field';
|
||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (confirmed: boolean) => void;
|
||||
}
|
||||
interface IProps extends IDialogProps {}
|
||||
|
||||
interface IState {
|
||||
eventIndexSize: number;
|
||||
|
@ -132,20 +133,20 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
|
|||
}
|
||||
|
||||
private onDisable = async () => {
|
||||
Modal.createTrackedDialogAsync("Disable message search", "Disable message search",
|
||||
import("./DisableEventIndexDialog"),
|
||||
const DisableEventIndexDialog = (await import("./DisableEventIndexDialog")).default;
|
||||
Modal.createTrackedDialog("Disable message search", "Disable message search",
|
||||
DisableEventIndexDialog,
|
||||
null, null, /* priority = */ false, /* static = */ true,
|
||||
);
|
||||
};
|
||||
|
||||
private onCrawlerSleepTimeChange = (e) => {
|
||||
this.setState({crawlerSleepTime: e.target.value});
|
||||
this.setState({ crawlerSleepTime: e.target.value });
|
||||
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const Field = sdk.getComponent('views.elements.Field');
|
||||
|
||||
let crawlerState;
|
||||
if (this.state.currentRoom === null) {
|
||||
|
@ -160,37 +161,34 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
|
|||
|
||||
const eventIndexingSettings = (
|
||||
<div>
|
||||
{_t(
|
||||
{ _t(
|
||||
"%(brand)s is securely caching encrypted messages locally for them " +
|
||||
"to appear in search results:",
|
||||
{ brand },
|
||||
)}
|
||||
) }
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{crawlerState}<br />
|
||||
{_t("Space used:")} {formatBytes(this.state.eventIndexSize, 0)}<br />
|
||||
{_t("Indexed messages:")} {formatCountLong(this.state.eventCount)}<br />
|
||||
{_t("Indexed rooms:")} {_t("%(doneRooms)s out of %(totalRooms)s", {
|
||||
{ crawlerState }<br />
|
||||
{ _t("Space used:") } { formatBytes(this.state.eventIndexSize, 0) }<br />
|
||||
{ _t("Indexed messages:") } { formatCountLong(this.state.eventCount) }<br />
|
||||
{ _t("Indexed rooms:") } { _t("%(doneRooms)s out of %(totalRooms)s", {
|
||||
doneRooms: formatCountLong(doneRooms),
|
||||
totalRooms: formatCountLong(this.state.roomCount),
|
||||
})} <br />
|
||||
}) } <br />
|
||||
<Field
|
||||
label={_t('Message downloading sleep time(ms)')}
|
||||
type='number'
|
||||
value={this.state.crawlerSleepTime}
|
||||
value={this.state.crawlerSleepTime.toString()}
|
||||
onChange={this.onCrawlerSleepTimeChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_ManageEventIndexDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("Message search")}
|
||||
>
|
||||
{eventIndexingSettings}
|
||||
{ eventIndexingSettings }
|
||||
<DialogButtons
|
||||
primaryButton={_t("Done")}
|
||||
onPrimaryButtonClick={this.props.onFinished}
|
||||
|
|
|
@ -15,15 +15,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import FileSaver from 'file-saver';
|
||||
import * as sdk from '../../../../index';
|
||||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t, _td} from '../../../../languageHandler';
|
||||
import { _t, _td } from '../../../../languageHandler';
|
||||
import { accessSecretStorage } from '../../../../SecurityManager';
|
||||
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
|
||||
import {copyNode} from "../../../../utils/strings";
|
||||
import { copyNode } from "../../../../utils/strings";
|
||||
import PassphraseField from "../../../../components/views/auth/PassphraseField";
|
||||
|
||||
const PHASE_PASSPHRASE = 0;
|
||||
|
@ -152,11 +152,11 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
_onOptOutClick = () => {
|
||||
this.setState({phase: PHASE_OPTOUT_CONFIRM});
|
||||
this.setState({ phase: PHASE_OPTOUT_CONFIRM });
|
||||
}
|
||||
|
||||
_onSetUpClick = () => {
|
||||
this.setState({phase: PHASE_PASSPHRASE});
|
||||
this.setState({ phase: PHASE_PASSPHRASE });
|
||||
}
|
||||
|
||||
_onSkipPassPhraseClick = async () => {
|
||||
|
@ -179,7 +179,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
|
||||
this.setState({ phase: PHASE_PASSPHRASE_CONFIRM });
|
||||
};
|
||||
|
||||
_onPassPhraseConfirmNextClick = async (e) => {
|
||||
|
@ -232,15 +232,15 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
return <form onSubmit={this._onPassPhraseNextClick}>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
{ b: sub => <b>{ sub }</b> },
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"We'll store an encrypted copy of your keys on our server. " +
|
||||
"Secure your backup with a Security Phrase.",
|
||||
)}</p>
|
||||
<p>{_t("For maximum security, this should be different from your account password.")}</p>
|
||||
) }</p>
|
||||
<p>{ _t("For maximum security, this should be different from your account password.") }</p>
|
||||
|
||||
<div className="mx_CreateKeyBackupDialog_primaryContainer">
|
||||
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
|
||||
|
@ -268,9 +268,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
/>
|
||||
|
||||
<details>
|
||||
<summary>{_t("Advanced")}</summary>
|
||||
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick} >
|
||||
{_t("Set up with a Security Key")}
|
||||
<summary>{ _t("Advanced") }</summary>
|
||||
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}>
|
||||
{ _t("Set up with a Security Key") }
|
||||
</AccessibleButton>
|
||||
</details>
|
||||
</form>;
|
||||
|
@ -299,19 +299,19 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
let passPhraseMatch = null;
|
||||
if (matchText) {
|
||||
passPhraseMatch = <div className="mx_CreateKeyBackupDialog_passPhraseMatch">
|
||||
<div>{matchText}</div>
|
||||
<div>{ matchText }</div>
|
||||
<div>
|
||||
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
|
||||
{changeText}
|
||||
{ changeText }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Enter your Security Phrase a second time to confirm it.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CreateKeyBackupDialog_primaryContainer">
|
||||
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
|
||||
<div>
|
||||
|
@ -323,7 +323,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
{passPhraseMatch}
|
||||
{ passPhraseMatch }
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtons
|
||||
|
@ -337,27 +337,27 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
|
||||
_renderPhaseShowKey() {
|
||||
return <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Your Security Key is a safety net - you can use it to restore " +
|
||||
"access to your encrypted messages if you forget your Security Phrase.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Keep a copy of it somewhere secure, like a password manager or even a safe.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CreateKeyBackupDialog_primaryContainer">
|
||||
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
|
||||
{_t("Your Security Key")}
|
||||
{ _t("Your Security Key") }
|
||||
</div>
|
||||
<div className="mx_CreateKeyBackupDialog_recoveryKeyContainer">
|
||||
<div className="mx_CreateKeyBackupDialog_recoveryKey">
|
||||
<code ref={this._collectRecoveryKeyNode}>{this._keyBackupInfo.recovery_key}</code>
|
||||
<code ref={this._collectRecoveryKeyNode}>{ this._keyBackupInfo.recovery_key }</code>
|
||||
</div>
|
||||
<div className="mx_CreateKeyBackupDialog_recoveryKeyButtons">
|
||||
<button className="mx_Dialog_primary" onClick={this._onCopyClick}>
|
||||
{_t("Copy")}
|
||||
{ _t("Copy") }
|
||||
</button>
|
||||
<button className="mx_Dialog_primary" onClick={this._onDownloadClick}>
|
||||
{_t("Download")}
|
||||
{ _t("Download") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -370,26 +370,26 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
if (this.state.copied) {
|
||||
introText = _t(
|
||||
"Your Security Key has been <b>copied to your clipboard</b>, paste it to:",
|
||||
{}, {b: s => <b>{s}</b>},
|
||||
{}, { b: s => <b>{ s }</b> },
|
||||
);
|
||||
} else if (this.state.downloaded) {
|
||||
introText = _t(
|
||||
"Your Security Key is in your <b>Downloads</b> folder.",
|
||||
{}, {b: s => <b>{s}</b>},
|
||||
{}, { b: s => <b>{ s }</b> },
|
||||
);
|
||||
}
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <div>
|
||||
{introText}
|
||||
{ introText }
|
||||
<ul>
|
||||
<li>{_t("<b>Print it</b> and store it somewhere safe", {}, {b: s => <b>{s}</b>})}</li>
|
||||
<li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li>
|
||||
<li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li>
|
||||
<li>{ _t("<b>Print it</b> and store it somewhere safe", {}, { b: s => <b>{ s }</b> }) }</li>
|
||||
<li>{ _t("<b>Save it</b> on a USB key or backup drive", {}, { b: s => <b>{ s }</b> }) }</li>
|
||||
<li>{ _t("<b>Copy it</b> to your personal cloud storage", {}, { b: s => <b>{ s }</b> }) }</li>
|
||||
</ul>
|
||||
<DialogButtons primaryButton={_t("Continue")}
|
||||
onPrimaryButtonClick={this._createBackup}
|
||||
hasCancel={false}>
|
||||
<button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button>
|
||||
<button onClick={this._onKeepItSafeBackClick}>{ _t("Back") }</button>
|
||||
</DialogButtons>
|
||||
</div>;
|
||||
}
|
||||
|
@ -404,9 +404,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
_renderPhaseDone() {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Your keys are being backed up (the first backup could take a few minutes).",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<DialogButtons primaryButton={_t('OK')}
|
||||
onPrimaryButtonClick={this._onDone}
|
||||
hasCancel={false}
|
||||
|
@ -417,10 +417,10 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
_renderPhaseOptOutConfirm() {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return <div>
|
||||
{_t(
|
||||
{ _t(
|
||||
"Without setting up Secure Message Recovery, you won't be able to restore your " +
|
||||
"encrypted message history if you log out or use another session.",
|
||||
)}
|
||||
) }
|
||||
<DialogButtons primaryButton={_t('Set up Secure Message Recovery')}
|
||||
onPrimaryButtonClick={this._onSetUpClick}
|
||||
hasCancel={false}
|
||||
|
@ -457,7 +457,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
if (this.state.error) {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
content = <div>
|
||||
<p>{_t("Unable to create key backup")}</p>
|
||||
<p>{ _t("Unable to create key backup") }</p>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons primaryButton={_t('Retry')}
|
||||
onPrimaryButtonClick={this._createBackup}
|
||||
|
@ -499,7 +499,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
|
|||
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)}
|
||||
>
|
||||
<div>
|
||||
{content}
|
||||
{ content }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -15,16 +15,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../../index';
|
||||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
|
||||
import FileSaver from 'file-saver';
|
||||
import {_t, _td} from '../../../../languageHandler';
|
||||
import { _t, _td } from '../../../../languageHandler';
|
||||
import Modal from '../../../../Modal';
|
||||
import { promptForBackupPassphrase } from '../../../../SecurityManager';
|
||||
import {copyNode} from "../../../../utils/strings";
|
||||
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
|
||||
import { copyNode } from "../../../../utils/strings";
|
||||
import { SSOAuthEntry } from "../../../../components/views/auth/InteractiveAuthEntryComponents";
|
||||
import PassphraseField from "../../../../components/views/auth/PassphraseField";
|
||||
import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton';
|
||||
import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
|
||||
|
@ -34,6 +34,8 @@ import RestoreKeyBackupDialog from "../../../../components/views/dialogs/securit
|
|||
import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils';
|
||||
import SecurityCustomisations from "../../../../customisations/Security";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const PHASE_LOADING = 0;
|
||||
const PHASE_LOADERROR = 1;
|
||||
const PHASE_CHOOSE_KEY_PASSPHRASE = 2;
|
||||
|
@ -122,7 +124,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
_getInitialPhase() {
|
||||
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
|
||||
if (keyFromCustomisations) {
|
||||
console.log("Created key via customisations, jumping to bootstrap step");
|
||||
logger.log("Created key via customisations, jumping to bootstrap step");
|
||||
this._recoveryKey = {
|
||||
privateKey: keyFromCustomisations,
|
||||
};
|
||||
|
@ -138,7 +140,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||
const backupSigStatus = (
|
||||
// we may not have started crypto yet, in which case we definitely don't trust the backup
|
||||
MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo)
|
||||
MatrixClientPeg.get().isCryptoEnabled() && (await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo))
|
||||
);
|
||||
|
||||
const { forceReset } = this.props;
|
||||
|
@ -155,7 +157,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
backupSigStatus,
|
||||
};
|
||||
} catch (e) {
|
||||
this.setState({phase: PHASE_LOADERROR});
|
||||
this.setState({ phase: PHASE_LOADERROR });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,10 +167,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
// We should never get here: the server should always require
|
||||
// UI auth to upload device signing keys. If we do, we upload
|
||||
// no keys which would be a no-op.
|
||||
console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
|
||||
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
|
||||
} catch (error) {
|
||||
if (!error.data || !error.data.flows) {
|
||||
console.log("uploadDeviceSigningKeys advertised no flows!");
|
||||
logger.log("uploadDeviceSigningKeys advertised no flows!");
|
||||
return;
|
||||
}
|
||||
const canUploadKeysWithPasswordOnly = error.data.flows.some(f => {
|
||||
|
@ -304,7 +306,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
try {
|
||||
if (forceReset) {
|
||||
console.log("Forcing secret storage reset");
|
||||
logger.log("Forcing secret storage reset");
|
||||
await cli.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => this._recoveryKey,
|
||||
setupNewKeyBackup: true,
|
||||
|
@ -385,7 +387,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
_onLoadRetryClick = () => {
|
||||
this.setState({phase: PHASE_LOADING});
|
||||
this.setState({ phase: PHASE_LOADING });
|
||||
this._fetchBackupInfo();
|
||||
}
|
||||
|
||||
|
@ -394,11 +396,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
}
|
||||
|
||||
_onCancelClick = () => {
|
||||
this.setState({phase: PHASE_CONFIRM_SKIP});
|
||||
this.setState({ phase: PHASE_CONFIRM_SKIP });
|
||||
}
|
||||
|
||||
_onGoBackClick = () => {
|
||||
this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE});
|
||||
this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE });
|
||||
}
|
||||
|
||||
_onPassPhraseNextClick = async (e) => {
|
||||
|
@ -412,7 +414,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
|
||||
this.setState({ phase: PHASE_PASSPHRASE_CONFIRM });
|
||||
};
|
||||
|
||||
_onPassPhraseConfirmNextClick = async (e) => {
|
||||
|
@ -474,10 +476,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
|
||||
{_t("Generate a Security Key")}
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup" />
|
||||
{ _t("Generate a Security Key") }
|
||||
</div>
|
||||
<div>{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}</div>
|
||||
<div>{ _t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.") }</div>
|
||||
</StyledRadioButton>
|
||||
);
|
||||
}
|
||||
|
@ -493,10 +495,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
outlined
|
||||
>
|
||||
<div className="mx_CreateSecretStorageDialog_optionTitle">
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>
|
||||
{_t("Enter a Security Phrase")}
|
||||
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase" />
|
||||
{ _t("Enter a Security Phrase") }
|
||||
</div>
|
||||
<div>{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}</div>
|
||||
<div>{ _t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.") }</div>
|
||||
</StyledRadioButton>
|
||||
);
|
||||
}
|
||||
|
@ -507,13 +509,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null;
|
||||
|
||||
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}>
|
||||
<p className="mx_CreateSecretStorageDialog_centeredBody">{_t(
|
||||
<p className="mx_CreateSecretStorageDialog_centeredBody">{ _t(
|
||||
"Safeguard against losing access to encrypted messages & data by " +
|
||||
"backing up encryption keys on your server.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CreateSecretStorageDialog_primaryContainer" role="radiogroup">
|
||||
{optionKey}
|
||||
{optionPassphrase}
|
||||
{ optionKey }
|
||||
{ optionPassphrase }
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Continue")}
|
||||
|
@ -536,7 +538,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
let nextCaption = _t("Next");
|
||||
if (this.state.canUploadKeysWithPasswordOnly) {
|
||||
authPrompt = <div>
|
||||
<div>{_t("Enter your account password to confirm the upgrade:")}</div>
|
||||
<div>{ _t("Enter your account password to confirm the upgrade:") }</div>
|
||||
<div><Field
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
|
@ -548,22 +550,22 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
</div>;
|
||||
} else if (!this.state.backupSigStatus.usable) {
|
||||
authPrompt = <div>
|
||||
<div>{_t("Restore your key backup to upgrade your encryption")}</div>
|
||||
<div>{ _t("Restore your key backup to upgrade your encryption") }</div>
|
||||
</div>;
|
||||
nextCaption = _t("Restore");
|
||||
} else {
|
||||
authPrompt = <p>
|
||||
{_t("You'll need to authenticate with the server to confirm the upgrade.")}
|
||||
{ _t("You'll need to authenticate with the server to confirm the upgrade.") }
|
||||
</p>;
|
||||
}
|
||||
|
||||
return <form onSubmit={this._onMigrateFormSubmit}>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Upgrade this session to allow it to verify other sessions, " +
|
||||
"granting them access to encrypted messages and marking them " +
|
||||
"as trusted for other users.",
|
||||
)}</p>
|
||||
<div>{authPrompt}</div>
|
||||
) }</p>
|
||||
<div>{ authPrompt }</div>
|
||||
<DialogButtons
|
||||
primaryButton={nextCaption}
|
||||
onPrimaryButtonClick={this._onMigrateFormSubmit}
|
||||
|
@ -571,7 +573,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
|
||||
>
|
||||
<button type="button" className="danger" onClick={this._onCancelClick}>
|
||||
{_t('Skip')}
|
||||
{ _t('Skip') }
|
||||
</button>
|
||||
</DialogButtons>
|
||||
</form>;
|
||||
|
@ -579,10 +581,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
_renderPhasePassPhrase() {
|
||||
return <form onSubmit={this._onPassPhraseNextClick}>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Enter a security phrase only you know, as it’s used to safeguard your data. " +
|
||||
"To be secure, you shouldn’t re-use your account password.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
|
||||
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
|
||||
<PassphraseField
|
||||
|
@ -609,7 +611,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<button type="button"
|
||||
onClick={this._onCancelClick}
|
||||
className="danger"
|
||||
>{_t("Cancel")}</button>
|
||||
>{ _t("Cancel") }</button>
|
||||
</DialogButtons>
|
||||
</form>;
|
||||
}
|
||||
|
@ -637,18 +639,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
let passPhraseMatch = null;
|
||||
if (matchText) {
|
||||
passPhraseMatch = <div>
|
||||
<div>{matchText}</div>
|
||||
<div>{ matchText }</div>
|
||||
<div>
|
||||
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}>
|
||||
{changeText}
|
||||
{ changeText }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Enter your Security Phrase a second time to confirm it.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CreateSecretStorageDialog_passPhraseContainer">
|
||||
<Field
|
||||
type="password"
|
||||
|
@ -660,7 +662,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="mx_CreateSecretStorageDialog_passPhraseMatch">
|
||||
{passPhraseMatch}
|
||||
{ passPhraseMatch }
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtons
|
||||
|
@ -672,7 +674,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
<button type="button"
|
||||
onClick={this._onCancelClick}
|
||||
className="danger"
|
||||
>{_t("Skip")}</button>
|
||||
>{ _t("Skip") }</button>
|
||||
</DialogButtons>
|
||||
</form>;
|
||||
}
|
||||
|
@ -691,35 +693,36 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
</div>;
|
||||
}
|
||||
return <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Store your Security Key somewhere safe, like a password manager or a safe, " +
|
||||
"as it’s used to safeguard your encrypted data.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<div className="mx_CreateSecretStorageDialog_primaryContainer">
|
||||
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
|
||||
<div className="mx_CreateSecretStorageDialog_recoveryKey">
|
||||
<code ref={this._collectRecoveryKeyNode}>{this._recoveryKey.encodedPrivateKey}</code>
|
||||
<code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code>
|
||||
</div>
|
||||
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
|
||||
<AccessibleButton kind='primary' className="mx_Dialog_primary"
|
||||
<AccessibleButton kind='primary'
|
||||
className="mx_Dialog_primary"
|
||||
onClick={this._onDownloadClick}
|
||||
disabled={this.state.phase === PHASE_STORING}
|
||||
>
|
||||
{_t("Download")}
|
||||
{ _t("Download") }
|
||||
</AccessibleButton>
|
||||
<span>{_t("or")}</span>
|
||||
<span>{ _t("or") }</span>
|
||||
<AccessibleButton
|
||||
kind='primary'
|
||||
className="mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn"
|
||||
onClick={this._onCopyClick}
|
||||
disabled={this.state.phase === PHASE_STORING}
|
||||
>
|
||||
{this.state.copied ? _t("Copied!") : _t("Copy")}
|
||||
{ this.state.copied ? _t("Copied!") : _t("Copy") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{continueButton}
|
||||
{ continueButton }
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -732,7 +735,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
_renderPhaseLoadError() {
|
||||
return <div>
|
||||
<p>{_t("Unable to query secret storage status")}</p>
|
||||
<p>{ _t("Unable to query secret storage status") }</p>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons primaryButton={_t('Retry')}
|
||||
onPrimaryButtonClick={this._onLoadRetryClick}
|
||||
|
@ -745,17 +748,17 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
_renderPhaseSkipConfirm() {
|
||||
return <div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"If you cancel now, you may lose encrypted messages & data if you lose access to your logins.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"You can also set up Secure Backup & manage your keys in Settings.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<DialogButtons primaryButton={_t('Go back')}
|
||||
onPrimaryButtonClick={this._onGoBackClick}
|
||||
hasCancel={false}
|
||||
>
|
||||
<button type="button" className="danger" onClick={this._onCancel}>{_t('Cancel')}</button>
|
||||
<button type="button" className="danger" onClick={this._onCancel}>{ _t('Cancel') }</button>
|
||||
</DialogButtons>
|
||||
</div>;
|
||||
}
|
||||
|
@ -787,7 +790,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
let content;
|
||||
if (this.state.error) {
|
||||
content = <div>
|
||||
<p>{_t("Unable to set up secret storage")}</p>
|
||||
<p>{ _t("Unable to set up secret storage") }</p>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<DialogButtons primaryButton={_t('Retry')}
|
||||
onPrimaryButtonClick={this._bootstrapSecretStorage}
|
||||
|
@ -857,7 +860,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
|
|||
fixedWidth={false}
|
||||
>
|
||||
<div>
|
||||
{content}
|
||||
{ content }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../../languageHandler';
|
||||
|
||||
|
@ -55,11 +55,11 @@ export default class ExportE2eKeysDialog extends React.Component {
|
|||
|
||||
const passphrase = this._passphrase1.current.value;
|
||||
if (passphrase !== this._passphrase2.current.value) {
|
||||
this.setState({errStr: _t('Passphrases must match')});
|
||||
this.setState({ errStr: _t('Passphrases must match') });
|
||||
return false;
|
||||
}
|
||||
if (!passphrase) {
|
||||
this.setState({errStr: _t('Passphrase must not be empty')});
|
||||
this.setState({ errStr: _t('Passphrase must not be empty') });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -148,8 +148,12 @@ export default class ExportE2eKeysDialog extends React.Component {
|
|||
</label>
|
||||
</div>
|
||||
<div className='mx_E2eKeysDialog_inputCell'>
|
||||
<input ref={this._passphrase1} id='passphrase1'
|
||||
autoFocus={true} size='64' type='password'
|
||||
<input
|
||||
ref={this._passphrase1}
|
||||
id='passphrase1'
|
||||
autoFocus={true}
|
||||
size='64'
|
||||
type='password'
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</div>
|
||||
|
@ -161,8 +165,10 @@ export default class ExportE2eKeysDialog extends React.Component {
|
|||
</label>
|
||||
</div>
|
||||
<div className='mx_E2eKeysDialog_inputCell'>
|
||||
<input ref={this._passphrase2} id='passphrase2'
|
||||
size='64' type='password'
|
||||
<input ref={this._passphrase2}
|
||||
id='passphrase2'
|
||||
size='64'
|
||||
type='password'
|
||||
disabled={disableForm}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {createRef} from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
|
@ -174,7 +174,10 @@ export default class ImportE2eKeysDialog extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
<div className='mx_Dialog_buttons'>
|
||||
<input className='mx_Dialog_primary' type='submit' value={_t('Import')}
|
||||
<input
|
||||
className='mx_Dialog_primary'
|
||||
type='submit'
|
||||
value={_t('Import')}
|
||||
disabled={!this.state.enableSubmit || disableForm}
|
||||
/>
|
||||
<button onClick={this._onCancelClick} disabled={disableForm}>
|
||||
|
|
|
@ -18,12 +18,12 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import * as sdk from "../../../../index";
|
||||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../../../../MatrixClientPeg';
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Modal from "../../../../Modal";
|
||||
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
|
||||
import {Action} from "../../../../dispatcher/actions";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
|
||||
export default class NewRecoveryMethodDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
|
@ -54,28 +54,28 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
|
|||
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
|
||||
|
||||
const title = <span className="mx_KeyBackupFailedDialog_title">
|
||||
{_t("New Recovery Method")}
|
||||
{ _t("New Recovery Method") }
|
||||
</span>;
|
||||
|
||||
const newMethodDetected = <p>{_t(
|
||||
const newMethodDetected = <p>{ _t(
|
||||
"A new Security Phrase and key for Secure Messages have been detected.",
|
||||
)}</p>;
|
||||
) }</p>;
|
||||
|
||||
const hackWarning = <p className="warning">{_t(
|
||||
const hackWarning = <p className="warning">{ _t(
|
||||
"If you didn't set the new recovery method, an " +
|
||||
"attacker may be trying to access your account. " +
|
||||
"Change your account password and set a new recovery " +
|
||||
"method immediately in Settings.",
|
||||
)}</p>;
|
||||
) }</p>;
|
||||
|
||||
let content;
|
||||
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
|
||||
content = <div>
|
||||
{newMethodDetected}
|
||||
<p>{_t(
|
||||
{ newMethodDetected }
|
||||
<p>{ _t(
|
||||
"This session is encrypting history using the new recovery method.",
|
||||
)}</p>
|
||||
{hackWarning}
|
||||
) }</p>
|
||||
{ hackWarning }
|
||||
<DialogButtons
|
||||
primaryButton={_t("OK")}
|
||||
onPrimaryButtonClick={this.onOkClick}
|
||||
|
@ -85,8 +85,8 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
|
|||
</div>;
|
||||
} else {
|
||||
content = <div>
|
||||
{newMethodDetected}
|
||||
{hackWarning}
|
||||
{ newMethodDetected }
|
||||
{ hackWarning }
|
||||
<DialogButtons
|
||||
primaryButton={_t("Set up Secure Messages")}
|
||||
onPrimaryButtonClick={this.onSetupClick}
|
||||
|
@ -101,7 +101,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
|
|||
onFinished={this.props.onFinished}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import * as sdk from "../../../../index";
|
|||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Modal from "../../../../Modal";
|
||||
import {Action} from "../../../../dispatcher/actions";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
|
||||
export default class RecoveryMethodRemovedDialog extends React.PureComponent {
|
||||
static propTypes = {
|
||||
|
@ -46,7 +46,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
|
|||
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
|
||||
|
||||
const title = <span className="mx_KeyBackupFailedDialog_title">
|
||||
{_t("Recovery Method Removed")}
|
||||
{ _t("Recovery Method Removed") }
|
||||
</span>;
|
||||
|
||||
return (
|
||||
|
@ -55,21 +55,21 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent {
|
|||
title={title}
|
||||
>
|
||||
<div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"This session has detected that your Security Phrase and key " +
|
||||
"for Secure Messages have been removed.",
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"If you did this accidentally, you can setup Secure Messages on " +
|
||||
"this session which will re-encrypt this session's message " +
|
||||
"history with a new recovery method.",
|
||||
)}</p>
|
||||
<p className="warning">{_t(
|
||||
) }</p>
|
||||
<p className="warning">{ _t(
|
||||
"If you didn't remove the recovery method, an " +
|
||||
"attacker may be trying to access your account. " +
|
||||
"Change your account password and set a new recovery " +
|
||||
"method immediately in Settings.",
|
||||
)}</p>
|
||||
) }</p>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Set up Secure Messages")}
|
||||
onPrimaryButtonClick={this.onSetupClick}
|
||||
|
|
37
src/audio/ManagedPlayback.ts
Normal file
37
src/audio/ManagedPlayback.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
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 { DEFAULT_WAVEFORM, Playback } from "./Playback";
|
||||
import { PlaybackManager } from "./PlaybackManager";
|
||||
|
||||
/**
|
||||
* A managed playback is a Playback instance that is guided by a PlaybackManager.
|
||||
*/
|
||||
export class ManagedPlayback extends Playback {
|
||||
public constructor(private manager: PlaybackManager, buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
|
||||
super(buf, seedWaveform);
|
||||
}
|
||||
|
||||
public async play(): Promise<void> {
|
||||
this.manager.pauseAllExcept(this);
|
||||
return super.play();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.manager.destroyPlaybackInstance(this);
|
||||
super.destroy();
|
||||
}
|
||||
}
|
310
src/audio/Playback.ts
Normal file
310
src/audio/Playback.ts
Normal file
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
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 EventEmitter from "events";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays";
|
||||
import { SimpleObservable } from "matrix-widget-api";
|
||||
import { IDestroyable } from "../utils/IDestroyable";
|
||||
import { PlaybackClock } from "./PlaybackClock";
|
||||
import { createAudioContext, decodeOgg } from "./compat";
|
||||
import { clamp } from "../utils/numbers";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export enum PlaybackState {
|
||||
Decoding = "decoding",
|
||||
Stopped = "stopped", // no progress on timeline
|
||||
Paused = "paused", // some progress on timeline
|
||||
Playing = "playing", // active progress through timeline
|
||||
}
|
||||
|
||||
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
|
||||
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
|
||||
export const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
|
||||
function makePlaybackWaveform(input: number[]): number[] {
|
||||
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
||||
const noiseWaveform = input.map(v => Math.abs(v));
|
||||
|
||||
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
|
||||
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
}
|
||||
|
||||
export class Playback extends EventEmitter implements IDestroyable {
|
||||
/**
|
||||
* Stable waveform for representing a thumbnail of the media. Values are
|
||||
* guaranteed to be between zero and one, inclusive.
|
||||
*/
|
||||
public readonly thumbnailWaveform: number[];
|
||||
|
||||
private readonly context: AudioContext;
|
||||
private source: AudioBufferSourceNode | MediaElementAudioSourceNode;
|
||||
private state = PlaybackState.Decoding;
|
||||
private audioBuf: AudioBuffer;
|
||||
private element: HTMLAudioElement;
|
||||
private resampledWaveform: number[];
|
||||
private waveformObservable = new SimpleObservable<number[]>();
|
||||
private readonly clock: PlaybackClock;
|
||||
private readonly fileSize: number;
|
||||
|
||||
/**
|
||||
* Creates a new playback instance from a buffer.
|
||||
* @param {ArrayBuffer} buf The buffer containing the sound sample.
|
||||
* @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform
|
||||
* can be calculated. Contains values between zero and one, inclusive.
|
||||
*/
|
||||
constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
|
||||
super();
|
||||
// Capture the file size early as reading the buffer will result in a 0-length buffer left behind
|
||||
this.fileSize = this.buf.byteLength;
|
||||
this.context = createAudioContext();
|
||||
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
this.thumbnailWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, THUMBNAIL_WAVEFORM_SAMPLES);
|
||||
this.waveformObservable.update(this.resampledWaveform);
|
||||
this.clock = new PlaybackClock(this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of the audio clip in bytes. May be zero if unknown. This is updated
|
||||
* when the playback goes through phase changes.
|
||||
*/
|
||||
public get sizeBytes(): number {
|
||||
return this.fileSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable waveform for the playback. Values are guaranteed to be between
|
||||
* zero and one, inclusive.
|
||||
*/
|
||||
public get waveform(): number[] {
|
||||
return this.resampledWaveform;
|
||||
}
|
||||
|
||||
public get waveformData(): SimpleObservable<number[]> {
|
||||
return this.waveformObservable;
|
||||
}
|
||||
|
||||
public get clockInfo(): PlaybackClock {
|
||||
return this.clock;
|
||||
}
|
||||
|
||||
public get currentState(): PlaybackState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public get isPlaying(): boolean {
|
||||
return this.currentState === PlaybackState.Playing;
|
||||
}
|
||||
|
||||
public emit(event: PlaybackState, ...args: any[]): boolean {
|
||||
this.state = event;
|
||||
super.emit(event, ...args);
|
||||
super.emit(UPDATE_EVENT, event, ...args);
|
||||
return true; // we don't ever care if the event had listeners, so just return "yes"
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
// Dev note: It's critical that we call stop() during cleanup to ensure that downstream callers
|
||||
// are aware of the final clock position before the user triggered an unload.
|
||||
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
|
||||
this.stop();
|
||||
this.removeAllListeners();
|
||||
this.clock.destroy();
|
||||
this.waveformObservable.close();
|
||||
if (this.element) {
|
||||
URL.revokeObjectURL(this.element.src);
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public async prepare() {
|
||||
// The point where we use an audio element is fairly arbitrary, though we don't want
|
||||
// it to be too low. As of writing, voice messages want to show a waveform but audio
|
||||
// messages do not. Using an audio element means we can't show a waveform preview, so
|
||||
// we try to target the difference between a voice message file and large audio file.
|
||||
// Overall, the point of this is to avoid memory-related issues due to storing a massive
|
||||
// audio buffer in memory, as that can balloon to far greater than the input buffer's
|
||||
// byte length.
|
||||
if (this.buf.byteLength > 5 * 1024 * 1024) { // 5mb
|
||||
logger.log("Audio file too large: processing through <audio /> element");
|
||||
this.element = document.createElement("AUDIO") as HTMLAudioElement;
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
this.element.onloadeddata = () => resolve(null);
|
||||
this.element.onerror = (e) => reject(e);
|
||||
});
|
||||
this.element.src = URL.createObjectURL(new Blob([this.buf]));
|
||||
await prom; // make sure the audio element is ready for us
|
||||
} else {
|
||||
// Safari compat: promise API not supported on this function
|
||||
this.audioBuf = await new Promise((resolve, reject) => {
|
||||
this.context.decodeAudioData(this.buf, b => resolve(b), async e => {
|
||||
try {
|
||||
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg
|
||||
// very well.
|
||||
console.error("Error decoding recording: ", e);
|
||||
console.warn("Trying to re-encode to WAV instead...");
|
||||
|
||||
const wav = await decodeOgg(this.buf);
|
||||
|
||||
// noinspection ES6MissingAwait - not needed when using callbacks
|
||||
this.context.decodeAudioData(wav, b => resolve(b), e => {
|
||||
console.error("Still failed to decode recording: ", e);
|
||||
reject(e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Caught decoding error:", e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update the waveform to the real waveform once we have channel data to use. We don't
|
||||
// exactly trust the user-provided waveform to be accurate...
|
||||
const waveform = Array.from(this.audioBuf.getChannelData(0));
|
||||
this.resampledWaveform = makePlaybackWaveform(waveform);
|
||||
}
|
||||
|
||||
this.waveformObservable.update(this.resampledWaveform);
|
||||
|
||||
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
|
||||
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
|
||||
|
||||
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
|
||||
// when the downstream callers try to use it.
|
||||
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
|
||||
}
|
||||
|
||||
private onPlaybackEnd = async () => {
|
||||
await this.context.suspend();
|
||||
this.emit(PlaybackState.Stopped);
|
||||
};
|
||||
|
||||
public async play() {
|
||||
// We can't restart a buffer source, so we need to create a new one if we hit the end
|
||||
if (this.state === PlaybackState.Stopped) {
|
||||
this.disconnectSource();
|
||||
this.makeNewSourceBuffer();
|
||||
if (this.element) {
|
||||
await this.element.play();
|
||||
} else {
|
||||
(this.source as AudioBufferSourceNode).start();
|
||||
}
|
||||
}
|
||||
|
||||
// We use the context suspend/resume functions because it allows us to pause a source
|
||||
// node, but that still doesn't help us when the source node runs out (see above).
|
||||
await this.context.resume();
|
||||
this.clock.flagStart();
|
||||
this.emit(PlaybackState.Playing);
|
||||
}
|
||||
|
||||
private disconnectSource() {
|
||||
if (this.element) return; // leave connected, we can (and must) re-use it
|
||||
this.source?.disconnect();
|
||||
this.source?.removeEventListener("ended", this.onPlaybackEnd);
|
||||
}
|
||||
|
||||
private makeNewSourceBuffer() {
|
||||
if (this.element && this.source) return; // leave connected, we can (and must) re-use it
|
||||
|
||||
if (this.element) {
|
||||
this.source = this.context.createMediaElementSource(this.element);
|
||||
} else {
|
||||
this.source = this.context.createBufferSource();
|
||||
this.source.buffer = this.audioBuf;
|
||||
}
|
||||
|
||||
this.source.addEventListener("ended", this.onPlaybackEnd);
|
||||
this.source.connect(this.context.destination);
|
||||
}
|
||||
|
||||
public async pause() {
|
||||
await this.context.suspend();
|
||||
this.emit(PlaybackState.Paused);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.onPlaybackEnd();
|
||||
this.clock.flagStop();
|
||||
}
|
||||
|
||||
public async toggle() {
|
||||
if (this.isPlaying) await this.pause();
|
||||
else await this.play();
|
||||
}
|
||||
|
||||
public async skipTo(timeSeconds: number) {
|
||||
// Dev note: this function talks a lot about clock desyncs. There is a clock running
|
||||
// independently to the audio context and buffer so that accurate human-perceptible
|
||||
// time can be exposed. The PlaybackClock class has more information, but the short
|
||||
// version is that we need to line up the useful time (clip position) with the context
|
||||
// time, and avoid as many deviations as possible as otherwise the user could see the
|
||||
// wrong time, and we stop playback at the wrong time, etc.
|
||||
|
||||
timeSeconds = clamp(timeSeconds, 0, this.clock.durationSeconds);
|
||||
|
||||
// Track playing state so we don't cause seeking to start playing the track.
|
||||
const isPlaying = this.isPlaying;
|
||||
|
||||
if (isPlaying) {
|
||||
// Pause first so we can get an accurate measurement of time
|
||||
await this.context.suspend();
|
||||
}
|
||||
|
||||
// We can't simply tell the context/buffer to jump to a time, so we have to
|
||||
// start a whole new buffer and start it from the new time offset.
|
||||
const now = this.context.currentTime;
|
||||
this.disconnectSource();
|
||||
this.makeNewSourceBuffer();
|
||||
|
||||
// We have to resync the clock because it can get confused about where we're
|
||||
// at in the audio clip.
|
||||
this.clock.syncTo(now, timeSeconds);
|
||||
|
||||
// Always start the source to queue it up. We have to do this now (and pause
|
||||
// quickly if we're not supposed to be playing) as otherwise the clock can desync
|
||||
// when it comes time to the user hitting play. After a couple jumps, the user
|
||||
// will have desynced the clock enough to be about 10-15 seconds off, while this
|
||||
// keeps it as close to perfect as humans can perceive.
|
||||
if (this.element) {
|
||||
this.element.currentTime = timeSeconds;
|
||||
} else {
|
||||
(this.source as AudioBufferSourceNode).start(now, timeSeconds);
|
||||
}
|
||||
|
||||
// Dev note: it's critical that the code gap between `this.source.start()` and
|
||||
// `this.pause()` is as small as possible: we do not want to delay *anything*
|
||||
// as that could cause a clock desync, or a buggy feeling as a single note plays
|
||||
// during seeking.
|
||||
|
||||
if (isPlaying) {
|
||||
// If we were playing before, continue the context so the clock doesn't desync.
|
||||
await this.context.resume();
|
||||
} else {
|
||||
// As mentioned above, we'll have to pause the clip if we weren't supposed to
|
||||
// be playing it just yet. If we didn't have this, the audio clip plays but all
|
||||
// the states will be wrong: clock won't advance, pause state doesn't match the
|
||||
// blaring noise leaving the user's speakers, etc.
|
||||
//
|
||||
// Also as mentioned, if the code gap is small enough then this should be
|
||||
// executed immediately after the start time, leaving no feasible time for the
|
||||
// user's speakers to play any sound.
|
||||
await this.pause();
|
||||
}
|
||||
}
|
||||
}
|
151
src/audio/PlaybackClock.ts
Normal file
151
src/audio/PlaybackClock.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
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 { SimpleObservable } from "matrix-widget-api";
|
||||
import { IDestroyable } from "../utils/IDestroyable";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
|
||||
/**
|
||||
* Tracks accurate human-perceptible time for an audio clip, as informed
|
||||
* by managed playback. This clock is tightly coupled with the operation
|
||||
* of the Playback class, making assumptions about how the provided
|
||||
* AudioContext will be used (suspended/resumed to preserve time, etc).
|
||||
*
|
||||
* But why do we need a clock? The AudioContext exposes time information,
|
||||
* and so does the audio buffer, but not in a way that is useful for humans
|
||||
* to perceive. The audio buffer time is often lagged behind the context
|
||||
* time due to internal processing delays of the audio API. Additionally,
|
||||
* the context's time is tracked from when it was first initialized/started,
|
||||
* not related to positioning within the clip. However, the context time
|
||||
* is the most accurate time we can use to determine position within the
|
||||
* clip if we're fast enough to track the pauses and stops.
|
||||
*
|
||||
* As a result, we track every play, pause, stop, and seek event from the
|
||||
* Playback class (kinda: it calls us, which is close enough to the same
|
||||
* thing). These events are then tracked on the AudioContext time scale,
|
||||
* with assumptions that code execution will result in negligible desync
|
||||
* of the clock, or at least no perceptible difference in time. It's
|
||||
* extremely important that the calling code, and the clock's own code,
|
||||
* is extremely fast between the event happening and the clock time being
|
||||
* tracked - anything more than a dozen milliseconds is likely to stack up
|
||||
* poorly, leading to clock desync.
|
||||
*
|
||||
* Clock desync can be dangerous for the stability of the playback controls:
|
||||
* if the clock thinks the user is somewhere else in the clip, it could
|
||||
* inform the playback of the wrong place in time, leading to dead air in
|
||||
* the output or, if severe enough, a clock that won't stop running while
|
||||
* the audio is paused/stopped. Other examples include the clip stopping at
|
||||
* 90% time due to playback ending, the clip playing from the wrong spot
|
||||
* relative to the time, and negative clock time.
|
||||
*
|
||||
* Note that the clip duration is fed to the clock: this is to ensure that
|
||||
* we have the most accurate time possible to present.
|
||||
*/
|
||||
export class PlaybackClock implements IDestroyable {
|
||||
private clipStart = 0;
|
||||
private stopped = true;
|
||||
private lastCheck = 0;
|
||||
private observable = new SimpleObservable<number[]>();
|
||||
private timerId: number;
|
||||
private clipDuration = 0;
|
||||
private placeholderDuration = 0;
|
||||
|
||||
public constructor(private context: AudioContext) {
|
||||
}
|
||||
|
||||
public get durationSeconds(): number {
|
||||
return this.clipDuration || this.placeholderDuration;
|
||||
}
|
||||
|
||||
public set durationSeconds(val: number) {
|
||||
this.clipDuration = val;
|
||||
this.observable.update([this.timeSeconds, this.clipDuration]);
|
||||
}
|
||||
|
||||
public get timeSeconds(): number {
|
||||
// The modulo is to ensure that we're only looking at the most recent clip
|
||||
// time, as the context is long-running and multiple plays might not be
|
||||
// informed to us (if the control is looping, for example). By taking the
|
||||
// remainder of the division operation, we're assuming that playback is
|
||||
// incomplete or stopped, thus giving an accurate position within the active
|
||||
// clip segment.
|
||||
return (this.context.currentTime - this.clipStart) % this.clipDuration;
|
||||
}
|
||||
|
||||
public get liveData(): SimpleObservable<number[]> {
|
||||
return this.observable;
|
||||
}
|
||||
|
||||
private checkTime = (force = false) => {
|
||||
const now = this.timeSeconds; // calculated dynamically
|
||||
if (this.lastCheck !== now || force) {
|
||||
this.observable.update([now, this.durationSeconds]);
|
||||
this.lastCheck = now;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Populates default information about the audio clip from the event body.
|
||||
* The placeholders will be overridden once known.
|
||||
* @param {MatrixEvent} event The event to use for placeholders.
|
||||
*/
|
||||
public populatePlaceholdersFrom(event: MatrixEvent) {
|
||||
const durationMs = Number(event.getContent()['info']?.['duration']);
|
||||
if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the time in the audio context where the clip starts/has been loaded.
|
||||
* This is to ensure the clock isn't skewed into thinking it is ~0.5s into
|
||||
* a clip when the duration is set.
|
||||
*/
|
||||
public flagLoadTime() {
|
||||
this.clipStart = this.context.currentTime;
|
||||
}
|
||||
|
||||
public flagStart() {
|
||||
if (this.stopped) {
|
||||
this.clipStart = this.context.currentTime;
|
||||
this.stopped = false;
|
||||
}
|
||||
|
||||
if (!this.timerId) {
|
||||
// cast to number because the types are wrong
|
||||
// 100ms interval to make sure the time is as accurate as possible without
|
||||
// being overly insane
|
||||
this.timerId = <number><any>setInterval(this.checkTime, 100);
|
||||
}
|
||||
}
|
||||
|
||||
public flagStop() {
|
||||
this.stopped = true;
|
||||
|
||||
// Reset the clock time now so that the update going out will trigger components
|
||||
// to check their seek/position information (alongside the clock).
|
||||
this.clipStart = this.context.currentTime;
|
||||
}
|
||||
|
||||
public syncTo(contextTime: number, clipTime: number) {
|
||||
this.clipStart = contextTime - clipTime;
|
||||
this.stopped = false; // count as a mid-stream pause (if we were stopped)
|
||||
this.checkTime(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.observable.close();
|
||||
if (this.timerId) clearInterval(this.timerId);
|
||||
}
|
||||
}
|
56
src/audio/PlaybackManager.ts
Normal file
56
src/audio/PlaybackManager.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
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 { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
|
||||
import { ManagedPlayback } from "./ManagedPlayback";
|
||||
|
||||
/**
|
||||
* Handles management of playback instances to ensure certain functionality, like
|
||||
* one playback operating at any one time.
|
||||
*/
|
||||
export class PlaybackManager {
|
||||
private static internalInstance: PlaybackManager;
|
||||
|
||||
private instances: ManagedPlayback[] = [];
|
||||
|
||||
public static get instance(): PlaybackManager {
|
||||
if (!PlaybackManager.internalInstance) {
|
||||
PlaybackManager.internalInstance = new PlaybackManager();
|
||||
}
|
||||
return PlaybackManager.internalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses all other playback instances. If no playback is provided, all playing
|
||||
* instances are paused.
|
||||
* @param playback Optional. The playback to leave untouched.
|
||||
*/
|
||||
public pauseAllExcept(playback?: Playback) {
|
||||
this.instances
|
||||
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
|
||||
.forEach(p => p.pause());
|
||||
}
|
||||
|
||||
public destroyPlaybackInstance(playback: ManagedPlayback) {
|
||||
this.instances = this.instances.filter(p => p !== playback);
|
||||
}
|
||||
|
||||
public createPlaybackInstance(buf: ArrayBuffer, waveform = DEFAULT_WAVEFORM): Playback {
|
||||
const instance = new ManagedPlayback(this, buf, waveform);
|
||||
this.instances.push(instance);
|
||||
return instance;
|
||||
}
|
||||
}
|
219
src/audio/PlaybackQueue.ts
Normal file
219
src/audio/PlaybackQueue.ts
Normal file
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { Playback, PlaybackState } from "./Playback";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { arrayFastClone } from "../utils/arrays";
|
||||
import { PlaybackManager } from "./PlaybackManager";
|
||||
import { isVoiceMessage } from "../utils/EventUtils";
|
||||
import RoomViewStore from "../stores/RoomViewStore";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
/**
|
||||
* Audio playback queue management for a given room. This keeps track of where the user
|
||||
* was at for each playback, what order the playbacks were played in, and triggers subsequent
|
||||
* playbacks.
|
||||
*
|
||||
* Currently this is only intended to be used by voice messages.
|
||||
*
|
||||
* The primary mechanics are:
|
||||
* * Persisted clock state for each playback instance (tied to Event ID).
|
||||
* * Limited memory of playback order (see code; not persisted).
|
||||
* * Autoplay of next eligible playback instance.
|
||||
*/
|
||||
export class PlaybackQueue {
|
||||
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
|
||||
|
||||
private playbacks = new Map<string, Playback>(); // keyed by event ID
|
||||
private clockStates = new Map<string, number>(); // keyed by event ID
|
||||
private playbackIdOrder: string[] = []; // event IDs, last == current
|
||||
private currentPlaybackId: string; // event ID, broken out from above for ease of use
|
||||
private recentFullPlays = new Set<string>(); // event IDs
|
||||
|
||||
constructor(private client: MatrixClient, private room: Room) {
|
||||
this.loadClocks();
|
||||
|
||||
RoomViewStore.addListener(() => {
|
||||
if (RoomViewStore.getRoomId() === this.room.roomId) {
|
||||
// Reset the state of the playbacks before they start mounting and enqueuing updates.
|
||||
// We reset the entirety of the queue, including order, to ensure the user isn't left
|
||||
// confused with what order the messages are playing in.
|
||||
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static forRoom(roomId: string): PlaybackQueue {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) throw new Error("Unknown room");
|
||||
if (PlaybackQueue.queues.has(room.roomId)) {
|
||||
return PlaybackQueue.queues.get(room.roomId);
|
||||
}
|
||||
const queue = new PlaybackQueue(cli, room);
|
||||
PlaybackQueue.queues.set(room.roomId, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
private persistClocks() {
|
||||
localStorage.setItem(
|
||||
`mx_voice_message_clocks_${this.room.roomId}`,
|
||||
JSON.stringify(Array.from(this.clockStates.entries())),
|
||||
);
|
||||
}
|
||||
|
||||
private loadClocks() {
|
||||
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
|
||||
if (!!val) {
|
||||
this.clockStates = new Map<string, number>(JSON.parse(val));
|
||||
}
|
||||
}
|
||||
|
||||
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
|
||||
// We don't ever detach our listeners: we expect the Playback to clean up for us
|
||||
this.playbacks.set(mxEvent.getId(), playback);
|
||||
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
|
||||
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
|
||||
}
|
||||
|
||||
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
|
||||
// Remember where the user got to in playback
|
||||
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
|
||||
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
playback.skipTo(this.clockStates.get(mxEvent.getId()));
|
||||
} else if (newState === PlaybackState.Stopped) {
|
||||
// Remove the now-useless clock for some space savings
|
||||
this.clockStates.delete(mxEvent.getId());
|
||||
|
||||
if (wasLastPlaying) {
|
||||
this.recentFullPlays.add(this.currentPlaybackId);
|
||||
const orderClone = arrayFastClone(this.playbackIdOrder);
|
||||
const last = orderClone.pop();
|
||||
if (last === this.currentPlaybackId) {
|
||||
const next = orderClone.pop();
|
||||
if (next) {
|
||||
const instance = this.playbacks.get(next);
|
||||
if (!instance) {
|
||||
console.warn(
|
||||
"Voice message queue desync: Missing playback for next message: "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
|
||||
);
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
} else {
|
||||
// else no explicit next event, so find an event we haven't played that comes next. The live
|
||||
// timeline is already most recent last, so we can iterate down that.
|
||||
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
|
||||
let scanForVoiceMessage = false;
|
||||
let nextEv: MatrixEvent;
|
||||
for (const event of timeline) {
|
||||
if (event.getId() === mxEvent.getId()) {
|
||||
scanForVoiceMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (!scanForVoiceMessage) continue;
|
||||
|
||||
if (!isVoiceMessage(event)) {
|
||||
const evType = event.getType();
|
||||
if (evType !== EventType.RoomMessage && evType !== EventType.Sticker) {
|
||||
continue; // Event can be skipped for automatic playback consideration
|
||||
}
|
||||
break; // Stop automatic playback: next useful event is not a voice message
|
||||
}
|
||||
|
||||
const havePlayback = this.playbacks.has(event.getId());
|
||||
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
|
||||
if (havePlayback && !isRecentlyCompleted) {
|
||||
nextEv = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nextEv) {
|
||||
// if we don't have anywhere to go, reset the recent playback queue so the user
|
||||
// can start a new chain of playbacks.
|
||||
this.recentFullPlays = new Set<string>();
|
||||
this.playbackIdOrder = [];
|
||||
} else {
|
||||
this.playbackIdOrder = orderClone;
|
||||
|
||||
const instance = this.playbacks.get(nextEv.getId());
|
||||
PlaybackManager.instance.pauseAllExcept(instance);
|
||||
|
||||
// This should cause a Play event, which will re-populate our playback order
|
||||
// and update our current playback ID.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
instance.play();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Voice message queue desync: Expected playback stop to be last in order. "
|
||||
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newState === PlaybackState.Playing) {
|
||||
const order = this.playbackIdOrder;
|
||||
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
const lastInstance = this.playbacks.get(this.currentPlaybackId);
|
||||
if (
|
||||
lastInstance.currentState === PlaybackState.Playing
|
||||
|| lastInstance.currentState === PlaybackState.Paused
|
||||
) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlaybackId = mxEvent.getId();
|
||||
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
|
||||
order.push(this.currentPlaybackId);
|
||||
}
|
||||
}
|
||||
|
||||
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
|
||||
// This should get triggered from normal voice message component unmount due to the playback
|
||||
// stopping itself for cleanup.
|
||||
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
|
||||
this.persistClocks();
|
||||
}
|
||||
}
|
||||
|
||||
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
|
||||
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
|
||||
|
||||
if (playback.currentState !== PlaybackState.Stopped) {
|
||||
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,23 +14,44 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts";
|
||||
import {percentageOf} from "../utils/numbers";
|
||||
import { IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
|
||||
import { percentageOf } from "../utils/numbers";
|
||||
|
||||
// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope
|
||||
declare const currentTime: number;
|
||||
// declare const currentFrame: number;
|
||||
// declare const sampleRate: number;
|
||||
|
||||
// We rate limit here to avoid overloading downstream consumers with amplitude information.
|
||||
// The two major consumers are the voice message waveform thumbnail (resampled down to an
|
||||
// appropriate length) and the live waveform shown to the user. Effectively, this controls
|
||||
// the refresh rate of that live waveform and the number of samples the thumbnail has to
|
||||
// work with.
|
||||
const TARGET_AMPLITUDE_FREQUENCY = 16; // Hz
|
||||
|
||||
function roundTimeToTargetFreq(seconds: number): number {
|
||||
// Epsilon helps avoid floating point rounding issues (1 + 1 = 1.999999, etc)
|
||||
return Math.round((seconds + Number.EPSILON) * TARGET_AMPLITUDE_FREQUENCY) / TARGET_AMPLITUDE_FREQUENCY;
|
||||
}
|
||||
|
||||
function nextTimeForTargetFreq(roundedSeconds: number): number {
|
||||
// The extra round is just to make sure we cut off any floating point issues
|
||||
return roundTimeToTargetFreq(roundedSeconds + (1 / TARGET_AMPLITUDE_FREQUENCY));
|
||||
}
|
||||
|
||||
class MxVoiceWorklet extends AudioWorkletProcessor {
|
||||
private nextAmplitudeSecond = 0;
|
||||
private amplitudeIndex = 0;
|
||||
|
||||
process(inputs, outputs, parameters) {
|
||||
// We only fire amplitude updates once a second to avoid flooding the recording instance
|
||||
// with useless data. Much of the data would end up discarded, so we ratelimit ourselves
|
||||
// here.
|
||||
const currentSecond = Math.round(currentTime);
|
||||
if (currentSecond === this.nextAmplitudeSecond) {
|
||||
const currentSecond = roundTimeToTargetFreq(currentTime);
|
||||
// We special case the first ping because there's a fairly good chance that we'll miss the zeroth
|
||||
// update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first
|
||||
// time. Edge and Chrome occasionally lag behind too, but for the most part are on time.
|
||||
//
|
||||
// When this doesn't work properly we end up producing a waveform of nulls and no live preview
|
||||
// of the recorded message.
|
||||
if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) {
|
||||
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||
// samples for the analysis.
|
||||
const monoChan = inputs[0][0];
|
||||
|
@ -47,13 +68,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
|
|||
this.port.postMessage(<IAmplitudePayload>{
|
||||
ev: PayloadEvent.AmplitudeMark,
|
||||
amplitude: amplitude,
|
||||
forSecond: currentSecond,
|
||||
forIndex: this.amplitudeIndex++,
|
||||
});
|
||||
this.nextAmplitudeSecond++;
|
||||
this.nextAmplitudeSecond = nextTimeForTargetFreq(currentSecond);
|
||||
}
|
||||
|
||||
// We mostly use this worklet to fire regular clock updates through to components
|
||||
this.port.postMessage(<ITimingPayload>{ev: PayloadEvent.Timekeep, timeSeconds: currentTime});
|
||||
this.port.postMessage(<ITimingPayload>{ ev: PayloadEvent.Timekeep, timeSeconds: currentTime });
|
||||
|
||||
// We're supposed to return false when we're "done" with the audio clip, but seeing as
|
||||
// we are acting as a passive processor we are never truly "done". The browser will clean
|
|
@ -16,17 +16,21 @@ limitations under the License.
|
|||
|
||||
import * as Recorder from 'opus-recorder';
|
||||
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
||||
import {MatrixClient} from "matrix-js-sdk/src/client";
|
||||
import CallMediaHandler from "../CallMediaHandler";
|
||||
import {SimpleObservable} from "matrix-widget-api";
|
||||
import {clamp, percentageOf, percentageWithin} from "../utils/numbers";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import MediaDeviceHandler from "../MediaDeviceHandler";
|
||||
import { SimpleObservable } from "matrix-widget-api";
|
||||
import EventEmitter from "events";
|
||||
import {IDestroyable} from "../utils/IDestroyable";
|
||||
import {Singleflight} from "../utils/Singleflight";
|
||||
import {PayloadEvent, WORKLET_NAME} from "./consts";
|
||||
import {UPDATE_EVENT} from "../stores/AsyncStore";
|
||||
import {Playback} from "./Playback";
|
||||
import {createAudioContext} from "./compat";
|
||||
import { IDestroyable } from "../utils/IDestroyable";
|
||||
import { Singleflight } from "../utils/Singleflight";
|
||||
import { PayloadEvent, WORKLET_NAME } from "./consts";
|
||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||
import { Playback } from "./Playback";
|
||||
import { createAudioContext } from "./compat";
|
||||
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
||||
import { uploadFile } from "../ContentMessages";
|
||||
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||
import { clamp } from "../utils/numbers";
|
||||
import mxRecorderWorkletPath from "./RecorderWorklet";
|
||||
|
||||
const CHANNELS = 1; // stereo isn't important
|
||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||
|
@ -49,20 +53,25 @@ export enum RecordingState {
|
|||
Uploaded = "uploaded",
|
||||
}
|
||||
|
||||
export interface IUpload {
|
||||
mxc?: string; // for unencrypted uploads
|
||||
encrypted?: IEncryptedFile;
|
||||
}
|
||||
|
||||
export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||
private recorder: Recorder;
|
||||
private recorderContext: AudioContext;
|
||||
private recorderSource: MediaStreamAudioSourceNode;
|
||||
private recorderStream: MediaStream;
|
||||
private recorderFFT: AnalyserNode;
|
||||
private recorderWorklet: AudioWorkletNode;
|
||||
private recorderProcessor: ScriptProcessorNode;
|
||||
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
||||
private mxc: string;
|
||||
private lastUpload: IUpload;
|
||||
private recording = false;
|
||||
private observable: SimpleObservable<IRecordingUpdate>;
|
||||
private amplitudes: number[] = []; // at each second mark, generated
|
||||
private playback: Playback;
|
||||
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
|
||||
|
||||
public constructor(private client: MatrixClient) {
|
||||
super();
|
||||
|
@ -97,34 +106,18 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
audio: {
|
||||
channelCount: CHANNELS,
|
||||
noiseSuppression: true, // browsers ignore constraints they can't honour
|
||||
deviceId: CallMediaHandler.getAudioInput(),
|
||||
deviceId: MediaDeviceHandler.getAudioInput(),
|
||||
},
|
||||
});
|
||||
this.recorderContext = createAudioContext({
|
||||
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
||||
});
|
||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||
this.recorderFFT = this.recorderContext.createAnalyser();
|
||||
|
||||
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
|
||||
// of two. We use 64 points because we happen to know down the line we need less than
|
||||
// that, but 32 would be too few. Large numbers are not helpful here and do not add
|
||||
// precision: they introduce higher precision outputs of the FFT (frequency data), but
|
||||
// it makes the time domain less than helpful.
|
||||
this.recorderFFT.fftSize = 64;
|
||||
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
||||
if (!mxRecorderWorkletPath) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Unable to create recorder: no worklet script registered");
|
||||
}
|
||||
|
||||
// Connect our inputs and outputs
|
||||
this.recorderSource.connect(this.recorderFFT);
|
||||
|
||||
if (this.recorderContext.audioWorklet) {
|
||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||
this.recorderSource.connect(this.recorderWorklet);
|
||||
|
@ -138,8 +131,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
break;
|
||||
case PayloadEvent.AmplitudeMark:
|
||||
// Sanity check to make sure we're adding about one sample per second
|
||||
if (ev.data['forSecond'] === this.amplitudes.length) {
|
||||
if (ev.data['forIndex'] === this.amplitudes.length) {
|
||||
this.amplitudes.push(ev.data['amplitude']);
|
||||
this.liveWaveform.pushValue(ev.data['amplitude']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -214,13 +208,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
return this.buffer.length > 0;
|
||||
}
|
||||
|
||||
public get mxcUri(): string {
|
||||
if (!this.mxc) {
|
||||
throw new Error("Recording has not been uploaded yet");
|
||||
}
|
||||
return this.mxc;
|
||||
}
|
||||
|
||||
private onAudioProcess = (ev: AudioProcessingEvent) => {
|
||||
this.processAudioUpdate(ev.playbackTime);
|
||||
|
||||
|
@ -231,36 +218,8 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
private processAudioUpdate = (timeSeconds: number) => {
|
||||
if (!this.recording) return;
|
||||
|
||||
// The time domain is the input to the FFT, which means we use an array of the same
|
||||
// size. The time domain is also known as the audio waveform. We're ignoring the
|
||||
// output of the FFT here (frequency data) because we're not interested in it.
|
||||
const data = new Float32Array(this.recorderFFT.fftSize);
|
||||
if (!this.recorderFFT.getFloatTimeDomainData) {
|
||||
// Safari compat
|
||||
const data2 = new Uint8Array(this.recorderFFT.fftSize);
|
||||
this.recorderFFT.getByteTimeDomainData(data2);
|
||||
for (let i = 0; i < data2.length; i++) {
|
||||
data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1);
|
||||
}
|
||||
} else {
|
||||
this.recorderFFT.getFloatTimeDomainData(data);
|
||||
}
|
||||
|
||||
// We can't just `Array.from()` the array because we're dealing with 32bit floats
|
||||
// and the built-in function won't consider that when converting between numbers.
|
||||
// However, the runtime will convert the float32 to a float64 during the math operations
|
||||
// which is why the loop works below. Note that a `.map()` call also doesn't work
|
||||
// and will instead return a Float32Array still.
|
||||
const translatedData: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
// We're clamping the values so we can do that math operation mentioned above,
|
||||
// and to ensure that we produce consistent data (it's possible for the array
|
||||
// to exceed the specified range with some audio input devices).
|
||||
translatedData.push(clamp(data[i], 0, 1));
|
||||
}
|
||||
|
||||
this.observable.update({
|
||||
waveform: translatedData,
|
||||
waveform: this.liveWaveform.value.map(v => clamp(v, 0, 1)),
|
||||
timeSeconds: timeSeconds,
|
||||
});
|
||||
|
||||
|
@ -283,14 +242,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
this.stop();
|
||||
} else if (secondsLeft <= TARGET_WARN_TIME_LEFT) {
|
||||
Singleflight.for(this, "ending_soon").do(() => {
|
||||
this.emit(RecordingState.EndingSoon, {secondsLeft});
|
||||
this.emit(RecordingState.EndingSoon, { secondsLeft });
|
||||
return Singleflight.Void;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.mxc || this.hasRecording) {
|
||||
if (this.lastUpload || this.hasRecording) {
|
||||
throw new Error("Recording already prepared");
|
||||
}
|
||||
if (this.recording) {
|
||||
|
@ -362,20 +321,24 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
this.observable.close();
|
||||
}
|
||||
|
||||
public async upload(): Promise<string> {
|
||||
public async upload(inRoomId: string): Promise<IUpload> {
|
||||
if (!this.hasRecording) {
|
||||
throw new Error("No recording available to upload");
|
||||
}
|
||||
|
||||
if (this.mxc) return this.mxc;
|
||||
if (this.lastUpload) return this.lastUpload;
|
||||
|
||||
this.emit(RecordingState.Uploading);
|
||||
this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], {
|
||||
type: this.contentType,
|
||||
}), {
|
||||
onlyContentUri: false, // to stop the warnings in the console
|
||||
}).then(r => r['content_uri']);
|
||||
this.emit(RecordingState.Uploaded);
|
||||
return this.mxc;
|
||||
try {
|
||||
this.emit(RecordingState.Uploading);
|
||||
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], {
|
||||
type: this.contentType,
|
||||
}));
|
||||
this.lastUpload = { mxc, encrypted };
|
||||
this.emit(RecordingState.Uploaded);
|
||||
} catch (e) {
|
||||
this.emit(RecordingState.Ended);
|
||||
throw e;
|
||||
}
|
||||
return this.lastUpload;
|
||||
}
|
||||
}
|
|
@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SAMPLE_RATE} from "./VoiceRecording";
|
||||
import { SAMPLE_RATE } from "./VoiceRecording";
|
||||
|
||||
// @ts-ignore - we know that this is not a module. We're looking for a path.
|
||||
import decoderWasmPath from 'opus-recorder/dist/decoderWorker.min.wasm';
|
||||
import wavEncoderPath from 'opus-recorder/dist/waveWorker.min.js';
|
||||
import decoderPath from 'opus-recorder/dist/decoderWorker.min.js';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export function createAudioContext(opts?: AudioContextOptions): AudioContext {
|
||||
if (window.AudioContext) {
|
||||
return new AudioContext(opts);
|
||||
|
@ -38,7 +40,7 @@ export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
|||
// Condensed version of decoder example, using a promise:
|
||||
// https://github.com/chris-rudmin/opus-recorder/blob/master/example/decoder.html
|
||||
return new Promise((resolve) => { // no reject because the workers don't seem to have a fail path
|
||||
console.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake)
|
||||
logger.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake)
|
||||
const typedArray = new Uint8Array(audioBuffer);
|
||||
const decoderWorker = new Worker(decoderPath);
|
||||
const wavWorker = new Worker(wavEncoderPath);
|
||||
|
@ -57,7 +59,7 @@ export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
|||
|
||||
decoderWorker.onmessage = (ev) => {
|
||||
if (ev.data === null) { // null == done
|
||||
wavWorker.postMessage({command: 'done'});
|
||||
wavWorker.postMessage({ command: 'done' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -70,7 +72,7 @@ export function decodeOgg(audioBuffer: ArrayBuffer): Promise<ArrayBuffer> {
|
|||
wavWorker.onmessage = (ev) => {
|
||||
if (ev.data.message === 'page') {
|
||||
// The encoding comes through as a single page
|
||||
resolve(new Blob([ev.data.page], {type: "audio/wav"}).arrayBuffer());
|
||||
resolve(new Blob([ev.data.page], { type: "audio/wav" }).arrayBuffer());
|
||||
}
|
||||
};
|
||||
|
|
@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload {
|
|||
|
||||
export interface IAmplitudePayload extends IPayload {
|
||||
ev: PayloadEvent.AmplitudeMark;
|
||||
forSecond: number;
|
||||
forIndex: number;
|
||||
amplitude: number;
|
||||
}
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type {ICompletion, ISelectionRange} from './Autocompleter';
|
||||
import type { ICompletion, ISelectionRange } from './Autocompleter';
|
||||
|
||||
export interface ICommand {
|
||||
command: string | null;
|
||||
|
@ -27,11 +27,11 @@ export interface ICommand {
|
|||
};
|
||||
}
|
||||
|
||||
export default class AutocompleteProvider {
|
||||
export default abstract class AutocompleteProvider {
|
||||
commandRegex: RegExp;
|
||||
forcedCommandRegex: RegExp;
|
||||
|
||||
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
|
||||
if (commandRegex) {
|
||||
if (!commandRegex.global) {
|
||||
throw new Error('commandRegex must have global flag set');
|
||||
|
@ -93,23 +93,16 @@ export default class AutocompleteProvider {
|
|||
};
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
abstract getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
return [];
|
||||
}
|
||||
force: boolean,
|
||||
limit: number,
|
||||
): Promise<ICompletion[]>;
|
||||
|
||||
getName(): string {
|
||||
return 'Default Provider';
|
||||
}
|
||||
abstract getName(): string;
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
|
||||
console.error('stub; should be implemented in subclasses');
|
||||
return null;
|
||||
}
|
||||
abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null;
|
||||
|
||||
// Whether we should provide completions even if triggered forcefully, without a sigil.
|
||||
shouldForceComplete(): boolean {
|
||||
|
|
|
@ -15,19 +15,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ReactElement} from 'react';
|
||||
import Room from 'matrix-js-sdk/src/models/room';
|
||||
import { ReactElement } from 'react';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
|
||||
import CommandProvider from './CommandProvider';
|
||||
import CommunityProvider from './CommunityProvider';
|
||||
import DuckDuckGoProvider from './DuckDuckGoProvider';
|
||||
import RoomProvider from './RoomProvider';
|
||||
import UserProvider from './UserProvider';
|
||||
import EmojiProvider from './EmojiProvider';
|
||||
import NotifProvider from './NotifProvider';
|
||||
import {timeout} from "../utils/promise";
|
||||
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { timeout } from "../utils/promise";
|
||||
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
|
||||
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
|
||||
|
@ -54,13 +54,12 @@ const PROVIDERS = [
|
|||
EmojiProvider,
|
||||
NotifProvider,
|
||||
CommandProvider,
|
||||
CommunityProvider,
|
||||
DuckDuckGoProvider,
|
||||
];
|
||||
|
||||
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
PROVIDERS.push(SpaceProvider);
|
||||
} else {
|
||||
PROVIDERS.push(CommunityProvider);
|
||||
}
|
||||
|
||||
// Providers will get rejected if they take longer than this.
|
||||
|
|
|
@ -18,12 +18,12 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {_t} from '../languageHandler';
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import {TextualCompletion} from './Components';
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import {Command, Commands, CommandMap} from '../SlashCommands';
|
||||
import { TextualCompletion } from './Components';
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
import { Command, Commands, CommandMap } from '../SlashCommands';
|
||||
|
||||
const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
|
||||
|
||||
|
@ -34,7 +34,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
super(COMMAND_RE);
|
||||
this.matcher = new QueryMatcher(Commands, {
|
||||
keys: ['command', 'args', 'description'],
|
||||
funcs: [({aliases}) => aliases.join(" ")], // aliases
|
||||
funcs: [({ aliases }) => aliases.join(" ")], // aliases
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -44,7 +44,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
force?: boolean,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (!command) return [];
|
||||
|
||||
let matches = [];
|
||||
|
@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
// The input looks like a command with arguments, perform exact match
|
||||
const name = command[1].substr(1); // strip leading `/`
|
||||
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) {
|
||||
// some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments
|
||||
// some commands, namely `me` don't suit having the usage shown whilst typing their arguments
|
||||
if (CommandMap.get(name).hideCompletionAfterSpace) return [];
|
||||
matches = [CommandMap.get(name)];
|
||||
}
|
||||
|
@ -68,7 +68,6 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
return matches.filter(cmd => cmd.isEnabled()).map((result) => {
|
||||
let completion = result.getCommand() + ' ';
|
||||
const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]);
|
||||
|
@ -96,8 +95,8 @@ export default class CommandProvider extends AutocompleteProvider {
|
|||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="presentation"
|
||||
aria-label={_t("Command Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -19,15 +19,15 @@ import React from 'react';
|
|||
import Group from "matrix-js-sdk/src/models/group";
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import {MatrixClientPeg} from '../MatrixClientPeg';
|
||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import {PillCompletion} from './Components';
|
||||
import * as sdk from '../index';
|
||||
import {sortBy} from "lodash";
|
||||
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
import { PillCompletion } from './Components';
|
||||
import { sortBy } from "lodash";
|
||||
import { makeGroupPermalink } from "../utils/permalinks/Permalinks";
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
import FlairStore from "../stores/FlairStore";
|
||||
import {mediaFromMxc} from "../customisations/Media";
|
||||
import { mediaFromMxc } from "../customisations/Media";
|
||||
import BaseAvatar from '../components/views/avatars/BaseAvatar';
|
||||
|
||||
const COMMUNITY_REGEX = /\B\+\S*/g;
|
||||
|
||||
|
@ -56,8 +56,6 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
|
||||
|
||||
// Disable autocompletions when composing commands because of various issues
|
||||
// (see https://github.com/vector-im/element-web/issues/4762)
|
||||
if (/^(\/join|\/leave)/.test(query)) {
|
||||
|
@ -66,11 +64,11 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
|
||||
const cli = MatrixClientPeg.get();
|
||||
let completions = [];
|
||||
const {command, range} = this.getCurrentCommand(query, selection, force);
|
||||
const { command, range } = this.getCurrentCommand(query, selection, force);
|
||||
if (command) {
|
||||
const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join');
|
||||
const joinedGroups = cli.getGroups().filter(({ myMembership }) => myMembership === 'join');
|
||||
|
||||
const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => {
|
||||
const groups = (await Promise.all(joinedGroups.map(async ({ groupId }) => {
|
||||
try {
|
||||
return FlairStore.getGroupProfileCached(cli, groupId);
|
||||
} catch (e) { // if FlairStore failed, fall back to just groupId
|
||||
|
@ -90,7 +88,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
completions = sortBy(completions, [
|
||||
(c) => score(matchedString, c.groupId),
|
||||
(c) => c.groupId.length,
|
||||
]).map(({avatarUrl, groupId, name}) => ({
|
||||
]).map(({ avatarUrl, groupId, name }) => ({
|
||||
completion: groupId,
|
||||
suffix: ' ',
|
||||
type: "community",
|
||||
|
@ -118,7 +116,7 @@ export default class CommunityProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Community Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {forwardRef} from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/* These were earlier stateless functional components but had to be converted
|
||||
|
@ -31,7 +31,7 @@ interface ITextualCompletionProps {
|
|||
}
|
||||
|
||||
export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props, ref) => {
|
||||
const {title, subtitle, description, className, ...restProps} = props;
|
||||
const { title, subtitle, description, className, ...restProps } = props;
|
||||
return (
|
||||
<div {...restProps}
|
||||
className={classNames('mx_Autocomplete_Completion_block', className)}
|
||||
|
@ -50,7 +50,7 @@ interface IPillCompletionProps extends ITextualCompletionProps {
|
|||
}
|
||||
|
||||
export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref) => {
|
||||
const {title, subtitle, description, className, children, ...restProps} = props;
|
||||
const { title, subtitle, description, className, children, ...restProps } = props;
|
||||
return (
|
||||
<div {...restProps}
|
||||
className={classNames('mx_Autocomplete_Completion_pill', className)}
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
|
||||
import {TextualCompletion} from './Components';
|
||||
import {ICompletion, ISelectionRange} from "./Autocompleter";
|
||||
|
||||
const DDG_REGEX = /\/ddg\s+(.+)$/g;
|
||||
const REFERRER = 'vector';
|
||||
|
||||
export default class DuckDuckGoProvider extends AutocompleteProvider {
|
||||
constructor() {
|
||||
super(DDG_REGEX);
|
||||
}
|
||||
|
||||
static getQueryUri(query: string) {
|
||||
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
|
||||
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
|
||||
}
|
||||
|
||||
async getCompletions(
|
||||
query: string,
|
||||
selection: ISelectionRange,
|
||||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const {command, range} = this.getCurrentCommand(query, selection);
|
||||
if (!query || !command) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
|
||||
method: 'GET',
|
||||
});
|
||||
const json = await response.json();
|
||||
const maxLength = limit > -1 ? limit : json.Results.length;
|
||||
const results = json.Results.slice(0, maxLength).map((result) => {
|
||||
return {
|
||||
completion: result.Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={result.Text}
|
||||
description={result.Result} />
|
||||
),
|
||||
range,
|
||||
};
|
||||
});
|
||||
if (json.Answer) {
|
||||
results.unshift({
|
||||
completion: json.Answer,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.Answer}
|
||||
description={json.AnswerType} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
|
||||
results.unshift({
|
||||
completion: json.RelatedTopics[0].Text,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.RelatedTopics[0].Text} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
if (json.AbstractText) {
|
||||
results.unshift({
|
||||
completion: json.AbstractText,
|
||||
component: (
|
||||
<TextualCompletion
|
||||
title={json.AbstractText} />
|
||||
),
|
||||
range,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return '🔍 ' + _t('Results from DuckDuckGo');
|
||||
}
|
||||
|
||||
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_block"
|
||||
role="listbox"
|
||||
aria-label={_t("DuckDuckGo Results")}
|
||||
>
|
||||
{ completions }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
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