Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/a11y/focus-lock-ctx-menu
Conflicts: src/components/views/spaces/SpaceCreateMenu.tsx
This commit is contained in:
commit
a6c780674a
952 changed files with 46775 additions and 36622 deletions
|
@ -14,7 +14,7 @@ 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};
|
||||
|
@ -22,3 +22,4 @@ export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<
|
|||
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;
|
||||
|
|
67
src/@types/global.d.ts
vendored
67
src/@types/global.d.ts
vendored
|
@ -15,7 +15,9 @@ 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";
|
||||
|
@ -46,10 +48,15 @@ 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: {
|
||||
|
@ -87,6 +94,30 @@ declare global {
|
|||
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 {
|
||||
|
@ -111,7 +142,7 @@ declare global {
|
|||
}
|
||||
|
||||
interface StorageEstimate {
|
||||
usageDetails?: {[key: string]: number};
|
||||
usageDetails?: { [key: string]: number };
|
||||
}
|
||||
|
||||
interface HTMLAudioElement {
|
||||
|
@ -128,6 +159,10 @@ declare global {
|
|||
setSinkId(outputId: string);
|
||||
}
|
||||
|
||||
interface HTMLStyleElement {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Add Chrome-specific `instant` ScrollBehaviour
|
||||
type _ScrollBehavior = ScrollBehavior | "instant";
|
||||
|
||||
|
@ -182,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.
|
||||
|
|
|
@ -17,13 +17,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import * as sdk from './index';
|
||||
import Modal from './Modal';
|
||||
import { _t } from './languageHandler';
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
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,9 +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"),
|
||||
|
@ -208,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,
|
||||
|
@ -235,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();
|
||||
|
@ -291,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.
|
||||
|
@ -302,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"),
|
||||
|
@ -324,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,
|
|
@ -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) =>
|
||||
|
|
|
@ -20,6 +20,8 @@ import * as sdk from './index';
|
|||
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 {
|
||||
|
@ -47,7 +49,7 @@ export default class AsyncWrapper extends React.Component<IProps, IState> {
|
|||
componentDidMount() {
|
||||
// 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;
|
||||
|
||||
|
@ -77,6 +79,7 @@ export default class AsyncWrapper extends React.Component<IProps, IState> {
|
|||
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")}>
|
||||
|
|
|
@ -18,10 +18,11 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
|||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
|
||||
import { split } from "lodash";
|
||||
|
||||
import DMRoomMap from './utils/DMRoomMap';
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import SpaceStore from "./stores/SpaceStore";
|
||||
|
||||
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already
|
||||
export function avatarUrlForMember(
|
||||
|
@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
const initial = name[0];
|
||||
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
|
||||
idx++;
|
||||
name = name.substring(1);
|
||||
}
|
||||
|
||||
// string.codePointAt(0) would do this, but that isn't supported by
|
||||
// some browsers (notably PhantomJS).
|
||||
let chars = 1;
|
||||
const first = name.charCodeAt(idx);
|
||||
|
||||
// check if it’s the start of a surrogate pair
|
||||
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
|
||||
const second = name.charCodeAt(idx+1);
|
||||
if (second >= 0xDC00 && second <= 0xDFFF) {
|
||||
chars++;
|
||||
}
|
||||
}
|
||||
|
||||
const firstChar = name.substring(idx, idx+chars);
|
||||
return firstChar.toUpperCase();
|
||||
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
|
||||
return split(name, "", 1)[0].toUpperCase();
|
||||
}
|
||||
|
||||
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
|
||||
|
@ -153,17 +140,13 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
|
|||
}
|
||||
|
||||
// space rooms cannot be DMs so skip the rest
|
||||
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
|
||||
if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
|
||||
|
||||
let otherMember = null;
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (otherUserId) {
|
||||
otherMember = room.getMember(otherUserId);
|
||||
} else {
|
||||
// if the room is not marked as a 1:1, but only has max 2 members
|
||||
// then still try to show any avatar (pref. other member)
|
||||
otherMember = room.getAvatarFallbackMember();
|
||||
}
|
||||
// If the room is not a DM don't fallback to a member avatar
|
||||
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null;
|
||||
|
||||
// If there are only two members in the DM use the avatar of the other member
|
||||
const otherMember = room.getAvatarFallbackMember();
|
||||
if (otherMember?.getMxcAvatarUrl()) {
|
||||
return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
|
||||
}
|
||||
|
|
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.
|
||||
|
@ -56,12 +57,10 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
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";
|
||||
|
@ -80,7 +79,6 @@ import CountlyAnalytics from "./CountlyAnalytics";
|
|||
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';
|
||||
|
@ -99,7 +103,7 @@ const CHECK_PROTOCOLS_ATTEMPTS = 3;
|
|||
// (and store the ID of their native room)
|
||||
export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
|
||||
|
||||
export enum AudioID {
|
||||
enum AudioID {
|
||||
Ring = 'ringAudio',
|
||||
Ringback = 'ringbackAudio',
|
||||
CallEnd = 'callendAudio',
|
||||
|
@ -129,19 +133,15 @@ interface ThirdpartyLookupResponse {
|
|||
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,6 +164,8 @@ 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();
|
||||
|
@ -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();
|
||||
|
@ -249,9 +286,9 @@ export default class CallHandler extends EventEmitter {
|
|||
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);
|
||||
|
@ -301,6 +338,13 @@ export default class CallHandler extends EventEmitter {
|
|||
}, 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 {
|
||||
return this.calls.get(roomId) || null;
|
||||
}
|
||||
|
@ -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,18 +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;
|
||||
console.log("Moving call to room " + mappedRoomId);
|
||||
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(
|
||||
|
@ -596,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,
|
||||
|
@ -608,30 +681,30 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
private removeCallForRoom(roomId: string) {
|
||||
console.log("Removing call for room ", roomId);
|
||||
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'),
|
||||
|
@ -649,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>;
|
||||
}
|
||||
|
@ -679,12 +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);
|
||||
|
||||
console.log("Adding call for room ", roomId);
|
||||
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;
|
||||
}
|
||||
|
@ -697,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);
|
||||
}
|
||||
|
@ -755,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) {
|
||||
|
@ -807,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",
|
||||
);
|
||||
|
@ -815,10 +870,11 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
|
||||
console.log("Adding call for room ", mappedRoomId);
|
||||
this.calls.set(mappedRoomId, call);
|
||||
this.emit(CallHandlerEvent.CallsChanged, this.calls);
|
||||
|
||||
this.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
|
||||
|
@ -829,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
|
||||
}
|
||||
|
@ -841,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
|
||||
}
|
||||
|
@ -871,9 +933,21 @@ 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) {
|
||||
const results = await this.pstnLookup(number);
|
||||
if (!results || results.length === 0 || !results[0].userid) {
|
||||
|
@ -892,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;
|
||||
}
|
||||
|
@ -903,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) {
|
||||
|
@ -940,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;
|
||||
}
|
||||
|
||||
|
@ -990,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, {
|
||||
|
@ -1035,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { encode } from "blurhash";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import dis from './dispatcher/dispatcher';
|
||||
|
@ -28,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore';
|
|||
import encrypt from "browser-encrypt-attachment";
|
||||
import extractPngChunks from "png-chunks-extract";
|
||||
import Spinner from "./components/views/elements/Spinner";
|
||||
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import CountlyAnalytics from "./CountlyAnalytics";
|
||||
import {
|
||||
|
@ -39,7 +37,12 @@ import {
|
|||
UploadStartedPayload,
|
||||
} from "./dispatcher/payloads/UploadPayload";
|
||||
import { IUpload } from "./models/IUpload";
|
||||
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
|
||||
import { BlurhashEncoder } from "./BlurhashEncoder";
|
||||
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,8 +52,6 @@ const MAX_HEIGHT = 600;
|
|||
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
|
||||
|
||||
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
|
||||
const BLURHASH_X_COMPONENTS = 6;
|
||||
const BLURHASH_Y_COMPONENTS = 6;
|
||||
|
||||
export class UploadCanceledError extends Error {}
|
||||
|
||||
|
@ -87,10 +88,6 @@ interface IThumbnail {
|
|||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
interface IAbortablePromise<T> extends Promise<T> {
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail for a image DOM element.
|
||||
* The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
|
||||
|
@ -109,54 +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;
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const blurhash = encode(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
BLURHASH_X_COMPONENTS,
|
||||
BLURHASH_Y_COMPONENTS,
|
||||
);
|
||||
canvas.toBlob(function(thumbnail) {
|
||||
resolve({
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
});
|
||||
}, mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.drawImage(element, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
let thumbnailPromise: Promise<Blob>;
|
||||
|
||||
if (window.OffscreenCanvas) {
|
||||
thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
|
||||
} else {
|
||||
thumbnailPromise = new Promise<Blob>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
|
||||
}
|
||||
|
||||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
|
||||
// thumbnailPromise and blurhash promise are being awaited concurrently
|
||||
const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
|
||||
const thumbnail = await thumbnailPromise;
|
||||
|
||||
return {
|
||||
info: {
|
||||
thumbnail_info: {
|
||||
w: targetWidth,
|
||||
h: targetHeight,
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
},
|
||||
w: inputWidth,
|
||||
h: inputHeight,
|
||||
[BLURHASH_FIELD]: blurhash,
|
||||
},
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -208,6 +213,14 @@ async function loadImageElement(imageFile: File) {
|
|||
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.
|
||||
*
|
||||
|
@ -216,23 +229,33 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -334,7 +357,7 @@ export function uploadFile(
|
|||
roomId: string,
|
||||
file: File | Blob,
|
||||
progressHandler?: any, // TODO: Types
|
||||
): Promise<{url?: string, file?: any}> { // TODO: Types
|
||||
): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types
|
||||
let canceled = false;
|
||||
if (matrixClient.isRoomEncrypted(roomId)) {
|
||||
// If the room is encrypted then encrypt the file before uploading it.
|
||||
|
@ -366,8 +389,8 @@ export function uploadFile(
|
|||
encryptInfo.mimetype = file.type;
|
||||
}
|
||||
return { "file": encryptInfo };
|
||||
});
|
||||
(prom as IAbortablePromise<any>).abort = () => {
|
||||
}) as IAbortablePromise<{ file: any }>;
|
||||
prom.abort = () => {
|
||||
canceled = true;
|
||||
if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
|
||||
};
|
||||
|
@ -380,8 +403,8 @@ export function uploadFile(
|
|||
if (canceled) throw new UploadCanceledError();
|
||||
// If the attachment isn't encrypted then include the URL directly.
|
||||
return { url };
|
||||
});
|
||||
(promise1 as any).abort = () => {
|
||||
}) as IAbortablePromise<{ url: string }>;
|
||||
promise1.abort = () => {
|
||||
canceled = true;
|
||||
matrixClient.cancelUpload(basePromise);
|
||||
};
|
||||
|
@ -419,14 +442,15 @@ export default class ContentMessages {
|
|||
|
||||
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, {
|
||||
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"),
|
||||
|
@ -453,6 +477,7 @@ 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, {
|
||||
badFiles: tooBigFiles,
|
||||
|
@ -463,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
|
||||
|
@ -471,6 +495,8 @@ export default class ContentMessages {
|
|||
for (let i = 0; i < okFiles.length; ++i) {
|
||||
const file = okFiles[i];
|
||||
if (!uploadAll) {
|
||||
// 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,
|
||||
|
@ -517,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;
|
||||
|
@ -549,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;
|
||||
};
|
||||
|
||||
|
@ -567,7 +597,7 @@ export default class ContentMessages {
|
|||
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;
|
||||
|
@ -581,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;
|
||||
|
@ -594,6 +622,11 @@ 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) {
|
||||
|
@ -606,6 +639,7 @@ export default class ContentMessages {
|
|||
{ 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'),
|
||||
|
@ -646,13 +680,13 @@ export default class ContentMessages {
|
|||
private ensureMediaConfigFetched(matrixClient: MatrixClient) {
|
||||
if (this.mediaConfig !== null) return;
|
||||
|
||||
console.log("[Media Config] Fetching");
|
||||
logger.log("[Media Config] Fetching");
|
||||
return matrixClient.getMediaConfig().then((config) => {
|
||||
console.log("[Media Config] Fetched config:", 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;
|
||||
|
|
|
@ -16,12 +16,12 @@ limitations under the License.
|
|||
|
||||
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 PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { sleep } from "./utils/promise";
|
||||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
|
||||
|
@ -30,6 +30,8 @@ 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",
|
||||
|
@ -364,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;
|
||||
|
@ -451,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);
|
||||
}
|
||||
|
||||
|
@ -536,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();
|
||||
|
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
|
|
@ -46,8 +46,8 @@ export class DecryptionFailureTracker {
|
|||
};
|
||||
|
||||
// Set to an interval ID when `start` is called
|
||||
public checkInterval: NodeJS.Timeout = null;
|
||||
public trackInterval: NodeJS.Timeout = null;
|
||||
public checkInterval: number = null;
|
||||
public trackInterval: number = null;
|
||||
|
||||
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
|
||||
static TRACK_INTERVAL_MS = 60000;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element';
|
|||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import url from 'url';
|
||||
import katex from 'katex';
|
||||
import { AllHtmlEntities } from 'html-entities';
|
||||
import { IContent } from 'matrix-js-sdk/src/models/event';
|
||||
|
@ -34,7 +33,7 @@ import { IExtendedSanitizeOptions } from './@types/sanitize-html';
|
|||
import linkifyMatrix from './linkify-matrix';
|
||||
import SettingsStore from './settings/SettingsStore';
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji";
|
||||
import { getEmojiFromUnicode } from "./emoji";
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
|
||||
|
@ -58,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
|
||||
|
@ -78,20 +105,8 @@ function mightContainEmoji(str: string): boolean {
|
|||
* @return {String} The shortcode (such as :thumbup:)
|
||||
*/
|
||||
export function unicodeToShortcode(char: string): 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): string {
|
||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
||||
return data ? data.unicode : null;
|
||||
const shortcodes = getEmojiFromUnicode(char)?.shortcodes;
|
||||
return shortcodes?.length ? `:${shortcodes[0]}:` : '';
|
||||
}
|
||||
|
||||
export function processHtmlForSending(html: string): string {
|
||||
|
@ -151,10 +166,8 @@ export function getHtmlText(insaneHtml: string): string {
|
|||
*/
|
||||
export function isUrlPermitted(inputUrl: string): boolean {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
// URL parser protocol includes the trailing colon
|
||||
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
|
||||
return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
@ -176,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")) {
|
||||
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) {
|
||||
|
|
|
@ -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 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;
|
||||
}
|
||||
}
|
|
@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
|
|||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix';
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
|
||||
import { QueryDict } from 'matrix-js-sdk/src/utils';
|
||||
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
|
||||
import SecurityCustomisations from "./customisations/Security";
|
||||
|
@ -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";
|
||||
|
@ -48,10 +48,17 @@ 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 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,
|
||||
|
@ -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,
|
||||
|
@ -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,9 +490,9 @@ 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);
|
||||
|
@ -546,7 +546,7 @@ async function doSetLoggedIn(
|
|||
|
||||
const softLogout = isSoftLogout();
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"setLoggedIn: mxid: " + credentials.userId +
|
||||
" deviceId: " + credentials.deviceId +
|
||||
" guest: " + credentials.guest +
|
||||
|
@ -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,7 +744,7 @@ 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
|
||||
|
@ -766,7 +770,7 @@ 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
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
|
10
src/Login.ts
10
src/Login.ts
|
@ -21,6 +21,8 @@ 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,7 +186,7 @@ export default class Login {
|
|||
}
|
||||
throw originalLoginError;
|
||||
}).catch((error) => {
|
||||
console.log("Login failed", error);
|
||||
logger.log("Login failed", error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
@ -218,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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
|
||||
import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
|
||||
import * as utils from 'matrix-js-sdk/src/utils';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
|
@ -36,6 +36,8 @@ import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } fro
|
|||
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;
|
||||
|
|
|
@ -17,15 +17,18 @@ limitations under the License.
|
|||
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
|
||||
import EventEmitter from 'events';
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
|
||||
interface IMediaDevices {
|
||||
audioOutput: Array<MediaDeviceInfo>;
|
||||
audioInput: Array<MediaDeviceInfo>;
|
||||
videoInput: Array<MediaDeviceInfo>;
|
||||
// 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",
|
||||
}
|
||||
|
@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const output = {
|
||||
[MediaDeviceKindEnum.AudioOutput]: [],
|
||||
[MediaDeviceKindEnum.AudioInput]: [],
|
||||
[MediaDeviceKindEnum.VideoInput]: [],
|
||||
};
|
||||
|
||||
const audioOutput = [];
|
||||
const audioInput = [];
|
||||
const videoInput = [];
|
||||
|
||||
devices.forEach((device) => {
|
||||
switch (device.kind) {
|
||||
case 'audiooutput': audioOutput.push(device); break;
|
||||
case 'audioinput': audioInput.push(device); break;
|
||||
case 'videoinput': videoInput.push(device); break;
|
||||
}
|
||||
});
|
||||
|
||||
return { audioOutput, audioInput, videoInput };
|
||||
devices.forEach((device) => output[device.kind].push(device));
|
||||
return output;
|
||||
} catch (error) {
|
||||
console.warn('Unable to refresh WebRTC Devices: ', error);
|
||||
}
|
||||
|
@ -77,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
|
||||
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
|
||||
|
||||
setMatrixCallAudioInput(audioDeviceId);
|
||||
setMatrixCallVideoInput(videoDeviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
|
||||
}
|
||||
|
||||
public setAudioOutput(deviceId: string): void {
|
||||
|
@ -93,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
*/
|
||||
public setAudioInput(deviceId: string): void {
|
||||
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
|
||||
setMatrixCallAudioInput(deviceId);
|
||||
MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -103,7 +100,15 @@ export default class MediaDeviceHandler extends EventEmitter {
|
|||
*/
|
||||
public setVideoInput(deviceId: string): void {
|
||||
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
|
||||
setMatrixCallVideoInput(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 {
|
||||
|
|
|
@ -18,10 +18,10 @@ 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";
|
||||
|
@ -378,7 +378,7 @@ 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>
|
||||
|
|
|
@ -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,7 +27,6 @@ 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";
|
||||
|
@ -37,6 +36,9 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl
|
|||
import RoomViewStore from "./stores/RoomViewStore";
|
||||
import UserActivity from "./UserActivity";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/*
|
||||
* Dispatches:
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,10 +54,15 @@ 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) {
|
|
@ -20,6 +20,8 @@ import { Room } from 'matrix-js-sdk/src/models/room';
|
|||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
export default class Resend {
|
||||
static resendUnsentEvents(room: Room): Promise<void[]> {
|
||||
return Promise.all(room.getPendingEvents().filter(function(ev: MatrixEvent) {
|
||||
|
@ -47,12 +49,7 @@ export default class Resend {
|
|||
}, 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 + ')');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -22,13 +22,13 @@ 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, Member } from "./components/views/dialogs/InviteDialog";
|
||||
import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
|
||||
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;
|
||||
|
@ -42,16 +42,20 @@ export interface IInviteResult {
|
|||
*
|
||||
* @param {string} roomId The ID of the room to invite to
|
||||
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
|
||||
* @param {function} progressCallback optional callback, fired after each invite.
|
||||
* @returns {Promise} Promise
|
||||
*/
|
||||
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> {
|
||||
const inviter = new MultiInviter(roomId);
|
||||
export function inviteMultipleToRoom(
|
||||
roomId: string,
|
||||
addresses: string[],
|
||||
progressCallback?: () => void,
|
||||
): Promise<IInviteResult> {
|
||||
const inviter = new MultiInviter(roomId, progressCallback);
|
||||
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
|
||||
}
|
||||
|
||||
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 },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
|
@ -105,13 +109,12 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> {
|
||||
return inviteMultipleToRoom(roomId, userIds).then((result) => {
|
||||
export function inviteUsersToRoom(roomId: string, userIds: string[], progressCallback?: () => void): Promise<void> {
|
||||
return inviteMultipleToRoom(roomId, userIds, progressCallback).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")),
|
||||
|
@ -131,7 +134,6 @@ export function showAnyInviteErrors(
|
|||
// 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 }),
|
||||
description: inviter.getErrorText(failedUsers[0]),
|
||||
|
@ -178,7 +180,6 @@ export function showAnyInviteErrors(
|
|||
</div>
|
||||
</div>;
|
||||
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, {
|
||||
title: _t("Some invites couldn't be sent"),
|
||||
description,
|
||||
|
|
|
@ -17,27 +17,31 @@ limitations under the License.
|
|||
|
||||
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);
|
||||
|
||||
|
@ -55,9 +59,9 @@ export function aggregateNotificationCount(rooms) {
|
|||
}, { 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');
|
||||
}
|
22
src/Rooms.ts
22
src/Rooms.ts
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
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,
|
||||
|
@ -28,7 +29,18 @@ import { MatrixClientPeg } from './MatrixClientPeg';
|
|||
* @returns {string} A display alias for the given room
|
||||
*/
|
||||
export function getDisplayAliasForRoom(room: Room): string {
|
||||
return room.getCanonicalAlias() || room.getAltAliases()[0];
|
||||
return getDisplayAliasForAliasSet(
|
||||
room.getCanonicalAlias(), room.getAltAliases(),
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -72,10 +84,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise<void>
|
|||
this room as a DM room
|
||||
* @returns {object} A promise
|
||||
*/
|
||||
export function setDMRoom(roomId: string, userId: string): Promise<void> {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
export async function setDMRoom(roomId: string, userId: string): Promise<void> {
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
|
||||
let dmRoomMap = {};
|
||||
|
@ -104,7 +114,7 @@ export function setDMRoom(roomId: string, userId: string): Promise<void> {
|
|||
dmRoomMap[userId] = roomList;
|
||||
}
|
||||
|
||||
return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -25,6 +25,8 @@ 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";
|
||||
|
||||
|
@ -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
|
||||
|
|
|
@ -245,13 +245,33 @@ 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 = {
|
||||
|
@ -262,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.'));
|
||||
|
@ -293,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;
|
||||
|
@ -354,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.'));
|
||||
|
@ -380,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.'));
|
||||
|
@ -396,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.'));
|
||||
|
@ -415,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.'));
|
||||
|
@ -431,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.'));
|
||||
|
@ -462,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.'));
|
||||
|
@ -492,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();
|
||||
|
@ -526,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.'));
|
||||
|
@ -545,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;
|
||||
}
|
||||
|
||||
|
@ -580,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;
|
||||
}
|
||||
|
@ -594,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 {
|
||||
|
@ -612,28 +634,28 @@ const onMessage = function(event) {
|
|||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
@ -643,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:
|
||||
|
@ -665,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);
|
||||
|
@ -689,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 { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
|
||||
async function serverSideSearch(term, roomId = undefined) {
|
||||
async function serverSideSearch(
|
||||
term: string,
|
||||
roomId: string = undefined,
|
||||
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const filter = {
|
||||
const filter: IRoomEventFilter = {
|
||||
limit: SEARCH_LIMIT,
|
||||
};
|
||||
|
||||
if (roomId !== undefined) filter.rooms = [roomId];
|
||||
|
||||
const body = {
|
||||
const body: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
room_events: {
|
||||
search_term: term,
|
||||
filter: filter,
|
||||
order_by: "recent",
|
||||
order_by: SearchOrderBy.Recent,
|
||||
event_context: {
|
||||
before_limit: 1,
|
||||
after_limit: 1,
|
||||
|
@ -45,31 +61,26 @@ async function serverSideSearch(term, roomId = undefined) {
|
|||
|
||||
const response = await client.search({ body: body });
|
||||
|
||||
const result = {
|
||||
response: response,
|
||||
query: body,
|
||||
};
|
||||
|
||||
return result;
|
||||
return { response, query: body };
|
||||
}
|
||||
|
||||
async function serverSideSearchProcess(term, roomId = undefined) {
|
||||
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
const result = await serverSideSearch(term, roomId);
|
||||
|
||||
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally
|
||||
// so we're reusing the concept here since we wan't to delegate the
|
||||
// so we're reusing the concept here since we want to delegate the
|
||||
// pagination back to backPaginateRoomEventsSearch() in some cases.
|
||||
const searchResult = {
|
||||
const searchResults: ISearchResults = {
|
||||
_query: result.query,
|
||||
results: [],
|
||||
highlights: [],
|
||||
};
|
||||
|
||||
return client.processRoomEventsSearch(searchResult, result.response);
|
||||
return client.processRoomEventsSearch(searchResults, result.response);
|
||||
}
|
||||
|
||||
function compareEvents(a, b) {
|
||||
function compareEvents(a: ISearchResult, b: ISearchResult): number {
|
||||
const aEvent = a.result;
|
||||
const bEvent = b.result;
|
||||
|
||||
|
@ -79,7 +90,7 @@ function compareEvents(a, b) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
async function combinedSearch(searchTerm) {
|
||||
async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
// Create two promises, one for the local search, one for the
|
||||
|
@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) {
|
|||
// returns since that one can be either a server-side one, a local one or a
|
||||
// fake one to fetch the remaining cached events. See the docs for
|
||||
// combineEvents() for an explanation why we need to cache events.
|
||||
const emptyResult = {
|
||||
const emptyResult: ISeshatSearchResults = {
|
||||
seshatQuery: localQuery,
|
||||
_query: serverQuery,
|
||||
serverSideNextBatch: serverResponse.next_batch,
|
||||
serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
|
||||
cachedEvents: [],
|
||||
oldestEventFrom: "server",
|
||||
results: [],
|
||||
|
@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) {
|
|||
const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
|
||||
|
||||
// Let the client process the combined result.
|
||||
const response = {
|
||||
const response: ISearchResponse = {
|
||||
search_categories: {
|
||||
room_events: combinedResult,
|
||||
},
|
||||
|
@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) {
|
|||
return result;
|
||||
}
|
||||
|
||||
async function localSearch(searchTerm, roomId = undefined, processResult = true) {
|
||||
async function localSearch(
|
||||
searchTerm: string,
|
||||
roomId: string = undefined,
|
||||
processResult = true,
|
||||
): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const searchArgs = {
|
||||
const searchArgs: ISearchArgs = {
|
||||
search_term: searchTerm,
|
||||
before_limit: 1,
|
||||
after_limit: 1,
|
||||
|
@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true)
|
|||
return result;
|
||||
}
|
||||
|
||||
async function localSearchProcess(searchTerm, roomId = undefined) {
|
||||
export interface ISeshatSearchResults extends ISearchResults {
|
||||
seshatQuery?: ISearchArgs;
|
||||
cachedEvents?: ISearchResult[];
|
||||
oldestEventFrom?: "local" | "server";
|
||||
serverSideNextBatch?: string;
|
||||
}
|
||||
|
||||
async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise<ISeshatSearchResults> {
|
||||
const emptyResult = {
|
||||
results: [],
|
||||
highlights: [],
|
||||
};
|
||||
} as ISeshatSearchResults;
|
||||
|
||||
if (searchTerm === "") return emptyResult;
|
||||
|
||||
|
@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
|
|||
|
||||
emptyResult.seshatQuery = result.query;
|
||||
|
||||
const response = {
|
||||
const response: ISearchResponse = {
|
||||
search_categories: {
|
||||
room_events: result.response,
|
||||
},
|
||||
|
@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
|
|||
return processedResult;
|
||||
}
|
||||
|
||||
async function localPagination(searchResult) {
|
||||
async function localPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
|
@ -221,10 +243,10 @@ async function localPagination(searchResult) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function compareOldestEvents(firstResults, secondResults) {
|
||||
function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
|
||||
try {
|
||||
const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result;
|
||||
const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result;
|
||||
const oldestFirstEvent = firstResults[firstResults.length - 1].result;
|
||||
const oldestSecondEvent = secondResults[secondResults.length - 1].result;
|
||||
|
||||
if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
|
||||
return -1;
|
||||
|
@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) {
|
|||
}
|
||||
}
|
||||
|
||||
function combineEventSources(previousSearchResult, response, a, b) {
|
||||
function combineEventSources(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
response: IResultRoomEvents,
|
||||
a: ISearchResult[],
|
||||
b: ISearchResult[],
|
||||
): void {
|
||||
// Merge event sources and sort the events.
|
||||
const combinedEvents = a.concat(b).sort(compareEvents);
|
||||
// Put half of the events in the response, and cache the other half.
|
||||
|
@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) {
|
|||
* different event sources.
|
||||
*
|
||||
*/
|
||||
function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
const response = {};
|
||||
function combineEvents(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
localEvents: IResultRoomEvents = undefined,
|
||||
serverEvents: IResultRoomEvents = undefined,
|
||||
): IResultRoomEvents {
|
||||
const response = {} as IResultRoomEvents;
|
||||
|
||||
const cachedEvents = previousSearchResult.cachedEvents;
|
||||
let oldestEventFrom = previousSearchResult.oldestEventFrom;
|
||||
|
@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// This is a first search call, combine the events from the server and
|
||||
// the local index. Note where our oldest event came from, we shall
|
||||
// fetch the next batch of events from the other source.
|
||||
if (compareOldestEvents(localEvents, serverEvents) < 0) {
|
||||
if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
|
||||
|
@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// meaning that our oldest event was on the server.
|
||||
// Change the source of the oldest event if our local event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(localEvents, cachedEvents) < 0) {
|
||||
if (compareOldestEvents(localEvents.results, cachedEvents) < 0) {
|
||||
oldestEventFrom = "local";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
|
||||
|
@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
// meaning that our oldest event was in the local index.
|
||||
// Change the source of the oldest event if our server event is older
|
||||
// than the cached one.
|
||||
if (compareOldestEvents(serverEvents, cachedEvents) < 0) {
|
||||
if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
|
||||
oldestEventFrom = "server";
|
||||
}
|
||||
combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
|
||||
|
@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
|
|||
* @return {object} A response object that combines the events from the
|
||||
* different event sources.
|
||||
*/
|
||||
function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
|
||||
function combineResponses(
|
||||
previousSearchResult: ISeshatSearchResults,
|
||||
localEvents: IResultRoomEvents = undefined,
|
||||
serverEvents: IResultRoomEvents = undefined,
|
||||
): IResultRoomEvents {
|
||||
// Combine our events first.
|
||||
const response = combineEvents(previousSearchResult, localEvents, serverEvents);
|
||||
|
||||
|
@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
|
|||
return response;
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice = []) {
|
||||
interface IEncryptedSeshatEvent {
|
||||
curve25519Key: string;
|
||||
ed25519Key: string;
|
||||
algorithm: string;
|
||||
forwardingCurve25519KeyChain: string[];
|
||||
}
|
||||
|
||||
function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
|
||||
for (let i = 0; i < searchResultSlice.length; i++) {
|
||||
const timeline = searchResultSlice[i].context.getTimeline();
|
||||
|
||||
for (let j = 0; j < timeline.length; j++) {
|
||||
const ev = timeline[j];
|
||||
const mxEv = timeline[j];
|
||||
const ev = mxEv.event as IEncryptedSeshatEvent;
|
||||
|
||||
if (ev.event.curve25519Key) {
|
||||
ev.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
{ algorithm: ev.event.algorithm },
|
||||
ev.event.curve25519Key,
|
||||
ev.event.ed25519Key,
|
||||
if (ev.curve25519Key) {
|
||||
mxEv.makeEncrypted(
|
||||
EventType.RoomMessageEncrypted,
|
||||
{ algorithm: ev.algorithm },
|
||||
ev.curve25519Key,
|
||||
ev.ed25519Key,
|
||||
);
|
||||
ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
|
||||
// @ts-ignore
|
||||
mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
|
||||
|
||||
delete ev.event.curve25519Key;
|
||||
delete ev.event.ed25519Key;
|
||||
delete ev.event.algorithm;
|
||||
delete ev.event.forwardingCurve25519KeyChain;
|
||||
delete ev.curve25519Key;
|
||||
delete ev.ed25519Key;
|
||||
delete ev.algorithm;
|
||||
delete ev.forwardingCurve25519KeyChain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function combinedPagination(searchResult) {
|
||||
async function combinedPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const searchArgs = searchResult.seshatQuery;
|
||||
const oldestEventFrom = searchResult.oldestEventFrom;
|
||||
|
||||
let localResult;
|
||||
let serverSideResult;
|
||||
let localResult: IResultRoomEvents;
|
||||
let serverSideResult: ISearchResponse;
|
||||
|
||||
// Fetch events from the local index if we have a token for itand if it's
|
||||
// Fetch events from the local index if we have a token for it and if it's
|
||||
// the local indexes turn or the server has exhausted its results.
|
||||
if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
|
||||
localResult = await eventIndex.search(searchArgs);
|
||||
|
@ -502,7 +546,7 @@ async function combinedPagination(searchResult) {
|
|||
serverSideResult = await client.search(body);
|
||||
}
|
||||
|
||||
let serverEvents;
|
||||
let serverEvents: IResultRoomEvents;
|
||||
|
||||
if (serverSideResult) {
|
||||
serverEvents = serverSideResult.search_categories.room_events;
|
||||
|
@ -532,8 +576,8 @@ async function combinedPagination(searchResult) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function eventIndexSearch(term, roomId = undefined) {
|
||||
let searchPromise;
|
||||
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
let searchPromise: Promise<ISearchResults>;
|
||||
|
||||
if (roomId !== undefined) {
|
||||
if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
|
||||
|
@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) {
|
|||
return searchPromise;
|
||||
}
|
||||
|
||||
function eventIndexSearchPagination(searchResult) {
|
||||
function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise<ISeshatSearchResults> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const seshatQuery = searchResult.seshatQuery;
|
||||
|
@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) {
|
|||
}
|
||||
}
|
||||
|
||||
export function searchPagination(searchResult) {
|
||||
export function searchPagination(searchResult: ISearchResults): Promise<ISearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
|
@ -590,7 +634,7 @@ export function searchPagination(searchResult) {
|
|||
else return eventIndexSearchPagination(searchResult);
|
||||
}
|
||||
|
||||
export default function eventSearch(term, roomId = undefined) {
|
||||
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
|
|
@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICryptoCallbacks, 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';
|
||||
|
@ -30,6 +31,8 @@ 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
|
||||
// during the same single operation. Use `accessSecretStorage` below to scope a
|
||||
|
@ -135,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];
|
||||
}
|
||||
|
@ -185,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;
|
||||
}
|
||||
|
||||
|
@ -247,13 +250,13 @@ async function onSecretRequested(
|
|||
name: string,
|
||||
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 (
|
||||
|
@ -266,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`,
|
||||
);
|
||||
}
|
||||
|
@ -274,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`,
|
||||
);
|
||||
}
|
||||
|
@ -328,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', '',
|
||||
|
@ -354,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) => {
|
||||
|
@ -381,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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -414,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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -23,7 +23,6 @@ import { User } from "matrix-js-sdk/src/models/user";
|
|||
import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import dis from './dispatcher/dispatcher';
|
||||
import * as sdk from './index';
|
||||
import { _t, _td } from './languageHandler';
|
||||
import Modal from './Modal';
|
||||
import MultiInviter from './utils/MultiInviter';
|
||||
|
@ -35,7 +34,6 @@ 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 { WidgetType } from "./widgets/WidgetType";
|
||||
import { Jitsi } from "./widgets/Jitsi";
|
||||
import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
|
||||
|
@ -50,6 +48,14 @@ import { UIFeature } from "./settings/UIFeature";
|
|||
import { CHAT_EFFECTS } from "./effects";
|
||||
import CallHandler from "./CallHandler";
|
||||
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) => {
|
||||
|
@ -241,22 +246,6 @@ export const Commands = [
|
|||
},
|
||||
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>',
|
||||
|
@ -269,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,
|
||||
/*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());
|
||||
|
@ -349,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()));
|
||||
|
@ -393,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);
|
||||
|
@ -434,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 }} />,
|
||||
|
@ -481,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"),
|
||||
},
|
||||
);
|
||||
|
@ -523,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
|
||||
|
@ -737,7 +680,6 @@ 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>
|
||||
|
@ -768,7 +710,6 @@ 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>
|
||||
|
@ -838,7 +779,6 @@ 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 });
|
||||
return success();
|
||||
},
|
||||
|
@ -863,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;
|
||||
}
|
||||
}
|
||||
|
@ -883,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;
|
||||
|
@ -943,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>
|
||||
|
@ -1000,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();
|
||||
},
|
||||
|
@ -1076,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);
|
||||
|
|
12
src/Terms.ts
12
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 * 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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -139,11 +142,11 @@ 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
|
||||
|
@ -187,7 +190,8 @@ export function dialogTermsInteractionCallback(
|
|||
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, {
|
||||
|
|
|
@ -13,9 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||
import { _t } from './languageHandler';
|
||||
import * as Roles from './Roles';
|
||||
import { isValid3pidInvite } from "./RoomInvite";
|
||||
|
@ -27,12 +25,45 @@ 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 textForMemberEvent(ev): () => string | null {
|
||||
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
let isVoice = true;
|
||||
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();
|
||||
|
@ -84,7 +115,7 @@ function textForMemberEvent(ev): () => string | null {
|
|||
return () => _t('%(senderName)s changed their profile picture', { senderName });
|
||||
} else if (!prevContent.avatar_url && content.avatar_url) {
|
||||
return () => _t('%(senderName)s set a profile picture', { senderName });
|
||||
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
} else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
|
||||
// This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
|
||||
return () => _t("%(senderName)s made no change", { senderName });
|
||||
} else {
|
||||
|
@ -127,7 +158,7 @@ function textForMemberEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForTopicEvent(ev): () => string | null {
|
||||
function textForTopicEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
|
||||
senderDisplayName,
|
||||
|
@ -135,7 +166,12 @@ function textForTopicEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForRoomNameEvent(ev): () => string | null {
|
||||
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) {
|
||||
|
@ -154,12 +190,12 @@ function textForRoomNameEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForTombstoneEvent(ev): () => string | null {
|
||||
function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
|
||||
}
|
||||
|
||||
function textForJoinRulesEvent(ev): () => string | null {
|
||||
function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().join_rule) {
|
||||
case "public":
|
||||
|
@ -179,7 +215,7 @@ function textForJoinRulesEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForGuestAccessEvent(ev): () => string | null {
|
||||
function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
switch (ev.getContent().guest_access) {
|
||||
case "can_join":
|
||||
|
@ -195,7 +231,7 @@ function textForGuestAccessEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForRelatedGroupsEvent(ev): () => string | null {
|
||||
function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const groups = ev.getContent().groups || [];
|
||||
const prevGroups = ev.getPrevContent().groups || [];
|
||||
|
@ -225,7 +261,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForServerACLEvent(ev): () => string | null {
|
||||
function textForServerACLEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const prevContent = ev.getPrevContent();
|
||||
const current = ev.getContent();
|
||||
|
@ -255,20 +291,36 @@ function textForServerACLEvent(ev): () => string | null {
|
|||
return getText;
|
||||
}
|
||||
|
||||
function textForMessageEvent(ev): () => string | null {
|
||||
function textForMessageEvent(ev: MatrixEvent): () => string | null {
|
||||
return () => {
|
||||
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
let message = senderDisplayName + ': ' + ev.getContent().body;
|
||||
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): () => string | null {
|
||||
function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
|
||||
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
|
||||
const oldAlias = ev.getPrevContent().alias;
|
||||
const oldAltAliases = ev.getPrevContent().alt_aliases || [];
|
||||
|
@ -319,91 +371,7 @@ function textForCanonicalAliasEvent(ev): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForCallAnswerEvent(event): () => string | null {
|
||||
return () => {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
|
||||
return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported;
|
||||
};
|
||||
}
|
||||
|
||||
function textForCallHangupEvent(event): () => string | null {
|
||||
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||
const eventContent = event.getContent();
|
||||
let getReason = () => "";
|
||||
if (!MatrixClientPeg.get().supportsVoip()) {
|
||||
getReason = () => _t('(not supported by this browser)');
|
||||
} else if (eventContent.reason) {
|
||||
if (eventContent.reason === "ice_failed") {
|
||||
// We couldn't establish a connection at all
|
||||
getReason = () => _t('(could not connect media)');
|
||||
} else if (eventContent.reason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
getReason = () => _t('(connection failed)');
|
||||
} else if (eventContent.reason === "user_media_failed") {
|
||||
// The other side couldn't open capture devices
|
||||
getReason = () => _t("(their device couldn't start the camera / microphone)");
|
||||
} else if (eventContent.reason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
getReason = () => _t("(an error occurred)");
|
||||
} else if (eventContent.reason === "invite_timeout") {
|
||||
getReason = () => _t('(no answer)');
|
||||
} else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") {
|
||||
// workaround for https://github.com/vector-im/element-web/issues/5178
|
||||
// it seems Android randomly sets a reason of "user hangup" which is
|
||||
// interpreted as an error code :(
|
||||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
getReason = () => '';
|
||||
} else {
|
||||
getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason });
|
||||
}
|
||||
}
|
||||
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
|
||||
}
|
||||
|
||||
function textForCallRejectEvent(event): () => string | null {
|
||||
return () => {
|
||||
const senderName = event.sender ? event.sender.name : _t('Someone');
|
||||
return _t('%(senderName)s declined the call.', { senderName });
|
||||
};
|
||||
}
|
||||
|
||||
function textForCallInviteEvent(event): () => 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 textForThreePidInviteEvent(event): () => string | null {
|
||||
function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
|
||||
if (!isValid3pidInvite(event)) {
|
||||
|
@ -419,7 +387,7 @@ function textForThreePidInviteEvent(event): () => string | null {
|
|||
});
|
||||
}
|
||||
|
||||
function textForHistoryVisibilityEvent(event): () => string | null {
|
||||
function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
switch (event.getContent().history_visibility) {
|
||||
case 'invited':
|
||||
|
@ -441,13 +409,14 @@ function textForHistoryVisibilityEvent(event): () => string | null {
|
|||
}
|
||||
|
||||
// Currently will only display a change if a user's power level is changed
|
||||
function textForPowerEvent(event): () => string | null {
|
||||
function textForPowerEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender ? event.sender.name : event.getSender();
|
||||
if (!event.getPrevContent() || !event.getPrevContent().users ||
|
||||
!event.getContent() || !event.getContent().users) {
|
||||
return null;
|
||||
}
|
||||
const userDefault = event.getContent().users_default || 0;
|
||||
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(
|
||||
|
@ -463,9 +432,16 @@ function textForPowerEvent(event): () => string | null {
|
|||
const diffs = [];
|
||||
users.forEach((userId) => {
|
||||
// Previous power level
|
||||
const from = event.getPrevContent().users[userId];
|
||||
let from = event.getPrevContent().users[userId];
|
||||
if (!Number.isInteger(from)) {
|
||||
from = previousUserDefault;
|
||||
}
|
||||
// Current power level
|
||||
const to = event.getContent().users[userId];
|
||||
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 });
|
||||
}
|
||||
|
@ -479,13 +455,22 @@ function textForPowerEvent(event): () => string | null {
|
|||
powerLevelDiffText: diffs.map(diff =>
|
||||
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
|
||||
userId: diff.userId,
|
||||
fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault),
|
||||
toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault),
|
||||
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,
|
||||
|
@ -497,17 +482,77 @@ const onPinnedMessagesClick = (): void => {
|
|||
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> },
|
||||
)
|
||||
}
|
||||
{ _t(
|
||||
"%(senderName)s changed the <a>pinned messages</a> for the room.",
|
||||
{ senderName },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> },
|
||||
) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -515,7 +560,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
|
|||
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
|
||||
}
|
||||
|
||||
function textForWidgetEvent(event): () => string | null {
|
||||
function textForWidgetEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
|
||||
const { name, type, url } = event.getContent() || {};
|
||||
|
@ -545,12 +590,12 @@ function textForWidgetEvent(event): () => string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function textForWidgetLayoutEvent(event): () => string | null {
|
||||
function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.sender?.name || event.getSender();
|
||||
return () => _t("%(senderName)s has updated the widget layout", { senderName });
|
||||
}
|
||||
|
||||
function textForMjolnirEvent(event): () => string | null {
|
||||
function textForMjolnirEvent(event: MatrixEvent): () => string | null {
|
||||
const senderName = event.getSender();
|
||||
const { entity: prevEntity } = event.getPrevContent();
|
||||
const { entity, recommendation, reason } = event.getContent();
|
||||
|
@ -638,15 +683,15 @@ function textForMjolnirEvent(event): () => string | null {
|
|||
}
|
||||
|
||||
interface IHandlers {
|
||||
[type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null);
|
||||
[type: string]:
|
||||
(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
|
||||
(() => string | JSX.Element | null);
|
||||
}
|
||||
|
||||
const handlers: IHandlers = {
|
||||
'm.room.message': textForMessageEvent,
|
||||
'm.sticker': textForMessageEvent,
|
||||
'm.call.invite': textForCallInviteEvent,
|
||||
'm.call.answer': textForCallAnswerEvent,
|
||||
'm.call.hangup': textForCallHangupEvent,
|
||||
'm.call.reject': textForCallRejectEvent,
|
||||
};
|
||||
|
||||
const stateHandlers: IHandlers = {
|
||||
|
@ -654,6 +699,7 @@ const stateHandlers: IHandlers = {
|
|||
'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,
|
||||
|
@ -674,14 +720,27 @@ for (const evType of ALL_RULE_TYPES) {
|
|||
stateHandlers[evType] = textForMjolnirEvent;
|
||||
}
|
||||
|
||||
export function hasText(ev): boolean {
|
||||
/**
|
||||
* Determines whether the given event has text to display.
|
||||
* @param ev The event
|
||||
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
|
||||
* to avoid hitting the settings store
|
||||
*/
|
||||
export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
return Boolean(handler?.(ev));
|
||||
return Boolean(handler?.(ev, false, showHiddenEvents));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the textual content of the given event.
|
||||
* @param ev The event
|
||||
* @param allowJSX Whether to output rich JSX content
|
||||
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
|
||||
* to avoid hitting the settings store
|
||||
*/
|
||||
export function textForEvent(ev: MatrixEvent): string;
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element;
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element {
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
|
||||
export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
|
||||
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
|
||||
return handler?.(ev, allowJSX)?.() || '';
|
||||
return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile";
|
|||
* @returns {boolean} True if the given event should affect the unread message count
|
||||
*/
|
||||
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
|
||||
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
|
||||
if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
|
|||
// https://github.com/vector-im/element-web/issues/2427
|
||||
// ...and possibly some of the others at
|
||||
// https://github.com/vector-im/element-web/issues/3363
|
||||
if (room.timeline.length &&
|
||||
room.timeline[room.timeline.length - 1].sender &&
|
||||
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
|
||||
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,35 +14,33 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const emailRegex = /^\S+@\S+\.\S+$/;
|
||||
const mxUserIdRegex = /^@\S+:\S+$/;
|
||||
const mxRoomIdRegex = /^!\S+:\S+$/;
|
||||
|
||||
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: string): AddressType | null {
|
||||
if (emailRegex.test(inputText)) {
|
||||
|
|
|
@ -20,6 +20,8 @@ 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.
|
||||
|
||||
|
@ -59,7 +61,7 @@ export default class VoipUserMapper {
|
|||
public nativeRoomForVirtualRoom(roomId: string): string {
|
||||
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
|
||||
if (cachedNativeRoomId) {
|
||||
console.log(
|
||||
logger.log(
|
||||
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
|
||||
);
|
||||
return cachedNativeRoomId;
|
||||
|
@ -98,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;
|
||||
|
|
|
@ -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 InfoDialog from "../components/views/dialogs/InfoDialog";
|
||||
|
||||
// TS: once languageHandler is TS we can probably inline this into the enum
|
||||
_td("Navigation");
|
||||
|
@ -163,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],
|
||||
|
@ -370,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"),
|
||||
|
|
|
@ -150,13 +150,14 @@ 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: [],
|
||||
|
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
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,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{ children({ onKeyDownHandler }) }
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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,7 +108,6 @@ 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 }),
|
||||
|
@ -129,7 +127,6 @@ 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 }),
|
||||
|
|
|
@ -15,8 +15,10 @@ 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';
|
||||
|
||||
|
@ -24,46 +26,44 @@ import SettingsStore from "../../../../settings/SettingsStore";
|
|||
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
|
||||
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,7 +15,6 @@ 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";
|
||||
|
@ -24,10 +23,12 @@ import Modal from '../../../../Modal';
|
|||
import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils";
|
||||
import EventIndexPeg from "../../../../indexing/EventIndexPeg";
|
||||
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,8 +133,9 @@ 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,
|
||||
);
|
||||
};
|
||||
|
@ -145,7 +147,6 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
|
|||
|
||||
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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -23,6 +23,8 @@ 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
|
||||
|
@ -31,30 +33,30 @@ export enum PlaybackState {
|
|||
}
|
||||
|
||||
export const PLAYBACK_WAVEFORM_SAMPLES = 39;
|
||||
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
|
||||
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));
|
||||
|
||||
// Next, 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 for the remaining function logic.
|
||||
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||
|
||||
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
|
||||
// waveform. Most speech happens below the 0.5 mark.
|
||||
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
|
||||
|
||||
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
|
||||
// sensible. This is what we return to keep our contract of "values between zero and one".
|
||||
return arrayRescale(filtered, 0, 1);
|
||||
// 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;
|
||||
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;
|
||||
|
@ -72,6 +74,7 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
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);
|
||||
}
|
||||
|
@ -116,41 +119,74 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
}
|
||||
|
||||
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() {
|
||||
// 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 => {
|
||||
// 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...");
|
||||
// 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);
|
||||
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);
|
||||
// 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);
|
||||
// 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.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
|
||||
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
|
||||
this.clock.durationSeconds = this.audioBuf.duration;
|
||||
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 () => {
|
||||
|
@ -163,7 +199,11 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
if (this.state === PlaybackState.Stopped) {
|
||||
this.disconnectSource();
|
||||
this.makeNewSourceBuffer();
|
||||
this.source.start();
|
||||
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
|
||||
|
@ -174,13 +214,21 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
}
|
||||
|
||||
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() {
|
||||
this.source = this.context.createBufferSource();
|
||||
this.source.buffer = this.audioBuf;
|
||||
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);
|
||||
}
|
||||
|
@ -233,7 +281,11 @@ export class Playback extends EventEmitter implements IDestroyable {
|
|||
// 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.
|
||||
this.source.start(now, timeSeconds);
|
||||
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*
|
|
@ -89,9 +89,9 @@ export class PlaybackClock implements IDestroyable {
|
|||
return this.observable;
|
||||
}
|
||||
|
||||
private checkTime = () => {
|
||||
private checkTime = (force = false) => {
|
||||
const now = this.timeSeconds; // calculated dynamically
|
||||
if (this.lastCheck !== now) {
|
||||
if (this.lastCheck !== now || force) {
|
||||
this.observable.update([now, this.durationSeconds]);
|
||||
this.lastCheck = now;
|
||||
}
|
||||
|
@ -103,8 +103,8 @@ export class PlaybackClock implements IDestroyable {
|
|||
* @param {MatrixEvent} event The event to use for placeholders.
|
||||
*/
|
||||
public populatePlaceholdersFrom(event: MatrixEvent) {
|
||||
const durationSeconds = Number(event.getContent()['info']?.['duration']);
|
||||
if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds;
|
||||
const durationMs = Number(event.getContent()['info']?.['duration']);
|
||||
if (Number.isFinite(durationMs)) this.placeholderDuration = durationMs / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -132,12 +132,16 @@ export class PlaybackClock implements IDestroyable {
|
|||
|
||||
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();
|
||||
this.checkTime(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,15 +22,36 @@ 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,9 +68,9 @@ 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
|
|
@ -19,7 +19,6 @@ import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
|||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import MediaDeviceHandler from "../MediaDeviceHandler";
|
||||
import { SimpleObservable } from "matrix-widget-api";
|
||||
import { clamp, percentageOf, percentageWithin } from "../utils/numbers";
|
||||
import EventEmitter from "events";
|
||||
import { IDestroyable } from "../utils/IDestroyable";
|
||||
import { Singleflight } from "../utils/Singleflight";
|
||||
|
@ -29,6 +28,9 @@ 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.
|
||||
|
@ -61,7 +63,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
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
|
||||
|
@ -70,6 +71,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
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();
|
||||
|
@ -111,27 +113,11 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
// 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);
|
||||
|
@ -145,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;
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -369,12 +328,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
|
||||
if (this.lastUpload) return this.lastUpload;
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -21,6 +21,8 @@ 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);
|
|
@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload {
|
|||
|
||||
export interface IAmplitudePayload extends IPayload {
|
||||
ev: PayloadEvent.AmplitudeMark;
|
||||
forSecond: number;
|
||||
forIndex: number;
|
||||
amplitude: number;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -20,15 +20,14 @@ 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 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
|
||||
|
@ -55,11 +54,9 @@ const PROVIDERS = [
|
|||
EmojiProvider,
|
||||
NotifProvider,
|
||||
CommandProvider,
|
||||
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);
|
||||
|
|
|
@ -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)];
|
||||
}
|
||||
|
@ -95,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 }
|
||||
|
|
|
@ -22,12 +22,12 @@ import AutocompleteProvider from './AutocompleteProvider';
|
|||
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 FlairStore from "../stores/FlairStore";
|
||||
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)) {
|
||||
|
@ -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 }
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -25,7 +25,6 @@ import { PillCompletion } from './Components';
|
|||
import { ICompletion, ISelectionRange } from './Autocompleter';
|
||||
import { uniq, sortBy } from 'lodash';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { shortcodeToUnicode } from '../HtmlUtils';
|
||||
import { EMOJI, IEmoji } from '../emoji';
|
||||
|
||||
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
|
||||
|
@ -36,20 +35,18 @@ const LIMIT = 20;
|
|||
// anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs
|
||||
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g');
|
||||
|
||||
interface IEmojiShort {
|
||||
interface ISortedEmoji {
|
||||
emoji: IEmoji;
|
||||
shortname: string;
|
||||
_orderBy: number;
|
||||
}
|
||||
|
||||
const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => {
|
||||
const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => {
|
||||
if (a.group === b.group) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.group - b.group;
|
||||
}).map((emoji, index) => ({
|
||||
emoji,
|
||||
shortname: `:${emoji.shortcodes[0]}:`,
|
||||
// Include the index so that we can preserve the original order
|
||||
_orderBy: index,
|
||||
}));
|
||||
|
@ -64,20 +61,18 @@ function score(query, space) {
|
|||
}
|
||||
|
||||
export default class EmojiProvider extends AutocompleteProvider {
|
||||
matcher: QueryMatcher<IEmojiShort>;
|
||||
nameMatcher: QueryMatcher<IEmojiShort>;
|
||||
matcher: QueryMatcher<ISortedEmoji>;
|
||||
nameMatcher: QueryMatcher<ISortedEmoji>;
|
||||
|
||||
constructor() {
|
||||
super(EMOJI_REGEX);
|
||||
this.matcher = new QueryMatcher<IEmojiShort>(EMOJI_SHORTNAMES, {
|
||||
keys: ['emoji.emoticon', 'shortname'],
|
||||
funcs: [
|
||||
(o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases
|
||||
],
|
||||
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
|
||||
keys: [],
|
||||
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
|
||||
// For matching against ascii equivalents
|
||||
shouldMatchWordsOnly: false,
|
||||
});
|
||||
this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, {
|
||||
this.nameMatcher = new QueryMatcher(SORTED_EMOJI, {
|
||||
keys: ['emoji.annotation'],
|
||||
// For removing punctuation
|
||||
shouldMatchWordsOnly: true,
|
||||
|
@ -96,7 +91,8 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
|
||||
let completions = [];
|
||||
const { command, range } = this.getCurrentCommand(query, selection);
|
||||
if (command) {
|
||||
|
||||
if (command && command[0].length > 2) {
|
||||
const matchedString = command[0];
|
||||
completions = this.matcher.match(matchedString, limit);
|
||||
|
||||
|
@ -105,34 +101,33 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
|
||||
const sorters = [];
|
||||
// make sure that emoticons come first
|
||||
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
|
||||
sorters.push(c => score(matchedString, c.emoji.emoticon || ""));
|
||||
|
||||
// then sort by score (Infinity if matchedString not in shortname)
|
||||
sorters.push((c) => score(matchedString, c.shortname));
|
||||
// then sort by score (Infinity if matchedString not in shortcode)
|
||||
sorters.push(c => score(matchedString, c.emoji.shortcodes[0]));
|
||||
// then sort by max score of all shortcodes, trim off the `:`
|
||||
sorters.push((c) => Math.min(...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s))));
|
||||
// If the matchedString is not empty, sort by length of shortname. Example:
|
||||
sorters.push(c => Math.min(
|
||||
...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)),
|
||||
));
|
||||
// If the matchedString is not empty, sort by length of shortcode. Example:
|
||||
// matchedString = ":bookmark"
|
||||
// completions = [":bookmark:", ":bookmark_tabs:", ...]
|
||||
if (matchedString.length > 1) {
|
||||
sorters.push((c) => c.shortname.length);
|
||||
sorters.push(c => c.emoji.shortcodes[0].length);
|
||||
}
|
||||
// Finally, sort by original ordering
|
||||
sorters.push((c) => c._orderBy);
|
||||
sorters.push(c => c._orderBy);
|
||||
completions = sortBy(uniq(completions), sorters);
|
||||
|
||||
completions = completions.map(({ shortname }) => {
|
||||
const unicode = shortcodeToUnicode(shortname);
|
||||
return {
|
||||
completion: unicode,
|
||||
component: (
|
||||
<PillCompletion title={shortname} aria-label={unicode}>
|
||||
<span>{ unicode }</span>
|
||||
</PillCompletion>
|
||||
),
|
||||
range,
|
||||
};
|
||||
}).slice(0, LIMIT);
|
||||
completions = completions.map(c => ({
|
||||
completion: c.emoji.unicode,
|
||||
component: (
|
||||
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
|
||||
<span>{ c.emoji.unicode }</span>
|
||||
</PillCompletion>
|
||||
),
|
||||
range,
|
||||
})).slice(0, LIMIT);
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
@ -145,7 +140,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Emoji Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -21,8 +21,8 @@ import AutocompleteProvider from './AutocompleteProvider';
|
|||
import { _t } from '../languageHandler';
|
||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||
import { PillCompletion } from './Components';
|
||||
import * as sdk from '../index';
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
import RoomAvatar from '../components/views/avatars/RoomAvatar';
|
||||
|
||||
const AT_ROOM_REGEX = /@\S*/g;
|
||||
|
||||
|
@ -40,8 +40,6 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return [];
|
||||
|
@ -72,7 +70,7 @@ export default class NotifProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Notification Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -28,7 +28,7 @@ import { PillCompletion } from './Components';
|
|||
import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
import RoomAvatar from '../components/views/avatars/RoomAvatar';
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import SpaceStore from "../stores/SpaceStore";
|
||||
|
||||
const ROOM_REGEX = /\B#\S*/g;
|
||||
|
||||
|
@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
const cli = MatrixClientPeg.get();
|
||||
let rooms = cli.getVisibleRooms();
|
||||
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
// if spaces are enabled then filter them out here as they get their own autocomplete provider
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
rooms = rooms.filter(r => !r.isSpaceRoom());
|
||||
}
|
||||
|
||||
|
@ -133,7 +134,7 @@ export default class RoomProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("Room Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -21,7 +21,6 @@ import React from 'react';
|
|||
import { _t } from '../languageHandler';
|
||||
import AutocompleteProvider from './AutocompleteProvider';
|
||||
import { PillCompletion } from './Components';
|
||||
import * as sdk from '../index';
|
||||
import QueryMatcher from './QueryMatcher';
|
||||
import { sortBy } from 'lodash';
|
||||
import { MatrixClientPeg } from '../MatrixClientPeg';
|
||||
|
@ -33,6 +32,7 @@ import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
|||
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { makeUserPermalink } from "../utils/permalinks/Permalinks";
|
||||
import { ICompletion, ISelectionRange } from "./Autocompleter";
|
||||
import MemberAvatar from '../components/views/avatars/MemberAvatar';
|
||||
|
||||
const USER_REGEX = /\B@\S*/g;
|
||||
|
||||
|
@ -108,10 +108,8 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
force = false,
|
||||
limit = -1,
|
||||
): Promise<ICompletion[]> {
|
||||
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
|
||||
|
||||
// lazy-load user list into matcher
|
||||
if (!this.users) this._makeUsers();
|
||||
if (!this.users) this.makeUsers();
|
||||
|
||||
let completions = [];
|
||||
const { command, range } = this.getCurrentCommand(rawQuery, selection, force);
|
||||
|
@ -149,7 +147,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
return _t('Users');
|
||||
}
|
||||
|
||||
_makeUsers() {
|
||||
private makeUsers() {
|
||||
const events = this.room.getLiveTimeline().getEvents();
|
||||
const lastSpoken = {};
|
||||
|
||||
|
@ -183,7 +181,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
return (
|
||||
<div
|
||||
className="mx_Autocomplete_Completion_container_pill"
|
||||
role="listbox"
|
||||
role="presentation"
|
||||
aria-label={_t("User Autocomplete")}
|
||||
>
|
||||
{ completions }
|
||||
|
|
|
@ -14,9 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import IdentityAuthClient from './IdentityAuthClient';
|
||||
|
||||
export async function getThreepidsWithBindStatus(client, filterMedium) {
|
||||
export async function getThreepidsWithBindStatus(
|
||||
client: MatrixClient, filterMedium?: ThreepidMedium,
|
||||
): Promise<IThreepid[]> {
|
||||
const userId = client.getUserId();
|
||||
|
||||
let { threepids } = await client.getThreePids();
|
||||
|
@ -31,7 +35,7 @@ export async function getThreepidsWithBindStatus(client, filterMedium) {
|
|||
const identityAccessToken = await authClient.getAccessToken({ check: false });
|
||||
|
||||
// Restructure for lookup query
|
||||
const query = threepids.map(({ medium, address }) => [medium, address]);
|
||||
const query = threepids.map(({ medium, address }): [string, string] => [medium, address]);
|
||||
const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken);
|
||||
|
||||
// Record which are already bound
|
|
@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
|||
style={style}
|
||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||
onWheel={onWheel}
|
||||
tabIndex={tabIndex}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order by default.
|
||||
tabIndex={tabIndex ?? -1}
|
||||
>
|
||||
{ children }
|
||||
</div>);
|
||||
|
|
44
src/components/structures/BackdropPanel.tsx
Normal file
44
src/components/structures/BackdropPanel.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
Copyright 2021 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, { CSSProperties } from "react";
|
||||
|
||||
interface IProps {
|
||||
backgroundImage?: string;
|
||||
blurMultiplier?: number;
|
||||
}
|
||||
|
||||
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
|
||||
if (!backgroundImage) return null;
|
||||
|
||||
const styles: CSSProperties = {};
|
||||
if (blurMultiplier) {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
|
||||
const pixelsValue = blurValue.replace('px', '');
|
||||
const parsed = parseInt(pixelsValue, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
styles.filter = `blur(${parsed * blurMultiplier}px)`;
|
||||
}
|
||||
}
|
||||
return <div className="mx_BackdropPanel">
|
||||
<img
|
||||
style={styles}
|
||||
className="mx_BackdropPanel--image"
|
||||
src={backgroundImage} />
|
||||
</div>;
|
||||
};
|
||||
export default BackdropPanel;
|
186
src/components/structures/CallEventGrouper.ts
Normal file
186
src/components/structures/CallEventGrouper.ts
Normal file
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
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 { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import { EventEmitter } from 'events';
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
|
||||
export enum CallEventGrouperEvent {
|
||||
StateChanged = "state_changed",
|
||||
SilencedChanged = "silenced_changed",
|
||||
LengthChanged = "length_changed",
|
||||
}
|
||||
|
||||
const CONNECTING_STATES = [
|
||||
CallState.Connecting,
|
||||
CallState.WaitLocalMedia,
|
||||
CallState.CreateOffer,
|
||||
CallState.CreateAnswer,
|
||||
];
|
||||
|
||||
const SUPPORTED_STATES = [
|
||||
CallState.Connected,
|
||||
CallState.Ringing,
|
||||
];
|
||||
|
||||
export enum CustomCallState {
|
||||
Missed = "missed",
|
||||
}
|
||||
|
||||
export default class CallEventGrouper extends EventEmitter {
|
||||
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
|
||||
private call: MatrixCall;
|
||||
public state: CallState | CustomCallState;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
|
||||
}
|
||||
|
||||
private get invite(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallInvite);
|
||||
}
|
||||
|
||||
private get hangup(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallHangup);
|
||||
}
|
||||
|
||||
private get reject(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
||||
}
|
||||
|
||||
private get selectAnswer(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
|
||||
}
|
||||
|
||||
public get isVoice(): boolean {
|
||||
const invite = this.invite;
|
||||
if (!invite) return;
|
||||
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public get hangupReason(): string | null {
|
||||
return this.hangup?.getContent()?.reason;
|
||||
}
|
||||
|
||||
public get rejectParty(): string {
|
||||
return this.reject?.getSender();
|
||||
}
|
||||
|
||||
public get gotRejected(): boolean {
|
||||
return Boolean(this.reject);
|
||||
}
|
||||
|
||||
public get duration(): Date {
|
||||
if (!this.hangup || !this.selectAnswer) return;
|
||||
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are only events from the other side - we missed the call
|
||||
*/
|
||||
private get callWasMissed(): boolean {
|
||||
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
|
||||
}
|
||||
|
||||
private get callId(): string | undefined {
|
||||
return [...this.events][0]?.getContent()?.call_id;
|
||||
}
|
||||
|
||||
private get roomId(): string | undefined {
|
||||
return [...this.events][0]?.getRoomId();
|
||||
}
|
||||
|
||||
private onSilencedCallsChanged = () => {
|
||||
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
|
||||
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
|
||||
};
|
||||
|
||||
private onLengthChanged = (length: number): void => {
|
||||
this.emit(CallEventGrouperEvent.LengthChanged, length);
|
||||
};
|
||||
|
||||
public answerCall = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'answer',
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
public rejectCall = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'reject',
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
public callBack = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'place_call',
|
||||
type: this.isVoice ? CallType.Voice : CallType.Video,
|
||||
room_id: this.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
public toggleSilenced = () => {
|
||||
const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId);
|
||||
silenced ?
|
||||
CallHandler.sharedInstance().unSilenceCall(this.callId) :
|
||||
CallHandler.sharedInstance().silenceCall(this.callId);
|
||||
};
|
||||
|
||||
private setCallListeners() {
|
||||
if (!this.call) return;
|
||||
this.call.addListener(CallEvent.State, this.setState);
|
||||
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
|
||||
}
|
||||
|
||||
private setState = () => {
|
||||
if (CONNECTING_STATES.includes(this.call?.state)) {
|
||||
this.state = CallState.Connecting;
|
||||
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||
this.state = this.call.state;
|
||||
} else {
|
||||
if (this.callWasMissed) this.state = CustomCallState.Missed;
|
||||
else if (this.reject) this.state = CallState.Ended;
|
||||
else if (this.hangup) this.state = CallState.Ended;
|
||||
else if (this.invite && this.call) this.state = CallState.Connecting;
|
||||
}
|
||||
this.emit(CallEventGrouperEvent.StateChanged, this.state);
|
||||
};
|
||||
|
||||
private setCall = () => {
|
||||
if (this.call) return;
|
||||
|
||||
this.call = CallHandler.sharedInstance().getCallById(this.callId);
|
||||
this.setCallListeners();
|
||||
this.setState();
|
||||
};
|
||||
|
||||
public add(event: MatrixEvent) {
|
||||
this.events.add(event);
|
||||
this.setCall();
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, RefObject, useRef, useState } from "react";
|
||||
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
import FocusLock from "react-focus-lock";
|
||||
|
@ -47,7 +47,7 @@ function getOrCreateContainer(): HTMLDivElement {
|
|||
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
||||
const ARIA_MENU_ITEM_SELECTOR = '[role^="menuitem"], [role^="menuitemcheckbox"], [role^="menuitemradio"]';
|
||||
|
||||
interface IPosition {
|
||||
export interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
|
@ -82,6 +82,10 @@ export interface IProps extends IPosition {
|
|||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
|
||||
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
||||
// it will be mounted to a container at the root of the DOM.
|
||||
mountAsChild?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
// on resize callback
|
||||
|
@ -213,6 +217,11 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onClick = (ev: React.MouseEvent) => {
|
||||
// Don't allow clicks to escape the context menu wrapper
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
|
@ -309,10 +318,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const menuClasses = classNames({
|
||||
'mx_ContextualMenu': true,
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
/**
|
||||
* In some cases we may get the number of 0, which still means that we're supposed to properly
|
||||
* add the specific position class, but as it was falsy things didn't work as intended.
|
||||
* In addition, defensively check for counter cases where we may get more than one value,
|
||||
* even if we shouldn't.
|
||||
*/
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
|
@ -364,6 +379,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
style={{ ...position, ...wrapperStyle }}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
<div
|
||||
|
@ -383,21 +399,41 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
render(): React.ReactChild {
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
if (this.props.mountAsChild) {
|
||||
// Render as a child of the current parent
|
||||
return this.renderMenu();
|
||||
} else {
|
||||
// Render as a child of a container at the root of the DOM
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ToRightOf = {
|
||||
left: number;
|
||||
top: number;
|
||||
chevronOffset: number;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
return { left, top, chevronOffset };
|
||||
};
|
||||
|
||||
export type AboveLeftOf = IPosition & {
|
||||
chevronFace: ChevronFace;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
export const aboveLeftOf = (
|
||||
elementRect: DOMRect,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): AboveLeftOf => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
|
@ -454,10 +490,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
|
|||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||
const button = useRef<T>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = () => {
|
||||
const open = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const close = () => {
|
||||
const close = (ev?: SyntheticEvent) => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ class CustomRoomTagPanel extends React.Component {
|
|||
return (<div className={classes}>
|
||||
<div className="mx_CustomRoomTagPanel_divider" />
|
||||
<AutoHideScrollbar className="mx_CustomRoomTagPanel_scroller">
|
||||
{tags}
|
||||
{ tags }
|
||||
</AutoHideScrollbar>
|
||||
</div>);
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ class CustomRoomTagTile extends React.Component {
|
|||
"mx_TagTile_badge": true,
|
||||
"mx_TagTile_badgeHighlight": badgeNotifState.hasMentions,
|
||||
});
|
||||
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badgeNotifState.count)}</div>);
|
||||
badgeElement = (<div className={badgeClasses}>{ FormattingUtils.formatCount(badgeNotifState.count) }</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
@ -26,38 +25,43 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
|
|||
import classnames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
|
||||
export default class EmbeddedPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// URL to request embedded page content from
|
||||
url: PropTypes.string,
|
||||
// Class name prefix to apply for a given instance
|
||||
className: PropTypes.string,
|
||||
// Whether to wrap the page in a scrollbar
|
||||
scrollbar: PropTypes.bool,
|
||||
// Map of keys to replace with values, e.g {$placeholder: "value"}
|
||||
replaceMap: PropTypes.object,
|
||||
};
|
||||
interface IProps {
|
||||
// URL to request embedded page content from
|
||||
url?: string;
|
||||
// Class name prefix to apply for a given instance
|
||||
className?: string;
|
||||
// Whether to wrap the page in a scrollbar
|
||||
scrollbar?: boolean;
|
||||
// Map of keys to replace with values, e.g {$placeholder: "value"}
|
||||
replaceMap?: Map<string, string>;
|
||||
}
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
interface IState {
|
||||
page: string;
|
||||
}
|
||||
|
||||
constructor(props, context) {
|
||||
export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
private unmounted = false;
|
||||
private dispatcherRef: string = null;
|
||||
|
||||
constructor(props: IProps, context: typeof MatrixClientContext) {
|
||||
super(props, context);
|
||||
|
||||
this._dispatcherRef = null;
|
||||
|
||||
this.state = {
|
||||
page: '',
|
||||
};
|
||||
}
|
||||
|
||||
translate(s) {
|
||||
protected translate(s: string): string {
|
||||
// default implementation - skins may wish to extend this
|
||||
return sanitizeHtml(_t(s));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (!this.props.url) {
|
||||
return;
|
||||
|
@ -70,7 +74,7 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
request(
|
||||
{ method: "GET", url: this.props.url },
|
||||
(err, response, body) => {
|
||||
if (this._unmounted) {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -92,22 +96,22 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
},
|
||||
);
|
||||
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef);
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
if (this.dispatcherRef !== null) dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
// HACK: Workaround for the context's MatrixClient not being set up at render time.
|
||||
if (payload.action === 'client_started') {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
// HACK: Workaround for the context's MatrixClient not updating.
|
||||
const client = this.context || MatrixClientPeg.get();
|
||||
const isGuest = client ? client.isGuest() : true;
|
||||
|
@ -120,16 +124,15 @@ export default class EmbeddedPage extends React.PureComponent {
|
|||
|
||||
const content = <div className={`${className}_body`}
|
||||
dangerouslySetInnerHTML={{ __html: this.state.page }}
|
||||
>
|
||||
</div>;
|
||||
/>;
|
||||
|
||||
if (this.props.scrollbar) {
|
||||
return <AutoHideScrollbar className={classes}>
|
||||
{content}
|
||||
{ content }
|
||||
</AutoHideScrollbar>;
|
||||
} else {
|
||||
return <div className={classes}>
|
||||
{content}
|
||||
{ content }
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -19,11 +19,11 @@ import React from 'react';
|
|||
|
||||
import { Filter } from 'matrix-js-sdk/src/filter';
|
||||
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
|
||||
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import { _t } from '../../languageHandler';
|
||||
|
@ -33,6 +33,10 @@ import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuild
|
|||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { TileShape } from '../views/rooms/EventTile';
|
||||
import { Layout } from "../../settings/Layout";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
|
@ -129,7 +133,7 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
public async fetchFileEventsServer(room: Room): Promise<void> {
|
||||
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const filter = new Filter(client.credentials.userId);
|
||||
|
@ -153,7 +157,11 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
return timelineSet;
|
||||
}
|
||||
|
||||
private onPaginationRequest = (timelineWindow: TimelineWindow, direction: string, limit: number): void => {
|
||||
private onPaginationRequest = (
|
||||
timelineWindow: TimelineWindow,
|
||||
direction: Direction,
|
||||
limit: number,
|
||||
): Promise<boolean> => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
|
@ -232,12 +240,10 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_FilePanel_empty">
|
||||
<h2>{_t('No files visible in this room')}</h2>
|
||||
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
|
||||
<h2>{ _t('No files visible in this room') }</h2>
|
||||
<p>{ _t('Attach files from chat or just drag and drop them anywhere in a room.') }</p>
|
||||
</div>);
|
||||
|
||||
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
|
||||
|
@ -257,11 +263,12 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview = {false}
|
||||
showUrlPreview={false}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
tileShape="file_grid"
|
||||
tileShape={TileShape.FileGrid}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={emptyState}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
</BaseCard>
|
||||
);
|
||||
|
@ -272,7 +279,7 @@ class FilePanel extends React.Component<IProps, IState> {
|
|||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<Loader />
|
||||
<Spinner />
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,21 +15,20 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.GenericErrorPage")
|
||||
export default class GenericErrorPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
title: PropTypes.object.isRequired, // jsx for title
|
||||
message: PropTypes.object.isRequired, // jsx to display
|
||||
};
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.GenericErrorPage")
|
||||
export default class GenericErrorPage extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
return <div className='mx_GenericErrorPage'>
|
||||
<div className='mx_GenericErrorPage_box'>
|
||||
<h1>{this.props.title}</h1>
|
||||
<p>{this.props.message}</p>
|
||||
<h1>{ this.props.title }</h1>
|
||||
<p>{ this.props.message }</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
|
@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { EventSubscription } from "fbemitter";
|
||||
import React from 'react';
|
||||
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
|
||||
|
||||
|
@ -30,22 +31,43 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
|
|||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import UserTagTile from "../views/elements/UserTagTile";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
||||
interface IGroupFilterPanelProps {
|
||||
|
||||
}
|
||||
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
type OrderedTagsTemporaryType = Array<{}>;
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
type SelectedTagsTemporaryType = Array<{}>;
|
||||
|
||||
interface IGroupFilterPanelState {
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
orderedTags: OrderedTagsTemporaryType;
|
||||
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
|
||||
selectedTags: SelectedTagsTemporaryType;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.GroupFilterPanel")
|
||||
class GroupFilterPanel extends React.Component {
|
||||
static contextType = MatrixClientContext;
|
||||
class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFilterPanelState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
|
||||
state = {
|
||||
public state = {
|
||||
orderedTags: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.on("sync", this._onClientSync);
|
||||
private ref = React.createRef<HTMLDivElement>();
|
||||
private unmounted = false;
|
||||
private groupFilterOrderStoreToken?: EventSubscription;
|
||||
|
||||
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
public componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.context.on("Group.myMembership", this.onGroupMyMembership);
|
||||
this.context.on("sync", this.onClientSync);
|
||||
|
||||
this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
@ -56,23 +78,25 @@ class GroupFilterPanel extends React.Component {
|
|||
});
|
||||
// This could be done by anything with a matrix client
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.removeListener("sync", this._onClientSync);
|
||||
if (this._groupFilterOrderStoreToken) {
|
||||
this._groupFilterOrderStoreToken.remove();
|
||||
this.context.removeListener("Group.myMembership", this.onGroupMyMembership);
|
||||
this.context.removeListener("sync", this.onClientSync);
|
||||
if (this.groupFilterOrderStoreToken) {
|
||||
this.groupFilterOrderStoreToken.remove();
|
||||
}
|
||||
UIStore.instance.stopTrackingElementDimensions("GroupPanel");
|
||||
}
|
||||
|
||||
_onGroupMyMembership = () => {
|
||||
private onGroupMyMembership = () => {
|
||||
if (this.unmounted) return;
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
|
||||
};
|
||||
|
||||
_onClientSync = (syncState, prevState) => {
|
||||
private onClientSync = (syncState, prevState) => {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
|
@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onClick = e => {
|
||||
private onClick = e => {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
}
|
||||
};
|
||||
|
||||
onClearFilterClick = ev => {
|
||||
private onClearFilterClick = ev => {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
};
|
||||
|
||||
renderGlobalIcon() {
|
||||
private renderGlobalIcon() {
|
||||
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
|
||||
|
||||
return (
|
||||
|
@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
|
||||
|
@ -122,19 +146,13 @@ class GroupFilterPanel extends React.Component {
|
|||
mx_GroupFilterPanel_items_selected: itemsSelected,
|
||||
});
|
||||
|
||||
let betaDot;
|
||||
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
|
||||
betaDot = <div className="mx_BetaDot" />;
|
||||
}
|
||||
|
||||
let createButton = (
|
||||
<ActionButton
|
||||
tooltip
|
||||
label={_t("Communities")}
|
||||
action="toggle_my_groups"
|
||||
className="mx_TagTile mx_TagTile_plus">
|
||||
{ betaDot }
|
||||
</ActionButton>
|
||||
className="mx_TagTile mx_TagTile_plus"
|
||||
/>
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
|
@ -147,7 +165,7 @@ class GroupFilterPanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
return <div className={classes} onClick={this.onClearFilterClick}>
|
||||
return <div className={classes} onClick={this.onClearFilterClick} ref={this.ref}>
|
||||
<AutoHideScrollbar
|
||||
className="mx_GroupFilterPanel_scroller"
|
||||
onClick={this.onClick}
|
|
@ -36,11 +36,14 @@ import FlairStore from '../../stores/FlairStore';
|
|||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { sleep } from "../../utils/promise";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { createSpaceFromCommunity } from "../../utils/space";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
|
@ -222,7 +225,7 @@ class FeaturedRoom extends React.Component {
|
|||
|
||||
let roomNameNode = null;
|
||||
if (permalink) {
|
||||
roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>;
|
||||
roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
|
||||
} else {
|
||||
roomNameNode = <span>{ roomName }</span>;
|
||||
}
|
||||
|
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
|
|||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
|
||||
|
||||
@replaceableComponent("structures.GroupView")
|
||||
export default class GroupView extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
|
|||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
|
|||
showGroupAddRoomDialog(this.props.groupId);
|
||||
};
|
||||
|
||||
_dismissUpgradeNotice = () => {
|
||||
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
|
||||
this.setState({ showUpgradeNotice: false });
|
||||
}
|
||||
|
||||
_onCreateSpaceClick = () => {
|
||||
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
|
||||
};
|
||||
|
||||
_onAdminsLinkClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.GroupMemberList,
|
||||
});
|
||||
};
|
||||
|
||||
_getGroupSection() {
|
||||
const groupSettingsSectionClasses = classnames({
|
||||
"mx_GroupView_group": this.state.editing,
|
||||
|
@ -819,12 +841,12 @@ export default class GroupView extends React.Component {
|
|||
let hostingSignup = null;
|
||||
if (hostingSignupLink && this.state.isUserPrivileged) {
|
||||
hostingSignup = <div className="mx_GroupView_hostingSignup">
|
||||
{_t(
|
||||
{ _t(
|
||||
"Want more than a community? <a>Get your own server</a>", {},
|
||||
{
|
||||
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{sub}</a>,
|
||||
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{ sub }</a>,
|
||||
},
|
||||
)}
|
||||
) }
|
||||
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">
|
||||
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
|
||||
</a>
|
||||
|
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
|
|||
},
|
||||
) }
|
||||
</div> : <div />;
|
||||
|
||||
let communitiesUpgradeNotice;
|
||||
if (this.state.showUpgradeNotice) {
|
||||
let text;
|
||||
if (this.state.isUserPrivileged) {
|
||||
text = _t("You can create a Space from this community <a>here</a>.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
} else {
|
||||
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
|
||||
"and keep a look out for the invite.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
}
|
||||
|
||||
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
|
||||
<h2>{ _t("Communities can now be made into Spaces") }</h2>
|
||||
<p>
|
||||
{ _t("Spaces are a new way to make a community, with new features coming.") }
|
||||
|
||||
{ text }
|
||||
|
||||
{ _t("Communities won't receive further updates.") }
|
||||
</p>
|
||||
<AccessibleButton
|
||||
className="mx_GroupView_spaceUpgradePrompt_close"
|
||||
onClick={this._dismissUpgradeNotice}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className={groupSettingsSectionClasses}>
|
||||
{ header }
|
||||
{ hostingSignup }
|
||||
{ changeDelayWarning }
|
||||
{ communitiesUpgradeNotice }
|
||||
{ this._getJoinableNode() }
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
|
@ -1185,10 +1243,13 @@ export default class GroupView extends React.Component {
|
|||
avatarImage = <Spinner />;
|
||||
} else {
|
||||
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
|
||||
avatarImage = <GroupAvatar groupId={this.props.groupId}
|
||||
avatarImage = <GroupAvatar
|
||||
groupId={this.props.groupId}
|
||||
groupName={this.state.profileForm.name}
|
||||
groupAvatarUrl={this.state.profileForm.avatar_url}
|
||||
width={28} height={28} resizeMethod='crop'
|
||||
width={28}
|
||||
height={28}
|
||||
resizeMethod='crop'
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -1199,9 +1260,12 @@ export default class GroupView extends React.Component {
|
|||
</label>
|
||||
<div className="mx_GroupView_avatarPicker_edit">
|
||||
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
|
||||
<img src={require("../../../res/img/camera.svg")}
|
||||
alt={_t("Upload avatar")} title={_t("Upload avatar")}
|
||||
width="17" height="15" />
|
||||
<img
|
||||
src={require("../../../res/img/camera.svg")}
|
||||
alt={_t("Upload avatar")}
|
||||
title={_t("Upload avatar")}
|
||||
width="17"
|
||||
height="15" />
|
||||
</label>
|
||||
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
|
||||
</div>
|
||||
|
@ -1238,7 +1302,8 @@ export default class GroupView extends React.Component {
|
|||
groupAvatarUrl={groupAvatarUrl}
|
||||
groupName={groupName}
|
||||
onClick={onGroupHeaderItemClick}
|
||||
width={28} height={28}
|
||||
width={28}
|
||||
height={28}
|
||||
/>;
|
||||
if (summary.profile && summary.profile.name) {
|
||||
nameNode = <div onClick={onGroupHeaderItemClick}>
|
||||
|
@ -1269,28 +1334,32 @@ export default class GroupView extends React.Component {
|
|||
key="_cancelButton"
|
||||
onClick={this._onCancelClick}
|
||||
>
|
||||
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
|
||||
width="18" height="18" alt={_t("Cancel")} />
|
||||
<img
|
||||
src={require("../../../res/img/cancel.svg")}
|
||||
className="mx_filterFlipColor"
|
||||
width="18"
|
||||
height="18"
|
||||
alt={_t("Cancel")} />
|
||||
</AccessibleButton>,
|
||||
);
|
||||
} else {
|
||||
if (summary.user && summary.user.membership === 'join') {
|
||||
rightButtons.push(
|
||||
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
|
||||
<AccessibleButton
|
||||
className="mx_GroupHeader_button mx_GroupHeader_editButton"
|
||||
key="_editButton"
|
||||
onClick={this._onEditClick}
|
||||
title={_t("Community Settings")}
|
||||
>
|
||||
</AccessibleButton>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
rightButtons.push(
|
||||
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
|
||||
<AccessibleButton
|
||||
className="mx_GroupHeader_button mx_GroupHeader_shareButton"
|
||||
key="_shareButton"
|
||||
onClick={this._onShareClick}
|
||||
title={_t('Share Community')}
|
||||
>
|
||||
</AccessibleButton>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
|
|||
const pageUrl = getHomePageUrl(config);
|
||||
|
||||
if (pageUrl) {
|
||||
// FIXME: Using an import will result in wrench-element-tests failures
|
||||
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
|
||||
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
|
||||
}
|
||||
|
|
|
@ -14,34 +14,39 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { createRef } from "react";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow?: boolean;
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
leftIndicatorOffset: number | string;
|
||||
rightIndicatorOffset: number | string;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.IndicatorScrollbar")
|
||||
export default class IndicatorScrollbar extends React.Component {
|
||||
static propTypes = {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow: PropTypes.bool,
|
||||
export default class IndicatorScrollbar extends React.Component<IProps, IState> {
|
||||
private autoHideScrollbar = createRef<AutoHideScrollbar>();
|
||||
private scrollElement: HTMLDivElement;
|
||||
private likelyTrackpadUser: boolean = null;
|
||||
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this._collectScroller = this._collectScroller.bind(this);
|
||||
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
|
||||
this.checkOverflow = this.checkOverflow.bind(this);
|
||||
this._scrollElement = null;
|
||||
this._autoHideScrollbar = null;
|
||||
this._likelyTrackpadUser = null;
|
||||
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
this.state = {
|
||||
leftIndicatorOffset: 0,
|
||||
|
@ -49,30 +54,19 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
moveToOrigin() {
|
||||
if (!this._scrollElement) return;
|
||||
|
||||
this._scrollElement.scrollLeft = 0;
|
||||
this._scrollElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
_collectScroller(scroller) {
|
||||
if (scroller && !this._scrollElement) {
|
||||
this._scrollElement = scroller;
|
||||
private collectScroller = (scroller: HTMLDivElement): void => {
|
||||
if (scroller && !this.scrollElement) {
|
||||
this.scrollElement = scroller;
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
|
||||
this.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
|
||||
this.checkOverflow();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_collectScrollerComponent(autoHideScrollbar) {
|
||||
this._autoHideScrollbar = autoHideScrollbar;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
|
||||
const curLen = this.props.children && this.props.children.length || 0;
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
const prevLen = React.Children.count(prevProps.children);
|
||||
const curLen = React.Children.count(this.props.children);
|
||||
// check overflow only if amount of children changes.
|
||||
// if we don't guard here, we end up with an infinite
|
||||
// render > componentDidUpdate > checkOverflow > setState > render loop
|
||||
|
@ -81,62 +75,58 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
this.checkOverflow();
|
||||
}
|
||||
|
||||
checkOverflow() {
|
||||
const hasTopOverflow = this._scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this._scrollElement.scrollHeight >
|
||||
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
|
||||
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow = this._scrollElement.scrollWidth >
|
||||
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
|
||||
private checkOverflow = (): void => {
|
||||
const hasTopOverflow = this.scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this.scrollElement.scrollHeight >
|
||||
(this.scrollElement.scrollTop + this.scrollElement.clientHeight);
|
||||
const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow = this.scrollElement.scrollWidth >
|
||||
(this.scrollElement.scrollLeft + this.scrollElement.clientWidth);
|
||||
|
||||
if (hasTopOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
|
||||
}
|
||||
if (hasBottomOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
}
|
||||
if (hasLeftOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
}
|
||||
if (hasRightOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
}
|
||||
|
||||
if (this.props.trackHorizontalOverflow) {
|
||||
this.setState({
|
||||
// Offset from absolute position of the container
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0',
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this.scrollElement.scrollLeft}px` : '0',
|
||||
|
||||
// Negative because we're coming from the right
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getScrollTop() {
|
||||
return this._autoHideScrollbar.getScrollTop();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._scrollElement) {
|
||||
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
|
||||
public componentWillUnmount(): void {
|
||||
if (this.scrollElement) {
|
||||
this.scrollElement.removeEventListener("scroll", this.checkOverflow);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseWheel = (e) => {
|
||||
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
|
||||
private onMouseWheel = (e: React.WheelEvent): void => {
|
||||
if (this.props.verticalScrollsHorizontally && this.scrollElement) {
|
||||
// xyThreshold is the amount of horizontal motion required for the component to
|
||||
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
|
||||
// strange ways. Should be positive.
|
||||
|
@ -150,19 +140,19 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
// for at least the next 1 minute.
|
||||
const now = new Date().getTime();
|
||||
if (Math.abs(e.deltaX) > 0) {
|
||||
this._likelyTrackpadUser = true;
|
||||
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
|
||||
this.likelyTrackpadUser = true;
|
||||
this.checkAgainForTrackpad = now + (1 * 60 * 1000);
|
||||
} else {
|
||||
// if we haven't seen any horizontal scrolling for a while, assume
|
||||
// the user might have plugged in a mousewheel
|
||||
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
|
||||
this._likelyTrackpadUser = false;
|
||||
if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
|
||||
this.likelyTrackpadUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
// don't mess with the horizontal scroll for trackpad users
|
||||
// See https://github.com/vector-im/element-web/issues/10005
|
||||
if (this._likelyTrackpadUser) {
|
||||
if (this.likelyTrackpadUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -178,13 +168,13 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
|
||||
this._scrollElement.scrollLeft += val * yRetention;
|
||||
this.scrollElement.scrollLeft += val * yRetention;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
public render(): JSX.Element {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
|
||||
|
||||
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
|
||||
|
@ -195,8 +185,8 @@ export default class IndicatorScrollbar extends React.Component {
|
|||
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
|
||||
|
||||
return (<AutoHideScrollbar
|
||||
ref={this._collectScrollerComponent}
|
||||
wrappedRef={this._collectScroller}
|
||||
ref={this.autoHideScrollbar}
|
||||
wrappedRef={this.collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
{...otherProps}
|
||||
>
|
|
@ -1,300 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
|
||||
|
||||
import * as sdk from '../../index';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component {
|
||||
static propTypes = {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: PropTypes.object.isRequired,
|
||||
|
||||
// response from initial request. If not supplied, will do a request on
|
||||
// mount.
|
||||
authData: PropTypes.shape({
|
||||
flows: PropTypes.array,
|
||||
params: PropTypes.object,
|
||||
session: PropTypes.string,
|
||||
}),
|
||||
|
||||
// callback
|
||||
makeRequest: PropTypes.func.isRequired,
|
||||
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {bool} status True if the operation requiring
|
||||
// auth was completed sucessfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
onAuthFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs: PropTypes.object,
|
||||
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken: PropTypes.func,
|
||||
sessionId: PropTypes.string,
|
||||
clientSecret: PropTypes.string,
|
||||
emailSid: PropTypes.string,
|
||||
|
||||
// If true, poll to see if the auth flow has been completed
|
||||
// out-of-band
|
||||
poll: PropTypes.bool,
|
||||
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged: PropTypes.bool,
|
||||
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange: PropTypes.func,
|
||||
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText: PropTypes.string,
|
||||
continueKind: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this._unmounted = false;
|
||||
this._authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this._requestCallback,
|
||||
busyChanged: this._onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this._authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this._requestEmailToken,
|
||||
});
|
||||
|
||||
this._intervalId = null;
|
||||
if (this.props.poll) {
|
||||
this._intervalId = setInterval(() => {
|
||||
this._authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
this._stageComponent = createRef();
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
|
||||
this._authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this._authLogic.getEmailSid(),
|
||||
clientSecret: this._authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
|
||||
if (this._intervalId !== null) {
|
||||
clearInterval(this._intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
_requestEmailToken = async (...args) => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(...args);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tryContinue = () => {
|
||||
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
|
||||
this._stageComponent.current.tryContinue();
|
||||
}
|
||||
};
|
||||
|
||||
_authStateUpdated = (stageType, stageState) => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this._setFocus();
|
||||
} else if (
|
||||
!stageState.error && this._stageComponent.current &&
|
||||
this._stageComponent.current.attemptFailed
|
||||
) {
|
||||
this._stageComponent.current.attemptFailed();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_requestCallback = (auth) => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
_onBusyChanged = (busy) => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `_authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
_setFocus() {
|
||||
if (this._stageComponent.current && this._stageComponent.current.focus) {
|
||||
this._stageComponent.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_submitAuthDict = authData => {
|
||||
this._authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
_onPhaseChange = newPhase => {
|
||||
if (this.props.onStagePhaseChange) {
|
||||
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
|
||||
}
|
||||
};
|
||||
|
||||
_onStageCancel = () => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
_renderCurrentStage() {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this._stageComponent}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this._authLogic.getSessionId()}
|
||||
clientSecret={this._authLogic.getClientSecret()}
|
||||
stageParams={this._authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this._submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this._onAuthStageFailed}
|
||||
setEmailSid={this._setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this._onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this._onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onAuthStageFailed = e => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
_setEmailSid = sid => {
|
||||
this._authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this._renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
300
src/components/structures/InteractiveAuth.tsx
Normal file
300
src/components/structures/InteractiveAuth.tsx
Normal file
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
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.
|
||||
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 {
|
||||
AuthType,
|
||||
IAuthData,
|
||||
IAuthDict,
|
||||
IInputs,
|
||||
InteractiveAuth,
|
||||
IStageStatus,
|
||||
} from "matrix-js-sdk/src/interactive-auth";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import React, { createRef } from 'react';
|
||||
|
||||
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
interface IProps {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: MatrixClient;
|
||||
// response from initial request. If not supplied, will do a request on mount.
|
||||
authData?: IAuthData;
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs?: IInputs;
|
||||
sessionId?: string;
|
||||
clientSecret?: string;
|
||||
emailSid?: string;
|
||||
// If true, poll to see if the auth flow has been completed out-of-band
|
||||
poll?: boolean;
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged?: boolean;
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText?: string;
|
||||
continueKind?: string;
|
||||
// callback
|
||||
makeRequest(auth: IAuthData): Promise<IAuthData>;
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {boolean} status True if the operation requiring
|
||||
// auth was completed successfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
onAuthFinished(
|
||||
status: boolean,
|
||||
result: IAuthData | Error,
|
||||
extra?: { emailSid?: string, clientSecret?: string },
|
||||
): void;
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange?(stage: string, phase: string | number): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
authStage?: AuthType;
|
||||
stageState?: IStageStatus;
|
||||
busy: boolean;
|
||||
errorText?: string;
|
||||
stageErrorText?: string;
|
||||
submitButtonEnabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.InteractiveAuthComponent")
|
||||
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
|
||||
private readonly authLogic: InteractiveAuth;
|
||||
private readonly intervalId: number = null;
|
||||
private readonly stageComponent = createRef<IStageComponent>();
|
||||
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
authStage: null,
|
||||
busy: false,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this.authLogic = new InteractiveAuth({
|
||||
authData: this.props.authData,
|
||||
doRequest: this.requestCallback,
|
||||
busyChanged: this.onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this.authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this.requestEmailToken,
|
||||
});
|
||||
|
||||
if (this.props.poll) {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
|
||||
UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
|
||||
this.authLogic.attemptAuth().then((result) => {
|
||||
const extra = {
|
||||
emailSid: this.authLogic.getEmailSid(),
|
||||
clientSecret: this.authLogic.getClientSecret(),
|
||||
};
|
||||
this.props.onAuthFinished(true, result, extra);
|
||||
}).catch((error) => {
|
||||
this.props.onAuthFinished(false, error);
|
||||
console.error("Error during user-interactive auth:", error);
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmailToken = async (
|
||||
email: string,
|
||||
secret: string,
|
||||
attempt: number,
|
||||
session: string,
|
||||
): Promise<{sid: string}> => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
return await this.props.requestEmailToken(email, secret, attempt, session);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private tryContinue = (): void => {
|
||||
this.stageComponent.current?.tryContinue?.();
|
||||
};
|
||||
|
||||
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
|
||||
const oldStage = this.state.authStage;
|
||||
this.setState({
|
||||
busy: false,
|
||||
authStage: stageType,
|
||||
stageState: stageState,
|
||||
errorText: stageState.error,
|
||||
}, () => {
|
||||
if (oldStage !== stageType) {
|
||||
this.setFocus();
|
||||
} else if (!stageState.error) {
|
||||
this.stageComponent.current?.attemptFailed?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
private onBusyChanged = (busy: boolean): void => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
stageErrorText: null,
|
||||
});
|
||||
}
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
};
|
||||
|
||||
private setFocus(): void {
|
||||
this.stageComponent.current?.focus?.();
|
||||
}
|
||||
|
||||
private submitAuthDict = (authData: IAuthDict): void => {
|
||||
this.authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
private onPhaseChange = (newPhase: number): void => {
|
||||
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
|
||||
};
|
||||
|
||||
private onStageCancel = (): void => {
|
||||
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
private renderCurrentStage(): JSX.Element {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this.stageComponent as any}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this.authLogic.getSessionId()}
|
||||
clientSecret={this.authLogic.getClientSecret()}
|
||||
stageParams={this.authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this.submitAuthDict}
|
||||
errorText={this.state.stageErrorText}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this.onAuthStageFailed}
|
||||
setEmailSid={this.setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this.onPhaseChange}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this.onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private onAuthStageFailed = (e: Error): void => {
|
||||
this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
private setEmailSid = (sid: string): void => {
|
||||
this.authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
render() {
|
||||
let error = null;
|
||||
if (this.state.errorText) {
|
||||
error = (
|
||||
<div className="error">
|
||||
{ this.state.errorText }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{ this.renderCurrentStage() }
|
||||
{ error }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,8 +19,6 @@ import { createRef } from "react";
|
|||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import GroupFilterPanel from "./GroupFilterPanel";
|
||||
import CustomRoomTagPanel from "./CustomRoomTagPanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
|
@ -33,15 +31,12 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
|
|||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
|
||||
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import RoomListNumResults from "../views/rooms/RoomListNumResults";
|
||||
import LeftPanelWidget from "./LeftPanelWidget";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
|
@ -53,7 +48,6 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
|
@ -70,8 +64,6 @@ const cssClasses = [
|
|||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private groupFilterPanelWatcherRef: string;
|
||||
private bgImageWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
|
@ -80,22 +72,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
|
||||
activeSpace: SpaceStore.instance.activeSpace,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
this.bgImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
|
||||
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") });
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
|
||||
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
|
||||
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
|
||||
// Using the passive option to not block the main thread
|
||||
|
@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
|
@ -149,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onBackgroundImageUpdate = () => {
|
||||
// Note: we do this in the LeftPanel as it uses this variable most prominently.
|
||||
const avatarSize = 32; // arbitrary
|
||||
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (settingBgMxc) {
|
||||
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
|
||||
}
|
||||
|
||||
const avatarUrlProp = `url(${avatarUrl})`;
|
||||
if (!avatarUrl) {
|
||||
document.body.style.removeProperty("--avatar-url");
|
||||
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
|
||||
document.body.style.setProperty("--avatar-url", avatarUrlProp);
|
||||
}
|
||||
};
|
||||
|
||||
private handleStickyHeaders(list: HTMLDivElement) {
|
||||
if (this.isDoingStickyHeaders) return;
|
||||
this.isDoingStickyHeaders = true;
|
||||
|
@ -392,9 +358,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RoomBreadcrumbs />
|
||||
</IndicatorScrollbar>
|
||||
|
@ -429,30 +392,22 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
onSelectRoom={this.selectRoom}
|
||||
/>
|
||||
|
||||
{dialPadButton}
|
||||
{ dialPadButton }
|
||||
|
||||
<AccessibleTooltipButton
|
||||
className={classNames("mx_LeftPanel_exploreButton", {
|
||||
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
|
||||
})}
|
||||
onClick={this.onExplore}
|
||||
title={_t("Explore rooms")}
|
||||
title={this.state.activeSpace
|
||||
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
|
||||
: _t("Explore rooms")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let leftLeftPanel;
|
||||
if (this.state.showGroupFilterPanel) {
|
||||
leftLeftPanel = (
|
||||
<div className="mx_LeftPanel_GroupFilterPanelContainer">
|
||||
<GroupFilterPanel />
|
||||
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
|
@ -476,11 +431,10 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{leftLeftPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchDialExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
{ this.renderHeader() }
|
||||
{ this.renderSearchDialExplore() }
|
||||
{ this.renderBreadcrumbs() }
|
||||
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
|
||||
<div className="mx_LeftPanel_roomListWrapper">
|
||||
<div
|
||||
|
@ -490,7 +444,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
{ roomList }
|
||||
</div>
|
||||
</div>
|
||||
{ !this.props.isMinimized && <LeftPanelWidget /> }
|
||||
|
|
|
@ -76,7 +76,6 @@ const LeftPanelWidget: React.FC = () => {
|
|||
<AppTile
|
||||
app={app}
|
||||
fullWidth
|
||||
show
|
||||
showMenubar={false}
|
||||
userWidget
|
||||
userId={cli.getUserId()}
|
||||
|
@ -115,7 +114,7 @@ const LeftPanelWidget: React.FC = () => {
|
|||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
|
@ -125,15 +124,15 @@ const LeftPanelWidget: React.FC = () => {
|
|||
<span>{ WidgetUtils.getWidgetName(app) }</span>
|
||||
</AccessibleButton>
|
||||
|
||||
{/* Code for the maximise button for once we have full screen widgets */}
|
||||
{/*<AccessibleTooltipButton
|
||||
{ /* Code for the maximise button for once we have full screen widgets */ }
|
||||
{ /*<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={() => {
|
||||
}}
|
||||
className="mx_LeftPanelWidget_maximizeButton"
|
||||
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
|
||||
title={_t("Maximize")}
|
||||
/>*/}
|
||||
/>*/ }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
115
src/components/structures/LegacyCommunityPreview.tsx
Normal file
115
src/components/structures/LegacyCommunityPreview.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
|
||||
interface IProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
const onSwapClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Preferences,
|
||||
});
|
||||
};
|
||||
|
||||
// XXX: temporary community migration component, reuses SpaceRoomView & SpacePreview classes for simplicity
|
||||
const LegacyCommunityPreview = ({ groupId }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||
|
||||
if (!groupSummary) {
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<div className="mx_MainSplit">
|
||||
<div className="mx_SpaceRoomView_preview">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</main>;
|
||||
}
|
||||
|
||||
let visibilitySection: JSX.Element;
|
||||
if (groupSummary.profile.is_public) {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_public">
|
||||
{ _t("Public community") }
|
||||
</span>;
|
||||
} else {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_private">
|
||||
{ _t("Private community") }
|
||||
</span>;
|
||||
}
|
||||
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<ErrorBoundary>
|
||||
<div className="mx_MainSplit">
|
||||
<div className="mx_SpaceRoomView_preview">
|
||||
<GroupAvatar
|
||||
groupId={groupId}
|
||||
groupName={groupSummary.profile.name}
|
||||
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||
height={80}
|
||||
width={80}
|
||||
resizeMethod='crop'
|
||||
/>
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
{ groupSummary.profile.name }
|
||||
</h1>
|
||||
<div className="mx_SpaceRoomView_info">
|
||||
{ visibilitySection }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_preview_topic" ref={e => e && linkifyElement(e)}>
|
||||
{ groupSummary.profile.short_description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ groupSummary.user?.membership === "join"
|
||||
? _t("To view %(communityName)s, swap to communities in your <a>preferences</a>", {
|
||||
communityName: groupSummary.profile.name,
|
||||
}, {
|
||||
a: sub => (
|
||||
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
|
||||
),
|
||||
})
|
||||
: _t("To join %(communityName)s, swap to communities in your <a>preferences</a>", {
|
||||
communityName: groupSummary.profile.name,
|
||||
}, {
|
||||
a: sub => (
|
||||
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
|
||||
),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</main>;
|
||||
};
|
||||
|
||||
export default LegacyCommunityPreview;
|
|
@ -1,7 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017, 2018, 2020 New Vector 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.
|
||||
|
@ -17,14 +15,13 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import { Key } from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import MediaDeviceHandler from '../../MediaDeviceHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { IMatrixClientCreds } from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
@ -56,9 +53,23 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
|
|||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import CallHandler from '../../CallHandler';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
|
||||
import { OwnProfileStore } from '../../stores/OwnProfileStore';
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import RoomView from './RoomView';
|
||||
import ToastContainer from './ToastContainer';
|
||||
import MyGroups from "./MyGroups";
|
||||
import UserView from "./UserView";
|
||||
import GroupView from "./GroupView";
|
||||
import BackdropPanel from "./BackdropPanel";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import classNames from 'classnames';
|
||||
import GroupFilterPanel from './GroupFilterPanel';
|
||||
import CustomRoomTagPanel from './CustomRoomTagPanel';
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import LegacyCommunityPreview from "./LegacyCommunityPreview";
|
||||
|
||||
// We need to fetch each pinned message individually (if we don't already have it)
|
||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||
|
@ -74,12 +85,14 @@ function canElementReceiveInput(el) {
|
|||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
// Called with the credentials of a registered user (if they were a ROU that
|
||||
// transitioned to PWLU)
|
||||
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type: string;
|
||||
autoJoin: boolean;
|
||||
page_type?: string;
|
||||
autoJoin?: boolean;
|
||||
threepidInvite?: IThreepidInvite;
|
||||
roomOobData?: IOOBData;
|
||||
currentRoomId: string;
|
||||
|
@ -120,6 +133,7 @@ interface IState {
|
|||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,22 +149,13 @@ interface IState {
|
|||
class LoggedInView extends React.Component<IProps, IState> {
|
||||
static displayName = 'LoggedInView';
|
||||
|
||||
static propTypes = {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
page_type: PropTypes.string.isRequired,
|
||||
onRoomCreated: PropTypes.func,
|
||||
|
||||
// Called with the credentials of a registered user (if they were a ROU that
|
||||
// transitioned to PWLU)
|
||||
onRegistered: PropTypes.func,
|
||||
|
||||
// and lots and lots of other stuff.
|
||||
};
|
||||
|
||||
private dispatcherRef: string;
|
||||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<any>;
|
||||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
|
||||
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
|
||||
protected compactLayoutWatcherRef: string;
|
||||
protected backgroundImageWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
|
@ -161,7 +166,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: [],
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
|
@ -173,13 +178,14 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
this.resizeHandler = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
document.addEventListener('keydown', this.onNativeKeyDown, false);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
this._updateServerNoticeEvents();
|
||||
this.updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on("accountData", this.onAccountData);
|
||||
this._matrixClient.on("sync", this.onSync);
|
||||
|
@ -194,64 +200,90 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
|
||||
);
|
||||
|
||||
this.resizer = this._createResizer();
|
||||
this.resizer = this.createResizer();
|
||||
this.resizer.attach();
|
||||
this._loadResizerPreferences();
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
this.loadResizerPreferences();
|
||||
this.refreshBackgroundImage();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
document.removeEventListener('keydown', this.onNativeKeyDown, false);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
|
||||
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
|
||||
this.resizer.detach();
|
||||
}
|
||||
|
||||
private onCallsChanged = () => {
|
||||
this.setState({
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
});
|
||||
private refreshBackgroundImage = async (): Promise<void> => {
|
||||
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (backgroundImage) {
|
||||
// convert to http before going much further
|
||||
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
|
||||
} else {
|
||||
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
|
||||
}
|
||||
this.setState({ backgroundImage });
|
||||
};
|
||||
|
||||
canResetTimelineInRoom = (roomId) => {
|
||||
private onAction = (payload): void => {
|
||||
switch (payload.action) {
|
||||
case 'call_state': {
|
||||
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
|
||||
if (activeCalls !== this.state.activeCalls) {
|
||||
this.setState({ activeCalls });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public canResetTimelineInRoom = (roomId: string) => {
|
||||
if (!this._roomView.current) {
|
||||
return true;
|
||||
}
|
||||
return this._roomView.current.canResetTimeline();
|
||||
};
|
||||
|
||||
_createResizer() {
|
||||
let size;
|
||||
let collapsed;
|
||||
private createResizer() {
|
||||
let panelSize;
|
||||
let panelCollapsed;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
|
||||
toggleSize: 206 - 50,
|
||||
onCollapsed: (_collapsed) => {
|
||||
collapsed = _collapsed;
|
||||
if (_collapsed) {
|
||||
onCollapsed: (collapsed) => {
|
||||
panelCollapsed = collapsed;
|
||||
if (collapsed) {
|
||||
dis.dispatch({ action: "hide_left_panel" });
|
||||
window.localStorage.setItem("mx_lhs_size", '0');
|
||||
} else {
|
||||
dis.dispatch({ action: "show_left_panel" });
|
||||
}
|
||||
},
|
||||
onResized: (_size) => {
|
||||
size = _size;
|
||||
onResized: (size) => {
|
||||
panelSize = size;
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
onResizeStart: () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
|
||||
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", '' + panelSize);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
isItemCollapsed: domNode => {
|
||||
return domNode.classList.contains("mx_LeftPanel_minimized");
|
||||
},
|
||||
handler: this.resizeHandler.current,
|
||||
};
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames({
|
||||
|
@ -262,15 +294,15 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
return resizer;
|
||||
}
|
||||
|
||||
_loadResizerPreferences() {
|
||||
private loadResizerPreferences() {
|
||||
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
|
||||
if (isNaN(lhsSize)) {
|
||||
lhsSize = 350;
|
||||
}
|
||||
this.resizer.forHandleAt(0).resize(lhsSize);
|
||||
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
|
||||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({ action: "ignore_state_changed" });
|
||||
}
|
||||
|
@ -302,16 +334,16 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
|
||||
this._updateServerNoticeEvents();
|
||||
this.updateServerNoticeEvents();
|
||||
} else {
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
|
||||
this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
|
||||
}
|
||||
};
|
||||
|
||||
onRoomStateEvents = (ev, state) => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
this.updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -321,7 +353,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
|
||||
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
if (error) {
|
||||
usageLimitEventContent = syncError.error.data;
|
||||
|
@ -341,7 +373,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
private updateServerNoticeEvents = async () => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return [];
|
||||
|
||||
|
@ -373,7 +405,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
);
|
||||
});
|
||||
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
|
||||
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
|
||||
this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
|
||||
this.setState({
|
||||
usageLimitEventContent,
|
||||
usageLimitEventTs: pinnedEventTs,
|
||||
|
@ -382,7 +414,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
_onPaste = (ev) => {
|
||||
private onPaste = (ev) => {
|
||||
let canReceiveInput = false;
|
||||
let element = ev.target;
|
||||
// test for all parents because the target can be a child of a contenteditable element
|
||||
|
@ -394,7 +426,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
// refocusing during a paste event will make the
|
||||
// paste end up in the newly focused element,
|
||||
// so dispatch synchronously before paste happens
|
||||
dis.fire(Action.FocusComposer, true);
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -420,22 +452,22 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
We also listen with a native listener on the document to get keydown events when no element is focused.
|
||||
Bubbling is irrelevant here as the target is the body element.
|
||||
*/
|
||||
_onReactKeyDown = (ev) => {
|
||||
private onReactKeyDown = (ev) => {
|
||||
// events caught while bubbling up on the root element
|
||||
// of this component, so something must be focused.
|
||||
this._onKeyDown(ev);
|
||||
this.onKeyDown(ev);
|
||||
};
|
||||
|
||||
_onNativeKeyDown = (ev) => {
|
||||
private onNativeKeyDown = (ev) => {
|
||||
// only pass this if there is no focused element.
|
||||
// if there is, _onKeyDown will be called by the
|
||||
// if there is, onKeyDown will be called by the
|
||||
// react keydown handler that respects the react bubbling order.
|
||||
if (ev.target === document.body) {
|
||||
this._onKeyDown(ev);
|
||||
this.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
private onKeyDown = (ev) => {
|
||||
let handled = false;
|
||||
|
||||
const roomAction = getKeyBindingsManager().getRoomAction(ev);
|
||||
|
@ -445,7 +477,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
case RoomAction.JumpToFirstMessage:
|
||||
case RoomAction.JumpToLatestMessage:
|
||||
// pass the event down to the scroll panel
|
||||
this._onScrollKeyPressed(ev);
|
||||
this.onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
break;
|
||||
case RoomAction.FocusSearch:
|
||||
|
@ -534,24 +566,24 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
||||
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
|
||||
// The above condition is crafted to _allow_ characters with Shift
|
||||
// already pressed (but not the Shift key down itself).
|
||||
|
||||
const isClickShortcut = ev.target !== document.body &&
|
||||
(ev.key === Key.SPACE || ev.key === Key.ENTER);
|
||||
|
||||
// Do not capture the context menu key to improve keyboard accessibility
|
||||
if (ev.key === Key.CONTEXT_MENU) {
|
||||
return;
|
||||
}
|
||||
// We explicitly allow alt to be held due to it being a common accent modifier.
|
||||
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
|
||||
// move focus to the composer so the user can re-type the dead key correctly.
|
||||
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
|
||||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// If the user is entering a printable character outside of an input field
|
||||
// redirect it to the composer for them.
|
||||
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.fire(Action.FocusComposer, true);
|
||||
dis.fire(Action.FocusSendMessageComposer, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -560,19 +592,13 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
* dispatch a page-up/page-down/etc to the appropriate component
|
||||
* @param {Object} ev The key event
|
||||
*/
|
||||
_onScrollKeyPressed = (ev) => {
|
||||
private onScrollKeyPressed = (ev) => {
|
||||
if (this._roomView.current) {
|
||||
this._roomView.current.handleScrollKey(ev);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
const UserView = sdk.getComponent('structures.UserView');
|
||||
const GroupView = sdk.getComponent('structures.GroupView');
|
||||
const MyGroups = sdk.getComponent('structures.MyGroups');
|
||||
const ToastContainer = sdk.getComponent('structures.ToastContainer');
|
||||
|
||||
let pageElement;
|
||||
|
||||
switch (this.props.page_type) {
|
||||
|
@ -604,18 +630,26 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
break;
|
||||
case PageTypes.GroupView:
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
pageElement = <LegacyCommunityPreview groupId={this.props.currentGroupId} />;
|
||||
} else {
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let bodyClasses = 'mx_MatrixChat';
|
||||
if (this.state.useCompactLayout) {
|
||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||
}
|
||||
const wrapperClasses = classNames({
|
||||
'mx_MatrixChat_wrapper': true,
|
||||
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
|
||||
});
|
||||
const bodyClasses = classNames({
|
||||
'mx_MatrixChat': true,
|
||||
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
|
||||
});
|
||||
|
||||
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
|
||||
return (
|
||||
|
@ -626,26 +660,55 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
onPaste={this._onPaste}
|
||||
onKeyDown={this._onReactKeyDown}
|
||||
className='mx_MatrixChat_wrapper'
|
||||
onPaste={this.onPaste}
|
||||
onKeyDown={this.onReactKeyDown}
|
||||
className={wrapperClasses}
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
>
|
||||
<ToastContainer />
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
<div className={bodyClasses}>
|
||||
<div className='mx_LeftPanel_wrapper'>
|
||||
{ SettingsStore.getValue('TagPanel.enableTagPanel') &&
|
||||
(<div className="mx_GroupFilterPanelContainer">
|
||||
<BackdropPanel
|
||||
blurMultiplier={0.5}
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<GroupFilterPanel />
|
||||
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
|
||||
</div>)
|
||||
}
|
||||
{ SpaceStore.spacesEnabled ? <>
|
||||
<BackdropPanel
|
||||
blurMultiplier={0.5}
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<SpacePanel />
|
||||
</> : null }
|
||||
<BackdropPanel
|
||||
backgroundImage={this.state.backgroundImage}
|
||||
/>
|
||||
<div
|
||||
className="mx_LeftPanel_wrapper--user"
|
||||
ref={this._resizeContainer}
|
||||
data-collapsed={this.props.collapseLhs ? true : undefined}
|
||||
>
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
|
||||
<div className="mx_RoomView_wrapper">
|
||||
{ pageElement }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
{ audioFeedArraysForCalls }
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,25 +16,35 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { NumberSize, Resizable } from 're-resizable';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { Direction } from "re-resizable/lib/resizer";
|
||||
|
||||
interface IProps {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
collapsedRhs?: boolean;
|
||||
panel?: JSX.Element;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.MainSplit")
|
||||
export default class MainSplit extends React.Component {
|
||||
_onResizeStart = () => {
|
||||
export default class MainSplit extends React.Component<IProps> {
|
||||
private onResizeStart = (): void => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
};
|
||||
|
||||
_onResize = () => {
|
||||
private onResize = (): void => {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
};
|
||||
|
||||
_onResizeStop = (event, direction, refToElement, delta) => {
|
||||
private onResizeStop = (
|
||||
event: MouseEvent | TouchEvent, direction: Direction, elementRef: HTMLElement, delta: NumberSize,
|
||||
): void => {
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width);
|
||||
window.localStorage.setItem("mx_rhs_size", (this.loadSidePanelSize().width + delta.width).toString());
|
||||
};
|
||||
|
||||
_loadSidePanelSize() {
|
||||
private loadSidePanelSize(): {height: string | number, width: number} {
|
||||
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
|
||||
|
||||
if (isNaN(rhsSize)) {
|
||||
|
@ -47,7 +57,7 @@ export default class MainSplit extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
const bodyView = React.Children.only(this.props.children);
|
||||
const panelView = this.props.panel;
|
||||
|
||||
|
@ -56,7 +66,7 @@ export default class MainSplit extends React.Component {
|
|||
let children;
|
||||
if (hasResizer) {
|
||||
children = <Resizable
|
||||
defaultSize={this._loadSidePanelSize()}
|
||||
defaultSize={this.loadSidePanelSize()}
|
||||
minWidth={264}
|
||||
maxWidth="50%"
|
||||
enable={{
|
||||
|
@ -69,9 +79,9 @@ export default class MainSplit extends React.Component {
|
|||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
onResizeStart={this._onResizeStart}
|
||||
onResize={this._onResize}
|
||||
onResizeStop={this._onResizeStop}
|
||||
onResizeStart={this.onResizeStart}
|
||||
onResize={this.onResize}
|
||||
onResizeStop={this.onResizeStop}
|
||||
className="mx_RightPanel_ResizeWrapper"
|
||||
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
|
||||
>
|
|
@ -19,6 +19,8 @@ import { createClient } from "matrix-js-sdk/src/matrix";
|
|||
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
|
||||
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
// what-input helps improve keyboard accessibility
|
||||
|
@ -34,14 +36,13 @@ import dis from "../../dispatcher/dispatcher";
|
|||
import Notifier from '../../Notifier';
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import * as sdk from '../../index';
|
||||
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import linkifyMatrix from "../../linkify-matrix";
|
||||
import * as Lifecycle from '../../Lifecycle';
|
||||
// LifecycleStore is not used but does listen to and dispatch actions
|
||||
import '../../stores/LifecycleStore';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import PageType from '../../PageTypes';
|
||||
|
||||
import createRoom, { IOpts } from "../../createRoom";
|
||||
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
|
||||
|
@ -55,7 +56,6 @@ import DMRoomMap from '../../utils/DMRoomMap';
|
|||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||
import { FontWatcher } from '../../settings/watchers/FontWatcher';
|
||||
import { storeRoomAliasInCache } from '../../RoomAliasCache';
|
||||
import { defer, IDeferred, sleep } from "../../utils/promise";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
import * as StorageManager from "../../utils/StorageManager";
|
||||
import type LoggedInViewType from "./LoggedInView";
|
||||
|
@ -84,9 +84,33 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
|
|||
import { RoomUpdateCause } from "../../stores/room-list/models";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import SecurityCustomisations from "../../customisations/Security";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
import UserSettingsDialog from '../views/dialogs/UserSettingsDialog';
|
||||
import CreateGroupDialog from '../views/dialogs/CreateGroupDialog';
|
||||
import CreateRoomDialog from '../views/dialogs/CreateRoomDialog';
|
||||
import RoomDirectory from './RoomDirectory';
|
||||
import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog";
|
||||
import IncomingSasDialog from "../views/dialogs/IncomingSasDialog";
|
||||
import CompleteSecurity from "./auth/CompleteSecurity";
|
||||
import LoggedInView from './LoggedInView';
|
||||
import Welcome from "../views/auth/Welcome";
|
||||
import ForgotPassword from "./auth/ForgotPassword";
|
||||
import E2eSetup from "./auth/E2eSetup";
|
||||
import Registration from './auth/Registration';
|
||||
import Login from "./auth/Login";
|
||||
import ErrorBoundary from '../views/elements/ErrorBoundary';
|
||||
import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
|
||||
|
||||
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
import SoftLogout from './auth/SoftLogout';
|
||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../utils/strings";
|
||||
import { PosthogAnalytics } from '../../PosthogAnalytics';
|
||||
import { initSentry } from "../../sentry";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -121,7 +145,7 @@ export enum Views {
|
|||
SOFT_LOGOUT,
|
||||
}
|
||||
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
|
||||
|
||||
// Actions that are redirected through the onboarding process prior to being
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
|
@ -135,7 +159,7 @@ const ONBOARDING_FLOW_STARTERS = [
|
|||
|
||||
interface IScreen {
|
||||
screen: string;
|
||||
params?: object;
|
||||
params?: QueryDict;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -155,14 +179,19 @@ interface IRoomInfo {
|
|||
/* eslint-enable camelcase */
|
||||
|
||||
interface IProps { // TODO type things better
|
||||
config: Record<string, any>;
|
||||
config: {
|
||||
piwik: {
|
||||
policyUrl: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
serverConfig?: ValidatedServerConfig;
|
||||
onNewScreen: (screen: string, replaceLast: boolean) => void;
|
||||
enableGuest?: boolean;
|
||||
// the queryParams extracted from the [real] query-string of the URI
|
||||
realQueryParams?: Record<string, string>;
|
||||
realQueryParams?: QueryDict;
|
||||
// the initial queryParams extracted from the hash-fragment of the URI
|
||||
startingFragmentQueryParams?: Record<string, string>;
|
||||
startingFragmentQueryParams?: QueryDict;
|
||||
// called when we have completed a token login
|
||||
onTokenLoginCompleted?: () => void;
|
||||
// Represents the screen to display as a result of parsing the initial window.location
|
||||
|
@ -170,7 +199,7 @@ interface IProps { // TODO type things better
|
|||
// displayname, if any, to set on the device when logging in/registering.
|
||||
defaultDeviceDisplayName?: string;
|
||||
// A function that makes a registration URL
|
||||
makeRegistrationUrl: (object) => string;
|
||||
makeRegistrationUrl: (params: QueryDict) => string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -178,7 +207,7 @@ interface IState {
|
|||
view: Views;
|
||||
// What the LoggedInView would be showing if visible
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type?: PageTypes;
|
||||
page_type?: PageType;
|
||||
// The ID of the room we're viewing. This is either populated directly
|
||||
// in the case where we view a room by ID or by RoomView when it resolves
|
||||
// what ID an alias points at.
|
||||
|
@ -228,7 +257,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
private pageChanging: boolean;
|
||||
private tokenLogin?: boolean;
|
||||
private accountPassword?: string;
|
||||
private accountPasswordTimer?: NodeJS.Timeout;
|
||||
private accountPasswordTimer?: number;
|
||||
private focusComposer: boolean;
|
||||
private subTitleStatus: string;
|
||||
private prevWindowWidth: number;
|
||||
|
@ -273,7 +302,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
|
||||
// probably a threepid invite - try to store it
|
||||
const roomId = this.screenAfterLogin.screen.substring("room/".length);
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat);
|
||||
ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -362,7 +391,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
|
||||
PosthogAnalytics.instance.updateAnonymityFromSettings();
|
||||
PosthogAnalytics.instance.updatePlatformSuperProperties();
|
||||
|
||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||
|
||||
initSentry(SdkConfig.get()["sentry"]);
|
||||
}
|
||||
|
||||
private async postLoginSetup() {
|
||||
|
@ -406,7 +441,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillUpdate(props, state) {
|
||||
if (this.shouldTrackPageChange(this.state, state)) {
|
||||
this.startPageChangeTimer();
|
||||
|
@ -418,9 +453,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||
PosthogAnalytics.instance.trackPageView(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
this.focusComposer = false;
|
||||
}
|
||||
}
|
||||
|
@ -518,7 +554,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
onAction = (payload) => {
|
||||
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
|
||||
// Start the onboarding process for certain actions
|
||||
if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() &&
|
||||
|
@ -539,7 +574,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
switch (payload.action) {
|
||||
case 'MatrixActions.accountData':
|
||||
// XXX: This is a collection of several hacks to solve a minor problem. We want to
|
||||
// update our local state when the ID server changes, but don't want to put that in
|
||||
// update our local state when the identity server changes, but don't want to put that in
|
||||
// the js-sdk as we'd be then dictating how all consumers need to behave. However,
|
||||
// this component is already bloated and we probably don't want this tiny logic in
|
||||
// here, but there's no better place in the react-sdk for it. Additionally, we're
|
||||
|
@ -605,6 +640,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
case 'forget_room':
|
||||
this.forgetRoom(payload.room_id);
|
||||
break;
|
||||
case 'copy_room':
|
||||
this.copyRoom(payload.room_id);
|
||||
break;
|
||||
case 'reject_invite':
|
||||
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
|
||||
title: _t('Reject invitation'),
|
||||
|
@ -612,8 +650,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
onFinished: (confirm) => {
|
||||
if (confirm) {
|
||||
// FIXME: controller shouldn't be loading a view :(
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
|
||||
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
|
||||
|
||||
MatrixClientPeg.get().leave(payload.room_id).then(() => {
|
||||
modal.close();
|
||||
|
@ -649,7 +686,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||
{ initialTabId: tabPayload.initialTabId },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
|
@ -662,11 +698,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.createRoom(payload.public, payload.defaultName);
|
||||
break;
|
||||
case 'view_create_group': {
|
||||
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
CreateGroupDialog = CreateCommunityPrototypeDialog;
|
||||
}
|
||||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
const prototype = SettingsStore.getValue("feature_communities_v2_prototypes");
|
||||
Modal.createTrackedDialog(
|
||||
'Create Community',
|
||||
'',
|
||||
prototype ? CreateCommunityPrototypeDialog : CreateGroupDialog,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case Action.ViewRoomDirectory: {
|
||||
|
@ -676,7 +713,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
room_id: SpaceStore.instance.activeSpace.roomId,
|
||||
});
|
||||
} else {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
|
||||
initialText: payload.initialText,
|
||||
}, 'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
|
@ -687,7 +723,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
break;
|
||||
}
|
||||
case 'view_my_groups':
|
||||
this.setPage(PageTypes.MyGroups);
|
||||
this.setPage(PageType.MyGroups);
|
||||
this.notifyNewScreen('groups');
|
||||
break;
|
||||
case 'view_group':
|
||||
|
@ -720,7 +756,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
localStorage.setItem("mx_seenSpacesBeta", "1");
|
||||
// We just dispatch the page change rather than have to worry about
|
||||
// what the logic is for each of these branches.
|
||||
if (this.state.page_type === PageTypes.MyGroups) {
|
||||
if (this.state.page_type === PageType.MyGroups) {
|
||||
dis.dispatch({ action: 'view_last_screen' });
|
||||
} else {
|
||||
dis.dispatch({ action: 'view_my_groups' });
|
||||
|
@ -806,7 +842,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private setPage(pageType: string) {
|
||||
private setPage(pageType: PageType) {
|
||||
this.setState({
|
||||
page_type: pageType,
|
||||
});
|
||||
|
@ -859,12 +895,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.focusComposer = true;
|
||||
|
||||
if (roomInfo.room_alias) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Switching to room alias ${roomInfo.room_alias} at event ` +
|
||||
roomInfo.event_id,
|
||||
);
|
||||
} else {
|
||||
console.log(`Switching to room id ${roomInfo.room_id} at event ` +
|
||||
logger.log(`Switching to room id ${roomInfo.room_id} at event ` +
|
||||
roomInfo.event_id,
|
||||
);
|
||||
}
|
||||
|
@ -913,7 +949,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.setState({
|
||||
view: Views.LOGGED_IN,
|
||||
currentRoomId: roomInfo.room_id || null,
|
||||
page_type: PageTypes.RoomView,
|
||||
page_type: PageType.RoomView,
|
||||
threepidInvite: roomInfo.threepid_invite,
|
||||
roomOobData: roomInfo.oob_data,
|
||||
ready: true,
|
||||
|
@ -941,7 +977,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
currentGroupId: groupId,
|
||||
currentGroupIsNew: payload.group_is_new,
|
||||
});
|
||||
this.setPage(PageTypes.GroupView);
|
||||
this.setPage(PageType.GroupView);
|
||||
this.notifyNewScreen('group/' + groupId);
|
||||
}
|
||||
|
||||
|
@ -982,8 +1018,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.setStateForNewView({
|
||||
view: Views.LOGGED_IN,
|
||||
justRegistered,
|
||||
currentRoomId: null,
|
||||
});
|
||||
this.setPage(PageTypes.HomePage);
|
||||
this.setPage(PageType.HomePage);
|
||||
this.notifyNewScreen('home');
|
||||
ThemeController.isLogin = false;
|
||||
this.themeWatcher.recheck();
|
||||
|
@ -1001,7 +1038,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
this.notifyNewScreen('user/' + userId);
|
||||
this.setState({ currentUserId: userId });
|
||||
this.setPage(PageTypes.UserView);
|
||||
this.setPage(PageType.UserView);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1018,7 +1055,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
|
||||
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
|
||||
defaultPublic,
|
||||
defaultName,
|
||||
|
@ -1080,7 +1116,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private leaveRoomWarnings(roomId: string) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
// Show a warning if there are additional complications.
|
||||
const warnings = [];
|
||||
|
||||
|
@ -1088,7 +1124,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (memberCount === 1) {
|
||||
warnings.push((
|
||||
<span className="warning" key="only_member_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ ' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ _t("You are the only person here. " +
|
||||
"If you leave, no one will be able to join in the future, including you.") }
|
||||
</span>
|
||||
|
@ -1103,7 +1139,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
if (rule !== "public") {
|
||||
warnings.push((
|
||||
<span className="warning" key="non_public_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ ' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ isSpace
|
||||
? _t("This space is not public. You will not be able to rejoin without an invite.")
|
||||
: _t("This room is not public. You will not be able to rejoin without an invite.") }
|
||||
|
@ -1115,11 +1151,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
private leaveRoom(roomId: string) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this.leaveRoomWarnings(roomId);
|
||||
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
|
||||
title: isSpace ? _t("Leave space") : _t("Leave room"),
|
||||
description: (
|
||||
|
@ -1132,7 +1167,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
: _t(
|
||||
"Are you sure you want to leave the room '%(roomName)s'?",
|
||||
{ roomName: roomToLeave.name },
|
||||
)}
|
||||
) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
|
@ -1142,8 +1177,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const d = leaveRoomBehaviour(roomId);
|
||||
|
||||
// FIXME: controller shouldn't be loading a view :(
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
|
||||
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
|
||||
|
||||
d.finally(() => modal.close());
|
||||
dis.dispatch({
|
||||
|
@ -1176,6 +1210,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
private async copyRoom(roomId: string) {
|
||||
const roomLink = makeRoomPermalink(roomId);
|
||||
const success = await copyPlaintext(roomLink);
|
||||
if (!success) {
|
||||
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
|
||||
title: _t("Unable to copy room link"),
|
||||
description: _t("Unable to copy a link to the room to the clipboard."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a chat with the welcome user, if the user doesn't already have one
|
||||
* @returns {string} The room ID of the new room, or null if no room was created
|
||||
|
@ -1364,7 +1409,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
// such as when laptops unsleep.
|
||||
// https://github.com/vector-im/element-web/issues/3307#issuecomment-282895568
|
||||
cli.setCanResetTimelineCallback((roomId) => {
|
||||
console.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId);
|
||||
logger.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId);
|
||||
if (roomId !== this.state.currentRoomId) {
|
||||
// It is safe to remove events from rooms we are not viewing.
|
||||
return true;
|
||||
|
@ -1410,7 +1455,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
showNotificationsToast(false);
|
||||
}
|
||||
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
this.setState({
|
||||
ready: true,
|
||||
});
|
||||
|
@ -1438,7 +1483,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
});
|
||||
cli.on('no_consent', function(message, consentUri) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, {
|
||||
title: _t('Terms and Conditions'),
|
||||
description: <div>
|
||||
|
@ -1547,8 +1591,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
|
||||
cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => {
|
||||
const KeySignatureUploadFailedDialog =
|
||||
sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog');
|
||||
Modal.createTrackedDialog(
|
||||
'Failed to upload key signatures',
|
||||
'Failed to upload key signatures',
|
||||
|
@ -1558,7 +1600,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
cli.on("crypto.verification.request", request => {
|
||||
if (request.verifier) {
|
||||
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
|
||||
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
|
||||
verifier: request.verifier,
|
||||
}, null, /* priority = */ false, /* static = */ true);
|
||||
|
@ -1568,7 +1609,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
title: _t("Verification requested"),
|
||||
icon: "verification",
|
||||
props: { request },
|
||||
component: sdk.getComponent("toasts.VerificationRequestToast"),
|
||||
component: VerificationRequestToast,
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
|
@ -1674,7 +1715,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
@ -1761,11 +1802,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = screen.substring(6);
|
||||
|
||||
// TODO: Check valid group ID
|
||||
|
@ -1835,13 +1871,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
dis.dispatch({ action: 'timeline_resize' });
|
||||
}
|
||||
|
||||
onRoomCreated(roomId: string) {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
}
|
||||
|
||||
onRegisterClick = () => {
|
||||
this.showScreen("register");
|
||||
};
|
||||
|
@ -1865,15 +1894,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
onSendEvent(roomId: string, event: MatrixEvent) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) {
|
||||
dis.dispatch({ action: 'message_send_failed' });
|
||||
return;
|
||||
}
|
||||
if (!cli) return;
|
||||
|
||||
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
|
||||
dis.dispatch({ action: 'message_sent' });
|
||||
}, (err) => {
|
||||
dis.dispatch({ action: 'message_send_failed' });
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1923,7 +1947,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.setState({ serverConfig });
|
||||
};
|
||||
|
||||
private makeRegistrationUrl = (params: {[key: string]: string}) => {
|
||||
private makeRegistrationUrl = (params: QueryDict) => {
|
||||
if (this.props.startingFragmentQueryParams.referrer) {
|
||||
params.referrer = this.props.startingFragmentQueryParams.referrer;
|
||||
}
|
||||
|
@ -1976,21 +2000,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
let view = null;
|
||||
|
||||
if (this.state.view === Views.LOADING) {
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
view = (
|
||||
<div className="mx_MatrixChat_splash">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.view === Views.COMPLETE_SECURITY) {
|
||||
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
|
||||
view = (
|
||||
<CompleteSecurity
|
||||
onFinished={this.onCompleteSecurityE2eSetupFinished}
|
||||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.E2E_SETUP) {
|
||||
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
|
||||
view = (
|
||||
<E2eSetup
|
||||
onFinished={this.onCompleteSecurityE2eSetupFinished}
|
||||
|
@ -2011,43 +2032,37 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
* we should go through and figure out what we actually need to pass down, as well
|
||||
* as using something like redux to avoid having a billion bits of state kicking around.
|
||||
*/
|
||||
const LoggedInView = sdk.getComponent('structures.LoggedInView');
|
||||
view = (
|
||||
<LoggedInView
|
||||
{...this.props}
|
||||
{...this.state}
|
||||
ref={this.loggedInView}
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
onRoomCreated={this.onRoomCreated}
|
||||
onCloseAllSettings={this.onCloseAllSettings}
|
||||
onRegistered={this.onRegistered}
|
||||
currentRoomId={this.state.currentRoomId}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// we think we are logged in, but are still waiting for the /sync to complete
|
||||
const Spinner = sdk.getComponent('elements.Spinner');
|
||||
let errorBox;
|
||||
if (this.state.syncError && !isStoreError) {
|
||||
errorBox = <div className="mx_MatrixChat_syncError">
|
||||
{messageForSyncError(this.state.syncError)}
|
||||
{ messageForSyncError(this.state.syncError) }
|
||||
</div>;
|
||||
}
|
||||
view = (
|
||||
<div className="mx_MatrixChat_splash">
|
||||
{errorBox}
|
||||
{ errorBox }
|
||||
<Spinner />
|
||||
<a href="#" className="mx_MatrixChat_splashButtons" onClick={this.onLogoutClick}>
|
||||
{_t('Logout')}
|
||||
{ _t('Logout') }
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (this.state.view === Views.WELCOME) {
|
||||
const Welcome = sdk.getComponent('auth.Welcome');
|
||||
view = <Welcome />;
|
||||
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
|
||||
const Registration = sdk.getComponent('structures.auth.Registration');
|
||||
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
|
||||
view = (
|
||||
<Registration
|
||||
|
@ -2066,7 +2081,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) {
|
||||
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
|
||||
view = (
|
||||
<ForgotPassword
|
||||
onComplete={this.onLoginClick}
|
||||
|
@ -2077,7 +2091,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
);
|
||||
} else if (this.state.view === Views.LOGIN) {
|
||||
const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset);
|
||||
const Login = sdk.getComponent('structures.auth.Login');
|
||||
view = (
|
||||
<Login
|
||||
isSyncing={this.state.pendingInitialSync}
|
||||
|
@ -2088,12 +2101,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
fragmentAfterLogin={fragmentAfterLogin}
|
||||
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
|
||||
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
|
||||
{...this.getServerProperties()}
|
||||
/>
|
||||
);
|
||||
} else if (this.state.view === Views.SOFT_LOGOUT) {
|
||||
const SoftLogout = sdk.getComponent('structures.auth.SoftLogout');
|
||||
view = (
|
||||
<SoftLogout
|
||||
realQueryParams={this.props.realQueryParams}
|
||||
|
@ -2105,9 +2117,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
console.error(`Unknown view ${this.state.view}`);
|
||||
}
|
||||
|
||||
const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary');
|
||||
return <ErrorBoundary>
|
||||
{view}
|
||||
{ view }
|
||||
</ErrorBoundary>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
|
|||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import defaultDispatcher from '../../dispatcher/dispatcher';
|
||||
import CallEventGrouper from "./CallEventGrouper";
|
||||
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
|
||||
import ScrollPanel, { IScrollState } from "./ScrollPanel";
|
||||
import EventListSummary from '../views/elements/EventListSummary';
|
||||
|
@ -47,14 +48,25 @@ import Spinner from "../views/elements/Spinner";
|
|||
import TileErrorBoundary from '../views/messages/TileErrorBoundary';
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
import { Action } from '../../dispatcher/actions';
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
|
||||
const groupedEvents = [
|
||||
EventType.RoomMember,
|
||||
EventType.RoomThirdPartyInvite,
|
||||
EventType.RoomServerAcl,
|
||||
EventType.RoomPinnedEvents,
|
||||
];
|
||||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean {
|
||||
export function shouldFormContinuation(
|
||||
prevEvent: MatrixEvent,
|
||||
mxEvent: MatrixEvent,
|
||||
showHiddenEvents: boolean,
|
||||
): boolean {
|
||||
// sanity check inputs
|
||||
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
|
||||
// check if within the max continuation period
|
||||
|
@ -74,7 +86,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b
|
|||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
|
||||
|
||||
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
|
||||
if (!haveTileForEvent(prevEvent)) return false;
|
||||
if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -163,6 +175,8 @@ interface IProps {
|
|||
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
|
||||
|
||||
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
|
||||
|
||||
hideThreadedMessages?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -228,6 +242,11 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
private readonly showTypingNotificationsWatcherRef: string;
|
||||
private eventNodes: Record<string, HTMLElement>;
|
||||
|
||||
// A map of <callId, CallEventGrouper>
|
||||
private callEventGroupers = new Map<string, CallEventGrouper>();
|
||||
|
||||
private membersCount = 0;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
|
@ -239,7 +258,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
// Cache hidden events setting on mount since Settings is expensive to
|
||||
// query, and we check this in a hot code path.
|
||||
// query, and we check this in a hot code path. This is also cached in
|
||||
// our RoomContext, however we still need a fallback for roomless MessagePanels.
|
||||
this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
|
||||
|
||||
this.showTypingNotificationsWatcherRef =
|
||||
|
@ -247,11 +267,17 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateRoomMembersCount();
|
||||
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
|
||||
if (SettingsStore.getValue("feature_thread")) {
|
||||
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
|
||||
}
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted = false;
|
||||
this.props.room?.off("RoomState.members", this.calculateRoomMembersCount);
|
||||
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
|
||||
}
|
||||
|
||||
|
@ -263,8 +289,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
ghostReadMarkers,
|
||||
});
|
||||
}
|
||||
|
||||
const pendingEditItem = this.pendingEditItem;
|
||||
if (!this.props.editState && this.props.room && pendingEditItem) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.EditEvent,
|
||||
event: this.props.room.findEventById(pendingEditItem),
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private calculateRoomMembersCount = (): void => {
|
||||
this.membersCount = this.props.room?.getMembers().length || 0;
|
||||
};
|
||||
|
||||
private onShowTypingNotificationsChange = (): void => {
|
||||
this.setState({
|
||||
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
|
||||
|
@ -399,23 +438,35 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
return !this.isMounted;
|
||||
};
|
||||
|
||||
private get showHiddenEvents(): boolean {
|
||||
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
|
||||
}
|
||||
|
||||
// TODO: Implement granular (per-room) hide options
|
||||
public shouldShowEvent(mxEv: MatrixEvent): boolean {
|
||||
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
|
||||
if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
|
||||
return false; // ignored = no show (only happens if the ignore happens after an event was received)
|
||||
}
|
||||
|
||||
if (this.showHiddenEventsInTimeline) {
|
||||
if (this.showHiddenEvents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
|
||||
return false; // no tile = no show
|
||||
}
|
||||
|
||||
// Always show highlighted event
|
||||
if (this.props.highlightedEventId === mxEv.getId()) return true;
|
||||
|
||||
// Checking if the message has a "parentEventId" as we do not
|
||||
// want to hide the root event of the thread
|
||||
if (mxEv.replyInThread && mxEv.parentEventId
|
||||
&& this.props.hideThreadedMessages
|
||||
&& SettingsStore.getValue("feature_thread")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideEvent(mxEv, this.context);
|
||||
}
|
||||
|
||||
|
@ -510,10 +561,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
return { nextEvent, nextTile };
|
||||
}
|
||||
|
||||
private get roomHasPendingEdit(): string {
|
||||
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
|
||||
private get pendingEditItem(): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEventTiles(): ReactNode[] {
|
||||
this.eventNodes = {};
|
||||
|
||||
|
@ -567,9 +622,23 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
const last = (mxEv === lastShownEvent);
|
||||
const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i);
|
||||
|
||||
if (
|
||||
mxEv.getType().indexOf("m.call.") === 0 ||
|
||||
mxEv.getType().indexOf("org.matrix.call.") === 0
|
||||
) {
|
||||
const callId = mxEv.getContent().call_id;
|
||||
if (this.callEventGroupers.has(callId)) {
|
||||
this.callEventGroupers.get(callId).add(mxEv);
|
||||
} else {
|
||||
const callEventGrouper = new CallEventGrouper();
|
||||
callEventGrouper.add(mxEv);
|
||||
this.callEventGroupers.set(callId, callEventGrouper);
|
||||
}
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
if (grouper.shouldGroup(mxEv)) {
|
||||
grouper.add(mxEv);
|
||||
grouper.add(mxEv, this.showHiddenEvents);
|
||||
continue;
|
||||
} else {
|
||||
// not part of group, so get the group tiles, close the
|
||||
|
@ -582,7 +651,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
|
||||
for (const Grouper of groupers) {
|
||||
if (Grouper.canStartGroup(this, mxEv)) {
|
||||
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
|
||||
grouper = new Grouper(
|
||||
this,
|
||||
mxEv,
|
||||
prevEvent,
|
||||
lastShownEvent,
|
||||
this.props.layout,
|
||||
nextEvent,
|
||||
nextTile,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!grouper) {
|
||||
|
@ -601,13 +678,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this.props.editState && this.roomHasPendingEdit) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "edit_event",
|
||||
event: this.props.room.findEventById(this.roomHasPendingEdit),
|
||||
});
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
ret.push(...grouper.getTiles());
|
||||
}
|
||||
|
@ -644,12 +714,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
let willWantDateSeparator = false;
|
||||
if (nextEvent) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
|
||||
let lastInSection = true;
|
||||
if (nextEventWithTile) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date());
|
||||
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender();
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
|
||||
const continuation = !wantsDateSeparator &&
|
||||
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
|
||||
|
||||
const eventId = mxEv.getId();
|
||||
const highlight = (eventId === this.props.highlightedEventId);
|
||||
|
@ -680,6 +753,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
// it's successful: we received it.
|
||||
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
|
||||
|
||||
const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id);
|
||||
// use txnId as key if available so that we don't remount during sending
|
||||
ret.push(
|
||||
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
|
||||
|
@ -702,7 +776,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last}
|
||||
lastInSection={willWantDateSeparator}
|
||||
lastInSection={lastInSection}
|
||||
lastSuccessful={isLastSuccessful}
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
|
@ -710,6 +784,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
layout={this.props.layout}
|
||||
enableFlair={this.props.enableFlair}
|
||||
showReadReceipts={this.props.showReadReceipts}
|
||||
callEventGrouper={callEventGrouper}
|
||||
hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
|
||||
/>
|
||||
</TileErrorBoundary>,
|
||||
);
|
||||
|
@ -939,6 +1015,7 @@ abstract class BaseGrouper {
|
|||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
public readonly nextEvent?: MatrixEvent,
|
||||
public readonly nextEventTile?: MatrixEvent,
|
||||
) {
|
||||
|
@ -946,7 +1023,7 @@ abstract class BaseGrouper {
|
|||
}
|
||||
|
||||
public abstract shouldGroup(ev: MatrixEvent): boolean;
|
||||
public abstract add(ev: MatrixEvent): void;
|
||||
public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void;
|
||||
public abstract getTiles(): ReactNode[];
|
||||
public abstract getNewPrevEvent(): MatrixEvent;
|
||||
}
|
||||
|
@ -1065,6 +1142,7 @@ class CreationGrouper extends BaseGrouper {
|
|||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
@ -1092,10 +1170,11 @@ class RedactionGrouper extends BaseGrouper {
|
|||
ev: MatrixEvent,
|
||||
prevEvent: MatrixEvent,
|
||||
lastShownEvent: MatrixEvent,
|
||||
layout: Layout,
|
||||
nextEvent: MatrixEvent,
|
||||
nextEventTile: MatrixEvent,
|
||||
) {
|
||||
super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
|
||||
super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile);
|
||||
this.events = [ev];
|
||||
}
|
||||
|
||||
|
@ -1160,6 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={Array.from(senders)}
|
||||
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
|
@ -1180,7 +1260,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||
class MemberGrouper extends BaseGrouper {
|
||||
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
||||
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
|
||||
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
@ -1188,8 +1268,9 @@ class MemberGrouper extends BaseGrouper {
|
|||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
) {
|
||||
super(panel, event, prevEvent, lastShownEvent);
|
||||
super(panel, event, prevEvent, lastShownEvent, layout);
|
||||
this.events = [event];
|
||||
}
|
||||
|
||||
|
@ -1197,13 +1278,13 @@ class MemberGrouper extends BaseGrouper {
|
|||
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||
return false;
|
||||
}
|
||||
return membershipTypes.includes(ev.getType() as EventType);
|
||||
return groupedEvents.includes(ev.getType() as EventType);
|
||||
}
|
||||
|
||||
public add(ev: MatrixEvent): void {
|
||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||
if (ev.getType() === EventType.RoomMember) {
|
||||
// We can ignore any events that don't actually have a message to display
|
||||
if (!hasText(ev)) return;
|
||||
if (!hasText(ev, showHiddenEvents)) return;
|
||||
}
|
||||
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
|
||||
ev.getId(),
|
||||
|
@ -1264,6 +1345,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
events={this.events}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</MemberEventListSummary>,
|
||||
|
|
|
@ -25,7 +25,6 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
|||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import BetaCard from "../views/beta/BetaCard";
|
||||
|
||||
@replaceableComponent("structures.MyGroups")
|
||||
export default class MyGroups extends React.Component {
|
||||
|
@ -109,8 +108,7 @@ export default class MyGroups extends React.Component {
|
|||
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
|
||||
<div className='mx_MyGroups_header'>
|
||||
<div className="mx_MyGroups_headerCard">
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
|
||||
<div className="mx_MyGroups_headerCard_content">
|
||||
<div className="mx_MyGroups_headerCard_header">
|
||||
{ _t('Create a new community') }
|
||||
|
@ -121,7 +119,7 @@ export default class MyGroups extends React.Component {
|
|||
) }
|
||||
</div>
|
||||
</div>
|
||||
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
|
||||
{ /*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
|
||||
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
|
||||
<img src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
|
||||
</AccessibleButton>
|
||||
|
@ -137,9 +135,8 @@ export default class MyGroups extends React.Component {
|
|||
{ 'i': (sub) => <i>{ sub }</i> })
|
||||
}
|
||||
</div>
|
||||
</div>*/}
|
||||
</div>*/ }
|
||||
</div>
|
||||
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
|
||||
<div className="mx_MyGroups_content">
|
||||
{ contentHeader }
|
||||
{ content }
|
||||
|
|
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